Bind Stored Client Credentials to the Authorization Server Issuer per SEP-2352
## Motivation and Context SEP-2352 (modelcontextprotocol/modelcontextprotocol#2352, merged for the 2026-07-28 spec release) clarifies authorization server binding and migration: clients MUST key persisted client credentials by the issuing authorization server's issuer identifier, MUST NOT reuse credentials across different authorization servers, and MUST re-register when the server's authorization server changes. CIMD `client_id`s (HTTPS URLs) are explicitly portable across authorization servers, and a mismatch SHOULD surface an error rather than silently use mismatched credentials. The Ruby flow previously reused any stored `client_information` with a `client_id` unconditionally, so a server that migrated to a new authorization server would have the old server's credentials (and its dead tokens) replayed forever. This change follows the detect-change, invalidate, and re-register approach that both reference SDKs converged on (typescript-sdk#2348/#2358, merged 2026-06-24; python-sdk#2933/#2936/#2946, which uses the same proactive compare-before-reuse design as this change) while keeping the 4-method storage contract intact by stamping the binding into the persisted hash: - Successful Dynamic Client Registration now persists the credentials with an `"issuer"` member set to the validated AS metadata `issuer` (which `ensure_issuer_matches!` has already pinned to the discovery URL). - `Flow#ensure_client_registered` reuses stored credentials only when the stored issuer matches the current one. Credentials without an issuer binding (data persisted by older SDK versions, or user-supplied pre-registered credentials) are bound to the current issuer on first use. A portable CIMD `client_id` is reused and re-bound. Any other issuer mismatch discards the stale registration and its tokens (tokens minted by the old AS are dead at the new one) and re-registers via CIMD/DCR. - `Flow#refresh!` raises `AuthorizationError` instead of replaying another authorization server's credentials at the token endpoint; `MCP::Client::HTTP#attempt_refresh` already rescues that error and falls back to the full flow, which performs the discard-and-re-register. - The `client_credentials` grant (machine-to-machine) reads static pre-registered credentials without going through `ensure_client_registered`, so it would have silently sent another authorization server's credentials after a migration. `run_client_credentials!` now surfaces an `AuthorizationError` when the stored `issuer` mismatches the current authorization server, implementing the spec's "SHOULD surface an error" (the TypeScript SDK raises `AuthorizationServerMismatchError` here). Re-registration is not an option for pre-registered m2m credentials, so the operator must update the stored credentials; credentials without a recorded issuer keep working unchanged. Two adjacent hardenings from the reference implementations were checked and need no change here: the legacy-fallback issuer stamp is self-consistent because this SDK's fallback registers at the same origin it uses as the issuer (the cross-origin never-reached-AS binding python-sdk#2946 closed cannot arise), and preserving a non-rotated `refresh_token` across refreshes already exists via `preserve_refresh_token`. The TypeScript SDK's redirected-to-issuer callback gate is not adopted: the RFC 9207 `iss` validation (SEP-2468) covers that mix-up leg. Resolves #385. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb`: - stored credentials with a matching issuer skip DCR - an issuer mismatch re-registers and persists the new `client_id` with the new issuer - the stale registration's tokens are cleared before re-registration (observed by aborting the flow at the DCR step) - legacy credentials without an issuer (string- and symbol-keyed) are reused and bound to the current issuer on first use - a CIMD `client_id` with a mismatched issuer is reused without DCR and re-bound - fresh DCR persists the issuer binding - `refresh!` succeeds with a matching stored issuer and raises (without contacting the token endpoint) with a mismatched one - the `client_credentials` grant succeeds with a matching stored issuer and raises (without contacting the token endpoint) with a mismatched one All existing flow, HTTP OAuth, and provider tests pass unchanged. ## Breaking Changes None for the documented storage contract: `client_information` hashes gain an additional `"issuer"` member that storages persist opaquely, and credentials stored without one keep working via first-use binding. Behavior changes only in the previously broken case where the authorization server changed, which now re-registers (or, for static machine-to-machine credentials, surfaces an error) instead of replaying the old server's credentials.
This commit is contained in:
parent
8b1a41aedd
commit
36ff2627e0
3 changed files with 288 additions and 6 deletions
|
|
@ -101,6 +101,7 @@ module MCP
|
|||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
||||
def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
|
||||
client_info = client_credentials_client_info
|
||||
ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
|
||||
|
||||
form = { "grant_type" => "client_credentials" }
|
||||
effective_scope = resolve_scope(scope: scope, prm: prm)
|
||||
|
|
@ -127,6 +128,25 @@ module MCP
|
|||
info
|
||||
end
|
||||
|
||||
# Per SEP-2352, static machine-to-machine credentials are bound to their authorization
|
||||
# server the same way registered ones are: when the stored `client_information` records
|
||||
# an `issuer` that differs from the current authorization server, surface an error
|
||||
# instead of silently sending another server's credentials (the spec's "SHOULD surface
|
||||
# an error"; the TypeScript SDK raises `AuthorizationServerMismatchError` here).
|
||||
# Re-registration is not an option for the `client_credentials` grant - the credentials
|
||||
# are pre-registered, not DCR results - so the operator must update the stored credentials.
|
||||
# Credentials without a recorded issuer keep working unchanged.
|
||||
def ensure_client_credentials_issuer!(client_info, as_metadata:)
|
||||
stored_issuer = client_info_required_value(client_info, "issuer")
|
||||
return if stored_issuer.nil?
|
||||
return if stored_issuer == as_metadata["issuer"]
|
||||
|
||||
raise AuthorizationError,
|
||||
"Stored client credentials are bound to a different authorization server " \
|
||||
"(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \
|
||||
"refusing to send them to the current authorization server (SEP-2352)."
|
||||
end
|
||||
|
||||
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
|
||||
# Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
|
||||
# checks before talking to it.
|
||||
|
|
@ -166,6 +186,7 @@ module MCP
|
|||
client_info = if have_stored_client_info
|
||||
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
|
||||
# do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
|
||||
ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata)
|
||||
stored_client_info
|
||||
elsif as_metadata["client_id_metadata_document_supported"] == true
|
||||
{ "client_id" => @provider.client_id_metadata_document_url }
|
||||
|
|
@ -412,8 +433,8 @@ module MCP
|
|||
end
|
||||
|
||||
def ensure_client_registered(as_metadata:)
|
||||
existing = @provider.client_information
|
||||
return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
|
||||
existing = stored_client_information_for(issuer: as_metadata["issuer"])
|
||||
return existing if existing
|
||||
|
||||
# Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
|
||||
# if the authorization server advertises Client ID Metadata Document support and the provider has
|
||||
|
|
@ -462,7 +483,12 @@ module MCP
|
|||
"Dynamic client registration response is missing `client_id`."
|
||||
end
|
||||
|
||||
@provider.save_client_information(info)
|
||||
# Per SEP-2352, persisted client credentials are keyed by the issuer identifier of
|
||||
# the authorization server that minted them, so a later flow can detect an AS change and
|
||||
# re-register instead of replaying another server's credentials. `issuer` is not an RFC 7591 response field;
|
||||
# the SDK adds it to the opaque persisted hash.
|
||||
@provider.save_client_information(info.merge("issuer" => as_metadata["issuer"]))
|
||||
|
||||
info
|
||||
end
|
||||
|
||||
|
|
@ -480,6 +506,60 @@ module MCP
|
|||
metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
|
||||
end
|
||||
|
||||
# Returns the stored `client_information` when it may be used against the authorization server
|
||||
# identified by `issuer`, applying SEP-2352's authorization server binding rules:
|
||||
#
|
||||
# - Credentials persisted with an `"issuer"` binding MUST NOT be reused against
|
||||
# a different authorization server. When the AS changed, the stale registration and its tokens
|
||||
# are discarded (tokens minted by the old AS are dead at the new one) and nil is returned so
|
||||
# the flow re-registers.
|
||||
# - Stored credentials without an `"issuer"` binding (data persisted by an older SDK version,
|
||||
# or user-supplied pre-registered credentials) are bound to the current issuer on first use.
|
||||
# - A CIMD `client_id` (an HTTPS URL) is portable across authorization servers, so it is reused
|
||||
# and re-bound instead of discarded.
|
||||
#
|
||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352
|
||||
def stored_client_information_for(issuer:)
|
||||
existing = @provider.client_information
|
||||
return unless existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
|
||||
|
||||
stored_issuer = client_info_required_value(existing, "issuer")
|
||||
return existing if stored_issuer == issuer
|
||||
return rebind_client_information(existing, issuer: issuer) if stored_issuer.nil?
|
||||
|
||||
client_id = client_info_required_value(existing, "client_id")
|
||||
return rebind_client_information(existing, issuer: issuer) if Discovery.client_id_metadata_document_url?(client_id)
|
||||
|
||||
@provider.save_client_information(nil)
|
||||
@provider.clear_tokens!
|
||||
nil
|
||||
end
|
||||
|
||||
def rebind_client_information(info, issuer:)
|
||||
rebound = info.merge("issuer" => issuer)
|
||||
@provider.save_client_information(rebound)
|
||||
rebound
|
||||
end
|
||||
|
||||
# Per SEP-2352, stored client credentials are bound to the authorization server that issued them;
|
||||
# refuse to replay another AS's credentials at this token endpoint. `HTTP#attempt_refresh` rescues
|
||||
# the error and falls back to the full flow, which discards the stale registration and re-registers.
|
||||
# Credentials without an `"issuer"` binding predate this check and are allowed through;
|
||||
# CIMD `client_id`s are portable.
|
||||
def ensure_refreshable_client_information!(client_info, as_metadata:)
|
||||
stored_issuer = client_info_required_value(client_info, "issuer")
|
||||
return if stored_issuer.nil?
|
||||
return if stored_issuer == as_metadata["issuer"]
|
||||
|
||||
client_id = client_info_required_value(client_info, "client_id")
|
||||
return if Discovery.client_id_metadata_document_url?(client_id)
|
||||
|
||||
raise AuthorizationError,
|
||||
"Cannot refresh: stored client credentials were issued by a different authorization server " \
|
||||
"(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \
|
||||
"re-registration is required (SEP-2352)."
|
||||
end
|
||||
|
||||
# Reads `key` from a `client_information` hash that may use either string or
|
||||
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
|
||||
# `{ client_id:, client_secret: }` and have both work.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ module MCP
|
|||
# - `client_information`: the hash returned by Dynamic Client Registration
|
||||
# or supplied as pre-registered credentials
|
||||
# (`client_id`, optional `client_secret`, optional
|
||||
# `token_endpoint_auth_method`).
|
||||
# `token_endpoint_auth_method`). The SDK additionally stamps an `"issuer"` member
|
||||
# binding the credentials to the authorization server that issued them (SEP-2352);
|
||||
# custom storages should treat the hash as opaque and persist it as-is.
|
||||
#
|
||||
# This class keeps everything in process memory, so the credentials live
|
||||
# only for the lifetime of the Ruby process. Applications that need
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@ module MCP
|
|||
# `WWW-Authenticate` does not specify one.
|
||||
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
||||
# `client_information`, and `save_client_information(info)`. Defaults to
|
||||
# an `InMemoryStorage`.
|
||||
# an `InMemoryStorage`. Persisted `client_information` is stamped with
|
||||
# an `"issuer"` member binding it to the authorization server that
|
||||
# issued it (SEP-2352); when the authorization server changes, the SDK discards
|
||||
# the stale registration and tokens and re-registers.
|
||||
# - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
|
||||
# (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
|
||||
# When the authorization server advertises `client_id_metadata_document_supported: true`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue