Skip to content

Commit 3bc0b4c

Browse files
committed
Derive the expected issuer before fetching authorization server metadata
Discovery now chooses its authorization server, and with it the issuer the metadata must carry (RFC 8414 section 3.3), before any metadata is fetched: 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. The issuer check runs on both paths instead of only when PRM was found; the SEP-2352 credential binding is decided against that same value before the fetch, so it also applies when no metadata is served and the default endpoints are used; newly registered clients are bound to it when metadata for it was found; and a pass replaces what an earlier pass discovered once it has chosen a server and looked for its metadata, so nothing left over from before feeds a later registration. The special case that re-checked the binding against the served issuer goes away. 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), re-checks the binding either way, and then re-authorizes with the SEP-2350 scope union, keeping the granted scope in the union even when the server changed; metadata already discovered in this process is reused as before, so for a server that publishes none each step-up repeats the discovery requests. A 403 that is not a scope challenge is handed back to the caller instead of being retried unchanged. This is the shape IdentityAssertionOAuthProvider already uses. Issuer strings are compared after parsing both sides the way the metadata `issuer` is parsed (scheme and host case, default ports, IDNA), with one tolerance beyond that: an origin with an empty path and the same origin with a lone "/" are the same issuer, since servers commonly render a root issuer with a trailing slash and both forms yield the same well-known URL. validate_metadata_issuer, credentials_match_issuer and the new issuers_equal share that rule. IdentityAssertionOAuthProvider passes the issuer as the metadata states it to the assertion provider, so the ID-JAG audience is the server's own spelling either way. docs/migration.md and the identity assertion page describe the comparison as it now is. This brings the no-PRM path in line with the TypeScript client, which applies the section 3.3 check on every discovery path.
1 parent 6705402 commit 3bc0b4c

8 files changed

Lines changed: 931 additions & 161 deletions

File tree

docs/client/identity-assertion.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,16 @@ Here is the inversion. `OAuthClientProvider` asks the resource server which auth
4949
The extension does not demand this; it is a deliberately stricter choice. This client carries two things worth stealing, a pre-registered secret and an audience-bound assertion, and a client that let a compromised MCP server steer it to an attacker's authorization server would post both to it. Pinning the issuer at construction deletes that conversation.
5050

5151
!!! warning
52-
The configured `issuer` is compared to the metadata document's `issuer` field by RFC 8414 §3.3
53-
simple string comparison: character for character, trailing slash included, no normalization.
54-
Do not guess it. Fetch `/.well-known/oauth-authorization-server` from your authorization server
55-
and copy the `issuer` value it returns. For the authorization server on this page that is
56-
`https://auth.example.com/`, with the slash, because its issuer was built from a pydantic URL
57-
object. A mismatch stops the flow at `OAuthFlowError: Authorization server metadata issuer
58-
mismatch` before a single credential or assertion is sent.
52+
The configured `issuer` is compared to the metadata document's `issuer` field (RFC 8414 §3.3)
53+
after both are parsed as URLs the way the SDK parses that field, so spellings of the same URL
54+
(scheme and host case, an explicit default port) compare equal, and `https://auth.example.com`
55+
and `https://auth.example.com/` count as the same issuer; a different path, port or host, or a
56+
trailing slash on a path, is a mismatch. The
57+
safest value is the one the server states: fetch `/.well-known/oauth-authorization-server`
58+
from your authorization server and copy the `issuer` it returns. A mismatch stops the flow at
59+
`OAuthFlowError: Authorization server metadata issuer mismatch` before a single credential or
60+
assertion is sent. The `audience` your callback receives is the issuer exactly as the
61+
metadata states it, so the ID-JAG's `aud` is always the server's own spelling.
5962

6063
### A confidential client
6164

@@ -140,7 +143,7 @@ And notice what the returned `OAuthToken` does not carry: a refresh token. The I
140143
* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) lets the enterprise identity provider, not the end user, decide which MCP servers a client may reach. The IdP signs that decision into an **ID-JAG**.
141144
* Obtaining the ID-JAG is an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange against *your IdP*, and the SDK does not make it. Presenting it to the MCP authorization server is the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant, and the SDK does both sides of that.
142145
* `IdentityAssertionOAuthProvider` is another `httpx2.Auth`: a pre-registered confidential client, a pinned `issuer`, and one `assertion_provider(audience, resource)` callback. No browser, no registration, no refresh token.
143-
* The authorization server is never discovered from the resource server. Configure `issuer` to exactly the string its metadata document serves; the comparison is character for character.
146+
* The authorization server is never discovered from the resource server. Configure `issuer` to the string its metadata document serves; different spellings of the same URL (scheme and host case, a default port, `https://host` versus `https://host/`) are accepted, a different URL is not.
144147
* Server side, `identity_assertion_enabled=True` plus `exchange_identity_assertion`. The SDK authenticates the client and gates the grant; validating the ID-JAG is entirely yours, and the issued token is bound to the ID-JAG's `resource`, not the request's.
145148

146149
The one party this page never touched is the MCP server. What it does with the token you just minted, it was already doing in **[Authorization](../run/authorization.md)**.

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: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2495,11 +2495,16 @@ If you passed `timeout` to bound how long you wait for the user to complete auth
24952495
### Client rejects authorization server metadata with a mismatched `issuer`
24962496

24972497
During OAuth discovery, `OAuthClientProvider` now validates that the authorization server
2498-
metadata's `issuer` exactly matches the authorization server URL advertised in the protected
2499-
resource metadata, as required by [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)
2500-
section 3.3 ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)).
2501-
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
2498+
metadata's `issuer` is the authorization server it expected, as required by
2499+
[RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) section 3.3
2500+
([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)): the URL
2501+
advertised in the protected resource metadata's `authorization_servers`, or, for an older
2502+
server that publishes no protected resource metadata, the MCP server's own origin (where the
2503+
2025-03-26 fallback looks for the metadata). The two values are compared after both are parsed
2504+
as URLs the way the SDK parses the metadata field, so spellings of the same URL (scheme and host
2505+
case, an explicit default port) compare equal, and `https://as.example.com` and
2506+
`https://as.example.com/` count as the same issuer; a different path, port or host, or a
2507+
trailing slash on a path, is a mismatch. v1 accepted the
25032508
metadata without checking, so a server pairing whose two values disagree authenticated fine
25042509
under v1 and now fails the entire flow. For example, when the MCP server's protected resource
25052510
metadata advertises
@@ -2508,16 +2513,17 @@ metadata advertises
25082513
{"authorization_servers": ["https://as.example.com"]}
25092514
```
25102515

2511-
while the authorization server's RFC 8414 metadata says `"issuer": "https://as.example.com/"`,
2516+
while the authorization server's RFC 8414 metadata says `"issuer": "https://as.example.com/auth"`,
25122517
v1 completes discovery and proceeds with the flow; v2 aborts with:
25132518

25142519
```text
2515-
OAuthFlowError: Authorization server metadata issuer mismatch: https://as.example.com/ != https://as.example.com
2520+
OAuthFlowError: Authorization server metadata issuer mismatch: https://as.example.com/auth != https://as.example.com
25162521
```
25172522

25182523
There is no client-side override. Fix the deployment instead: make the authorization server's
2519-
`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)
2524+
`issuer` name the same server as the URL in the protected resource metadata's
2525+
`authorization_servers` list (or, without protected resource metadata, as the MCP server's
2526+
origin). See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash)
25212527
for how v2 preserves the exact string form of these URLs.
25222528

25232529
### 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/extensions/identity_assertion.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414
1515
Obtaining the ID-JAG (logging into the IdP and the leg-1 token exchange against it) is
1616
deployment-specific and out of scope for the SDK. The caller supplies it through the
17-
`assertion_provider` callback, which receives the configured issuer (the `aud` the ID-JAG must
18-
carry) and the MCP server's resource identifier (the `resource` claim it must carry, per ext-auth
19-
section 4.3), and returns the ID-JAG.
17+
`assertion_provider` callback, which receives the authorization server's issuer as its metadata
18+
states it (the `aud` the ID-JAG must carry; it names the configured issuer) and the MCP server's
19+
resource identifier (the `resource` claim it must carry, per ext-auth section 4.3), and returns the
20+
ID-JAG.
2021
"""
2122

2223
import base64
@@ -67,7 +68,7 @@ class IdentityAssertionOAuthProvider(httpx2.Auth):
6768
Example:
6869
```python
6970
async def fetch_id_jag(audience: str, resource: str) -> str:
70-
# `audience` is the configured issuer (the ID-JAG `aud`); `resource` is the MCP
71+
# `audience` is the authorization server's issuer (the ID-JAG `aud`); `resource` is the MCP
7172
# server's identifier (the ID-JAG `resource` claim). Obtaining the ID-JAG from the
7273
# enterprise IdP is deployment-specific and not handled by the SDK.
7374
return await my_idp.issue_id_jag(audience=audience, resource=resource)
@@ -107,8 +108,9 @@ def __init__(
107108
issuer: The issuer identifier of the MCP authorization server this client is provisioned
108109
for. Authorization-server metadata is fetched from this issuer's well-known and the
109110
ID-JAG and secret are sent only to its token endpoint.
110-
assertion_provider: Async callback taking `(audience, resource)` - the configured issuer
111-
and the MCP server's resource identifier - and returning the ID-JAG.
111+
assertion_provider: Async callback taking `(audience, resource)` - the authorization
112+
server's issuer exactly as its metadata states it (it names the same server as the
113+
configured `issuer`) and the MCP server's resource identifier - and returning the ID-JAG.
112114
scope: Optional space-separated list of scopes to request.
113115
token_endpoint_auth_method: Confidential-client auth method, either `client_secret_post`
114116
(default) or `client_secret_basic`.
@@ -131,6 +133,7 @@ def __init__(
131133
issuer=issuer,
132134
)
133135
self._token_endpoint: str | None = None
136+
self._audience: str | None = None
134137
self._tokens: OAuthToken | None = None
135138
self._expiry: float | None = None
136139
self._lock = anyio.Lock()
@@ -193,11 +196,13 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
193196
f"Token endpoint {token_endpoint} is not on the configured issuer origin {self._issuer}"
194197
)
195198
self._token_endpoint = token_endpoint
199+
self._audience = str(asm.issuer)
196200
break
197201
if self._token_endpoint is None:
198202
raise OAuthFlowError(f"No authorization server metadata at configured issuer {self._issuer}")
199203

200-
assertion = await self._assertion_provider(self._issuer, self._resource)
204+
assert self._audience is not None
205+
assertion = await self._assertion_provider(self._audience, self._resource)
201206
token_response = yield self._build_token_request(scope_to_request, assertion)
202207
if token_response.status_code != 200:
203208
body = (await token_response.aread()).decode(errors="replace")

0 commit comments

Comments
 (0)