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:
commit
cf44475ce9
5 changed files with 229 additions and 2 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue