Skip to content

Commit 2d97e7e

Browse files
committed
Derive the expected issuer before fetching authorization server metadata
The discovery step now knows which issuer the authorization server metadata must carry before fetching it: the PRM-advertised server, or on the 2025-03-26 no-PRM fallback the resource server's origin, which is what that well-known URL is built from (RFC 8414 section 3.3). The metadata issuer check runs on both paths instead of only when PRM was found (on the no-PRM path a root issuer rendered with its trailing slash still names the origin, and the origin is written the way metadata issuers are, so host case or an explicit default port in `server_url` do not matter). The SEP-2352 stored-credential binding is evaluated once against that same value before metadata discovery, so it also applies when no metadata is served and the default endpoints are used, and the special case that re-evaluated it against the served issuer goes away; newly registered clients are bound to it when metadata for it was found. A 403 insufficient_scope step-up takes the same path as a 401. Until now it re-authorized with whatever metadata was in memory, and with none after a restart, in which case it used the 2025-03-26 default endpoints on the resource server's origin regardless of what the server advertises and never consulted the binding. It now discovers first when no metadata is held (extract_resource_metadata_from_www_auth also reads the `resource_metadata` hint from a 403 challenge), then re-authorizes with the SEP-2350 scope union, keeping the granted scope in the union even if discovery dropped the old token; metadata already discovered in this process is reused as before. A 403 that is not a scope challenge is handed back to the caller instead of being retried unchanged, as IdentityAssertionOAuthProvider already does. credentials_match_issuer treats a root issuer recorded with and without its trailing slash as the same server, so records stamped in either form keep matching on both paths. This brings the no-PRM path in line with the TypeScript client, which applies the section 3.3 check on every discovery path (that client also offers an opt-out; this one does not, as on the PRM path since 2.0).
1 parent 6705402 commit 2d97e7e

5 files changed

Lines changed: 404 additions & 129 deletions

File tree

docs/client/oauth-clients.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Look at `main()`. The provider goes on the **httpx2 client**, the httpx2 client
7676

7777
The first time `Client` sends a request, the server answers `401`. The provider takes over:
7878

79-
1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata.
79+
1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata. (An older server that publishes no resource metadata is asked for authorization server metadata at its own origin instead.) Either way the metadata must name, as its `issuer`, the server it was fetched for; anything else is refused.
8080
2. **Registration.** Nothing in storage? It registers you dynamically with your `OAuthClientMetadata` and stores the result.
8181
3. **Authorization.** It generates the PKCE pair and a `state`, builds the authorization URL, awaits your `redirect_handler`, then awaits your `callback_handler` for the code.
8282
4. **Exchange.** It trades the code for an `OAuthToken`, stores it, and replays your original request with `Authorization: Bearer ...`.

docs/migration.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2499,7 +2499,9 @@ metadata's `issuer` exactly matches the authorization server URL advertised in t
24992499
resource metadata, as required by [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)
25002500
section 3.3 ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)).
25012501
The comparison is a simple string comparison ([RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986)
2502-
section 6.2.1), so even a trailing-slash disagreement counts as a mismatch. v1 accepted the
2502+
section 6.2.1), so even a trailing-slash disagreement counts as a mismatch. (For an older server
2503+
that publishes no protected resource metadata the expected value is the MCP server's own origin,
2504+
and there a root issuer with a trailing slash is accepted too.) v1 accepted the
25032505
metadata without checking, so a server pairing whose two values disagree authenticated fine
25042506
under v1 and now fails the entire flow. For example, when the MCP server's protected resource
25052507
metadata advertises
@@ -2517,7 +2519,7 @@ OAuthFlowError: Authorization server metadata issuer mismatch: https://as.exampl
25172519

25182520
There is no client-side override. Fix the deployment instead: make the authorization server's
25192521
`issuer` string-equal the URL in the protected resource metadata's `authorization_servers`
2520-
list. See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash)
2522+
list (or the MCP server's origin, without protected resource metadata). See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash)
25212523
for how v2 preserves the exact string form of these URLs.
25222524

25232525
### OAuth client requests `offline_access` and adds `prompt=consent` when the authorization server supports it ([SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207))

src/mcp/client/auth/oauth2.py

Lines changed: 104 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import anyio
1818
import httpx2
1919
from mcp_types.version import is_version_at_least
20-
from pydantic import BaseModel, Field, ValidationError
20+
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
2121

2222
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
2323
from mcp.client.auth.utils import (
@@ -276,6 +276,16 @@ def prepare_token_auth(
276276
return data, headers
277277

278278

279+
_ORIGIN_URL = TypeAdapter(AnyHttpUrl, config=ConfigDict(url_preserve_empty_path=True))
280+
281+
282+
def _origin_issuer(server_url: str) -> str:
283+
"""The resource server's origin as an issuer identifier: `scheme://authority`, rendered the way
284+
`OAuthMetadata.issuer` renders URLs (host case, default ports) so the two compare as strings."""
285+
parsed = urlparse(server_url)
286+
return str(_ORIGIN_URL.validate_python(f"{parsed.scheme}://{parsed.netloc}"))
287+
288+
279289
class OAuthClientProvider(httpx2.Auth):
280290
"""OAuth2 authentication for httpx2.
281291
@@ -577,6 +587,12 @@ async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None
577587
if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource):
578588
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")
579589

590+
def _expected_issuer(self) -> str:
591+
"""The issuer that authorization server metadata and client credentials must belong to: the
592+
PRM-advertised server, or on the legacy no-PRM path the resource server's origin, which is what
593+
the 2025-03-26 well-known URL is built from (RFC 8414 §3.3)."""
594+
return self.context.auth_server_url or _origin_issuer(self.context.server_url)
595+
580596
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
581597
"""httpx2 auth flow integration."""
582598
async with self.context.lock:
@@ -600,107 +616,113 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
600616

601617
response = yield request
602618

603-
if response.status_code == 401:
619+
step_up = (
620+
response.status_code == 403 and extract_field_from_www_auth(response, "error") == "insufficient_scope"
621+
)
622+
623+
if response.status_code == 401 or step_up:
604624
# Perform full OAuth flow
605625
try:
606-
# OAuth flow must be inline due to generator constraints
607-
www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response)
626+
# Read before discovery, which may clear the tokens: on a restart the stored
627+
# token's scope is the only record of what was granted (see Step 3).
628+
granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None
629+
630+
# OAuth flow must be inline due to generator constraints.
631+
# Steps 1-2 run on every 401. A scope step-up reuses the metadata discovered earlier
632+
# in this process, and discovers it first when none is held yet (for example when
633+
# tokens were loaded from storage), so re-authorization targets the right server.
634+
if response.status_code == 401 or self.context.oauth_metadata is None:
635+
www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response)
636+
637+
# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
638+
prm_discovery_urls = build_protected_resource_metadata_discovery_urls(
639+
www_auth_resource_metadata_url, self.context.server_url
640+
)
608641

609-
# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
610-
prm_discovery_urls = build_protected_resource_metadata_discovery_urls(
611-
www_auth_resource_metadata_url, self.context.server_url
612-
)
642+
for url in prm_discovery_urls: # pragma: no branch
643+
discovery_request = create_oauth_metadata_request(url)
613644

614-
for url in prm_discovery_urls: # pragma: no branch
615-
discovery_request = create_oauth_metadata_request(url)
645+
discovery_response = yield discovery_request # sending request
616646

617-
discovery_response = yield discovery_request # sending request
647+
prm = await handle_protected_resource_response(discovery_response)
648+
if prm:
649+
# Validate PRM resource matches server URL (RFC 8707)
650+
await self._validate_resource_match(prm)
651+
self.context.protected_resource_metadata = prm
618652

619-
prm = await handle_protected_resource_response(discovery_response)
620-
if prm:
621-
# Validate PRM resource matches server URL (RFC 8707)
622-
await self._validate_resource_match(prm)
623-
self.context.protected_resource_metadata = prm
653+
# todo: try all authorization_servers to find the OASM
654+
assert (
655+
len(prm.authorization_servers) > 0
656+
) # this is always true as authorization_servers has a min length of 1
624657

625-
# todo: try all authorization_servers to find the OASM
626-
assert (
627-
len(prm.authorization_servers) > 0
628-
) # this is always true as authorization_servers has a min length of 1
658+
self.context.auth_server_url = str(prm.authorization_servers[0])
659+
break
660+
else:
661+
logger.debug(f"Protected resource metadata discovery failed: {url}")
629662

630-
self.context.auth_server_url = str(prm.authorization_servers[0])
631-
break
632-
else:
633-
logger.debug(f"Protected resource metadata discovery failed: {url}")
634-
635-
# SEP-2352: stored credentials are bound to the issuer that registered them.
636-
# If the authorization server changed, drop them (and the old tokens) so the
637-
# flow re-registers instead of presenting another server's credentials.
638-
if (
639-
self.context.client_info is not None
640-
and self.context.auth_server_url is not None
641-
and not credentials_match_issuer(
642-
self.context.client_info, self.context.auth_server_url, self.context.client_metadata_url
643-
)
644-
):
645-
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
646-
self.context.client_info = None
647-
self.context.clear_tokens()
648-
# Any cached AS metadata is for the old server; drop it so a failed
649-
# rediscovery cannot leak the old registration/token endpoints into Step 4.
650-
self.context.oauth_metadata = None
651-
652-
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
653-
self.context.auth_server_url, self.context.server_url
654-
)
663+
expected_issuer = self._expected_issuer()
655664

656-
# Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers)
657-
for url in asm_discovery_urls: # pragma: no branch
658-
oauth_metadata_request = create_oauth_metadata_request(url)
659-
oauth_metadata_response = yield oauth_metadata_request
660-
661-
ok, asm = await handle_auth_metadata_response(oauth_metadata_response)
662-
if not ok:
663-
break
664-
if ok and asm:
665-
# SEP-2468: metadata issuer must match the discovery issuer
666-
if self.context.auth_server_url is not None:
667-
validate_metadata_issuer(asm, self.context.auth_server_url)
668-
self.context.oauth_metadata = asm
669-
break
670-
else:
671-
logger.debug(f"OAuth metadata discovery failed: {url}")
672-
673-
# SEP-2352: on the legacy no-PRM path the issuer is only known after ASM
674-
# discovery, so re-evaluate the binding here using the discovered metadata
675-
# issuer (mirroring the bound_issuer fallback in Step 4).
676-
if (
677-
self.context.client_info is not None
678-
and self.context.auth_server_url is None
679-
and self.context.oauth_metadata is not None
680-
and not credentials_match_issuer(
681-
self.context.client_info,
682-
str(self.context.oauth_metadata.issuer),
683-
self.context.client_metadata_url,
665+
# SEP-2352: stored credentials are bound to the issuer that registered them.
666+
# Decided before any metadata is fetched: if the expected issuer is a different
667+
# server, drop them (and the old tokens) so the flow re-registers instead of
668+
# presenting another server's credentials.
669+
if self.context.client_info is not None and not credentials_match_issuer(
670+
self.context.client_info, expected_issuer, self.context.client_metadata_url
671+
):
672+
logger.debug(
673+
"Authorization server changed; discarding bound credentials and re-registering"
674+
)
675+
self.context.client_info = None
676+
self.context.clear_tokens()
677+
# Any cached AS metadata is for the old server; drop it so a failed
678+
# rediscovery cannot leak the old registration/token endpoints into Step 4.
679+
self.context.oauth_metadata = None
680+
681+
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
682+
self.context.auth_server_url, self.context.server_url
684683
)
685-
):
686-
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
687-
self.context.client_info = None
688-
self.context.clear_tokens()
684+
685+
# Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers)
686+
for url in asm_discovery_urls: # pragma: no branch
687+
oauth_metadata_request = create_oauth_metadata_request(url)
688+
oauth_metadata_response = yield oauth_metadata_request
689+
690+
ok, asm = await handle_auth_metadata_response(oauth_metadata_response)
691+
if not ok:
692+
break
693+
if ok and asm:
694+
# SEP-2468 / RFC 8414 §3.3: the metadata must name the expected issuer.
695+
# On the legacy path a root issuer rendered with its trailing slash
696+
# names the same origin.
697+
if self.context.auth_server_url is None and str(asm.issuer) == f"{expected_issuer}/":
698+
expected_issuer = str(asm.issuer)
699+
validate_metadata_issuer(asm, expected_issuer)
700+
self.context.oauth_metadata = asm
701+
break
702+
else:
703+
logger.debug(f"OAuth metadata discovery failed: {url}")
689704

690705
# Step 3: Apply scope selection strategy
691-
self.context.client_metadata.scope = get_client_metadata_scopes(
706+
challenged_scope = get_client_metadata_scopes(
692707
extract_scope_from_www_auth(response),
693708
self.context.protected_resource_metadata,
694709
self.context.oauth_metadata,
695710
self.context.client_metadata.grant_types,
696711
)
712+
if step_up:
713+
# SEP-2350: union previously requested scopes with the newly challenged ones so
714+
# escalating one operation keeps the others' grants, folding in the granted
715+
# scope read above since client_metadata.scope is not reloaded on a restart.
716+
prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope)
717+
self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope)
718+
else:
719+
self.context.client_metadata.scope = challenged_scope
697720

698721
# Step 4: Register client or use URL-based client ID (CIMD)
699722
if not self.context.client_info:
700-
# SEP-2352: the issuer to bind these credentials to, when known.
701-
discovered_issuer: str | None = None
702-
if self.context.oauth_metadata is not None:
703-
discovered_issuer = self.context.auth_server_url or str(self.context.oauth_metadata.issuer)
723+
# SEP-2352: the issuer to bind these credentials to, once metadata for it
724+
# was actually found.
725+
discovered_issuer = self._expected_issuer() if self.context.oauth_metadata is not None else None
704726

705727
if should_use_client_metadata_url(
706728
self.context.oauth_metadata, self.context.client_metadata_url
@@ -752,34 +774,3 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
752774
# Retry with new tokens
753775
self._add_auth_header(request)
754776
yield request
755-
elif response.status_code == 403:
756-
# Step 1: Extract error field from WWW-Authenticate header
757-
error = extract_field_from_www_auth(response, "error")
758-
759-
# Step 2: Check if we need to step-up authorization
760-
if error == "insufficient_scope": # pragma: no branch
761-
try:
762-
# Step 2a: Union previously requested scopes with the newly challenged
763-
# scopes (SEP-2350) so escalating one operation keeps the others' grants.
764-
# Fold in the stored token's scope too: on a restart the token is reloaded
765-
# but client_metadata.scope is not, so it would otherwise be the only basis.
766-
challenged_scope = get_client_metadata_scopes(
767-
extract_scope_from_www_auth(response),
768-
self.context.protected_resource_metadata,
769-
self.context.oauth_metadata,
770-
self.context.client_metadata.grant_types,
771-
)
772-
granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None
773-
prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope)
774-
self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope)
775-
776-
# Step 2b: Perform (re-)authorization and token exchange
777-
token_response = yield await self._perform_authorization()
778-
await self._handle_token_response(token_response)
779-
except Exception: # pragma: no cover
780-
logger.exception("OAuth flow error")
781-
raise
782-
783-
# Retry with new tokens
784-
self._add_auth_header(request)
785-
yield request

0 commit comments

Comments
 (0)