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.
This commit is contained in:
Koichi ITO 2026-05-26 19:11:29 +09:00
commit fdca6f5a2f

View file

@ -68,6 +68,34 @@ module MCP
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 @@ module MCP
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: {