Merge pull request #365 from koic/conformance_client_auth_offline_access_scope

Request offline_access scope when supported (SEP-2207)
This commit is contained in:
Koichi ITO 2026-06-01 01:09:01 +09:00 • committed by GitHub
commit cf44475ce9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 229 additions and 2 deletions

View file

@ -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), - 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. 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). - 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 ```ruby
require "mcp" require "mcp"

View file

@ -87,7 +87,7 @@ def build_oauth_provider(context, scenario:)
client_metadata: { client_metadata: {
client_name: "ruby-sdk-conformance-client", client_name: "ruby-sdk-conformance-client",
redirect_uris: [redirect_uri], redirect_uris: [redirect_uri],
grant_types: ["authorization_code"], grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"], response_types: ["code"],
token_endpoint_auth_method: "none", token_endpoint_auth_method: "none",
}, },

View file

@ -11,4 +11,3 @@ client:
- auth/client-credentials-jwt - auth/client-credentials-jwt
- auth/client-credentials-basic - auth/client-credentials-basic
- auth/cross-app-access-complete-flow - auth/cross-app-access-complete-flow
- auth/offline-access-scope

View file

@ -59,6 +59,7 @@ module MCP
client_info = ensure_client_registered(as_metadata: as_metadata) client_info = ensure_client_registered(as_metadata: 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)
pkce = PKCE.generate pkce = PKCE.generate
state = SecureRandom.urlsafe_base64(32) state = SecureRandom.urlsafe_base64(32)
@ -441,6 +442,48 @@ module MCP
nil nil
end 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:) def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
authorization_endpoint = as_metadata["authorization_endpoint"] authorization_endpoint = as_metadata["authorization_endpoint"]
unless authorization_endpoint unless authorization_endpoint

View file

@ -68,6 +68,34 @@ module MCP
WebMock.reset! WebMock.reset!
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)
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 def test_run_completes_full_authorization_flow
captured_authorization_url = nil captured_authorization_url = nil
state_value = nil state_value = nil
@ -112,6 +140,160 @@ module MCP
end end
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 def test_run_raises_on_state_mismatch
provider = Provider.new( provider = Provider.new(
client_metadata: { client_metadata: {