Skip to content

Commit d9a19c1

Browse files
authored
Merge pull request #680 from koic/select_the_advertised_authorization_server_by_the_stored_issuer
Select the advertised authorization server by the stored issuer
2 parents 83b06e1 + 04336d0 commit d9a19c1

3 files changed

Lines changed: 174 additions & 21 deletions

File tree

‎docs/_client/authorization.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,10 +321,10 @@ Keyword arguments:
321321
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
322322
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
323323
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
324-
- `issuer`: Optional. The issuer identifier of the authorization server the credentials belong to. A different authorization server raises
325-
`Flow::AuthorizationError`. A single trailing slash difference is accepted, as in the TypeScript SDK. Omitting `issuer:` is deprecated
326-
and emits a warning at construction. Without it, credentials go to whichever authorization server the MCP server names, as before.
327-
To use credentials for another authorization server, construct a provider with that server's `issuer:` and credentials.
324+
- `issuer`: Optional. The issuer identifier of the authorization server the credentials belong to. A different authorization server raises `Flow::AuthorizationError`.
325+
A single trailing slash difference is accepted, as in the TypeScript SDK. When the protected resource metadata lists several authorization servers,
326+
the one naming `issuer:` is used. Omitting `issuer:` is deprecated and emits a warning at construction. Without it, credentials go to whichever authorization server
327+
the MCP server names, as before. To use credentials for another authorization server, construct a provider with that server's `issuer:` and credentials.
328328
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
329329
Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`.
330330

‎lib/mcp/client/oauth/flow.rb‎

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -380,9 +380,7 @@ def ensure_client_credentials_issuer!(client_info, as_metadata:)
380380
stored_issuer = client_info_required_value(client_info, "issuer")
381381
return if stored_issuer.nil?
382382

383-
current_issuer = as_metadata["issuer"]
384-
return if stored_issuer == current_issuer
385-
return if stored_issuer == "#{current_issuer}/" || "#{stored_issuer}/" == current_issuer
383+
return if issuers_match?(stored_issuer, as_metadata["issuer"])
386384

387385
raise AuthorizationError,
388386
"Stored client credentials are bound to a different authorization server " \
@@ -633,7 +631,7 @@ def locate_authorization_server(server_url:, resource_metadata_url:)
633631
end
634632

635633
if prm
636-
authorization_server = first_authorization_server(prm)
634+
authorization_server = advertised_authorization_server(prm)
637635
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
638636
ensure_routable_destination!(
639637
authorization_server,
@@ -722,31 +720,50 @@ def ensure_legacy_issuer_matches!(expected:, returned:)
722720
ensure_issuer_matches!(expected: expected, returned: returned)
723721
end
724722

725-
# Reads `authorization_servers` from a PRM document and returns
726-
# the first entry, raising `AuthorizationError` for any of the malformed
727-
# shapes a non-compliant server could emit (missing field, non-Array
728-
# value, empty Array, non-String first entry). Centralizing this lets
729-
# both the full flow and the refresh flow share the same defensive
730-
# parse instead of each one duplicating a Hash-and-Array check.
731-
def first_authorization_server(prm)
723+
# Reads `authorization_servers` from a PRM document and returns the entry to use, raising `AuthorizationError` for any of
724+
# the malformed shapes a non-compliant server could emit (missing field, non-Array value, empty Array, non-String entry).
725+
# Centralizing this lets both the full flow and the refresh flow share the same defensive parse instead of each one duplicating
726+
# a Hash-and-Array check.
727+
#
728+
# When the provider's stored client information records an `issuer` (the `issuer:` of a machine-to-machine provider,
729+
# or the authorization server that issued a registration), the entry naming that issuer is preferred over the first one,
730+
# as the Python SDK does: a protected resource may list several authorization servers, and the client's credentials are
731+
# usable at only one of them. Without a match the first entry is used as before, and the issuer checks that follow decide.
732+
def advertised_authorization_server(prm)
732733
authorization_servers = prm["authorization_servers"]
733734
unless authorization_servers.is_a?(Array)
734-
raise AuthorizationError,
735-
"Protected resource metadata `authorization_servers` is not an array " \
736-
"(got #{authorization_servers.class})."
735+
raise AuthorizationError, "Protected resource metadata `authorization_servers` is not an array (got #{authorization_servers.class})."
737736
end
738737

739738
if authorization_servers.empty?
740739
raise AuthorizationError, "Protected resource metadata has no authorization_servers."
741740
end
742741

743-
first = authorization_servers.first
744-
unless first.is_a?(String) && !first.empty?
742+
stored_issuer = stored_client_information_issuer
743+
chosen = authorization_servers.find { |entry| entry.is_a?(String) && issuers_match?(entry, stored_issuer) } if stored_issuer
744+
chosen ||= authorization_servers.first
745+
746+
unless chosen.is_a?(String) && !chosen.empty?
745747
raise AuthorizationError,
746748
"Protected resource metadata `authorization_servers[0]` is not a non-empty string."
747749
end
748750

749-
first
751+
chosen
752+
end
753+
754+
# The `issuer` recorded in the provider's stored client information, or `nil`.
755+
def stored_client_information_issuer
756+
info = @provider.client_information
757+
return unless info.is_a?(Hash)
758+
759+
client_info_required_value(info, "issuer")
760+
end
761+
762+
# Whether two issuer identifiers name the same authorization server: identical, or differing by one trailing slash,
763+
# as the TypeScript SDK's `issuersMatch` accepts (an issuer derived from a URL is often rendered with the slash
764+
# the metadata leaves out). Any other difference keeps them distinct.
765+
def issuers_match?(first, second)
766+
first == second || first == "#{second}/" || "#{first}/" == second
750767
end
751768

752769
# Walks candidate metadata URLs and returns the parsed body of the first 2xx response that is a JSON object;

‎test/mcp/client/oauth/flow_test.rb‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,104 @@ def test_run_machine_to_machine_grants_refuse_other_differences_in_the_configure
745745
end
746746
end
747747

748+
def test_run_machine_to_machine_grants_use_the_advertised_authorization_server_naming_the_configured_issuer
749+
other_auth_base = "https://other-auth.example.com"
750+
stub_authorization_servers([other_auth_base, @auth_base])
751+
stub_other_authorization_server(other_auth_base)
752+
753+
[ClientCredentialsProvider, CrossAppAccessProvider].each do |provider_class|
754+
args = { client_id: "test-client", client_secret: "test-secret", issuer: @auth_base }
755+
args[:assertion_provider] = ->(**) { "id-jag-assertion" } if provider_class == CrossAppAccessProvider
756+
provider = provider_class.new(**args)
757+
758+
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
759+
760+
assert_equal(:authorized, result)
761+
end
762+
763+
assert_requested(:post, "#{@auth_base}/token", times: 2)
764+
assert_not_requested(:post, "#{other_auth_base}/token")
765+
end
766+
767+
def test_run_machine_to_machine_grants_match_the_advertised_authorization_server_despite_one_trailing_slash
768+
other_auth_base = "https://other-auth.example.com"
769+
stub_authorization_servers([other_auth_base, @auth_base])
770+
stub_other_authorization_server(other_auth_base)
771+
provider = ClientCredentialsProvider.new(client_id: "test-client", client_secret: "test-secret", issuer: "#{@auth_base}/")
772+
773+
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
774+
775+
assert_equal(:authorized, result)
776+
assert_not_requested(:post, "#{other_auth_base}/token")
777+
end
778+
779+
def test_run_machine_to_machine_grants_refuse_a_configured_issuer_the_protected_resource_does_not_list
780+
other_auth_base = "https://other-auth.example.com"
781+
stub_authorization_servers([other_auth_base, "https://third-auth.example.com"])
782+
stub_other_authorization_server(other_auth_base)
783+
provider = ClientCredentialsProvider.new(client_id: "test-client", client_secret: "test-secret", issuer: @auth_base)
784+
785+
error = assert_raises(Flow::AuthorizationError) do
786+
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
787+
end
788+
789+
assert_match(/bound to a different authorization server/, error.message)
790+
assert_not_requested(:post, "#{other_auth_base}/token")
791+
end
792+
793+
def test_run_without_a_stored_issuer_uses_the_first_advertised_authorization_server
794+
other_auth_base = "https://other-auth.example.com"
795+
stub_authorization_servers([other_auth_base, @auth_base])
796+
stub_other_authorization_server(other_auth_base)
797+
provider = client_credentials_provider
798+
799+
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
800+
801+
assert_equal(:authorized, result)
802+
assert_requested(:post, "#{other_auth_base}/token")
803+
assert_not_requested(:post, "#{@auth_base}/token")
804+
end
805+
806+
def test_refresh_machine_to_machine_grants_use_the_advertised_authorization_server_naming_the_configured_issuer
807+
other_auth_base = "https://other-auth.example.com"
808+
stub_authorization_servers([other_auth_base, @auth_base])
809+
stub_other_authorization_server(other_auth_base)
810+
provider = ClientCredentialsProvider.new(client_id: "test-client", client_secret: "test-secret", issuer: @auth_base)
811+
provider.save_tokens("refresh_token" => "refresh-token", "issuer" => @auth_base)
812+
813+
result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
814+
815+
assert_equal(:refreshed, result)
816+
assert_not_requested(:post, "#{other_auth_base}/token")
817+
end
818+
819+
def test_run_authorization_code_keeps_the_authorization_server_that_registered_the_client
820+
other_auth_base = "https://other-auth.example.com"
821+
state_holder = {}
822+
provider = Provider.new(
823+
client_metadata: {
824+
redirect_uris: ["http://localhost:0/callback"],
825+
grant_types: ["authorization_code"],
826+
response_types: ["code"],
827+
token_endpoint_auth_method: "none",
828+
},
829+
redirect_uri: "http://localhost:0/callback",
830+
redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") },
831+
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
832+
)
833+
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
834+
assert_equal(@auth_base, provider.client_information["issuer"])
835+
836+
stub_authorization_servers([other_auth_base, @auth_base])
837+
stub_other_authorization_server(other_auth_base)
838+
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
839+
840+
assert_equal(:authorized, result)
841+
assert_requested(:post, "#{@auth_base}/register", times: 1)
842+
assert_requested(:post, "#{@auth_base}/token", times: 2)
843+
assert_not_requested(:post, "#{other_auth_base}/token")
844+
end
845+
748846
def test_refresh_machine_to_machine_grants_accept_one_trailing_slash_difference_in_the_configured_issuer
749847
[ClientCredentialsProvider, CrossAppAccessProvider].each do |provider_class|
750848
args = { client_id: "test-client", client_secret: "test-secret", issuer: "#{@auth_base}/" }
@@ -772,6 +870,44 @@ def test_run_client_credentials_does_not_bind_to_an_issuer_the_validator_refuses
772870
assert_nil(provider.client_information["issuer"])
773871
end
774872

873+
# Points the Protected Resource Metadata at the given authorization servers, in that order.
874+
def stub_authorization_servers(issuers)
875+
stub_request(:get, @prm_url).to_return(
876+
status: 200,
877+
headers: { "Content-Type" => "application/json" },
878+
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: issuers),
879+
)
880+
end
881+
882+
# Serves metadata and endpoints for another authorization server that every grant could run at, so a flow
883+
# that picks it over the configured one fails on the assertions about where the requests went, not earlier.
884+
def stub_other_authorization_server(issuer)
885+
stub_request(:get, "#{issuer}/.well-known/oauth-authorization-server").to_return(
886+
status: 200,
887+
headers: { "Content-Type" => "application/json" },
888+
body: JSON.generate(
889+
issuer: issuer,
890+
authorization_endpoint: "#{issuer}/authorize",
891+
token_endpoint: "#{issuer}/token",
892+
registration_endpoint: "#{issuer}/register",
893+
response_types_supported: ["code"],
894+
grant_types_supported: ["authorization_code", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"],
895+
code_challenge_methods_supported: ["S256"],
896+
token_endpoint_auth_methods_supported: ["none", "client_secret_basic"],
897+
),
898+
)
899+
stub_request(:post, "#{issuer}/register").to_return(
900+
status: 201,
901+
headers: { "Content-Type" => "application/json" },
902+
body: JSON.generate(client_id: "other-client", client_name: "ruby-sdk-test"),
903+
)
904+
stub_request(:post, "#{issuer}/token").to_return(
905+
status: 200,
906+
headers: { "Content-Type" => "application/json" },
907+
body: JSON.generate(access_token: "other-token", token_type: "Bearer"),
908+
)
909+
end
910+
775911
# Points the Protected Resource Metadata at another authorization server with its own metadata and token endpoint.
776912
def stub_authorization_server_switch_to(issuer)
777913
endpoint_base = issuer.delete_suffix("/")

0 commit comments

Comments
 (0)