Skip to content

Commit 558656d

Browse files
committed
Support SEP-990 Cross-App Access via ID-JAG and the jwt-bearer Grant
## 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`.
1 parent e85f6d0 commit 558656d

9 files changed

Lines changed: 492 additions & 6 deletions

File tree

conformance/client.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,27 @@ def build_client_credentials_provider(context)
8787
end
8888
end
8989

90+
# Builds a SEP-990 Cross-App Access provider: the harness injects an IdP ID token plus the IdP token endpoint
91+
# via context; the assertion provider exchanges them for an ID-JAG (RFC 8693), which the flow then presents to
92+
# the MCP authorization server with the jwt-bearer grant.
93+
def build_cross_app_access_provider(context)
94+
assertion_provider = ->(audience:, resource:) do
95+
MCP::Client::OAuth::IDJAGTokenExchange.request(
96+
token_endpoint: context["idp_token_endpoint"],
97+
id_token: context["idp_id_token"],
98+
client_id: context["idp_client_id"],
99+
audience: audience,
100+
resource: resource,
101+
)
102+
end
103+
104+
MCP::Client::OAuth::CrossAppAccessProvider.new(
105+
client_id: context["client_id"],
106+
client_secret: context["client_secret"],
107+
assertion_provider: assertion_provider,
108+
)
109+
end
110+
90111
# Builds an OAuth provider that drives the authorization code + PKCE + DCR flow
91112
# non-interactively against the conformance test's auth server. The conformance
92113
# `/authorize` endpoint redirects synchronously to `redirect_uri` with
@@ -129,7 +150,9 @@ def build_oauth_provider(context, scenario:)
129150
end
130151

131152
def build_provider_for(scenario, context)
132-
if scenario.start_with?("auth/client-credentials")
153+
if context["idp_id_token"]
154+
build_cross_app_access_provider(context)
155+
elsif scenario.start_with?("auth/client-credentials")
133156
build_client_credentials_provider(context)
134157
else
135158
build_oauth_provider(context, scenario: scenario)

conformance/expected_failures.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,2 @@
11
server: []
2-
client:
3-
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.
4-
- auth/cross-app-access-complete-flow
2+
client: []

lib/mcp/client/oauth.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
require_relative "oauth/jwt_client_assertion"
99
require_relative "oauth/provider"
1010
require_relative "oauth/client_credentials_provider"
11+
require_relative "oauth/id_jag_token_exchange"
12+
require_relative "oauth/cross_app_access_provider"
1113

1214
module MCP
1315
class Client
1416
# OAuth client support for the MCP Authorization spec (PRM discovery,
1517
# Authorization Server metadata discovery, Dynamic Client Registration,
16-
# OAuth 2.1 Authorization Code + PKCE, and the client_credentials grant).
18+
# OAuth 2.1 Authorization Code + PKCE, the client_credentials grant,
19+
# and the SEP-990 Enterprise Managed Authorization jwt-bearer grant).
1720
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
1821
module OAuth
1922
end
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
class Client
5+
module OAuth
6+
# OAuth client configuration for the MCP Enterprise Managed Authorization extension (SEP-990, "Cross-App Access"):
7+
# the client obtains an Identity Assertion Authorization Grant (ID-JAG) from an enterprise identity provider and
8+
# presents it to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with
9+
# `client_secret_basic`. Handed to `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`.
10+
#
11+
# Mirrors `CrossAppAccessProvider` in the TypeScript SDK: the assertion is supplied by a callable so it can come
12+
# from `IDJAGTokenExchange` (the common case), an enterprise secret store, or a test double.
13+
#
14+
# Required keyword arguments:
15+
#
16+
# - `client_id` - String identifying the pre-registered confidential client at the MCP authorization server.
17+
# - `client_secret` - String shared secret for `client_secret_basic`.
18+
# - `assertion_provider` - Callable invoked as `call(audience:, resource:)`, returning the ID-JAG assertion to
19+
# present. `audience` is the authorization server's issuer identifier and `resource` is the canonical MCP server URL;
20+
# pass both through to `IDJAGTokenExchange.request` when exchanging an IdP ID token.
21+
#
22+
# Optional keyword arguments:
23+
#
24+
# - `scope` - String of space-separated scopes to request when the server's `WWW-Authenticate` and
25+
# the Protected Resource Metadata do not specify one.
26+
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
27+
# Defaults to an `InMemoryStorage`.
28+
#
29+
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
30+
class CrossAppAccessProvider
31+
include StorageBackedProvider
32+
33+
# Raised when the provider is constructed without the pieces the `jwt-bearer` grant needs.
34+
class InvalidConfigurationError < ArgumentError; end
35+
36+
attr_reader :scope, :storage
37+
38+
def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil)
39+
if blank?(client_id)
40+
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
41+
end
42+
43+
if blank?(client_secret)
44+
raise InvalidConfigurationError, "client_secret is required: SEP-990 authenticates the jwt-bearer grant with client_secret_basic."
45+
end
46+
47+
unless assertion_provider.respond_to?(:call)
48+
raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
49+
end
50+
51+
@assertion_provider = assertion_provider
52+
@scope = scope
53+
@storage = storage || InMemoryStorage.new
54+
@storage.save_client_information(
55+
"client_id" => client_id,
56+
"client_secret" => client_secret,
57+
"token_endpoint_auth_method" => "client_secret_basic",
58+
)
59+
end
60+
61+
# See `Provider#authorization_flow`.
62+
def authorization_flow
63+
:jwt_bearer
64+
end
65+
66+
# Returns the ID-JAG assertion to present at the MCP authorization server. Called by `Flow#run_jwt_bearer!` with the audience
67+
# and resource resolved during discovery.
68+
def jwt_bearer_assertion(audience:, resource:)
69+
@assertion_provider.call(audience: audience, resource: resource)
70+
end
71+
72+
private
73+
74+
def blank?(value)
75+
value.nil? || (value.is_a?(String) && value.strip.empty?)
76+
end
77+
end
78+
end
79+
end
80+
end

lib/mcp/client/oauth/flow.rb

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,11 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
5151

5252
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
5353

54-
if provider_authorization_flow == :client_credentials
54+
case provider_authorization_flow
55+
when :client_credentials
5556
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
57+
when :jwt_bearer
58+
return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
5659
end
5760

5861
ensure_pkce_supported!(as_metadata)
@@ -154,6 +157,36 @@ def ensure_client_credentials_issuer!(client_info, as_metadata:)
154157
"refusing to send them to the current authorization server (SEP-2352)."
155158
end
156159

160+
# Runs the RFC 7523 `jwt-bearer` grant for the SEP-990 Enterprise Managed Authorization extension:
161+
# the provider supplies an ID-JAG assertion (typically obtained from an enterprise IdP via `IDJAGTokenExchange`),
162+
# which is presented at the token endpoint with `client_secret_basic` authentication. Shares the same discovery
163+
# and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request.
164+
# The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated.
165+
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
166+
def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:)
167+
client_info = @provider.client_information
168+
unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
169+
raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`."
170+
end
171+
172+
assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource)
173+
if assertion.nil? || assertion.to_s.empty?
174+
raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion."
175+
end
176+
177+
form = {
178+
"grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
179+
"assertion" => assertion,
180+
}
181+
effective_scope = resolve_scope(scope: scope, prm: prm)
182+
form["scope"] = effective_scope if effective_scope
183+
form["resource"] = resource if resource
184+
185+
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
186+
@provider.save_tokens(tokens)
187+
:authorized
188+
end
189+
157190
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
158191
# Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
159192
# checks before talking to it.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# frozen_string_literal: true
2+
3+
require "json"
4+
require "uri"
5+
6+
module MCP
7+
class Client
8+
module OAuth
9+
# RFC 8693 token exchange against an enterprise identity provider, turning an IdP-issued ID token into
10+
# an Identity Assertion Authorization Grant (ID-JAG) per the MCP Enterprise Managed Authorization extension (SEP-990).
11+
# The returned ID-JAG is an opaque assertion the client then presents to the MCP authorization server with
12+
# the RFC 7523 `jwt-bearer` grant (see `CrossAppAccessProvider`).
13+
# Mirrors `requestJwtAuthorizationGrant` in the TypeScript SDK.
14+
#
15+
# - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
16+
# - https://www.rfc-editor.org/rfc/rfc8693
17+
module IDJAGTokenExchange
18+
# Raised when the identity provider's token exchange fails or returns something other than an ID-JAG.
19+
class ExchangeError < StandardError; end
20+
21+
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
22+
ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"
23+
ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag"
24+
25+
class << self
26+
# Exchanges `id_token` for an ID-JAG at the IdP's token endpoint and returns the assertion string.
27+
#
28+
# @param token_endpoint [String] The identity provider's token endpoint.
29+
# @param id_token [String] The IdP-issued ID token (the subject token).
30+
# @param client_id [String] The client's identifier at the IdP.
31+
# @param audience [String] The MCP authorization server's issuer identifier.
32+
# @param resource [String] The canonical MCP server URL (RFC 8707).
33+
# @param http_client [Object, nil] Faraday-compatible client; built lazily by default.
34+
def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil)
35+
http_client ||= default_http_client
36+
37+
response = begin
38+
http_client.post(token_endpoint) do |req|
39+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
40+
req.headers["Accept"] = "application/json"
41+
req.body = URI.encode_www_form(
42+
"grant_type" => GRANT_TYPE,
43+
"subject_token" => id_token,
44+
"subject_token_type" => ID_TOKEN_TYPE,
45+
"requested_token_type" => ID_JAG_TOKEN_TYPE,
46+
"audience" => audience,
47+
"resource" => resource,
48+
"client_id" => client_id,
49+
)
50+
end
51+
rescue Faraday::Error => e
52+
raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}."
53+
end
54+
55+
if response.status < 200 || response.status >= 300
56+
raise ExchangeError, "Identity provider token exchange returned status #{response.status}."
57+
end
58+
59+
parse_id_jag(response)
60+
end
61+
62+
private
63+
64+
def parse_id_jag(response)
65+
body = response.body.is_a?(String) ? response.body : response.body.to_s
66+
parsed = begin
67+
JSON.parse(body)
68+
rescue JSON::ParserError => e
69+
raise ExchangeError, "Failed to parse token exchange response: #{e.message}."
70+
end
71+
72+
unless parsed.is_a?(Hash)
73+
raise ExchangeError, "Token exchange response is not a JSON object (got #{parsed.class})."
74+
end
75+
76+
issued_token_type = parsed["issued_token_type"]
77+
unless issued_token_type == ID_JAG_TOKEN_TYPE
78+
raise ExchangeError,
79+
"Token exchange did not issue an ID-JAG " \
80+
"(expected issued_token_type #{ID_JAG_TOKEN_TYPE.inspect}, got #{issued_token_type.inspect})."
81+
end
82+
83+
assertion = parsed["access_token"]
84+
if assertion.nil? || assertion.to_s.empty?
85+
raise ExchangeError, "Token exchange response is missing `access_token`."
86+
end
87+
88+
assertion
89+
end
90+
91+
def default_http_client
92+
require "faraday"
93+
Faraday.new do |faraday|
94+
faraday.headers["Accept"] = "application/json"
95+
end
96+
end
97+
end
98+
end
99+
end
100+
end
101+
end
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "mcp/client/oauth"
5+
6+
module MCP
7+
class Client
8+
module OAuth
9+
class CrossAppAccessProviderTest < Minitest::Test
10+
def build_provider(assertion_provider: ->(**) { "id-jag" })
11+
CrossAppAccessProvider.new(
12+
client_id: "xaa-client",
13+
client_secret: "xaa-secret",
14+
assertion_provider: assertion_provider,
15+
)
16+
end
17+
18+
def test_initialize_stores_credentials_with_basic_auth_method
19+
provider = build_provider
20+
21+
info = provider.client_information
22+
assert_equal("xaa-client", info["client_id"])
23+
assert_equal("xaa-secret", info["client_secret"])
24+
assert_equal("client_secret_basic", info["token_endpoint_auth_method"])
25+
end
26+
27+
def test_authorization_flow_is_jwt_bearer
28+
assert_equal(:jwt_bearer, build_provider.authorization_flow)
29+
end
30+
31+
def test_jwt_bearer_assertion_passes_audience_and_resource_through
32+
received = nil
33+
provider = build_provider(
34+
assertion_provider: ->(audience:, resource:) {
35+
received = { audience: audience, resource: resource }
36+
"id-jag-assertion"
37+
},
38+
)
39+
40+
assertion = provider.jwt_bearer_assertion(
41+
audience: "https://auth.example.com",
42+
resource: "https://srv.example.com/mcp",
43+
)
44+
45+
assert_equal("id-jag-assertion", assertion)
46+
assert_equal(
47+
{ audience: "https://auth.example.com", resource: "https://srv.example.com/mcp" },
48+
received,
49+
)
50+
end
51+
52+
def test_initialize_rejects_missing_client_id
53+
assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do
54+
CrossAppAccessProvider.new(
55+
client_id: " ",
56+
client_secret: "xaa-secret",
57+
assertion_provider: ->(**) { "id-jag" },
58+
)
59+
end
60+
end
61+
62+
def test_initialize_rejects_missing_client_secret
63+
# SEP-990 authenticates the jwt-bearer grant with client_secret_basic.
64+
assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do
65+
CrossAppAccessProvider.new(
66+
client_id: "xaa-client",
67+
client_secret: nil,
68+
assertion_provider: ->(**) { "id-jag" },
69+
)
70+
end
71+
end
72+
73+
def test_initialize_rejects_non_callable_assertion_provider
74+
assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do
75+
CrossAppAccessProvider.new(
76+
client_id: "xaa-client",
77+
client_secret: "xaa-secret",
78+
assertion_provider: "not callable",
79+
)
80+
end
81+
end
82+
83+
def test_token_helpers_delegate_to_storage
84+
provider = build_provider
85+
provider.save_tokens("access_token" => "xaa-token")
86+
87+
assert_equal("xaa-token", provider.access_token)
88+
provider.clear_tokens!
89+
assert_nil(provider.tokens)
90+
end
91+
end
92+
end
93+
end
94+
end

0 commit comments

Comments
 (0)