## 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.
45 lines
1.8 KiB
Ruby
45 lines
1.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module MCP
|
|
class Client
|
|
module OAuth
|
|
# Default reference implementation of the storage contract that
|
|
# `Provider` uses to persist OAuth state. Holds the two pieces of data
|
|
# the flow saves and reads on every request:
|
|
#
|
|
# - `tokens`: the hash returned by the token endpoint
|
|
# (`access_token`, optional `refresh_token`, `expires_in`, `scope`, etc.).
|
|
# - `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`). 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
|
|
# persistence across restarts should supply a custom object responding to
|
|
# the same four-method contract (`tokens`, `save_tokens(t)`,
|
|
# `client_information`, `save_client_information(info)`) and pass it via
|
|
# `Provider.new(storage: ...)`. The shape mirrors Python SDK's
|
|
# `TokenStorage` Protocol; TypeScript's `OAuthClientProvider` rolls
|
|
# the same responsibilities into a single object.
|
|
class InMemoryStorage
|
|
attr_accessor :tokens, :client_information
|
|
|
|
def initialize
|
|
@tokens = nil
|
|
@client_information = nil
|
|
end
|
|
|
|
def save_tokens(tokens)
|
|
@tokens = tokens
|
|
end
|
|
|
|
def save_client_information(info)
|
|
@client_information = info
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|