Skip to content

Support RFC 8693 token exchange for enterprise IdP flows (SEP-990)#2988

Open
Kludex wants to merge 7 commits into
mainfrom
worktree-synthetic-stirring-treehouse
Open

Support RFC 8693 token exchange for enterprise IdP flows (SEP-990)#2988
Kludex wants to merge 7 commits into
mainfrom
worktree-synthetic-stirring-treehouse

Conversation

@Kludex

@Kludex Kludex commented Jun 26, 2026

Copy link
Copy Markdown
Member

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 an httpx.Auth that posts the exchange, mirroring the client_credentials extension. The ID-JAG is supplied lazily via an async subject_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_type defaults to the access-token URN.

Server

OAuthAuthorizationServerProvider.exchange_token validates the subject token (signature/issuer/audience/expiry/policy — the provider's responsibility) and issues the token. Gated by AuthSettings(token_exchange_enabled=True), which:

  • advertises the grant + the public-client none auth method in AS metadata, and
  • gates the /token endpoint: with the flag off, /token rejects the grant with unsupported_grant_type even if the provider implements exchange_token.

TokenError gains RFC 8693's invalid_target; TokenExchangeToken carries the required issued_token_type (the handler defaults it so responses are compliant regardless of provider). TokenExchangeRequest enforces 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

  • Unit + ASGI HTTP tests for the client extension and the server token handler (13 server, 7 client, plus shared-model coverage).
  • Verified end to end over real Uvicorn sockets with a real RS256-signed JWT acting as the ID-JAG: AS metadata discovery, raw /token exchange, forged-signature rejection, and a full MCP session (discovery → 401 → token exchange → bearer-gated tool call) all pass.
  • ./scripts/test: 100% coverage, strict-no-cover clean, pyright clean.

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 under examples/snippets/.

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

Kludex added 3 commits June 26, 2026 11:26
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 15 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread examples/snippets/servers/token_exchange_server.py Outdated
Base automatically changed from auth-settings-preserve-empty-path to main June 26, 2026 09:41

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Comment thread examples/snippets/servers/token_exchange_server.py Outdated
- 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 maxisbey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_type urn:ietf:params:oauth:grant-type:jwt-bearer and the ID-JAG as the assertion.

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_issuer is gated on auth_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_provider is 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_provider is RS-influenced. go-sdk and csharp-sdk both require the integrator to explicitly configure the MCP AS URL; if the callback honours audience properly 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 no client_info.validate_scope() check. The same client that gets error=invalid_scope at /authorize for scope=admin gets 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 grants params.scopes verbatim.
  • DCR + this flag = unauthenticated path to exchange_token. OAuthClientMetadata.grant_types is list[Literal[...] | str], register.py#L73 only requires authorization_code to be present, and token_endpoint_auth_method="none" is honoured. Anyone can self-register a public client that includes this grant and reach provider.exchange_token with an attacker-chosen client and arbitrary subject_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 to none and advertising it in metadata sits uneasily with that. Stripping the grant from DCR-registered clients (require pre-registration) would be the simple fix.
  • refresh_token passthrough. The wrap at token.py#L289 preserves a provider-set refresh_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's resource claim. 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-body resource (token.py#L275), and the example writes it straight into AccessToken.resource. A client holding an ID-JAG for resource A can send resource=B and the example mints a B-audience token.
  • client arg may be unauthenticated. For none-method clients, client.client_id is self-asserted; the exchange_token docstring doesn't warn that it must not drive authorization decisions. ext-auth §5.1 requires the ID-JAG's client_id claim 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_jag accepts any non-empty string. The repo's existing oauth_server.py stub fails closed (returns None); this one fails open. raise NotImplementedError(...) would match the convention and force the replacement.
  • Line 60/77 stores the raw subject_token string as AccessToken.subject — a bearer credential in a field that's designed to be logged and surfaced as identity (bearer_auth.py propagates it into the auth context).
  • Line 59 writes client-supplied params.resource into AccessToken.resource with no invalid_target check (ties into the §5.1 point above).
  • Line 48 returns invalid_grant for a rejected subject token; RFC 8693 §2.2.2 specifies invalid_request for that case.
  • It never inspects subject_token_type, actor_token, requested_token_type, or audience — a request carrying actor_token (delegation) is silently treated as plain impersonation.

Smaller / informational

  • OAuthToken.token_type is Literal["Bearer"], so TokenExchangeToken can't emit RFC 8693's N_A, and the client rejects a third-party AS that returns it. Moot if reshaped to jwt-bearer.
  • The client never checks issued_token_type against requested_token_type; the sibling SDKs all do. Also moot post-reshape.
  • Advertising none in token_endpoint_auth_methods_supported is 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.
  • TokenExchangeRequest is 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 cover on the oauth_metadata guard at token_exchange.py#L119-L120 is reachable when ASM discovery 404s.
  • OAuthAuthorizationServerProvider is a Protocol, so the default exchange_token body is only inherited by explicit subclasses; a structural-only implementer hits AttributeError → 500 rather than unsupported_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.

AI Disclaimer

Comment thread src/mcp/client/auth/extensions/token_exchange.py Outdated
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.
@Kludex

Kludex commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

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 grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer with the ID-JAG as assertion, per ext-auth §5. Server: JwtBearerRequest + exchange_identity_assertion(IdentityAssertionParams) returning a plain RFC 6749 OAuthToken. Client: IdentityAssertionOAuthProvider posts assertion. Discovery advertises urn:ietf:params:oauth:grant-profile:id-jag in authorization_grant_profiles_supported (§6), not the token-exchange grant. As you noted, the issued_token_type / N_A / actor-token / multi-resource findings all became moot and are gone.

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.

Client AS pinning. New expected_issuer parameter: _fixed_client_info.issuer is set to it (so the SEP-2352 guard fires) and the provider asserts the discovered issuer matches before releasing the ID-JAG or secret. The assertion_provider callback now takes (audience, resource) so the ID-JAG can carry the resource claim §5.1 binds against. The provider docstring spells out the RFC 7523 §3 / §5.1 duties (decode the assertion, match the client_id claim to the authenticated client, audience-restrict to the ID-JAG's resource, no refresh token).

Example. Rewritten to fail closed (raises rather than trusting), bind to the ID-JAG resource, and implement get_client (it would otherwise 401 before the exchange).

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 resource claim are documented as the provider's responsibility (the SDK can't enforce them without decoding the assertion). If you'd prefer the SDK take a firmer stance there, happy to iterate.

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 Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants