Validate the authorization server metadata issuer on every discovery path (#3398)

This commit is contained in:
Max 2026-09-02 16:51:22 +01:00 • committed by GitHub
commit d060b36e1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 760 additions and 135 deletions

View file

@ -9,6 +9,7 @@ Provides OAuth providers for machine-to-machine authentication flows:
import time
from collections.abc import Awaitable, Callable
from typing import Any, Literal
from urllib.parse import urlparse
from uuid import uuid4
import httpx2
@ -16,14 +17,50 @@ import jwt
from pydantic import BaseModel, Field
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage
from mcp.client.auth.oauth2 import OAuthContext
from mcp.client.auth.utils import issuers_match
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
def _checked_issuer(issuer: str | None) -> str | None:
if issuer is not None and urlparse(issuer).scheme not in ("http", "https"):
raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}")
return issuer
def _preferred_authorization_server(advertised: list[str], issuer: str | None) -> str:
"""The advertised server matching the configured issuer if there is one, else the first."""
return next(
(server for server in advertised if issuer is not None and issuers_match(server, issuer)), advertised[0]
)
def _require_metadata_for_configured_issuer(context: OAuthContext, issuer: str | None) -> None:
"""With an issuer configured, a token request is only built from metadata discovered for that issuer.
Anything else held is dropped along with the tokens, so the next request starts discovery afresh
rather than refreshing against it.
"""
if issuer is None:
return
metadata = context.oauth_metadata
if metadata is not None and issuers_match(str(metadata.issuer), issuer):
return
context.oauth_metadata = None
context.clear_tokens()
if metadata is None:
raise OAuthFlowError(f"No authorization server metadata discovered for configured issuer {issuer}")
raise OAuthFlowError(f"Authorization server metadata issuer mismatch: {metadata.issuer} != {issuer}")
class ClientCredentialsOAuthProvider(OAuthClientProvider):
"""OAuth provider for client_credentials grant with client_id + client_secret.
This provider sets client_info directly, bypassing dynamic client registration.
Use this when you already have client credentials (client_id and client_secret).
Pass `issuer` to name the authorization server those credentials belong to: token
requests are then only built from authorization server metadata for that issuer, and
the flow stops if the MCP server leads anywhere else.
Example:
```python
@ -32,6 +69,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
storage=my_token_storage,
client_id="my-client-id",
client_secret="my-client-secret",
issuer="https://auth.example.com",
)
```
"""
@ -44,6 +82,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
client_secret: str,
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic",
scope: str | None = None,
issuer: str | None = None,
) -> None:
"""Initialize client_credentials OAuth provider.
@ -55,6 +94,11 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
token_endpoint_auth_method: Authentication method for token endpoint.
Either "client_secret_basic" (default) or "client_secret_post".
scope: Optional space-separated list of scopes to request.
issuer: The issuer identifier of the authorization server that issued
`client_id` and `client_secret`. When set, token requests are only built from
discovered authorization server metadata whose `issuer` is exactly this string;
otherwise the flow stops with `OAuthFlowError`. When omitted, whichever
authorization server discovery yields is used.
"""
# Build minimal client_metadata for the base class
client_metadata = OAuthClientMetadata(
@ -64,6 +108,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
scope=scope,
)
super().__init__(server_url, client_metadata, storage, None, None)
self._issuer = _checked_issuer(issuer)
# Store client_info to be set during _initialize - no dynamic registration needed
self._fixed_client_info = OAuthClientInformationFull(
redirect_uris=None,
@ -80,12 +125,17 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
self.context.client_info = self._fixed_client_info
self._initialized = True
def _select_authorization_server(self, advertised: list[str]) -> str:
return _preferred_authorization_server(advertised, self._issuer)
async def _perform_authorization(self) -> httpx2.Request:
"""Perform client_credentials authorization."""
return await self._exchange_token_client_credentials()
async def _exchange_token_client_credentials(self) -> httpx2.Request:
"""Build token exchange request for client_credentials grant."""
_require_metadata_for_configured_issuer(self.context, self._issuer)
token_data: dict[str, Any] = {
"grant_type": "client_credentials",
}
@ -196,7 +246,10 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
The JWT assertion's audience MUST be the authorization server's issuer identifier
(per RFC 7523bis security updates). The `assertion_provider` callback receives
this audience value and must return a JWT with that audience.
this audience value and must return a JWT with that audience. Pass `issuer` to name
the authorization server this client is registered with: an assertion is then only
minted once metadata for that issuer has been discovered, and token requests are only
built from that metadata.
**Option 1: Pre-built JWT via Workload Identity Federation**
@ -256,6 +309,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
client_id: str,
assertion_provider: Callable[[str], Awaitable[str]],
scope: str | None = None,
issuer: str | None = None,
) -> None:
"""Initialize private_key_jwt OAuth provider.
@ -269,6 +323,11 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
`static_assertion_provider()` for pre-built JWTs, or provide your own
callback for workload identity federation.
scope: Optional space-separated list of scopes to request.
issuer: The issuer identifier of the authorization server `client_id` is
registered with. When set, an assertion is only minted, and token requests
are only built, once authorization server metadata whose `issuer` is exactly this
string has been discovered; otherwise the flow stops with `OAuthFlowError`.
When omitted, whichever authorization server discovery yields is used.
"""
# Build minimal client_metadata for the base class
client_metadata = OAuthClientMetadata(
@ -279,6 +338,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
)
super().__init__(server_url, client_metadata, storage, None, None)
self._assertion_provider = assertion_provider
self._issuer = _checked_issuer(issuer)
# Store client_info to be set during _initialize - no dynamic registration needed
self._fixed_client_info = OAuthClientInformationFull(
redirect_uris=None,
@ -294,6 +354,9 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
self.context.client_info = self._fixed_client_info
self._initialized = True
def _select_authorization_server(self, advertised: list[str]) -> str:
return _preferred_authorization_server(advertised, self._issuer)
async def _perform_authorization(self) -> httpx2.Request:
"""Perform client_credentials authorization with private_key_jwt."""
return await self._exchange_token_client_credentials()
@ -314,6 +377,8 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
async def _exchange_token_client_credentials(self) -> httpx2.Request:
"""Build token exchange request for client_credentials grant with private_key_jwt."""
_require_metadata_for_configured_issuer(self.context, self._issuer)
token_data: dict[str, Any] = {
"grant_type": "client_credentials",
}

View file

@ -17,7 +17,7 @@ from urllib.parse import quote, urlencode, urljoin, urlparse
import anyio
import httpx2
from mcp_types.version import is_version_at_least
from pydantic import BaseModel, Field, ValidationError
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.client.auth.utils import (
@ -36,6 +36,7 @@ from mcp.client.auth.utils import (
handle_registration_response,
handle_token_response_scopes,
is_valid_client_metadata_url,
issuers_match,
should_use_client_metadata_url,
union_scopes,
validate_authorization_response_iss,
@ -276,6 +277,16 @@ class OAuthContext:
return data, headers
_ORIGIN_URL = TypeAdapter(AnyHttpUrl, config=ConfigDict(url_preserve_empty_path=True))
def _origin_issuer(server_url: str) -> str:
"""The resource server's origin as an issuer identifier: `scheme://authority`, rendered the way
`OAuthMetadata.issuer` renders URLs (host case, default ports) so the two compare as strings."""
parsed = urlparse(server_url)
return str(_ORIGIN_URL.validate_python(f"{parsed.scheme}://{parsed.netloc}"))
class OAuthClientProvider(httpx2.Auth):
"""OAuth2 authentication for httpx2.
@ -577,6 +588,16 @@ class OAuthClientProvider(httpx2.Auth):
if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource):
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")
def _select_authorization_server(self, advertised: list[str]) -> str:
"""Which of the servers listed in protected resource metadata to use: the first (the list is never empty)."""
return advertised[0]
def _expected_issuer(self) -> str:
"""The issuer that authorization server metadata and client credentials must belong to: the
PRM-advertised server, or on the legacy no-PRM path the resource server's origin, which is what
the 2025-03-26 well-known URL is built from (RFC 8414 §3.3)."""
return self.context.auth_server_url or _origin_issuer(self.context.server_url)
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
"""httpx2 auth flow integration."""
async with self.context.lock:
@ -600,107 +621,122 @@ class OAuthClientProvider(httpx2.Auth):
response = yield request
if response.status_code == 401:
step_up = (
response.status_code == 403 and extract_field_from_www_auth(response, "error") == "insufficient_scope"
)
if response.status_code == 401 or step_up:
# Perform full OAuth flow
try:
# OAuth flow must be inline due to generator constraints
www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response)
# Read before discovery, which may clear the tokens: on a restart the stored
# token's scope is the only record of what was granted (see Step 3).
granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None
# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
prm_discovery_urls = build_protected_resource_metadata_discovery_urls(
www_auth_resource_metadata_url, self.context.server_url
)
# OAuth flow must be inline due to generator constraints.
# Steps 1-2 run on every 401. A scope step-up reuses the metadata discovered earlier
# in this process, and discovers it first when none is held yet (for example when
# tokens were loaded from storage), so re-authorization targets the right server.
if response.status_code == 401 or self.context.oauth_metadata is None:
www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response)
for url in prm_discovery_urls: # pragma: no branch
discovery_request = create_oauth_metadata_request(url)
discovery_response = yield discovery_request # sending request
prm = await handle_protected_resource_response(discovery_response)
if prm:
# Validate PRM resource matches server URL (RFC 8707)
await self._validate_resource_match(prm)
self.context.protected_resource_metadata = prm
# todo: try all authorization_servers to find the OASM
assert (
len(prm.authorization_servers) > 0
) # this is always true as authorization_servers has a min length of 1
self.context.auth_server_url = str(prm.authorization_servers[0])
break
else:
logger.debug(f"Protected resource metadata discovery failed: {url}")
# SEP-2352: stored credentials are bound to the issuer that registered them.
# If the authorization server changed, drop them (and the old tokens) so the
# flow re-registers instead of presenting another server's credentials.
if (
self.context.client_info is not None
and self.context.auth_server_url is not None
and not credentials_match_issuer(
self.context.client_info, self.context.auth_server_url, self.context.client_metadata_url
# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
prm_discovery_urls = build_protected_resource_metadata_discovery_urls(
www_auth_resource_metadata_url, self.context.server_url
)
):
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
self.context.client_info = None
self.context.clear_tokens()
# Any cached AS metadata is for the old server; drop it so a failed
# rediscovery cannot leak the old registration/token endpoints into Step 4.
self.context.oauth_metadata = None
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
self.context.auth_server_url, self.context.server_url
)
prm_request_failed: int | None = None
for url in prm_discovery_urls:
discovery_request = create_oauth_metadata_request(url)
# Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers)
for url in asm_discovery_urls: # pragma: no branch
oauth_metadata_request = create_oauth_metadata_request(url)
oauth_metadata_response = yield oauth_metadata_request
discovery_response = yield discovery_request # sending request
ok, asm = await handle_auth_metadata_response(oauth_metadata_response)
if not ok:
break
if ok and asm:
# SEP-2468: metadata issuer must match the discovery issuer
if self.context.auth_server_url is not None:
validate_metadata_issuer(asm, self.context.auth_server_url)
self.context.oauth_metadata = asm
break
if discovery_response.status_code >= 500 or discovery_response.status_code == 429:
prm_request_failed = discovery_response.status_code
prm = await handle_protected_resource_response(discovery_response)
if prm:
# Validate PRM resource matches server URL (RFC 8707)
await self._validate_resource_match(prm)
self.context.protected_resource_metadata = prm
self.context.auth_server_url = self._select_authorization_server(
[str(url) for url in prm.authorization_servers]
)
break
else:
logger.debug(f"Protected resource metadata discovery failed: {url}")
else:
logger.debug(f"OAuth metadata discovery failed: {url}")
if prm_request_failed is not None:
# A server error says nothing about whether the resource publishes
# metadata, so it must not send the flow down the legacy path.
raise OAuthFlowError(
f"Protected resource metadata request failed: HTTP {prm_request_failed}"
)
# SEP-2352: on the legacy no-PRM path the issuer is only known after ASM
# discovery, so re-evaluate the binding here using the discovered metadata
# issuer (mirroring the bound_issuer fallback in Step 4).
if (
self.context.client_info is not None
and self.context.auth_server_url is None
and self.context.oauth_metadata is not None
and not credentials_match_issuer(
self.context.client_info,
str(self.context.oauth_metadata.issuer),
self.context.client_metadata_url,
expected_issuer = self._expected_issuer()
# SEP-2352: stored credentials are bound to the issuer that registered them.
# Decided before any metadata is fetched: if the expected issuer is a different
# server, drop them (and the old tokens) so the flow re-registers instead of
# presenting another server's credentials.
if self.context.client_info is not None and not credentials_match_issuer(
self.context.client_info, expected_issuer, self.context.client_metadata_url
):
logger.debug(
"Authorization server changed; discarding bound credentials and re-registering"
)
self.context.client_info = None
self.context.clear_tokens()
# Any cached AS metadata is for the old server; drop it so a failed
# rediscovery cannot leak the old registration/token endpoints into Step 4.
self.context.oauth_metadata = None
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
self.context.auth_server_url, self.context.server_url
)
):
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
self.context.client_info = None
self.context.clear_tokens()
# Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers)
for url in asm_discovery_urls: # pragma: no branch
oauth_metadata_request = create_oauth_metadata_request(url)
oauth_metadata_response = yield oauth_metadata_request
ok, asm = await handle_auth_metadata_response(oauth_metadata_response)
if not ok:
break
if ok and asm:
# SEP-2468 / RFC 8414 §3.3: the metadata must name the expected issuer.
# On the legacy path a root issuer rendered with its trailing slash
# names the same origin.
if self.context.auth_server_url is None and issuers_match(
str(asm.issuer), expected_issuer
):
expected_issuer = str(asm.issuer)
validate_metadata_issuer(asm, expected_issuer)
self.context.oauth_metadata = asm
break
else:
logger.debug(f"OAuth metadata discovery failed: {url}")
# Step 3: Apply scope selection strategy
self.context.client_metadata.scope = get_client_metadata_scopes(
challenged_scope = get_client_metadata_scopes(
extract_scope_from_www_auth(response),
self.context.protected_resource_metadata,
self.context.oauth_metadata,
self.context.client_metadata.grant_types,
)
if step_up:
# SEP-2350: union previously requested scopes with the newly challenged ones so
# escalating one operation keeps the others' grants, folding in the granted
# scope read above since client_metadata.scope is not reloaded on a restart.
prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope)
self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope)
else:
self.context.client_metadata.scope = challenged_scope
# Step 4: Register client or use URL-based client ID (CIMD)
if not self.context.client_info:
# SEP-2352: the issuer to bind these credentials to, when known.
discovered_issuer: str | None = None
if self.context.oauth_metadata is not None:
discovered_issuer = self.context.auth_server_url or str(self.context.oauth_metadata.issuer)
# SEP-2352: the issuer to bind these credentials to, once metadata for it
# was actually found.
discovered_issuer = self._expected_issuer() if self.context.oauth_metadata is not None else None
if should_use_client_metadata_url(
self.context.oauth_metadata, self.context.client_metadata_url
@ -752,34 +788,3 @@ class OAuthClientProvider(httpx2.Auth):
# Retry with new tokens
self._add_auth_header(request)
yield request
elif response.status_code == 403:
# Step 1: Extract error field from WWW-Authenticate header
error = extract_field_from_www_auth(response, "error")
# Step 2: Check if we need to step-up authorization
if error == "insufficient_scope": # pragma: no branch
try:
# Step 2a: Union previously requested scopes with the newly challenged
# scopes (SEP-2350) so escalating one operation keeps the others' grants.
# Fold in the stored token's scope too: on a restart the token is reloaded
# but client_metadata.scope is not, so it would otherwise be the only basis.
challenged_scope = get_client_metadata_scopes(
extract_scope_from_www_auth(response),
self.context.protected_resource_metadata,
self.context.oauth_metadata,
self.context.client_metadata.grant_types,
)
granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None
prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope)
self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope)
# Step 2b: Perform (re-)authorization and token exchange
token_response = yield await self._perform_authorization()
await self._handle_token_response(token_response)
except Exception: # pragma: no cover
logger.exception("OAuth flow error")
raise
# Retry with new tokens
self._add_auth_header(request)
yield request

View file

@ -54,7 +54,7 @@ def extract_resource_metadata_from_www_auth(response: Response) -> str | None:
Returns:
Resource metadata URL if found in WWW-Authenticate header, None otherwise
"""
if not response or response.status_code != 401:
if not response or response.status_code not in (401, 403):
return None # pragma: no cover
return extract_field_from_www_auth(response, "resource_metadata")
@ -343,7 +343,8 @@ def credentials_match_issuer(
document is resolved by whichever server is in use — so it always matches; CIMD is identified
by the client ID being the configured `client_metadata_url`, not by URL shape (a registration
server may also issue URL-shaped IDs that are bound to it). Credentials with a recorded issuer
match only when it equals `issuer` (simple string comparison). Credentials with no recorded
match only when it equals `issuer` (simple string comparison; a root issuer with and without
its trailing slash count as equal). Credentials with no recorded
issuer (pre-registered, or stored before issuer binding existed) carry no binding to enforce
and are left as-is.
"""
@ -351,7 +352,18 @@ def credentials_match_issuer(
return True
if client_info.issuer is None:
return True
return client_info.issuer == issuer
return issuers_match(client_info.issuer, issuer)
def issuers_match(a: str, b: str) -> bool:
"""Simple string comparison of two issuer identifiers (RFC 8414 section 3.3), except that a root
issuer with and without its trailing slash (`scheme://authority` and `scheme://authority/`) name
the same server."""
if a == b:
return True
shorter, longer = sorted((a, b), key=len)
parsed = urlparse(shorter)
return longer == f"{shorter}/" and shorter == f"{parsed.scheme}://{parsed.netloc}"
def should_use_client_metadata_url(