Skip to content

Bind Stored Client Credentials to the Authorization Server Issuer per SEP-2352#439

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:issuer_bound_client_credentials
Open

Bind Stored Client Credentials to the Authorization Server Issuer per SEP-2352#439
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:issuer_bound_client_credentials

Conversation

@koic

@koic koic commented Jul 4, 2026

Copy link
Copy Markdown
Member

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_ids (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.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

… 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 modelcontextprotocol#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SEP-2352: Clarify authorization server binding and migration

1 participant