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

Merged
KL
4mo ago
+1830-14

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.