Support RFC 8693 token exchange for enterprise IdP flows (SEP-990)#2988
Support RFC 8693 token exchange for enterprise IdP flows (SEP-990)#2988Kludex wants to merge 7 commits into
Conversation
Add the OAuth 2.0 token-exchange grant (urn:ietf:params:oauth:grant-type:token-exchange), the wire mechanism behind SEP-990. A client exchanges an enterprise IdP-issued ID-JAG for an MCP access token at the authorization server's token endpoint. Client: TokenExchangeOAuthProvider posts the exchange, sourcing the ID-JAG from an async subject_token_provider(audience) callback. Server: OAuthAuthorizationServerProvider.exchange_token validates the subject token and issues the access token; gated by AuthSettings.token_exchange_enabled, which also advertises the grant (and the public-client 'none' auth method) in metadata. TokenError gains invalid_target; TokenExchangeToken carries RFC 8693 issued_token_type.
AuthSettings.issuer_url and resource_server_url are typed AnyHttpUrl, which normalized a path-less URL with a trailing slash before the model's config could apply. The authorization server therefore advertised issuer as https://as.example.com/ instead of https://as.example.com, inconsistent with the exact string comparison RFC 8414/9207 require. Apply url_preserve_empty_path=True to AuthSettings (matching #2925 for the metadata models) so a string issuer_url/resource_server_url keeps its canonical form end to end.
…tic-stirring-treehouse
There was a problem hiding this comment.
1 issue found across 15 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
There was a problem hiding this comment.
I didn't find any bugs in my review, but this PR adds a new OAuth grant type across the server token endpoint, provider protocol, AS metadata, and a new client auth extension — security-sensitive surface that warrants a human reviewer's sign-off.
Extended reasoning...
Overview
This PR implements SEP-990 / RFC 8693 token exchange end to end: a new TokenExchangeRequest branch in the server token handler, a new exchange_token method on OAuthAuthorizationServerProvider, a token_exchange_enabled flag threaded through AuthSettings, create_auth_routes, and AS metadata, a new TokenExchangeToken response model, and a new client-side TokenExchangeOAuthProvider extension, plus docs, snippets, and substantial test coverage (~480 lines of server tests, ~220 of client tests).
Security risks
The change is squarely in the auth path. The design looks careful — the grant is opt-in and gated both at metadata advertisement and at the /token endpoint, the default exchange_token rejects with unsupported_grant_type, the handler still enforces the client's registered grant_types, requested scopes are passed to the provider rather than granted verbatim, and RFC 8693 actor-token pairing is validated. Subject-token validation is delegated entirely to provider implementations, and advertising the none token-endpoint auth method when the flag is on is a deliberate policy choice — both are decisions a human maintainer should explicitly endorse. There is also an outstanding bot comment about an assert in the example server snippet.
Level of scrutiny
High. New OAuth grant handling on the authorization server token endpoint is among the most security-sensitive code in the SDK, and the PR also introduces new public API surface (provider method, settings flag, client extension, shared model) that implies long-term maintenance commitments. This is well outside the simple/mechanical category eligible for shadow approval.
Other factors
The automated bug-hunting pass found no issues, tests are extensive (success, error, gating, disabled-flag, actor-token pairing, basic-auth, metadata advertisement cases), and the existing authorization-code/refresh-token paths are only touched by removing now-covered pragma: no cover markers. Those are positive signals, but they don't substitute for human judgment on the spec-conformance and API-design questions above.
Exercise the full stack - the real TokenExchangeOAuthProvider client against the real authorization-server token endpoint with InMemoryAuthorizationServerProvider implementing exchange_token - rather than shimming the grant as the M2M providers do. Covers: successful exchange + bearer authorizes the request, the subject_token_provider callback audience, server-disabled rejection (unsupported_grant_type), an unaccepted subject token, scope adoption from AS metadata, and metadata advertisement of the grant + public-client auth method. Adds the client-auth:token-exchange* requirement entries and threads token_exchange_enabled through the auth harness. Removes a now-covered pragma in OAuthClientProvider._handle_token_response: these tests are the first to drive a non-2xx token response through the full client flow.
- Negative tests assert the specific OAuth error (unsupported_grant_type when disabled, invalid_grant for a rejected subject token) instead of any token-endpoint failure. - Reorder positive tests recording-first, matching the suite's stated debugging style. - Add a full-stack interaction test for a confidential (client_secret_basic) token-exchange client, exercising the TokenExchangeOAuthProvider / ClientAuthenticator seam.
maxisbey
left a comment
There was a problem hiding this comment.
I went through this fairly carefully against the SEP-990 normative text, RFC 8693, RFC 7523, and the sibling SDKs. There's one architectural issue that I think needs resolving before the rest is worth iterating on, plus a cluster of client-side discovery concerns and some smaller hardening points.
Reviewed at 55c2413.
Wrong grant type for the MCP-AS leg
The normative SEP-990 text — ext-auth/specification/stable/enterprise-managed-authorization.mdx §5 — specifies that the client presents the ID-JAG to the MCP authorization server using the RFC 7523 jwt-bearer grant:
The MCP Client presents the ID-JAG to the Resource Authorization Server's token endpoint as defined in [§4.4 of draft-ietf-oauth-identity-assertion-authz-grant-04], using
grant_typeurn:ietf:params:oauth:grant-type:jwt-bearerand the ID-JAG as theassertion.
RFC 8693 token-exchange is leg 1 only (client → enterprise IdP, ID-token → ID-JAG), which this PR correctly scopes out via subject_token_provider. But for leg 2 the PR sends and accepts grant_type=urn:ietf:params:oauth:grant-type:token-exchange with subject_token=<ID-JAG> instead.
The other SDKs all match the spec on this — typescript-sdk's crossAppAccess.ts uses token-exchange against the IdP and jwt-bearer against the MCP AS; go-sdk's auth/extauth/enterprise_handler.go and csharp-sdk's IdentityAssertionGrant.cs likewise. A python-sdk AS built from this PR rejects all of them with unsupported_grant_type (the discriminated union doesn't include jwt-bearer), and the python client is rejected by any spec-conforming AS.
Beyond interop, the grant-type choice has security weight. RFC 7523 §3 carries normative validation rules (the JWT MUST contain aud identifying the AS, iss/exp/signature MUST be checked) that map directly onto SEP-990 §5.1's processing rules — typ=oauth-id-jag+jwt, aud = own issuer, client_id claim ↔ authenticated client. RFC 8693 imposes nothing on subject_token, so those checks become provider-discretionary rather than something the SDK can structurally encourage. The §5.1 MUST that "the issued access token MUST be audience-restricted to the MCP Server identified by the resource claim in the ID-JAG" is similarly hard to surface when the SDK never decodes the assertion.
Discovery is also different from what's implemented: ext-auth §6 says clients detect support via authorization_grant_profiles_supported containing urn:ietf:params:oauth:grant-profile:id-jag (per draft-04 §7.2), not via grant_types_supported containing the token-exchange URN.
I think this means a reshape rather than a patch: a JwtBearerRequest (grant_type + assertion) on the server, a provider hook that's documented against RFC 7523 §3 + the §5.1 processing rules, and a client that posts assertion instead of subject_token. A lot of the smaller findings below (issued_token_type, token_type: N_A, actor-token pairing, multi-valued resource/audience) become moot under jwt-bearer since the response is plain RFC 6749.
Client: pre-registered credentials go through RS-controlled discovery
TokenExchangeOAuthProvider reuses the base OAuthClientProvider 401→PRM→ASM discovery flow, which was designed around DCR — the client_secret comes from the discovered AS, so sending it back there is harmless. Here the secret is pre-supplied for a specific known AS, but the constructor offers no way to pin that AS, and _fixed_client_info is built with issuer=None (token_exchange.py#L98-L105), so the existing credentials_match_issuer guard always passes.
Three concrete consequences I was able to demonstrate with httpx.MockTransport driving the real async_auth_flow:
- A hostile/compromised resource server that publishes
authorization_servers: ["https://attacker.example"]in its PRM receives the confidential-client secret in the Basic header (or form body) on the first 401. The secret isn't audience-bound, so unlike the ID-JAG it's fully replayable against the real AS. - On the legacy fallback (PRM 404s), AS metadata is fetched from the MCP server's own origin and
validate_metadata_issueris gated onauth_server_url is not None(oauth2.py#L616-L617) — which it isn't on this path. So the server can serve{issuer:"https://corp-as.example", token_endpoint:"https://evil/steal"},subject_token_provideris called with the real AS issuer as audience, and a real-AS-bound ID-JAG plus the client secret go to the attacker. - More generally, the audience handed to
subject_token_provideris RS-influenced. go-sdk and csharp-sdk both require the integrator to explicitly configure the MCP AS URL; if the callback honoursaudienceproperly the aud-binding limits damage, but a callback that ignores it (or wraps a static-token helper) leaks a replayable credential.
I'd suggest an expected_issuer: str constructor parameter, set _fixed_client_info.issuer = expected_issuer so the existing guard fires, and assert str(oauth_metadata.issuer) == expected_issuer before invoking subject_token_provider or attaching the secret. The legacy-path issuer-check skip is arguably a pre-existing gap in the base class, but this PR is what makes it carry pre-registered credentials.
Server-side hardening
- Requested scopes bypass per-client registration. token.py#L274 passes
scope.split(" ")straight to the provider with noclient_info.validate_scope()check. The same client that getserror=invalid_scopeat/authorizeforscope=admingets a 200 via this branch. For SEP-990 the provider should derive entitlement from the ID-JAG, so this may be intentional, but it's inconsistent with the other two grants and the test harness provider grantsparams.scopesverbatim. - DCR + this flag = unauthenticated path to
exchange_token.OAuthClientMetadata.grant_typesislist[Literal[...] | str], register.py#L73 only requiresauthorization_codeto be present, andtoken_endpoint_auth_method="none"is honoured. Anyone can self-register a public client that includes this grant and reachprovider.exchange_tokenwith an attacker-chosenclientand arbitrarysubject_token. The IETF draft's §8.1 says this flow "SHOULD only be supported for confidential clients," and ext-auth §5 says "the MCP Client authenticates with its credentials" — defaulting tononeand advertising it in metadata sits uneasily with that. Stripping the grant from DCR-registered clients (require pre-registration) would be the simple fix. refresh_tokenpassthrough. The wrap at token.py#L289 preserves a provider-setrefresh_token. Returning one defeats the IdP lifetime control that SEP-990 exists to provide (off-boarded user keeps minting tokens). Probably worth dropping it in the wrap, or at least flagging in the docstring.- Issued token bound to the request's
resource, not the ID-JAG'sresourceclaim. ext-auth §5.1: the issued token MUST be audience-restricted to the resource named in the ID-JAG. The handler surfaces only the form-bodyresource(token.py#L275), and the example writes it straight intoAccessToken.resource. A client holding an ID-JAG for resource A can sendresource=Band the example mints a B-audience token. clientarg may be unauthenticated. Fornone-method clients,client.client_idis self-asserted; theexchange_tokendocstring doesn't warn that it must not drive authorization decisions. ext-auth §5.1 requires the ID-JAG'sclient_idclaim to match the authenticated client — worth surfacing as a documented requirement on the hook.
subject_token_provider callback signature
The callback receives only audience (token_exchange.py#L59). ext-auth §4 has the leg-1 request to the IdP carrying both audience (the AS issuer — which is what's passed, correctly) and resource (the MCP server's RFC 9728 identifier); the resulting ID-JAG MUST carry that resource claim (§4.3) for §5.1 to bind against. The SDK has the PRM-derived value via self.context.get_resource_url() but never surfaces it to the callback. Widening to (audience, resource) would let callers send a correct leg-1 request without hardcoding the server URL.
Example server (examples/snippets/servers/token_exchange_server.py)
A few things here that I'd tighten regardless of how the grant-type question lands, since this is the file people will copy:
_validate_id_jagaccepts any non-empty string. The repo's existingoauth_server.pystub fails closed (returnsNone); this one fails open.raise NotImplementedError(...)would match the convention and force the replacement.- Line 60/77 stores the raw
subject_tokenstring asAccessToken.subject— a bearer credential in a field that's designed to be logged and surfaced as identity (bearer_auth.pypropagates it into the auth context). - Line 59 writes client-supplied
params.resourceintoAccessToken.resourcewith noinvalid_targetcheck (ties into the §5.1 point above). - Line 48 returns
invalid_grantfor a rejected subject token; RFC 8693 §2.2.2 specifiesinvalid_requestfor that case. - It never inspects
subject_token_type,actor_token,requested_token_type, oraudience— a request carryingactor_token(delegation) is silently treated as plain impersonation.
Smaller / informational
OAuthToken.token_typeisLiteral["Bearer"], soTokenExchangeTokencan't emit RFC 8693'sN_A, and the client rejects a third-party AS that returns it. Moot if reshaped to jwt-bearer.- The client never checks
issued_token_typeagainstrequested_token_type; the sibling SDKs all do. Also moot post-reshape. - Advertising
noneintoken_endpoint_auth_methods_supportedis endpoint-wide (RFC 8414 has no per-grant slot); enforcement is per-registered-client so there's no bypass, but it tells auth-code clients the AS accepts unauthenticated/token. TokenExchangeRequestis in the union unconditionally, so a server with the flag off still emits RFC 8693 field-level validation errors (e.g. the actor-token-pairing message) before the gate at line 260 runs.- The
# pragma: no coveron theoauth_metadataguard at token_exchange.py#L119-L120 is reachable when ASM discovery 404s. OAuthAuthorizationServerProvideris aProtocol, so the defaultexchange_tokenbody is only inherited by explicit subclasses; a structural-only implementer hitsAttributeError→ 500 rather thanunsupported_grant_type.scope.split(" ")forwards empty-string entries on multi-space input.
Happy to share the repro tests for any of the above if useful.
Addresses the maintainer review: SEP-990 §5 specifies that the client presents the ID-JAG to the MCP authorization server using grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer with the ID-JAG as the assertion (RFC 7523), not the RFC 8693 token-exchange grant. The prior implementation was non-conformant and non-interoperable with the other MCP SDKs and any spec-conforming AS. Reshape: - Server: JwtBearerRequest (grant_type + assertion); provider hook exchange_identity_assertion taking IdentityAssertionParams; response is a plain RFC 6749 OAuthToken (TokenExchangeToken / issued_token_type removed). - Discovery: advertise the id-jag profile in authorization_grant_profiles_supported (ext-auth §6), not the token-exchange grant in grant_types_supported. - Client: IdentityAssertionOAuthProvider posts assertion; AuthSettings.identity_assertion_enabled. Security hardening from the review: - Confidential clients only: the handler rejects token_endpoint_auth_method=none, DCR refuses the jwt-bearer grant (pre-registration required), and metadata no longer advertises none. - AS pinning: the client takes expected_issuer, binds credentials to it, and refuses to send the ID-JAG or secret to a mismatched (resource-server-advertised) issuer. - The assertion_provider callback receives (audience, resource) so the ID-JAG can carry the resource claim §5.1 binds against; provider docs spell out the RFC 7523 §3 / §5.1 duties. Also fixes two bot-review findings: the example provider now implements get_client (it would otherwise 401 before the exchange), and the client honours its configured scope instead of letting the discovery scope-selection step overwrite it.
|
Thanks for the careful review against the normative text - you were right on the core point, and I've reshaped the PR accordingly (pushed in 7783020). Grant type (leg 2). Now uses Confidential clients only. The handler rejects Client AS pinning. New Example. Rewritten to fail closed (raises rather than trusting), bind to the ID-JAG Two server-side items I'd flag for your call rather than assume: requested-scope validation and binding the issued token to the ID-JAG's Validated end to end (full client+server stack, including an issuer-pinning case); 100% coverage, strict-no-cover/pyright/ruff clean. I have not been able to test against a live Okta CAA tenant (gated preview), so real-IdP interop is unverified - flagging that explicitly. AI DisclaimerThis PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes. |
Summary
Implements SEP-990 (enterprise IdP policy controls during MCP OAuth flows). The wire mechanism is the RFC 8693 token-exchange grant (
urn:ietf:params:oauth:grant-type:token-exchange): a client exchanges an enterprise IdP-issued Identity Assertion Authorization Grant (ID-JAG) for an MCP access token at the authorization server's token endpoint.This is additive and opt-in on both sides; existing flows are unchanged.
Client
TokenExchangeOAuthProvider(mcp.client.auth.extensions.token_exchange) is anhttpx.Auththat posts the exchange, mirroring theclient_credentialsextension. The ID-JAG is supplied lazily via an asyncsubject_token_provider(audience)callback — the SDK does not implement IdP login or the first exchange against the IdP (deployment-specific). Public (default,none) and confidential (client_secret_post/client_secret_basic) clients are supported;requested_token_typedefaults to the access-token URN.Server
OAuthAuthorizationServerProvider.exchange_tokenvalidates the subject token (signature/issuer/audience/expiry/policy — the provider's responsibility) and issues the token. Gated byAuthSettings(token_exchange_enabled=True), which:noneauth method in AS metadata, and/tokenendpoint: with the flag off,/tokenrejects the grant withunsupported_grant_typeeven if the provider implementsexchange_token.TokenErrorgains RFC 8693'sinvalid_target;TokenExchangeTokencarries the requiredissued_token_type(the handler defaults it so responses are compliant regardless of provider).TokenExchangeRequestenforces RFC 8693 actor-token pairing.The provider owns scope decisions: requested scopes are never granted verbatim. The example server narrows the request against an allowed set so a valid ID-JAG cannot be exchanged for broader access than policy permits.
Review
Reviewed by Codex across two rounds. Round one raised seven should-fix findings; all were addressed (dispatch gating,
issued_token_type, scope-escalation hardening in the example,invalid_target, actor-token pairing, Basic-auth confidential client, public-client advertisement). The follow-up review confirmed every finding resolved with no new Critical/Should-fix issues.Testing
/tokenexchange, forged-signature rejection, and a full MCP session (discovery → 401 → token exchange → bearer-gated tool call) all pass../scripts/test: 100% coverage,strict-no-coverclean,pyrightclean.This exercise surfaced a separate AS-metadata issuer normalization bug, fixed in #2987 (merged); this branch builds on it.
Docs: new SEP-990 section in
docs/migration.md; client + server snippets underexamples/snippets/.AI Disclaimer
This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.