Merge pull request #454 from koic/cross_app_access

Support SEP-990 Cross-App Access via ID-JAG and the jwt-bearer Grant
This commit is contained in:
Koichi ITO 2026-07-16 00:58:08 +09:00 • committed by GitHub
commit 5533a6df92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 529 additions and 6 deletions

View file

@ -0,0 +1,94 @@
# frozen_string_literal: true
require "test_helper"
require "mcp/client/oauth"
module MCP
class Client
module OAuth
class CrossAppAccessProviderTest < Minitest::Test
def build_provider(assertion_provider: ->(**) { "id-jag" })
CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: assertion_provider,
)
end
def test_initialize_stores_credentials_with_basic_auth_method
provider = build_provider
info = provider.client_information
assert_equal("xaa-client", info["client_id"])
assert_equal("xaa-secret", info["client_secret"])
assert_equal("client_secret_basic", info["token_endpoint_auth_method"])
end
def test_authorization_flow_is_jwt_bearer
assert_equal(:jwt_bearer, build_provider.authorization_flow)
end
def test_jwt_bearer_assertion_passes_audience_and_resource_through
received = nil
provider = build_provider(
assertion_provider: ->(audience:, resource:) {
received = { audience: audience, resource: resource }
"id-jag-assertion"
},
)
assertion = provider.jwt_bearer_assertion(
audience: "https://auth.example.com",
resource: "https://srv.example.com/mcp",
)
assert_equal("id-jag-assertion", assertion)
assert_equal(
{ audience: "https://auth.example.com", resource: "https://srv.example.com/mcp" },
received,
)
end
def test_initialize_rejects_missing_client_id
assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do
CrossAppAccessProvider.new(
client_id: " ",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag" },
)
end
end
def test_initialize_rejects_missing_client_secret
# SEP-990 authenticates the jwt-bearer grant with client_secret_basic.
assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do
CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: nil,
assertion_provider: ->(**) { "id-jag" },
)
end
end
def test_initialize_rejects_non_callable_assertion_provider
assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do
CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: "not callable",
)
end
end
def test_token_helpers_delegate_to_storage
provider = build_provider
provider.save_tokens("access_token" => "xaa-token")
assert_equal("xaa-token", provider.access_token)
provider.clear_tokens!
assert_nil(provider.tokens)
end
end
end
end
end

View file

@ -253,6 +253,53 @@ module MCP
end
end
def test_run_uses_jwt_bearer_grant_for_cross_app_access_provider
# SEP-990: the ID-JAG assertion (obtained out of band, typically via IdP token exchange) is presented at
# the token endpoint with client_secret_basic; no PKCE, redirect, or DCR is involved.
received = nil
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(audience:, resource:) {
received = { audience: audience, resource: resource }
"id-jag-assertion"
},
)
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:authorized, result)
assert_equal("test-token-from-flow", provider.access_token)
# The assertion audience is the validated AS issuer; the resource is the canonical MCP server URL.
assert_equal({ audience: @auth_base, resource: "https://srv.example.com/mcp" }, received)
assert_not_requested(:post, "#{@auth_base}/register")
assert_requested(:post, "#{@auth_base}/token") do |req|
form = URI.decode_www_form(req.body).to_h
expected_basic = "Basic " + Base64.strict_encode64("xaa-client:xaa-secret")
form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" &&
form["assertion"] == "id-jag-assertion" &&
form["resource"] == "https://srv.example.com/mcp" &&
!form.key?("code") &&
!form.key?("code_verifier") &&
req.headers["Authorization"] == expected_basic
end
end
def test_run_jwt_bearer_raises_when_assertion_provider_returns_nothing
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(audience:, resource:) {},
)
error = assert_raises(Flow::AuthorizationError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_match(/returned no ID-JAG/, error.message)
assert_not_requested(:post, "#{@auth_base}/token")
end
def test_run_uses_authorization_code_grant_for_default_provider
# A standard `Provider` declares `authorization_flow == :authorization_code`,
# so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list.

View file

@ -0,0 +1,107 @@
# frozen_string_literal: true
require "test_helper"
require "webmock/minitest"
require "faraday"
require "mcp/client/oauth"
module MCP
class Client
module OAuth
class IDJAGTokenExchangeTest < Minitest::Test
def setup
WebMock.enable!
@token_endpoint = "https://idp.example.com/token"
end
def teardown
WebMock.reset!
end
def request_exchange
IDJAGTokenExchange.request(
token_endpoint: @token_endpoint,
id_token: "idp-id-token",
client_id: "idp-client",
audience: "https://auth.example.com",
resource: "https://srv.example.com/mcp",
)
end
def test_request_sends_rfc8693_token_exchange_parameters
stub_request(:post, @token_endpoint).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
access_token: "id-jag-assertion",
issued_token_type: "urn:ietf:params:oauth:token-type:id-jag",
token_type: "N_A",
),
)
assertion = request_exchange
assert_equal("id-jag-assertion", assertion)
assert_requested(:post, @token_endpoint) do |req|
form = URI.decode_www_form(req.body).to_h
form["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange" &&
form["subject_token"] == "idp-id-token" &&
form["subject_token_type"] == "urn:ietf:params:oauth:token-type:id_token" &&
form["requested_token_type"] == "urn:ietf:params:oauth:token-type:id-jag" &&
form["audience"] == "https://auth.example.com" &&
form["resource"] == "https://srv.example.com/mcp" &&
form["client_id"] == "idp-client"
end
end
def test_request_rejects_response_without_id_jag_token_type
# A plain access token is not an ID-JAG; presenting it as a
# jwt-bearer assertion would fail confusingly downstream.
stub_request(:post, @token_endpoint).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
access_token: "ordinary-token",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
),
)
error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange }
assert_match(/did not issue an ID-JAG/, error.message)
end
def test_request_rejects_missing_access_token
stub_request(:post, @token_endpoint).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(issued_token_type: "urn:ietf:params:oauth:token-type:id-jag"),
)
error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange }
assert_match(/missing `access_token`/, error.message)
end
def test_request_raises_on_non_2xx_response
stub_request(:post, @token_endpoint).to_return(status: 400, body: JSON.generate(error: "invalid_grant"))
error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange }
assert_match(/status 400/, error.message)
end
def test_request_raises_on_unparseable_response
stub_request(:post, @token_endpoint).to_return(status: 200, body: "not json")
error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange }
assert_match(/Failed to parse/, error.message)
end
def test_request_raises_on_non_object_json_response
stub_request(:post, @token_endpoint).to_return(status: 200, body: "[]")
error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange }
assert_match(/not a JSON object/, error.message)
end
end
end
end
end