ruby-sdk/lib/mcp/client/oauth/in_memory_storage.rb
Koichi ITO 36ff2627e0 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.
2026-07-05 01:12:08 +09:00

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