diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md index 4f0cf0a5..292d03ae 100644 --- a/docs/_client/authorization.md +++ b/docs/_client/authorization.md @@ -303,6 +303,7 @@ provider = MCP::Client::OAuth::ClientCredentialsProvider.new( client_id: "my-service", client_secret: ENV.fetch("MCP_CLIENT_SECRET"), # token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt" + issuer: "https://auth.example.com", # scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes) # token_request_params: { "audience" => "https://api.example.com" } (optional; parameters the authorization server requires) ) @@ -320,6 +321,10 @@ Keyword arguments: - `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"` or `"RS256"`; `client_secret` must not be set, because the private key is the credential. +- `issuer`: Optional. The issuer identifier of the authorization server the credentials belong to. A different authorization server raises + `Flow::AuthorizationError`. A single trailing slash difference is accepted, as in the TypeScript SDK. Omitting `issuer:` is deprecated + and emits a warning at construction. Without it, credentials go to whichever authorization server the MCP server names, as before. + To use credentials for another authorization server, construct a provider with that server's `issuer:` and credentials. - `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`. Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`. @@ -338,6 +343,7 @@ an enterprise secret store or a test double without changing the transport wirin provider = MCP::Client::OAuth::CrossAppAccessProvider.new( client_id: "my-mcp-client", client_secret: ENV.fetch("MCP_CLIENT_SECRET"), + issuer: "https://auth.example.com", assertion_provider: ->(audience:, resource:) { MCP::Client::OAuth::IDJAGTokenExchange.request( token_endpoint: "https://idp.example.com/token", @@ -359,6 +365,8 @@ Keyword arguments: - `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion. `audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707). Passing both through to `IDJAGTokenExchange.request` covers the common case. +- `issuer`: Optional, same meaning and binding as on `ClientCredentialsProvider`. A mismatch is refused before `assertion_provider` is called, + so the identity provider is never asked for an ID-JAG addressed to that authorization server. - `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`. ### Customizing the OAuth HTTP Client diff --git a/lib/mcp/client/oauth/client_credentials_provider.rb b/lib/mcp/client/oauth/client_credentials_provider.rb index 4e1e1cff..cb4ab430 100644 --- a/lib/mcp/client/oauth/client_credentials_provider.rb +++ b/lib/mcp/client/oauth/client_credentials_provider.rb @@ -35,6 +35,9 @@ module OAuth # the server's `token_endpoint_auth_signing_alg_values_supported` fails # loudly at construction instead of as a 401 (the TypeScript and Python SDKs # also take the algorithm as an explicit option). + # - `issuer` - String issuer identifier of the authorization server the credentials belong to. + # A different authorization server raises `Flow::AuthorizationError`. Omitting this is deprecated + # and emits a warning; without it, credentials go to whichever authorization server the MCP server names. # - `scope` - String of space-separated scopes to request when the server's # `WWW-Authenticate` and the Protected Resource Metadata do not specify one. # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, @@ -59,12 +62,14 @@ class InvalidCredentialsError < ArgumentError; end attr_reader :scope, :storage + # @note Omitting `issuer:` is deprecated. Pass the authorization server's issuer URL to limit the credentials to that server. def initialize( client_id:, client_secret: nil, token_endpoint_auth_method: "client_secret_basic", private_key: nil, signing_algorithm: nil, + issuer: nil, scope: nil, storage: nil, authorization_request_validator: nil, @@ -82,7 +87,18 @@ def initialize( "client_credentials request is not allowed." end + if issuer.nil? + warn( + "Omitting `issuer:` is deprecated. Without it, the credentials go to whichever authorization server " \ + "the MCP server names. Pass the authorization server's issuer URL as `issuer:` so they are only sent there.", + uplevel: 1, + ) + elsif !issuer.is_a?(String) || blank?(issuer) + raise InvalidCredentialsError, "issuer must be a non-empty String: the issuer identifier of the authorization server the credentials belong to." + end + client_information = { "client_id" => client_id, "token_endpoint_auth_method" => token_endpoint_auth_method } + client_information["issuer"] = issuer if issuer if token_endpoint_auth_method == "private_key_jwt" validate_private_key_jwt_arguments!( diff --git a/lib/mcp/client/oauth/cross_app_access_provider.rb b/lib/mcp/client/oauth/cross_app_access_provider.rb index 44b5ffd8..3f88eef0 100644 --- a/lib/mcp/client/oauth/cross_app_access_provider.rb +++ b/lib/mcp/client/oauth/cross_app_access_provider.rb @@ -21,6 +21,9 @@ module OAuth # # Optional keyword arguments: # + # - `issuer` - String issuer identifier of the MCP authorization server the credentials belong to. + # A mismatch raises `Flow::AuthorizationError` before `assertion_provider` is called. Omitting this is + # deprecated and emits a warning; without it, credentials go to whichever authorization server the MCP server names. # - `scope` - String of space-separated scopes to request when the server's `WWW-Authenticate` and # the Protected Resource Metadata do not specify one. # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`. @@ -41,10 +44,12 @@ class InvalidConfigurationError < ArgumentError; end attr_reader :scope, :storage + # @note Omitting `issuer:` is deprecated. Pass the authorization server's issuer URL to limit the credentials to that server. def initialize( client_id:, client_secret:, assertion_provider:, + issuer: nil, scope: nil, storage: nil, authorization_request_validator: nil, @@ -63,6 +68,16 @@ def initialize( raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion." end + if issuer.nil? + warn( + "Omitting `issuer:` is deprecated. Without it, the credentials go to whichever authorization server " \ + "the MCP server names. Pass the authorization server's issuer URL as `issuer:` so they are only sent there.", + uplevel: 1, + ) + elsif !issuer.is_a?(String) || blank?(issuer) + raise InvalidConfigurationError, "issuer must be a non-empty String: the issuer identifier of the authorization server the credentials belong to." + end + http_client_customizer = validated_http_client_customizer(http_client_customizer) @assertion_provider = assertion_provider @@ -71,11 +86,13 @@ def initialize( @authorization_request_validator = authorization_request_validator @token_request_params = frozen_token_request_params(token_request_params) @http_client_customizer = http_client_customizer - @storage.save_client_information( + client_information = { "client_id" => client_id, "client_secret" => client_secret, "token_endpoint_auth_method" => "client_secret_basic", - ) + } + client_information["issuer"] = issuer if issuer + @storage.save_client_information(client_information) end # See `Provider#authorization_flow`. diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index ddf81648..a9aba15d 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -380,7 +380,10 @@ def client_credentials_client_info def ensure_client_credentials_issuer!(client_info, as_metadata:) stored_issuer = client_info_required_value(client_info, "issuer") return if stored_issuer.nil? - return if stored_issuer == as_metadata["issuer"] + + current_issuer = as_metadata["issuer"] + return if stored_issuer == current_issuer + return if stored_issuer == "#{current_issuer}/" || "#{stored_issuer}/" == current_issuer raise AuthorizationError, "Stored client credentials are bound to a different authorization server " \ @@ -399,6 +402,7 @@ def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:, server_url:) unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id") raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`." end + ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata) # Asked before the assertion is minted, for the same reason registration waits on the authorization-code grant: # obtaining an ID-JAG sends the identity provider an audience of this authorization server, which must not happen once @@ -1106,6 +1110,11 @@ def ensure_refresh_token_issuer_recorded! end def ensure_refreshable_client_information!(client_info, as_metadata:) + case provider_authorization_flow + when :client_credentials, :jwt_bearer + return ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata) + end + stored_issuer = client_info_required_value(client_info, "issuer") return if stored_issuer.nil? return if stored_issuer == as_metadata["issuer"] diff --git a/test/mcp/client/oauth/client_credentials_provider_test.rb b/test/mcp/client/oauth/client_credentials_provider_test.rb index 6c679e3e..ee83c365 100644 --- a/test/mcp/client/oauth/client_credentials_provider_test.rb +++ b/test/mcp/client/oauth/client_credentials_provider_test.rb @@ -7,6 +7,8 @@ module MCP class Client module OAuth class ClientCredentialsProviderTest < Minitest::Test + include DeprecationWarningTestHelper + # Every parameter the flow sets on a token request, spelled out rather than read from # `Flow::RESERVED_TOKEN_REQUEST_PARAMS`, so a key dropped from that constant fails here. RESERVED_TOKEN_REQUEST_PARAMS = [ @@ -31,6 +33,37 @@ def test_initialize_stores_credentials_as_client_information assert_equal("cc-client", info["client_id"]) assert_equal("cc-secret", info["client_secret"]) assert_equal("client_secret_basic", info["token_endpoint_auth_method"]) + refute(info.key?("issuer")) + end + + def test_initialize_warns_at_the_call_site_when_issuer_is_omitted + [{}, { issuer: nil }].each do |args| + constructor_line = __LINE__ + 2 + assert_deprecation_warning("#{__FILE__}:#{constructor_line}: warning: Omitting `issuer:` is deprecated. Without it, the credentials go to whichever authorization server the MCP server names. Pass the authorization server's issuer URL as `issuer:` so they are only sent there.\n") do + ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", **args) + end + end + end + + def test_initialize_does_not_warn_when_issuer_is_set + assert_no_deprecation_warning do + args = { issuer: "https://auth.example.com" } + ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", **args) + end + end + + def test_initialize_stores_the_issuer_with_the_credentials + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", issuer: "https://auth.example.com") + + assert_equal("https://auth.example.com", provider.client_information["issuer"]) + end + + def test_initialize_rejects_an_issuer_that_is_not_a_non_empty_string + ["", " ", :issuer, false, 123].each do |issuer| + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", issuer: issuer) + end + end end def test_authorization_flow_is_client_credentials diff --git a/test/mcp/client/oauth/cross_app_access_provider_test.rb b/test/mcp/client/oauth/cross_app_access_provider_test.rb index ed49b47d..4354dfe6 100644 --- a/test/mcp/client/oauth/cross_app_access_provider_test.rb +++ b/test/mcp/client/oauth/cross_app_access_provider_test.rb @@ -7,6 +7,8 @@ module MCP class Client module OAuth class CrossAppAccessProviderTest < Minitest::Test + include DeprecationWarningTestHelper + def build_provider(assertion_provider: ->(**) { "id-jag" }) CrossAppAccessProvider.new( client_id: "xaa-client", @@ -22,6 +24,47 @@ def test_initialize_stores_credentials_with_basic_auth_method assert_equal("xaa-client", info["client_id"]) assert_equal("xaa-secret", info["client_secret"]) assert_equal("client_secret_basic", info["token_endpoint_auth_method"]) + refute(info.key?("issuer")) + end + + def test_initialize_warns_at_the_call_site_when_issuer_is_omitted + [{}, { issuer: nil }].each do |args| + constructor_line = __LINE__ + 2 + assert_deprecation_warning("#{__FILE__}:#{constructor_line}: warning: Omitting `issuer:` is deprecated. Without it, the credentials go to whichever authorization server the MCP server names. Pass the authorization server's issuer URL as `issuer:` so they are only sent there.\n") do + CrossAppAccessProvider.new(client_id: "xaa-client", client_secret: "xaa-secret", assertion_provider: ->(**) { "id-jag" }, **args) + end + end + end + + def test_initialize_does_not_warn_when_issuer_is_set + assert_no_deprecation_warning do + args = { issuer: "https://auth.example.com" } + CrossAppAccessProvider.new(client_id: "xaa-client", client_secret: "xaa-secret", assertion_provider: ->(**) { "id-jag" }, **args) + end + end + + def test_initialize_stores_the_issuer_with_the_credentials + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag" }, + issuer: "https://auth.example.com", + ) + + assert_equal("https://auth.example.com", provider.client_information["issuer"]) + end + + def test_initialize_rejects_an_issuer_that_is_not_a_non_empty_string + ["", " ", :issuer, false, 123].each do |issuer| + assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do + CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag" }, + issuer: issuer, + ) + end + end end def test_authorization_flow_is_jwt_bearer diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index cb78282d..f10efd5b 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -651,6 +651,147 @@ def test_run_client_credentials_raises_for_mismatched_stored_issuer assert_not_requested(:post, "#{@auth_base}/token") end + def test_run_client_credentials_without_issuer_keeps_credentials_without_a_binding + provider = client_credentials_provider + issuer_at_token_request = nil + stub_request(:post, "#{@auth_base}/token").to_return do + issuer_at_token_request = provider.client_information["issuer"] + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "cc-token", token_type: "Bearer"), + } + end + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_nil(issuer_at_token_request) + assert_nil(provider.client_information["issuer"]) + end + + def test_run_client_credentials_without_issuer_keeps_using_the_discovered_authorization_server + provider = client_credentials_provider + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + stub_authorization_server_switch_to("https://other-auth.example.com") + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_nil(provider.client_information["issuer"]) + assert_requested(:post, "https://other-auth.example.com/token") + end + + def test_run_client_credentials_with_issuer_refuses_another_issuer_on_the_first_request + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", issuer: "https://login.example.com") + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/bound to a different authorization server/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_client_credentials_with_issuer_runs_the_grant_at_that_issuer + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", issuer: @auth_base) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + end + + def test_run_machine_to_machine_grants_accept_one_trailing_slash_difference_in_the_configured_issuer + [ + [@auth_base, @auth_base], + ["#{@auth_base}/", @auth_base], + [@auth_base, "#{@auth_base}/"], + ["#{@auth_base}/", "#{@auth_base}/"], + ].each do |configured_issuer, discovered_issuer| + stub_authorization_server_switch_to(discovered_issuer) + + [ClientCredentialsProvider, CrossAppAccessProvider].each do |provider_class| + args = { client_id: "test-client", client_secret: "test-secret", issuer: configured_issuer } + args[:assertion_provider] = ->(**) { "id-jag-assertion" } if provider_class == CrossAppAccessProvider + provider = provider_class.new(**args) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + end + end + end + + def test_run_machine_to_machine_grants_refuse_other_differences_in_the_configured_issuer + [ + "#{@auth_base}/tenant", + "https://other-auth.example.com", + "#{@auth_base}//", + "HTTPS://auth.example.com", + "https://AUTH.example.com", + "https://auth.example.com:443", + ].each do |issuer| + [ClientCredentialsProvider, CrossAppAccessProvider].each do |provider_class| + args = { client_id: "test-client", client_secret: "test-secret", issuer: issuer } + args[:assertion_provider] = ->(**) { "id-jag-assertion" } if provider_class == CrossAppAccessProvider + provider = provider_class.new(**args) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/bound to a different authorization server/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + end + end + + def test_refresh_machine_to_machine_grants_accept_one_trailing_slash_difference_in_the_configured_issuer + [ClientCredentialsProvider, CrossAppAccessProvider].each do |provider_class| + args = { client_id: "test-client", client_secret: "test-secret", issuer: "#{@auth_base}/" } + args[:assertion_provider] = ->(**) { "id-jag-assertion" } if provider_class == CrossAppAccessProvider + provider = provider_class.new(**args) + provider.save_tokens("refresh_token" => "refresh-token", "issuer" => @auth_base) + + result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:refreshed, result) + end + end + + def test_run_client_credentials_does_not_bind_to_an_issuer_the_validator_refuses + provider = ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + authorization_request_validator: ->(_request) { false }, + ) + + assert_raises(Flow::AuthorizationRefusedError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_nil(provider.client_information["issuer"]) + end + + # Points the Protected Resource Metadata at another authorization server with its own metadata and token endpoint. + def stub_authorization_server_switch_to(issuer) + endpoint_base = issuer.delete_suffix("/") + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [issuer]), + ) + stub_request(:get, "#{endpoint_base}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(issuer: issuer, token_endpoint: "#{endpoint_base}/token"), + ) + stub_request(:post, "#{endpoint_base}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "other-token", token_type: "Bearer"), + ) + end + def test_run_client_credentials_requests_scope_from_prm_scopes_supported stub_request(:get, @prm_url).to_return( status: 200, @@ -1248,6 +1389,45 @@ def test_run_jwt_bearer_consults_the_validator refute(minted, "the ID-JAG assertion must not be minted for a refused authorization server") end + def test_run_jwt_bearer_refuses_a_different_stored_issuer_before_asking_for_an_assertion + minted = false + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(audience:, resource:) { + minted = true + "id-jag-assertion" + }, + ) + provider.save_client_information(provider.client_information.merge("issuer" => "https://login.example.com")) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + + assert_match(/bound to a different authorization server/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + refute(minted, "the ID-JAG assertion must not be minted for another authorization server") + end + + def test_run_jwt_bearer_without_issuer_keeps_using_the_discovered_authorization_server + provider = CrossAppAccessProvider.new( + client_id: "xaa-client", + client_secret: "xaa-secret", + assertion_provider: ->(**) { "id-jag-assertion" }, + ) + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_nil(provider.client_information["issuer"]) + + stub_authorization_server_switch_to("https://other-auth.example.com") + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_nil(provider.client_information["issuer"]) + assert_requested(:post, "https://other-auth.example.com/token") + end + def test_run_registers_native_application_type_for_loopback_redirect_uri run_authorization_flow diff --git a/test/mcp/client/oauth/http_oauth_test.rb b/test/mcp/client/oauth/http_oauth_test.rb index 883689a9..c625a900 100644 --- a/test/mcp/client/oauth/http_oauth_test.rb +++ b/test/mcp/client/oauth/http_oauth_test.rb @@ -1223,6 +1223,120 @@ def test_send_request_falls_back_to_full_flow_when_refresh_fails assert_equal("brand-new-token", provider.access_token) end + def test_send_request_does_not_send_client_credentials_to_a_new_authorization_server + # The configured issuer applies to every grant. A different authorization server raises an error + # before the token request. + authorization_server = @auth_base + other_auth_base = "https://other-auth.example.com" + + stub_request(:post, @mcp_url).to_return do |req| + if req.headers["Authorization"] == "Bearer token-from-#{authorization_server}" + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + } + else + { status: 401, headers: { "WWW-Authenticate" => %(Bearer resource_metadata="#{@prm_url}") }, body: "" } + end + end + + stub_request(:get, @prm_url).to_return do + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [authorization_server]), + } + end + + [@auth_base, other_auth_base].each do |issuer| + stub_request(:get, "#{issuer}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(issuer: issuer, token_endpoint: "#{issuer}/token"), + ) + stub_request(:post, "#{issuer}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "token-from-#{issuer}", token_type: "Bearer", expires_in: 3600), + ) + end + + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", issuer: @auth_base) + transport = HTTP.new(url: @mcp_url, oauth: provider) + request = { jsonrpc: "2.0", id: "1", method: "tools/list" } + transport.send_request(request: request) + + authorization_server = other_auth_base + + error = assert_raises(Flow::AuthorizationError) do + transport.send_request(request: request) + end + + assert_match(/bound to a different authorization server/, error.message) + assert_not_requested(:post, "#{other_auth_base}/token") + end + + def test_send_request_does_not_send_client_credentials_to_a_new_authorization_server_after_a_refused_refresh + # A provider that holds a refresh token tries a refresh first. The refresh is refused at the new authorization server + # and the transport falls back to the grant, which must refuse too, so neither request reaches the new server. + authorization_server = @auth_base + other_auth_base = "https://other-auth.example.com" + + stub_request(:post, @mcp_url).to_return do |req| + if req.headers["Authorization"] == "Bearer token-from-#{authorization_server}" + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), + } + else + { status: 401, headers: { "WWW-Authenticate" => %(Bearer resource_metadata="#{@prm_url}") }, body: "" } + end + end + + stub_request(:get, @prm_url).to_return do + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [authorization_server]), + } + end + + [@auth_base, other_auth_base].each do |issuer| + stub_request(:get, "#{issuer}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(issuer: issuer, token_endpoint: "#{issuer}/token"), + ) + stub_request(:post, "#{issuer}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + access_token: "token-from-#{issuer}", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rt-from-#{issuer}", + ), + ) + end + + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", issuer: @auth_base) + transport = HTTP.new(url: @mcp_url, oauth: provider) + request = { jsonrpc: "2.0", id: "1", method: "tools/list" } + transport.send_request(request: request) + + authorization_server = other_auth_base + + error = assert_raises(Flow::AuthorizationError) do + transport.send_request(request: request) + end + + assert_match(/bound to a different authorization server/, error.message) + assert_not_requested(:post, "#{other_auth_base}/token") + assert_equal("rt-from-#{@auth_base}", provider.tokens["refresh_token"]) + end + def test_oauth_flow_preserves_query_string_in_resource_claim # Query parameters on the MCP URL (e.g. `?tenant=...`) must be carried # into the RFC 8707 `resource` claim sent on the authorization and