Skip to content

feat(client): SEP-2352 per-authorization-server credential isolation#2348

Merged
felixweinberger merged 1 commit into
fweinberger/auth-1-sep2468-clientfrom
fweinberger/auth-5-sep2352-isolation
Jun 24, 2026
Merged

feat(client): SEP-2352 per-authorization-server credential isolation#2348
felixweinberger merged 1 commit into
fweinberger/auth-1-sep2468-clientfrom
fweinberger/auth-5-sep2352-isolation

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

e7059212c

authInternal resolves the validated metadata.issuer (PR 2; raw PRM entry as fallback) and threads it as ctx.issuer to OAuthClientProvider.clientInformation()/.saveClientInformation(). It tracks two distinct persisted issuer slots — authenticated-at (authorizationServerUrl(), advanced only after saveTokens) and redirected-to (discoveryState().authorizationServerMetadata.issuer, written at REDIRECT) — and gates every credential type against the appropriate slot so no credential issued by one AS is ever sent to another AS's endpoints. For caching providers, an explicit resourceMetadataUrl re-probes PRM so AS changes are detectable through cached discovery. On mismatch: discard clientInformation() for this flow and re-register (CIMD client_ids are portable — skip re-register); invalidateCredentials is best-effort hygiene only. The four authExtensions m2m providers gain an expectedIssuer constructor seed and throw AuthorizationServerMismatchError on mismatch instead of silently sending the static credential.

Motivation and Context

SEP-2352 (modelcontextprotocol/modelcontextprotocol#2352).

Normative sentences implemented:

Clients MUST maintain separate registration state (client credentials, tokens) per authorization server and MUST NOT assume that credentials valid for one authorization server will be accepted by another.
basic/authorization/authorization-server-discovery.mdx

Clients that use pre-registered credentials, or persist client credentials obtained via Dynamic Client Registration, MUST associate those credentials with the specific authorization server that issued them, keyed by the authorization server's issuer identifier. When the authorization server changes …, clients MUST NOT reuse client credentials from a different authorization server and MUST re-register with the new authorization server.
basic/authorization/client-registration.mdx §Authorization Server Binding

If the authorization server indicated by protected resource metadata no longer matches the one the credentials were registered with, clients SHOULD surface an error rather than silently attempting to use mismatched credentials.
basic/authorization/client-registration.mdx (the m2m pre-registered throw)

Security model

The threat model is an attacker who controls (or can poison) the protected-resource metadata's authorization_servers[] entry and the callback query parameters, but does not control the legitimate AS. The goal is that no credential issued by AS-A is ever transmitted to AS-B: client_id/client_secret, refresh_token, and authorization_code + PKCE code_verifier.

authInternal reads two persisted issuer values on entry, before this call's discovery overwrites either:

slot read from written gates
authenticated-at provider.authorizationServerUrl() saveAuthorizationServerUrl(issuer)only inside persistAuthorizationServerIdentity(), which runs immediately after saveTokens() (including first contact). Never written on REDIRECT or on a throw path. authorizationServerChangedclient_id/client_secret discard-and-re-register; the !authorizationServerChanged guard on the refresh branch (so a previous-AS refresh_token is never POSTed to the new AS's token endpoint); the m2m pre-registered throw.
redirected-to discoveryState()?.authorizationServerMetadata?.issuer ?? .authorizationServerUrl saveDiscoveryState({...}) — on every REDIRECT immediately before redirectToAuthorization, recording which AS the in-flight authorization request targets. The hoisted callback-leg gate: if (authorizationCode && redirectedToIssuer && redirectedToIssuer !== issuer) throw AuthorizationServerMismatchError. Runs on every callback regardless of previousIssuer / authorizationServerChanged.

The two slots are deliberately decoupled. Advancing authenticated-at at REDIRECT re-opens a two-call refresh_token leak (an aborted redirect would record AS-B as authenticated while tokens() still returns rt-A); advancing it only at saveTokens would deadlock a legitimate migration (the callback leg would forever read previousIssuer = AS-A and throw). The redirected-to slot resolves the deadlock — it lets the callback leg recognise "this is the AS we redirected to" — while the hoisted gate keyed on it (not on previousIssuer) closes the first-contact flip-flip poison: an attacker who flips PRM → AS-B (poison) → AS-A (REDIRECT) → AS-B (callback) cannot make redirectedToIssuer read AS-B, so the gate fires even when authorizationServerChanged is false. saveDiscoveryState is also deferred past every SEP-2352 throw path so a rejected migration cannot poison the redirected-to record.

Net: the wire MUST holds for every credential type independently of invalidateCredentials() — the SDK never records an AS as authenticated-at until it has obtained a fresh token from it, and never redeems a code at any AS other than the one the redirect targeted.

Consumer obligation

discoveryState() / saveDiscoveryState() is now security-relevant and SHOULD be implemented — it is the only input to the callback-leg gate. Providers that omit it have no callback-leg AS binding on first contact (both previousIssuer and redirectedToIssuer read undefined, so neither gate can run); after a prior successful authorization they fail closed with AuthorizationServerMismatchError but cannot complete a migration. The SDK emits a console.warn on the callback leg when the provider lacks saveDiscoveryState so the gap is visible at integration time. The reference InMemoryOAuthClientProvider and the conformance fixture provider now implement both methods.

RFC 9207 iss validation (PR 2) covers the same callback-leg attack independently — when the legitimate AS emits iss, the mix-up is rejected before the SEP-2352 gate is reached. The SEP-2352 gate is the defence for ASes that do not advertise authorization_response_iss_parameter_supported (and against an attacker who strips iss from the callback).

How Has This Been Tested?

  • Adversarial review: 5 rounds (15 lenses on round 1, 3-reviewer per-trace verification on rounds 2-5) over the seven traced shapes — cimd_code_verifier, two_call_refresh, issuer_keyed_map, m2m_first_contact, flip_flip, legitimate_migration, attacker_flips_again — converged to invariant=true / liveness=true with no remaining majors in code.
  • Unit: 16 SEP-2352 cases in packages/client/test/client/auth.test.ts (describe('SEP-2352 …')), including: first-contact REDIRECT does not seed authenticated-at; saveDiscoveryState not poisoned on the throw path; flip-flip first-contact poison rejected; legitimate AS migration completes through the callback leg and only then advances authenticated-at; PRM flipped again between REDIRECT and callback rejected; no-saveDiscoveryState provider warns on the callback leg; refresh_token never POSTed to the new AS across calls; one-directional trailing-slash tolerance.
  • E2E: client-auth:as-migration:* rows in test/e2e/scenarios/client-auth.test.ts (re-register, CIMD-skip, PRM-rediscovery, m2m-throw, ctx-issuer-threaded, no-token-reuse).
  • Conformance: auth/authorization-server-migration (31 checks) burns; the auth/iss-* block and auth/scope-retry-limit burn on the 2026 leg.

Breaking Changes

None at the type level (PR 1 made the ctx parameter optional; discoveryState/saveDiscoveryState were already optional members). Existing OAuthClientProvider implementations compile unchanged.

Behaviorally:

  • discoveryState() / saveDiscoveryState() is promoted from a discovery-cache hint to a security-relevant SHOULD-implement — it is the persistence slot for the callback-leg gate. Providers that omit it receive a console.warn on the callback leg and are not protected against PRM hijack on first contact (see Consumer obligation).
  • A provider that returns the same persisted clientInformation regardless of ctx.issuer will see it discarded and re-registered when the AS changes — that is the spec-mandated behavior and is what an unmodified provider already experienced as a hard failure at the AS.
  • ClientCredentialsProvider / PrivateKeyJwtProvider / StaticPrivateKeyJwtProvider / CrossAppAccessProvider no longer define saveClientInformation() (so they are classified pre-registered) and now throw AuthorizationServerMismatchError on AS change instead of silently sending the credential. They accept a new optional expectedIssuer to seed first-call detection.

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

Additional context

The wire MUST is enforced structurally regardless of whether the consumer's provider implements issuer-keyed storage; invalidateCredentials() is best-effort hygiene, not load-bearing. The consumer-side storage obligation (key persisted DCR credentials by ctx.issuer; implement discoveryState/saveDiscoveryState) is documented in migration.md and demonstrated in the reshaped examples/oauth/simpleOAuthClientProvider.ts. Secondary hardening folded into this commit: assertSecureEndpoint() extended to the registration endpoint, and InsufficientScopeError / RegistrationRejectedError now JSON.stringify wire values in their messages (matching IssuerMismatchError).


@felixweinberger felixweinberger requested a review from a team as a code owner June 23, 2026 13:18
@changeset-bot

changeset-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9bc98d3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@modelcontextprotocol/client Minor
@modelcontextprotocol/server-legacy Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2348

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2348

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2348

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2348

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2348

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2348

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2348

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2348

commit: 71b2687

Comment thread docs/migration.md Outdated
Comment thread examples/oauth/simpleOAuthClientProvider.ts Outdated
Comment thread packages/client/src/client/auth.ts
Comment thread packages/client/src/client/auth.ts Outdated
// discoveryState() at REDIRECT; compare it on EVERY callback regardless of `previousIssuer`
// / `authorizationServerChanged` — an attacker who controls PRM at first contact can flip
// → AS-B (poison) → AS-A (REDIRECT) → AS-B (callback) so that previousIssuer reads
// undefined or AS-B and the `authorizationServerChanged` block below never runs.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wonder if we want a more explicit binding mechanism than sort of probing around here.

Comment thread packages/client/src/client/auth.ts Outdated
Comment on lines +1260 to +1271
// Record the resolved authorization-server identity so the previous-issuer read on the
// next auth() call round-trips exactly. SEP-2352 invariant: the SDK MUST NOT durably
// record an authorization server as "authenticated-at" until a fresh authorization at
// that AS has completed (saveTokens) — including FIRST CONTACT. Persisting earlier
// desynchronizes the per-call `authorizationServerChanged` gate from stateful credential
// storage: an attacker who poisons the first PRM probe to AS-B then flips → AS-A → AS-B
// would read previousIssuer === issuer on the callback and the credential-isolation block
// above never runs. saveAuthorizationServerUrl is therefore written ONLY here, immediately
// after saveTokens (see :authCodePersist / :refreshPersist below).
// TODO: resourceMetadataUrl is only populated when explicitly provided via options
// or loaded from cached state. The URL derived internally by
// discoverOAuthProtectedResourceMetadata() is not captured back here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these super long comments feel like a smell here. can we make the invalid state harder to get to and not rely on these big comments?

@felixweinberger felixweinberger force-pushed the fweinberger/auth-4-sep2468-server branch from b1ecab4 to 0097867 Compare June 23, 2026 19:03
@felixweinberger felixweinberger force-pushed the fweinberger/auth-5-sep2352-isolation branch from e705921 to b3547d6 Compare June 23, 2026 19:03
Comment thread packages/client/src/client/auth.ts Outdated
Comment thread docs/migration.md Outdated
Comment thread packages/client/src/client/auth.ts Outdated
Comment thread packages/client/src/client/auth.ts Outdated
Comment thread docs/migration.md Outdated
Comment thread packages/client/src/client/auth.ts Outdated
Comment thread packages/client/src/client/authExtensions.ts Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/auth-5-sep2352-isolation branch from b3547d6 to edbbd84 Compare June 23, 2026 21:20
@felixweinberger felixweinberger force-pushed the fweinberger/auth-4-sep2468-server branch from 0097867 to 646cea6 Compare June 23, 2026 21:20
Comment thread packages/client/src/client/auth.ts Outdated
Comment thread packages/client/src/client/authErrors.ts Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/auth-5-sep2352-isolation branch from edbbd84 to 354f0ac Compare June 23, 2026 21:50
@felixweinberger felixweinberger force-pushed the fweinberger/auth-4-sep2468-server branch from 646cea6 to 1196cf1 Compare June 23, 2026 21:50
Comment thread docs/migration.md Outdated
Comment thread test/e2e/scenarios/client-auth.test.ts
Comment thread packages/client/src/client/authErrors.ts Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/auth-5-sep2352-isolation branch from 354f0ac to 794b732 Compare June 23, 2026 22:57
@felixweinberger felixweinberger force-pushed the fweinberger/auth-4-sep2468-server branch 2 times, most recently from 6b3f82b to ef079b3 Compare June 23, 2026 23:05
@felixweinberger felixweinberger force-pushed the fweinberger/auth-5-sep2352-isolation branch from 794b732 to 71b2687 Compare June 23, 2026 23:05
Comment thread packages/client/src/client/auth.ts
Comment thread packages/client/src/index.ts Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/auth-5-sep2352-isolation branch from 71b2687 to 9bc98d3 Compare June 24, 2026 09:43
@felixweinberger felixweinberger force-pushed the fweinberger/auth-4-sep2468-server branch from ef079b3 to 46b6b01 Compare June 24, 2026 09:43
Base automatically changed from fweinberger/auth-4-sep2468-server to fweinberger/auth-1-sep2468-client June 24, 2026 09:43
@felixweinberger felixweinberger merged commit 9bc98d3 into fweinberger/auth-1-sep2468-client Jun 24, 2026
4 of 16 checks passed
@felixweinberger felixweinberger deleted the fweinberger/auth-5-sep2352-isolation branch June 24, 2026 09:43
Comment on lines +561 to +588
export async function resolveAuthorizationCallbackParams(
codeOrParams: string | URLSearchParams,
iss: string | undefined,
provider: OAuthClientProvider,
serverUrl: string | URL,
opts?: { fetchFn?: FetchLike; resourceMetadataUrl?: URL }
): Promise<{ authorizationCode: string; iss: string | undefined }> {
if (typeof codeOrParams === 'string') {
return { authorizationCode: codeOrParams, iss };
}
const issParam = codeOrParams.get('iss') ?? undefined;
const code = codeOrParams.get('code');
if (code) {
return { authorizationCode: code, iss: issParam };
}
// No code → error response. Gate the (potentially attacker-supplied) error params on the
// issuer first. Prefer the provider's recorded discovery state; when absent, mirror auth()'s
// code-present path and run a fresh discovery so the iss gate has an authentic baseline.
const discoveryState = await provider.discoveryState?.();
let metadata = discoveryState?.authorizationServerMetadata;
if (!metadata) {
try {
const serverInfo = await discoverOAuthServerInfo(serverUrl, opts);
metadata = serverInfo.authorizationServerMetadata;
} catch {
metadata = undefined;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 On the no-code (error-shaped) callback path, resolveAuthorizationCallbackParams() falls back to a fresh discoverOAuthServerInfo(serverUrl, opts), but its opts type only accepts { fetchFn, resourceMetadataUrl }, so the user-configured skipIssuerMetadataValidation flag (which both transports hold and pass to auth() on the code-present leg one statement later) is never forwarded. For the documented known-misconfigured-AS opt-out case, the fresh discovery throws IssuerMismatchError, the bare catch swallows it, and the generic UnauthorizedError('Authorization callback failed and the issuer could not be verified') replaces the AS's real OAuth error — inconsistent behavior between the two legs of the same finishAuth overload. Fix: add skipIssuerMetadataValidation to the resolver's opts and forward this._skipIssuerMetadataValidation from both SSEClientTransport.finishAuth and StreamableHTTPClientTransport.finishAuth.

Extended reasoning...

The gap. The new shared resolver resolveAuthorizationCallbackParams(codeOrParams, iss, provider, serverUrl, opts) (packages/client/src/client/auth.ts:561-588) declares opts as only { fetchFn?: FetchLike; resourceMetadataUrl?: URL }. On the URLSearchParams overload's no-code path (an error-shaped callback), when the provider has no recorded discoveryState, the resolver runs a fresh discoverOAuthServerInfo(serverUrl, opts) to obtain an authentic issuer baseline before surfacing the callback's error params. discoverOAuthServerInfo() accepts a skipIssuerMetadataValidation option and forwards it to discoverAuthorizationServerMetadata as skipIssuerValidation (auth.ts:1744/1777) — but the resolver's opts type doesn't even allow the flag, and neither transport passes it.\n\nThe inconsistency this PR introduces. Both SSEClientTransport and StreamableHTTPClientTransport hold this._skipIssuerMetadataValidation (the documented opt-out for a known-misconfigured AS, per migration.md) and pass it to auth() on the code-present leg of the very same finishAuth call — one statement after the resolveAuthorizationCallbackParams call that omits it (e.g. streamableHttp.ts:860-875, and the same pattern in sse.ts). So a user who explicitly opted out of the RFC 8414 §3.3 issuer-echo check gets it honored when the callback carries a code, but silently ignored when the same callback is error-shaped.\n\nCode path / step-by-step proof.\n1. A user connects to an MCP server whose AS publishes metadata with a mismatched issuer (the documented known-misconfigured-AS case), and sets skipIssuerMetadataValidation: true on the transport options.\n2. The AS rejects the authorization request (e.g. user denies consent, or unauthorized_client) and redirects back with error=access_denied&error_description=... and no code.\n3. The host calls transport.finishAuth(new URL(callbackUrl).searchParams) — the preferred overload this PR adds.\n4. resolveAuthorizationCallbackParams sees no code; the provider has no cached discoveryState (e.g. it doesn't implement the optional method, or state was lost across the redirect), so it calls discoverOAuthServerInfo(serverUrl, { fetchFn, resourceMetadataUrl }) — without the flag.\n5. discoverAuthorizationServerMetadata runs the issuer-echo check, the misconfigured AS fails it, and IssuerMismatchError is thrown (auth.ts:1682).\n6. The resolver's bare catch { metadata = undefined; } swallows it, metadata stays undefined, and the function throws the generic UnauthorizedError('Authorization callback failed and the issuer could not be verified') — replacing the AS's real error/error_description (e.g. access_denied) that the user opted in to receiving from this AS.\n\nWhy nothing else prevents it. The flag is threaded everywhere else on this surface: the transports pass it to adaptOAuthProvider() at construction and to auth() in both finishAuth legs and the 401 path — only this new fresh-discovery fallback misses it. Providers that do implement discoveryState() and have cached metadata from the redirect leg avoid the fresh discovery entirely, which narrows the impact, but the resolver explicitly handles the no-cached-state case (its own comment says it mirrors what auth() does on the code-present path — and auth() does receive the flag there).\n\nImpact. No security regression and no mainline functional break — the flow was failing anyway, and the failure mode is a degraded diagnostic: the operator/user sees a generic "issuer could not be verified" error instead of the AS's actual OAuth error, and the documented opt-out behaves inconsistently between the two legs of one overload. All three verifiers confirmed the trace; none refuted it.\n\nFix (trivial). Add skipIssuerMetadataValidation?: boolean to the resolver's opts type, forward it into discoverOAuthServerInfo(serverUrl, opts) (it already accepts it), and pass skipIssuerMetadataValidation: this._skipIssuerMetadataValidation from both transports' resolveAuthorizationCallbackParams call sites — mirroring what they already do for the auth() call directly below.

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.

2 participants