From fdca6f5a2fc1b2449b45f084f49badb5ff854099 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 26 May 2026 19:11:29 +0900 Subject: [PATCH] Request offline_access scope when supported (SEP-2207) ## Motivation and Context SEP-2207 describes how an OAuth client obtains refresh tokens: it declares the `refresh_token` grant type in its client metadata and requests the `offline_access` scope, but only when the authorization server advertises `offline_access` in its metadata `scopes_supported`. Requesting `offline_access` when the server does not list it is forbidden. The Ruby SDK already supports the refresh grant (`Flow#refresh!`) but never requested `offline_access`, so it could not obtain a refresh token from a server that gates it behind that scope. This aligns the SDK with the Python and TypeScript SDKs and makes the `auth/offline-access-scope` conformance scenario pass (previously it reported one warning, `sep-2207-client-metadata-grant-types`, and was listed in the expected-failures baseline). The change adds: - `Flow#augment_scope_with_offline_access`, called from `run!` after `resolve_scope`, appends `offline_access` to the requested scope when both the client opted into refresh tokens (its registered `grant_types` include `refresh_token`) and the authorization server advertises `offline_access` in its metadata `scopes_supported`. Already-present `offline_access` is not duplicated. Gating on the server advertisement keeps the SDK from ever requesting the scope where it is not supported. - `conformance/client.rb` declares `refresh_token` in `grant_types`, an honest reflection of the SDK's refresh support, which clears the SEP-2207 grant-types warning. - `auth/offline-access-scope` is removed from `conformance/expected_failures.yml`. ## How Has This Been Tested? New `Flow` tests cover: `offline_access` is requested when the server advertises it and the client declared the `refresh_token` grant; `offline_access` is NOT requested when the client did not declare the grant; `offline_access` is NOT requested when the server does not advertise it (even with the grant declared); and `offline_access` is not duplicated when it is already part of the resolved scope. Conformance: `auth/offline-access-scope` now passes 14/14 with no warnings, and `auth/offline-access-not-supported` still passes 13/13 (no regression). `bundle exec rake test`, `bundle exec rake rubocop`, and `bundle exec rake conformance` are all green. ## Breaking Changes None. `offline_access` is appended only when the client's own `grant_types` opt into refresh tokens and the authorization server advertises the scope, so existing clients that do neither see no change in the scope they request. --- README.md | 3 + conformance/client.rb | 2 +- conformance/expected_failures.yml | 1 - lib/mcp/client/oauth/flow.rb | 43 +++++++ test/mcp/client/oauth/flow_test.rb | 182 +++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 81881a71..9b7d597e 100644 --- a/README.md +++ b/README.md @@ -1913,6 +1913,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut - On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata), perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token. - On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). +- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata + `scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise + `offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it. ```ruby require "mcp" diff --git a/conformance/client.rb b/conformance/client.rb index f1efe612..c08c0492 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -82,7 +82,7 @@ def build_oauth_provider(context) client_metadata: { client_name: "ruby-sdk-conformance-client", redirect_uris: [redirect_uri], - grant_types: ["authorization_code"], + grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", }, diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index acac4174..ca3179f2 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -12,4 +12,3 @@ client: - auth/client-credentials-jwt - auth/client-credentials-basic - auth/cross-app-access-complete-flow - - auth/offline-access-scope diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 3708a48e..b7703633 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -59,6 +59,7 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil) client_info = ensure_client_registered(as_metadata: as_metadata) effective_scope = resolve_scope(scope: scope, prm: prm) + effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata) pkce = PKCE.generate state = SecureRandom.urlsafe_base64(32) @@ -403,6 +404,48 @@ def resolve_scope(scope:, prm:) nil end + # Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves: + # + # - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing + # `refresh_token` in its registered `grant_types`) MAY request `offline_access` + # when the authorization server advertises it in metadata `scopes_supported`. + # When the server advertises it and the client opted in, add it if absent. + # + # - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`, + # strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge, + # PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT + # request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge, + # or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into + # the authorization request even though the AS will not honour it. Stripping here keeps the SDK's + # own request consistent with the AS's advertisement. + # + # Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207 + def normalize_offline_access_scope(scope, as_metadata:) + scopes = scope.to_s.split + + if server_supports_offline_access?(as_metadata) + scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access") + else + scopes.delete("offline_access") + end + + scopes.empty? ? nil : scopes.join(" ") + end + + def server_supports_offline_access?(as_metadata) + supported = as_metadata["scopes_supported"] + + supported.is_a?(Array) && supported.include?("offline_access") + end + + def wants_refresh_token? + metadata = @provider.client_metadata + grant_types = metadata[:grant_types] || metadata["grant_types"] + + Array(grant_types).include?("refresh_token") + end + def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:) authorization_endpoint = as_metadata["authorization_endpoint"] unless authorization_endpoint diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 5da4ea86..2b6d22ab 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -68,6 +68,34 @@ def teardown WebMock.reset! end + # Runs the full authorization flow and returns the `scope` query parameter + # sent on the authorization request. The caller stubs the AS metadata; + # this helper supplies a provider whose `grant_types` and optional pre-set + # `scope` drive the SEP-2207 offline_access decision. + def capture_authorization_scope(grant_types:, provider_scope: nil) + captured_scope = nil + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: grant_types, + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + query = URI.decode_www_form(url.query).to_h + captured_scope = query["scope"] + state_holder[:state] = query.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + scope: provider_scope, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + captured_scope + end + def test_run_completes_full_authorization_flow captured_authorization_url = nil state_value = nil @@ -112,6 +140,160 @@ def test_run_completes_full_authorization_flow end end + def test_run_requests_offline_access_when_advertised_and_refresh_token_grant_declared + # SEP-2207: a client that declares the `refresh_token` grant type requests `offline_access` + # when the AS advertises it, so it can obtain a refresh token. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic", "offline_access"], + ), + ) + + captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"]) + + assert_includes(captured.split, "offline_access") + end + + def test_run_does_not_request_offline_access_when_refresh_token_grant_not_declared + # The AS advertises offline_access, but the client did not opt into refresh tokens, + # so the scope is not requested. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic", "offline_access"], + ), + ) + + captured = capture_authorization_scope(grant_types: ["authorization_code"]) + + refute_includes(captured.to_s.split, "offline_access") + end + + def test_run_does_not_request_offline_access_when_server_does_not_advertise_it + # SEP-2207 forbids requesting offline_access when the AS does not list it, + # even if the client declared the refresh_token grant type. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic", "mcp:read"], + ), + ) + + captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"]) + + refute_includes(captured.to_s.split, "offline_access") + end + + def test_run_strips_offline_access_from_provider_scope_when_server_does_not_advertise_it + # SDK policy: even when `offline_access` reaches the resolved scope from a provider-supplied scope + # (or a challenge / PRM scope), do not propagate it to the AS when the AS does not advertise the scope. + # SEP-2207 itself only says clients should not request unsupported scopes; this strip is the SDK's + # defensive layer against misbehaving resource servers and misconfigured PRMs that surface `offline_access` + # even though the AS has not opted in. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic"], + ), + ) + + captured = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + provider_scope: "mcp:basic offline_access", + ) + + refute_includes(captured.to_s.split, "offline_access") + assert_includes(captured.to_s.split, "mcp:basic") + end + + def test_run_strips_sole_offline_access_scope_when_server_does_not_advertise_it + # When stripping leaves an empty scope, no `scope` parameter is sent. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic"], + ), + ) + + captured = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + provider_scope: "offline_access", + ) + + assert_nil(captured) + end + + def test_run_does_not_duplicate_offline_access_already_in_scope + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + authorization_endpoint: "#{@auth_base}/authorize", + token_endpoint: "#{@auth_base}/token", + registration_endpoint: "#{@auth_base}/register", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:basic", "offline_access"], + ), + ) + + captured = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + provider_scope: "mcp:basic offline_access", + ) + + assert_equal(1, captured.split.count("offline_access")) + end + def test_run_raises_on_state_mismatch provider = Provider.new( client_metadata: {