Skip to content

Support SEP-990 Cross-App Access via ID-JAG and the jwt-bearer Grant#454

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:cross_app_access
Open

Support SEP-990 Cross-App Access via ID-JAG and the jwt-bearer Grant#454
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:cross_app_access

Conversation

@koic

@koic koic commented Jul 13, 2026

Copy link
Copy Markdown
Member

Motivation and Context

The MCP Enterprise Managed Authorization extension (SEP-990, modelcontextprotocol/modelcontextprotocol#990, accepted/final, published in the ext-auth repository) lets enterprise identity providers govern MCP authorization: the client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) via RFC 8693 token exchange at the IdP, then presents the ID-JAG to the MCP authorization server with the RFC 7523 jwt-bearer grant, authenticating with client_secret_basic. The auth/cross-app-access-complete-flow conformance scenario exercises the complete flow and previously failed its two core checks (the Ruby client ran the authorization-code flow instead).

This mirrors the merged TypeScript SDK implementation (requestJwtAuthorizationGrant / CrossAppAccessProvider in typescript-sdk; the Python SDK tracks the same work in the open python-sdk#1721), adapted to this SDK's provider design:

  • New MCP::Client::OAuth::IDJAGTokenExchange.request performs the RFC 8693 exchange at the IdP token endpoint (subject_token_type id_token, requested_token_type id-jag, audience = the MCP authorization server's issuer, resource = the canonical MCP server URL) and validates that the response's issued_token_type is an ID-JAG before returning the assertion. The ID-JAG itself is treated as opaque, as in the TypeScript SDK.
  • New MCP::Client::OAuth::CrossAppAccessProvider declares authorization_flow :jwt_bearer and takes the assertion from a callable (call(audience:, resource:)), so the common IDJAGTokenExchange case, enterprise secret stores, and tests all plug in the same way (the TypeScript provider's assertion callback works identically). The MCP AS credentials are stored as client_secret_basic client information.
  • Flow dispatches :jwt_bearer alongside :client_credentials: discovery, issuer validation, and endpoint security checks are shared with run!, then run_jwt_bearer! requests the assertion with the validated issuer as the audience and posts the jwt-bearer grant through the existing post_to_token_endpoint.
  • The conformance client builds a CrossAppAccessProvider when the harness injects idp_id_token (exchanging it at idp_token_endpoint with idp_client_id, like the TypeScript and Python conformance clients), and auth/cross-app-access-complete-flow is removed from conformance/expected_failures.yml.

How Has This Been Tested?

  • New test/mcp/client/oauth/id_jag_token_exchange_test.rb asserts the exact RFC 8693 form parameters on the wire and the response validation: a non-ID-JAG issued_token_type is rejected, as are a missing access_token, non-2xx responses, unparseable bodies, and non-object JSON.
  • New test/mcp/client/oauth/cross_app_access_provider_test.rb covers the provider surface: stored client_secret_basic credentials, the :jwt_bearer flow declaration, audience/resource passthrough to the assertion callable, constructor validation (missing client_id, missing client_secret, non-callable assertion provider), and storage delegation.
  • New tests in test/mcp/client/oauth/flow_test.rb run the full grant against WebMock stubs: the token request carries grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, the assertion, the RFC 8707 resource, and HTTP Basic credentials, with no DCR and no authorization-code machinery; the assertion callable receives the validated issuer as audience and the canonical server URL as resource; an empty assertion aborts before the token endpoint is contacted.

Breaking Changes

None. IDJAGTokenExchange and CrossAppAccessProvider are new, and the Flow dispatch only activates for providers that declare authorization_flow :jwt_bearer.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

## Motivation and Context

The MCP Enterprise Managed Authorization extension (SEP-990, modelcontextprotocol/modelcontextprotocol#990,
accepted/final, published in the ext-auth repository) lets enterprise identity providers govern MCP authorization:
the client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) via
RFC 8693 token exchange at the IdP, then presents the ID-JAG to the MCP authorization server with
the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`.
The `auth/cross-app-access-complete-flow` conformance scenario exercises the complete flow and previously failed
its two core checks (the Ruby client ran the authorization-code flow instead).

This mirrors the merged TypeScript SDK implementation (`requestJwtAuthorizationGrant` / `CrossAppAccessProvider` in
typescript-sdk; the Python SDK tracks the same work in the open python-sdk#1721), adapted to this SDK's provider design:

- New `MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint
  (`subject_token_type` id_token, `requested_token_type` id-jag, `audience` = the MCP authorization server's issuer,
  `resource` = the canonical MCP server URL) and validates that the response's `issued_token_type` is an ID-JAG
  before returning the assertion. The ID-JAG itself is treated as opaque, as in the TypeScript SDK.
- New `MCP::Client::OAuth::CrossAppAccessProvider` declares `authorization_flow :jwt_bearer` and takes the assertion
  from a callable (`call(audience:, resource:)`), so the common `IDJAGTokenExchange` case, enterprise secret stores,
  and tests all plug in the same way (the TypeScript provider's assertion callback works identically).
  The MCP AS credentials are stored as `client_secret_basic` client information.
- `Flow` dispatches `:jwt_bearer` alongside `:client_credentials`: discovery, issuer validation, and endpoint security checks
  are shared with `run!`, then `run_jwt_bearer!` requests the assertion with the validated issuer as the audience and posts
  the `jwt-bearer` grant through the existing `post_to_token_endpoint`.
- The conformance client builds a `CrossAppAccessProvider` when the harness injects `idp_id_token` (exchanging it at
  `idp_token_endpoint` with `idp_client_id`, like the TypeScript and Python conformance clients),
  and `auth/cross-app-access-complete-flow` is removed from `conformance/expected_failures.yml`.

## How Has This Been Tested?

- New `test/mcp/client/oauth/id_jag_token_exchange_test.rb` asserts the exact RFC 8693 form parameters on the wire
  and the response validation: a non-ID-JAG `issued_token_type` is rejected, as are a missing `access_token`,
  non-2xx responses, unparseable bodies, and non-object JSON.
- New `test/mcp/client/oauth/cross_app_access_provider_test.rb` covers the provider surface:
  stored `client_secret_basic` credentials, the `:jwt_bearer` flow declaration, audience/resource passthrough to
  the assertion callable, constructor validation (missing client_id, missing client_secret, non-callable assertion provider),
  and storage delegation.
- New tests in `test/mcp/client/oauth/flow_test.rb` run the full grant against WebMock stubs: the token request carries
  `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the assertion, the RFC 8707 `resource`, and HTTP Basic credentials,
  with no DCR and no authorization-code machinery; the assertion callable receives the validated issuer as `audience`
  and the canonical server URL as `resource`; an empty assertion aborts before the token endpoint is contacted.

## Breaking Changes

None. `IDJAGTokenExchange` and `CrossAppAccessProvider` are new, and the `Flow` dispatch only activates for providers
that declare `authorization_flow :jwt_bearer`.
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.

1 participant