Skip to content

Commit 095f189

Browse files
committed
Make IdentityAssertionOAuthProvider a standalone httpx.Auth
The previous implementation inherited OAuthClientProvider.async_auth_flow, whose trust model (RS chooses the AS via PRM, client DCRs there, AS-advertised scopes win) is the inverse of SEP-990's (AS is enterprise configuration, RS is untrusted for AS selection, client is pre-provisioned). Each inherited step was neutralized by a guard, and successive review rounds kept finding paths around them - PRM authorization_servers[0] overwriting the pin, the legacy ASM-from-RS fallback, SEP-2352 clear-and-re-DCR, scope-selection overwrite. This drops the inheritance. IdentityAssertionOAuthProvider is now a standalone httpx.Auth: the AS issuer is a constructor argument, ASM is fetched from that issuer's RFC 8414 well-known, validate_metadata_issuer and a same-origin token_endpoint check run, and the jwt-bearer request goes only there. There is no PRM fetch, no DCR step, no credentials_match_issuer step, and no mutable client_metadata.scope - the resource server has no input into where the ID-JAG or client secret are sent. This matches go-sdk and csharp-sdk, which take the AS URL as configuration. Also: - ClientAuthenticator now rejects a secret-based auth method registered without a stored secret (the gap the per-grant confidential gate was compensating for). - JWT_BEARER_GRANT_TYPE is defined once in mcp.shared.auth and imported. - expected_issuer renamed to issuer (it is the address, not a check); scopes renamed to scope for consistency with the other extensions. - Client tests rewritten around the new flow, including a by-construction test asserting the RS is never consulted for AS selection.
1 parent 6689e0a commit 095f189

13 files changed

Lines changed: 511 additions & 407 deletions

File tree

docs/migration.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,16 +1460,16 @@ provider = IdentityAssertionOAuthProvider(
14601460
storage=my_token_storage,
14611461
client_id="enterprise-mcp-client",
14621462
client_secret="enterprise-mcp-secret",
1463-
expected_issuer="https://auth.example.com",
1463+
issuer="https://auth.example.com",
14641464
assertion_provider=fetch_id_jag,
14651465
)
14661466
```
14671467

1468-
SEP-990 §5.1 requires the client to authenticate; this SDK currently requires a shared secret, so `client_secret` is mandatory (`token_endpoint_auth_method` chooses `client_secret_post` (default) or `client_secret_basic`; the spec also permits `private_key_jwt`). `expected_issuer` pins the authorization server the client is provisioned for: the provider fixes authorization-server discovery to that issuer (rather than trusting the untrusted resource server's metadata) and refuses to send the ID-JAG or secret to any other, preventing a hostile resource server from redirecting the credentials.
1468+
SEP-990 §5.1 requires the client to authenticate; this SDK currently requires a shared secret, so `client_secret` is mandatory (`token_endpoint_auth_method` chooses `client_secret_post` (default) or `client_secret_basic`; the spec also permits `private_key_jwt`). The authorization server is configuration, not discovery: `issuer` is the AS the client is provisioned for, authorization-server metadata is fetched from that issuer's RFC 8414 well-known, and the resource server is never asked which AS to use - so a hostile resource server cannot redirect the ID-JAG or secret.
14691469

14701470
On the authorization server, set `AuthSettings(identity_assertion_enabled=True)` (or pass `identity_assertion_enabled=True` to `create_auth_routes`) and implement `exchange_identity_assertion` on your `OAuthAuthorizationServerProvider`. The method receives an `IdentityAssertionParams` (the ID-JAG `assertion`, requested scopes, and request `resource`) and returns a plain RFC 6749 `OAuthToken`. The flag gates both metadata advertisement and the token endpoint: when off, `/token` rejects the grant with `unsupported_grant_type` even if the provider implements the hook. When on, the metadata advertises the jwt-bearer grant and the `urn:ietf:params:oauth:grant-profile:id-jag` profile in `authorization_grant_profiles_supported` (the discovery mechanism per ext-auth §6).
14711471

1472-
The implementation is responsible for validating the assertion per RFC 7523 §3 and SEP-990 §5.1 - verify the signature/`iss`/`exp`/`typ`, require `aud` to be this AS, require the ID-JAG's `client_id` claim to match the authenticated client, audience-restrict the issued token to the ID-JAG's `resource` claim (not the client-controlled request `resource`), and derive scopes from the ID-JAG rather than granting the request verbatim. See `examples/snippets/servers/identity_assertion_server.py`, which fails closed. Two hardening points are enforced by the SDK: the handler rejects public (`none`) clients before calling the hook, and Dynamic Client Registration refuses the jwt-bearer grant so the ID-JAG flow requires a pre-registered confidential client.
1472+
The implementation is responsible for validating the assertion per RFC 7523 §3 and SEP-990 §5.1 - verify the signature/`iss`/`exp`/`typ`, require `aud` to be this AS, require the ID-JAG's `client_id` claim to match the authenticated client, audience-restrict the issued token to the ID-JAG's `resource` claim (not the client-controlled request `resource`), and derive scopes from the ID-JAG rather than granting the request verbatim. See `examples/snippets/servers/identity_assertion_server.py`, which fails closed. Two hardening points are enforced by the SDK: the handler rejects clients without a stored secret before calling the hook (and `ClientAuthenticator` itself now refuses a secret-based auth method registered without a secret), and Dynamic Client Registration refuses the jwt-bearer grant so the ID-JAG flow requires a pre-registered confidential client.
14731473

14741474
### 2025-11-25 and 2026-07-28 protocol fields modeled
14751475

examples/snippets/clients/identity_assertion_client.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
and out of scope for the SDK; supply it through the `assertion_provider` callback. The callback
1010
receives the authorization server's issuer (the ID-JAG `aud`) and the MCP server's resource
1111
identifier (the ID-JAG `resource` claim). SEP-990 requires a confidential client, so a client secret
12-
is mandatory, and `expected_issuer` pins the authorization server the credentials are provisioned for.
12+
is mandatory, and `issuer` is the authorization server the credentials are provisioned for - the
13+
provider fetches metadata from that issuer's well-known and never asks the resource server which AS
14+
to use.
1315
"""
1416

1517
import asyncio
@@ -59,9 +61,9 @@ async def main() -> None:
5961
storage=InMemoryTokenStorage(),
6062
client_id="enterprise-mcp-client",
6163
client_secret="enterprise-mcp-secret",
62-
expected_issuer="http://localhost:8001",
64+
issuer="http://localhost:8001",
6365
assertion_provider=fetch_id_jag,
64-
scopes="user",
66+
scope="user",
6567
)
6668

6769
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client:

0 commit comments

Comments
 (0)