From 7194f1e544e3c66072d0c8661ef10da7a9b25f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ate=C5=9F=20G=C3=B6ral?= Date: Thu, 1 Oct 2026 15:32:23 -0400 Subject: [PATCH] feat: add OAuth scope selector for authorization-code clients --- docs/_client/authorization.md | 16 +- lib/mcp/client/oauth/flow.rb | 43 ++++-- lib/mcp/client/oauth/provider.rb | 12 ++ test/mcp/client/oauth/flow_test.rb | 198 ++++++++++++++++++++++++- test/mcp/client/oauth/provider_test.rb | 19 +++ 5 files changed, 266 insertions(+), 22 deletions(-) diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md index 436332e4..16bc09f5 100644 --- a/docs/_client/authorization.md +++ b/docs/_client/authorization.md @@ -47,9 +47,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut - On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy), run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once. The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged. -- 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. +- By default, request `offline_access` when the client declares the `refresh_token` grant and the authorization server advertises it in + `scopes_supported` (SEP-2207). This can enable refresh tokens. The optional `scope_selector` below can remove it, while a selector + cannot reintroduce `offline_access` when the authorization server does not advertise it. ```ruby require "mcp" @@ -99,7 +99,15 @@ Optional keyword arguments: Omit it when the redirect arrives in a later request, as it does in a web application; see [Authorization in Web Applications](#authorization-in-web-applications). - `pending_authorization_max_age`: Integer seconds a pending authorization stays redeemable, counted from the moment `run!` saves it, when `callback_handler` is omitted. Defaults to 600. -- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. +- `scope`: Space-separated fallback scopes when neither a challenge nor PRM advertises scopes. +- `scope_selector`: Optional callable invoked after the SDK chooses scopes from the challenge, PRM, or `scope` fallback and augments + `offline_access`, but before request validation and client registration. It receives a read-only array of candidate scope tokens; + return an array of valid OAuth scope tokens to replace them, or `nil` / `[]` to omit the authorization URL's `scope` parameter. + The default (`nil`) keeps the MCP scope-selection strategy unchanged. A selector can narrow or add custom scopes, but cannot + reintroduce unsupported `offline_access`. Filtering a challenged scope may leave the current operation unauthorized. + Return `[]` to omit the client's `scope` parameter on the first request, even if PRM advertises scopes. The AS can still apply + default scopes or reject the request ([RFC 6749 ยง3.3](https://www.rfc-editor.org/rfc/rfc6749#section-3.3)); check the granted + scope before treating the connection as unscoped, and allow later challenged scopes when needed. - `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built. Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request). - `http_client_customizer`: Callable invoked with the Faraday connection the SDK builds for the OAuth flow's own requests, after its defaults and before its origin guard. diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index c79a271e..0978df75 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -19,6 +19,9 @@ class Flow METADATA_DIAGNOSTIC_MAX_LENGTH = 128 METADATA_URL_MAX_LENGTH = 2048 + # RFC 6749 scope-token: visible ASCII except space, double quote, and backslash. + SCOPE_TOKEN_FORMAT = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/.freeze + # Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`, # so a provider naming one of these is refused rather than left believing its value was sent. RESERVED_TOKEN_REQUEST_PARAMS = [ @@ -266,6 +269,7 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil) effective_scope = resolve_scope(scope: scope, prm: prm) effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata) + effective_scope = select_scope(effective_scope, as_metadata: as_metadata) # Asked before registering, not after: a refusal must not leave this client registered at an authorization server # the embedding application has just rejected. @@ -857,9 +861,9 @@ def ensure_same_origin!(url, label:, server_url:) # places on MCP servers rather than on clients. # A host that knows which providers its user deals with can apply that knowledge here. # - # The scopes are passed on unchanged whatever the host decides, because the specification requires - # a client to treat the challenged scopes as authoritative for the operation; the choice offered is - # to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before. + # By default, challenged scopes pass through unchanged. An explicit scope selector may narrow + # them, accepting that the current operation could remain unauthorized. The validator sees the + # final selection and can still refuse the authorization before client registration. # # Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered # this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead: @@ -1302,11 +1306,8 @@ def authorization_response_error(error, description) AuthorizationError.new(message, error: error, error_description: description) end - # Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution - # prefers the `WWW-Authenticate` challenge first, then `scopes_supported` - # from the Protected Resource Metadata, and falls back to a provider-supplied - # scope only if both are absent. The provider-supplied scope must not pre-empt - # a server-advertised one. + # MCP scope selection prefers the challenge, then PRM `scopes_supported`, then the provider's fallback. + # A provider's optional selector can adjust the result after `offline_access` augmentation. def resolve_scope(scope:, prm:) return scope if scope && !scope.empty? @@ -1354,6 +1355,26 @@ def server_supports_offline_access?(as_metadata) supported.is_a?(Array) && supported.include?("offline_access") end + # Applies application policy after standard scope selection but before validation or registration. + def select_scope(scope, as_metadata:) + selector = @provider.scope_selector if @provider.respond_to?(:scope_selector) + return scope unless selector + + selected = selector.call(scope.to_s.split.freeze) + valid = selected.is_a?(Array) && + selected.all? { |token| token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) } + unless selected.nil? || valid + raise ArgumentError, "scope_selector must return nil or an Array of valid OAuth scope tokens." + end + return if selected.nil? + + # Custom scopes are allowed, but unsupported offline_access remains refused. + unless server_supports_offline_access?(as_metadata) + selected = selected.reject { |token| token == "offline_access" } + end + selected.empty? ? nil : selected.join(" ") + end + def wants_refresh_token? metadata = @provider.client_metadata grant_types = metadata[:grant_types] || metadata["grant_types"] @@ -1416,8 +1437,9 @@ def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_chall # RFC 6749 Section 3.1 forbids sending a parameter twice, and which of two values a server would honor is # its own choice; on the legacy path the endpoint URL is served by the MCP server, whose query must not speak # for the client's `client_id`, `redirect_uri`, `code_challenge`, or `resource`. - # Other parameters in the URL are kept, as the TypeScript SDK's `searchParams.set` keeps them; that includes - # a `scope` when the flow has none, since an authorization server may set a default scope there. + # Other endpoint parameters are kept, as the TypeScript SDK's `searchParams.set` keeps them. + # Without a selector, an endpoint `scope` survives when the flow has none: the AS may set a default there. + # A configured selector owns `scope` even when omitting it, so the endpoint query cannot override policy. # RFC 9101 `request` and `request_uri` are dropped as well, though the flow sets neither: a server takes # the whole authorization request from the object they carry, over every parameter in the query, and both are # the client's to send, never an endpoint URL's to supply. @@ -1432,6 +1454,7 @@ def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_chall own_params << ["scope", scope] if scope own_params << ["resource", resource] if resource dropped_names = own_params.map(&:first) + ["request", "request_uri"] + dropped_names << "scope" if @provider.respond_to?(:scope_selector) && @provider.scope_selector params = URI.decode_www_form(uri.query.to_s).reject { |name, _value| dropped_names.include?(name) } uri.query = URI.encode_www_form(params + own_params) diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb index ddeff730..39a6f43b 100644 --- a/lib/mcp/client/oauth/provider.rb +++ b/lib/mcp/client/oauth/provider.rb @@ -37,6 +37,11 @@ module OAuth # `run!` saves it, when `callback_handler` is omitted. Defaults to `DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`. # - `scope` - String of space-separated scopes to request when the server's # `WWW-Authenticate` does not specify one. + # - `scope_selector` - Callable receiving a read-only Array of candidate scope tokens after + # challenge/PRM/provider selection and `offline_access` augmentation. Return an Array of valid + # scope tokens to replace them, or `nil` / `[]` to omit the URL's `scope` parameter. The AS may + # still apply default scopes. Unsupported `offline_access` remains stripped; filtering challenged + # scopes may leave the operation unauthorized. # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, # `client_information`, and `save_client_information(info)`. Defaults to # an `InMemoryStorage`. Persisted `client_information` is stamped with @@ -108,6 +113,7 @@ class PendingAuthorizationStorageError < ArgumentError; end attr_reader :client_metadata, :redirect_uri, :scope, + :scope_selector, :storage, :redirect_handler, :callback_handler, @@ -120,6 +126,7 @@ def initialize( redirect_handler:, callback_handler: nil, scope: nil, + scope_selector: nil, storage: nil, client_id_metadata_document_url: nil, authorization_request_validator: nil, @@ -147,6 +154,10 @@ def initialize( "per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`." end + unless scope_selector.nil? || scope_selector.respond_to?(:call) + raise ArgumentError, "scope_selector must respond to call (got #{scope_selector.class})." + end + http_client_customizer = validated_http_client_customizer(http_client_customizer) unless pending_authorization_max_age.is_a?(Integer) && pending_authorization_max_age.positive? @@ -170,6 +181,7 @@ def initialize( @redirect_handler = redirect_handler @callback_handler = callback_handler @scope = scope + @scope_selector = scope_selector @storage = storage @client_id_metadata_document_url = client_id_metadata_document_url @authorization_request_validator = authorization_request_validator diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 15cf8dc2..7be7fcd3 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -254,11 +254,10 @@ def call(env) end 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) + # Returns the authorization URL's `scope` query parameter from a full flow. + # The caller stubs AS metadata; grant types and selection decide whether + # `offline_access` is added automatically. + def capture_authorization_scope(grant_types:, provider_scope: nil, scope_selector: nil, requested_scope: nil) captured_scope = nil state_holder = {} provider = Provider.new( @@ -276,9 +275,14 @@ def capture_authorization_scope(grant_types:, provider_scope: nil) }, callback_handler: -> { ["test-auth-code", state_holder[:state]] }, scope: provider_scope, + scope_selector: scope_selector, ) - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + Flow.new(provider: provider).run!( + server_url: @server_url, + resource_metadata_url: @prm_url, + scope: requested_scope, + ) captured_scope end @@ -1123,7 +1127,7 @@ def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_m # Runs the authorization-code flow with an `authorization_request_validator` that records what it # was handed and answers `approve`. - private def run_flow_with_validator(approve:, recorder: []) + private def run_flow_with_validator(approve:, recorder: [], scope_selector: nil) state_holder = {} provider = Provider.new( client_metadata: { @@ -1142,6 +1146,7 @@ def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_m recorder << request approve }, + scope_selector: scope_selector, ) Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) @@ -1169,6 +1174,27 @@ def test_run_hands_the_authorization_server_and_scopes_to_the_validator assert_equal("https://srv.example.com/mcp", request.resource) end + def test_run_validates_the_selected_scopes_before_client_registration + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: @server_url, + authorization_servers: [@auth_base], + scopes_supported: ["mcp:read", "admin"], + ), + ) + + recorder = [] + assert_raises(Flow::AuthorizationRefusedError) do + run_flow_with_validator(approve: false, recorder: recorder, scope_selector: ->(_candidates) { [] }) + end + + assert_empty(recorder.first.scopes) + assert_not_requested(:post, "#{@auth_base}/register") + refute_includes(recorder, :redirected) + end + def test_run_refuses_the_flow_and_registers_nothing_when_the_validator_declines recorder = [] @@ -1618,6 +1644,33 @@ def test_run_keeps_an_endpoint_scope_when_the_flow_has_none assert_equal("openid", query.to_h["scope"]) end + def test_run_selector_controls_scope_even_when_endpoint_query_sets_it + [ + ["scope=admin&scope=write&audience=api", []], + ["sc%6Fpe=admin&audience=api", nil], + ].each do |endpoint_query, selection| + observed_scopes = nil + query = authorization_url_query_for_endpoint_query( + endpoint_query, + scope_selector: ->(_candidates) { selection }, + validator: ->(request) { + observed_scopes = request.scopes + true + }, + ) + + assert_empty(observed_scopes) + refute_includes(query.map(&:first), "scope") + assert_equal("api", query.to_h["audience"]) + end + + replaced = authorization_url_query_for_endpoint_query( + "scope=admin&scope=write&audience=api", + scope_selector: ->(_candidates) { ["mcp:read"] }, + ) + assert_equal(["mcp:read"], replaced.filter_map { |name, value| value if name == "scope" }) + end + def test_run_raises_when_prm_resource_is_malformed_uri stub_request(:get, @prm_url).to_return( status: 200, @@ -4157,6 +4210,133 @@ def test_resolve_scope_prefers_prm_scopes_supported_over_provider_scope ) end + def test_scope_selector_omits_prm_and_augmented_offline_scopes + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: @server_url, + authorization_servers: [@auth_base], + scopes_supported: ["mcp:read", "mcp:write", "admin"], + ), + ) + 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:read", "mcp:write", "admin", "offline_access"], + ), + ) + + candidates = nil + selector = ->(values) { + candidates = values + [] + } + scope = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + scope_selector: selector, + ) + + assert_nil(scope) + assert_equal(["mcp:read", "mcp:write", "admin", "offline_access"], candidates) + assert_predicate(candidates, :frozen?) + + empty_scope = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + scope_selector: ->(_values) { [] }, + requested_scope: "", + ) + assert_nil(empty_scope) + + nil_scope = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + scope_selector: ->(_values) { nil }, + ) + assert_nil(nil_scope) + + selected_scope = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + scope_selector: ->(values) { values }, + requested_scope: "mcp:read offline_access", + ) + assert_equal("mcp:read offline_access", selected_scope) + end + + def test_scope_selector_filters_prm_catalog_and_allows_custom_scopes + stub_request(:get, @prm_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + resource: @server_url, + authorization_servers: [@auth_base], + scopes_supported: ["mcp:read", "mcp:write", "admin"], + ), + ) + + scope = capture_authorization_scope( + grant_types: ["authorization_code"], + provider_scope: "mcp:read", + scope_selector: ->(values) { values & ["mcp:read"] }, + ) + + assert_equal("mcp:read", scope) + + custom_scope = capture_authorization_scope( + grant_types: ["authorization_code"], + scope_selector: ->(_values) { ["custom:read"] }, + ) + assert_equal("custom:read", custom_scope) + end + + def test_scope_selector_receives_requested_scope_before_provider_fallback + candidates = nil + scope = capture_authorization_scope( + grant_types: ["authorization_code"], + provider_scope: "mcp:read", + scope_selector: ->(values) { + candidates = values + values + }, + requested_scope: "mcp:write", + ) + + assert_equal(["mcp:write"], candidates) + assert_equal("mcp:write", scope) + end + + def test_scope_selector_cannot_reintroduce_unadvertised_offline_access + scope = capture_authorization_scope( + grant_types: ["authorization_code", "refresh_token"], + provider_scope: "mcp:read offline_access", + scope_selector: ->(_values) { ["mcp:read", "offline_access"] }, + ) + + assert_equal("mcp:read", scope) + end + + def test_scope_selector_rejects_invalid_results_before_registration + ["mcp:read", ["mcp:read admin"], [""], [123]].each do |selection| + error = assert_raises(ArgumentError) do + capture_authorization_scope( + grant_types: ["authorization_code"], + scope_selector: ->(_candidates) { selection }, + ) + end + assert_equal("scope_selector must return nil or an Array of valid OAuth scope tokens.", error.message) + end + + assert_not_requested(:post, "#{@auth_base}/register") + end + def test_resolve_scope_falls_back_to_provider_scope_when_prm_omits_scopes_supported captured = nil provider = Provider.new( @@ -4485,7 +4665,7 @@ def test_run_sends_server_url_as_resource_when_prm_omits_it # Serves authorization server metadata whose `authorization_endpoint` carries `endpoint_query`, # runs the authorization-code flow to completion, and returns the query of the URL the browser was # sent to as name/value pairs in order. - def authorization_url_query_for_endpoint_query(endpoint_query) + def authorization_url_query_for_endpoint_query(endpoint_query, scope_selector: nil, validator: nil) stub_request(:get, @as_metadata_url).to_return( status: 200, headers: { "Content-Type" => "application/json" }, @@ -4505,6 +4685,8 @@ def authorization_url_query_for_endpoint_query(endpoint_query) ->(url) { holder[:authorization_url] = url }, -> { ["test-auth-code", URI.decode_www_form(holder[:authorization_url].query).to_h.fetch("state")] }, ), + scope_selector: scope_selector, + authorization_request_validator: validator, ) result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) diff --git a/test/mcp/client/oauth/provider_test.rb b/test/mcp/client/oauth/provider_test.rb index 312bfdb7..d99857bc 100644 --- a/test/mcp/client/oauth/provider_test.rb +++ b/test/mcp/client/oauth/provider_test.rb @@ -58,6 +58,25 @@ def test_initialize_rejects_a_non_callable_http_client_customizer assert_equal("http_client_customizer must respond to call (got Object).", error.message) end + def test_initialize_accepts_optional_scope_selector + default_provider = Provider.new(**args_for("https://app.example.com/callback")) + selector = ->(_candidates) { [] } + provider = Provider.new(**args_for("https://app.example.com/callback"), scope_selector: selector) + + assert_nil(default_provider.scope_selector) + assert_same(selector, provider.scope_selector) + end + + def test_initialize_rejects_a_non_callable_scope_selector + ["mcp:read", false].each do |selector| + error = assert_raises(ArgumentError) do + Provider.new(**args_for("https://app.example.com/callback"), scope_selector: selector) + end + + assert_equal("scope_selector must respond to call (got #{selector.class}).", error.message) + end + end + def test_initialize_rejects_non_loopback_http_redirect_uri # Communication Security: a non-loopback `http://` redirect URI would # let an attacker steal the authorization code from a network sniffer,