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:
parent
f6e43d971e
commit
fdca6f5a2f
1 changed files with 229 additions and 2 deletions
|
|
@ -59,6 +59,7 @@ module MCP
|
|||
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 @@ module MCP
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue