Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2124,7 +2124,9 @@ Optional keyword arguments:

- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
which keeps credentials in process memory only.
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
(portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is.
- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
When the authorization server advertises `client_id_metadata_document_supported: true`,
Expand Down
86 changes: 83 additions & 3 deletions lib/mcp/client/oauth/flow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
# 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)
Expand All @@ -127,6 +128,25 @@ def client_credentials_client_info
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.
Expand Down Expand Up @@ -166,6 +186,7 @@ def refresh!(server_url:, resource_metadata_url: nil)
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 }
Expand Down Expand Up @@ -412,8 +433,8 @@ def ensure_issuer_matches!(expected:, returned:)
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
Expand Down Expand Up @@ -462,7 +483,12 @@ def ensure_client_registered(as_metadata:)
"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

Expand All @@ -480,6 +506,60 @@ def registration_client_metadata
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.
Expand Down
4 changes: 3 additions & 1 deletion lib/mcp/client/oauth/in_memory_storage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ module OAuth
# - `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
Expand Down
5 changes: 4 additions & 1 deletion lib/mcp/client/oauth/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ module OAuth
# `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`,
Expand Down
Loading