Skip to content

Commit e1fabe3

Browse files
authored
Merge pull request #439 from koic/issuer_bound_client_credentials
Bind Stored Client Credentials to the Authorization Server Issuer per SEP-2352
2 parents c43ea6e + 36ff262 commit e1fabe3

5 files changed

Lines changed: 288 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2356,7 +2356,9 @@ Optional keyword arguments:
23562356

23572357
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
23582358
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
2359-
which keeps credentials in process memory only.
2359+
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
2360+
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
2361+
(portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is.
23602362
- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
23612363
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
23622364
When the authorization server advertises `client_id_metadata_document_supported: true`,

‎lib/mcp/client/oauth/flow.rb‎

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
108108
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
109109
def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
110110
client_info = client_credentials_client_info
111+
ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
111112

112113
form = { "grant_type" => "client_credentials" }
113114
effective_scope = resolve_scope(scope: scope, prm: prm)
@@ -134,6 +135,25 @@ def client_credentials_client_info
134135
info
135136
end
136137

138+
# Per SEP-2352, static machine-to-machine credentials are bound to their authorization
139+
# server the same way registered ones are: when the stored `client_information` records
140+
# an `issuer` that differs from the current authorization server, surface an error
141+
# instead of silently sending another server's credentials (the spec's "SHOULD surface
142+
# an error"; the TypeScript SDK raises `AuthorizationServerMismatchError` here).
143+
# Re-registration is not an option for the `client_credentials` grant - the credentials
144+
# are pre-registered, not DCR results - so the operator must update the stored credentials.
145+
# Credentials without a recorded issuer keep working unchanged.
146+
def ensure_client_credentials_issuer!(client_info, as_metadata:)
147+
stored_issuer = client_info_required_value(client_info, "issuer")
148+
return if stored_issuer.nil?
149+
return if stored_issuer == as_metadata["issuer"]
150+
151+
raise AuthorizationError,
152+
"Stored client credentials are bound to a different authorization server " \
153+
"(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \
154+
"refusing to send them to the current authorization server (SEP-2352)."
155+
end
156+
137157
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
138158
# Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
139159
# checks before talking to it.
@@ -173,6 +193,7 @@ def refresh!(server_url:, resource_metadata_url: nil)
173193
client_info = if have_stored_client_info
174194
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
175195
# do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
196+
ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata)
176197
stored_client_info
177198
elsif as_metadata["client_id_metadata_document_supported"] == true
178199
{ "client_id" => @provider.client_id_metadata_document_url }
@@ -419,8 +440,8 @@ def ensure_issuer_matches!(expected:, returned:)
419440
end
420441

421442
def ensure_client_registered(as_metadata:)
422-
existing = @provider.client_information
423-
return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
443+
existing = stored_client_information_for(issuer: as_metadata["issuer"])
444+
return existing if existing
424445

425446
# Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
426447
# if the authorization server advertises Client ID Metadata Document support and the provider has
@@ -469,7 +490,12 @@ def ensure_client_registered(as_metadata:)
469490
"Dynamic client registration response is missing `client_id`."
470491
end
471492

472-
@provider.save_client_information(info)
493+
# Per SEP-2352, persisted client credentials are keyed by the issuer identifier of
494+
# the authorization server that minted them, so a later flow can detect an AS change and
495+
# re-register instead of replaying another server's credentials. `issuer` is not an RFC 7591 response field;
496+
# the SDK adds it to the opaque persisted hash.
497+
@provider.save_client_information(info.merge("issuer" => as_metadata["issuer"]))
498+
473499
info
474500
end
475501

@@ -487,6 +513,60 @@ def registration_client_metadata
487513
metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
488514
end
489515

516+
# Returns the stored `client_information` when it may be used against the authorization server
517+
# identified by `issuer`, applying SEP-2352's authorization server binding rules:
518+
#
519+
# - Credentials persisted with an `"issuer"` binding MUST NOT be reused against
520+
# a different authorization server. When the AS changed, the stale registration and its tokens
521+
# are discarded (tokens minted by the old AS are dead at the new one) and nil is returned so
522+
# the flow re-registers.
523+
# - Stored credentials without an `"issuer"` binding (data persisted by an older SDK version,
524+
# or user-supplied pre-registered credentials) are bound to the current issuer on first use.
525+
# - A CIMD `client_id` (an HTTPS URL) is portable across authorization servers, so it is reused
526+
# and re-bound instead of discarded.
527+
#
528+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352
529+
def stored_client_information_for(issuer:)
530+
existing = @provider.client_information
531+
return unless existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
532+
533+
stored_issuer = client_info_required_value(existing, "issuer")
534+
return existing if stored_issuer == issuer
535+
return rebind_client_information(existing, issuer: issuer) if stored_issuer.nil?
536+
537+
client_id = client_info_required_value(existing, "client_id")
538+
return rebind_client_information(existing, issuer: issuer) if Discovery.client_id_metadata_document_url?(client_id)
539+
540+
@provider.save_client_information(nil)
541+
@provider.clear_tokens!
542+
nil
543+
end
544+
545+
def rebind_client_information(info, issuer:)
546+
rebound = info.merge("issuer" => issuer)
547+
@provider.save_client_information(rebound)
548+
rebound
549+
end
550+
551+
# Per SEP-2352, stored client credentials are bound to the authorization server that issued them;
552+
# refuse to replay another AS's credentials at this token endpoint. `HTTP#attempt_refresh` rescues
553+
# the error and falls back to the full flow, which discards the stale registration and re-registers.
554+
# Credentials without an `"issuer"` binding predate this check and are allowed through;
555+
# CIMD `client_id`s are portable.
556+
def ensure_refreshable_client_information!(client_info, as_metadata:)
557+
stored_issuer = client_info_required_value(client_info, "issuer")
558+
return if stored_issuer.nil?
559+
return if stored_issuer == as_metadata["issuer"]
560+
561+
client_id = client_info_required_value(client_info, "client_id")
562+
return if Discovery.client_id_metadata_document_url?(client_id)
563+
564+
raise AuthorizationError,
565+
"Cannot refresh: stored client credentials were issued by a different authorization server " \
566+
"(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \
567+
"re-registration is required (SEP-2352)."
568+
end
569+
490570
# Reads `key` from a `client_information` hash that may use either string or
491571
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
492572
# `{ client_id:, client_secret: }` and have both work.

‎lib/mcp/client/oauth/in_memory_storage.rb‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ module OAuth
1212
# - `client_information`: the hash returned by Dynamic Client Registration
1313
# or supplied as pre-registered credentials
1414
# (`client_id`, optional `client_secret`, optional
15-
# `token_endpoint_auth_method`).
15+
# `token_endpoint_auth_method`). The SDK additionally stamps an `"issuer"` member
16+
# binding the credentials to the authorization server that issued them (SEP-2352);
17+
# custom storages should treat the hash as opaque and persist it as-is.
1618
#
1719
# This class keeps everything in process memory, so the credentials live
1820
# only for the lifetime of the Ruby process. Applications that need

‎lib/mcp/client/oauth/provider.rb‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ module OAuth
3434
# `WWW-Authenticate` does not specify one.
3535
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
3636
# `client_information`, and `save_client_information(info)`. Defaults to
37-
# an `InMemoryStorage`.
37+
# an `InMemoryStorage`. Persisted `client_information` is stamped with
38+
# an `"issuer"` member binding it to the authorization server that
39+
# issued it (SEP-2352); when the authorization server changes, the SDK discards
40+
# the stale registration and tokens and re-registers.
3841
# - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
3942
# (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
4043
# When the authorization server advertises `client_id_metadata_document_supported: true`,

0 commit comments

Comments
 (0)