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:
commit
5533a6df92
3 changed files with 529 additions and 6 deletions
94
test/mcp/client/oauth/cross_app_access_provider_test.rb
Normal file
94
test/mcp/client/oauth/cross_app_access_provider_test.rb
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
107
test/mcp/client/oauth/id_jag_token_exchange_test.rb
Normal file
107
test/mcp/client/oauth/id_jag_token_exchange_test.rb
Normal 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
|
||||
Loading…
Reference in a new issue