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
1 changed files with 288 additions and 6 deletions
|
|
@ -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