From 29e8d3f2b91259f526c66329dff08a697d2fcf6f Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 17 Sep 2026 19:05:51 +0900 Subject: [PATCH] Let the client_credentials and jwt-bearer grants run without Protected Resource Metadata ## Motivation and Context When a server publishes no Protected Resource Metadata, discovery takes the legacy 2025-03-26 path and `prm` is `nil`. The authorization-code branch of `run!` allowed for that by passing `prm || {}` to `resolve_scope`, but `run_client_credentials!` and `run_jwt_bearer!` passed `prm` as is, and `resolve_scope` then called `prm["scopes_supported"]` on `nil`. Without a non-empty challenge scope, a `ClientCredentialsProvider` or `CrossAppAccessProvider` pointed at such a server therefore failed with `NoMethodError` after discovery and before the token request or the ID-JAG callback, instead of running its grant against the legacy authorization base the way `Provider` does. `resolve_scope` now treats a missing PRM as advertising no scopes, so the challenge scope or the provider's own scope applies, and the authorization-code branch passes `prm` unchanged like the other two. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` run the `client_credentials` grant against a PRM-less server with metadata at the origin, checking the provider's scope, the `resource`, and the recorded issuer, and run the `jwt-bearer` grant against a server without any metadata, checking the default token endpoint, the assertion audience, the `resource`, and the recorded issuer. Both raise `NoMethodError` against the previous library. ## Breaking Changes None. --- lib/mcp/client/oauth/flow.rb | 5 ++- test/mcp/client/oauth/flow_test.rb | 69 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index acc0f1c6..f99b6f16 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -87,7 +87,7 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil) ensure_pkce_supported!(as_metadata) - effective_scope = resolve_scope(scope: scope, prm: prm || {}) + effective_scope = resolve_scope(scope: scope, prm: prm) effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata) # Asked before registering, not after: a refusal must not leave this client registered at an authorization server @@ -898,7 +898,8 @@ def states_match?(returned, expected) def resolve_scope(scope:, prm:) return scope if scope && !scope.empty? - supported = prm["scopes_supported"] + # `prm` is nil on the legacy path, where nothing advertises scopes. + supported = prm && prm["scopes_supported"] return supported.join(" ") if supported.is_a?(Array) && !supported.empty? return @provider.scope if @provider.scope && !@provider.scope.empty? diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 5daff121..a91006d3 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -991,6 +991,75 @@ def test_run_falls_back_to_default_endpoints_without_any_metadata end end + def test_run_client_credentials_without_prm_uses_the_legacy_authorization_base + # A PRM-less server takes the legacy path for this grant too: metadata at the origin, no advertised scopes, + # so the provider's own scope is used and the tokens are bound to the origin. + stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: "https://srv.example.com", + token_endpoint: "https://srv.example.com/token", + token_endpoint_auth_methods_supported: ["client_secret_basic"], + ), + ) + stub_request(:post, "https://srv.example.com/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), + ) + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", scope: "mcp:read") + + result = Flow.new(provider: provider).run!(server_url: @server_url) + + assert_equal(:authorized, result) + assert_equal("legacy-token", provider.access_token) + assert_equal("https://srv.example.com", provider.tokens["issuer"]) + assert_requested(:post, "https://srv.example.com/token") do |req| + form = URI.decode_www_form(req.body).to_h + + form["grant_type"] == "client_credentials" && + form["scope"] == "mcp:read" && + form["resource"] == "https://srv.example.com/mcp" + end + end + + def test_run_jwt_bearer_without_any_metadata_uses_the_legacy_default_endpoints + stub_prm_not_found + stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404) + stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404) + stub_request(:post, "https://srv.example.com/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), + ) + 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) + + assert_equal(:authorized, result) + # The assertion is minted for the legacy authorization base, which is the only identity such a server has. + assert_equal({ audience: "https://srv.example.com", resource: "https://srv.example.com/mcp" }, received) + assert_equal("https://srv.example.com", provider.tokens["issuer"]) + assert_requested(:post, "https://srv.example.com/token") do |req| + form = URI.decode_www_form(req.body).to_h + + 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?("scope") + end + end + def test_run_legacy_fallback_rejects_insecure_authorization_base # The Communication Security requirement still applies on the legacy path: a remote plain-http origin must not # become the authorization base URL.