Skip to content

Commit 9ee8468

Browse files
feat(client): SEP-2352 per-authorization-server credential isolation (#2358)
1 parent 4242f84 commit 9ee8468

18 files changed

Lines changed: 1087 additions & 147 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
---
4+
5+
Per-authorization-server credential isolation (SEP-2352). `auth()` now stamps an `issuer` field onto every value it passes to `saveTokens()` / `saveClientInformation()` and threads `{ issuer }` to `tokens()` / `clientInformation()`; on read, a stored credential whose stamp names a different authorization server is treated as `undefined`, so a `client_id` / `refresh_token` issued by one AS is never sent to another. Providers that round-trip stored values verbatim are protected with no code change; multi-AS providers may key storage on `ctx.issuer`. New `AuthorizationServerMismatchError` (callback-leg gate). `OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are deprecated (still written, never read). `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and `CrossAppAccessProvider` gain `expectedIssuer` and no longer define `saveClientInformation()`.

docs/migration-SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ The OAuth client flow additionally throws dedicated classes from `@modelcontextp
223223
| `exchangeAuthorization()` / `refreshAuthorization()` / `fetchToken()` / `requestJwtAuthorizationGrant()` / `exchangeJwtAuthGrant()` non-https token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) |
224224
| RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) |
225225
| Transport 403 `insufficient_scope` with `onInsufficientScope: 'throw'`, or default mode without an `OAuthClientProvider` | `InsufficientScopeError` (`requiredScope`, `resourceMetadataUrl`, `errorDescription`) |
226+
| `auth()` callback leg: discovery resolves a different AS than the recorded redirect target | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) |
226227

227228
Update OAuth error handling:
228229

@@ -598,6 +599,8 @@ OAuth callback handling: pass the callback URL's `URLSearchParams` to `transport
598599

599600
Token-exchange / refresh now refuse to send credentials to a non-`https:` token endpoint (loopback `localhost` / `127.0.0.1` / `::1` exempt), throwing `InsecureTokenEndpointError` with no opt-out. `auth()` surfaces this on every path including refresh — switch any plain-`http:` AS on a non-loopback host to TLS.
600601

602+
`auth()` stamps an `issuer` field onto every value it passes to `saveTokens()` / `saveClientInformation()` and threads `{ issuer }` as the `ctx` argument to those methods plus `tokens()` / `clientInformation()` (SEP-2352). On read, a stored value whose `issuer` names a different AS is treated as `undefined` and the flow re-registers / re-authorizes. Round-trip the stored object verbatim and you're protected; multi-AS providers key storage on `ctx.issuer`. `OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are `@deprecated` (still written for back-compat, never read by the SDK). `ClientCredentialsProvider` / `PrivateKeyJwtProvider` / `StaticPrivateKeyJwtProvider` / `CrossAppAccessProvider` gain `expectedIssuer?: string` and no longer define `saveClientInformation()`.
603+
601604
No code changes required; wire-behavior note: on a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (caller `signal` / timeout) closes that request's SSE response stream as the spec cancellation signal — `notifications/cancelled` is no longer POSTed
602605
there. 2025-era connections and stdio at any era still send `notifications/cancelled`. Custom `Transport` implementations that open one underlying request per outbound message and honor `TransportSendOptions.requestSignal` may declare `readonly hasPerRequestStream = true` to opt
603606
into the same routing.

docs/migration.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1633,10 +1633,28 @@ Step-up retries are now hard-capped per send (`maxStepUpRetries`, default 1) reg
16331633

16341634
The GET listen-stream open path now applies the same step-up handling as the POST send path.
16351635

1636+
### Credentials are bound to the issuing authorization server (SEP-2352)
1637+
1638+
`auth()` now stamps an `issuer` field onto every value it passes to `saveTokens()` and `saveClientInformation()`, and passes `{ issuer }` as the second (`ctx`) argument to `tokens()` / `saveTokens()` / `clientInformation()` / `saveClientInformation()`. On read, a stored value whose `issuer` stamp names a different authorization server is treated as `undefined` — the flow re-registers / re-authorizes exactly as if nothing were stored. A `client_id` or `refresh_token` issued by one authorization server is therefore never sent to another.
1639+
1640+
**Round-trip the stored object unchanged and you're protected** — single-slot storage works. To hold credentials for several authorization servers at once, key your storage on `ctx.issuer` instead (and treat **`ctx === undefined` as "return the most-recently-saved token set"** — the transport's per-request `Authorization: Bearer` read (`adaptOAuthProvider().token()`) calls `tokens()` with no `ctx`).
1641+
1642+
Implement `discoveryState()` / `saveDiscoveryState()` so the callback leg can verify it is exchanging the authorization code at the same AS the redirect targeted; without it the SDK `console.warn`s once per callback. **`discoveryState` must persist with the same durability as `codeVerifier`** — it has to survive the redirect round-trip (page navigation, app restart). A provider that implements `saveDiscoveryState()` but cannot return it on the callback leg fails closed with `AuthorizationServerMismatchError`. Hosts that drive 401 handling themselves (custom middleware / `withOAuthRetry`) should call `provider.invalidateCredentials?.('discovery')` on a fresh 401 so a changed `authorization_servers` list is picked up on re-discovery. The built-in `StreamableHTTPClientTransport` / `SSEClientTransport` 401 path does not currently invalidate discovery state.
1643+
1644+
`OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are **deprecated**`auth()` still calls `saveAuthorizationServerUrl()` for back-compat with providers that read it internally (Cross-App Access), but the SDK never reads `authorizationServerUrl()`. The call timing changed in v2: it was post-discovery, it is now post-`saveTokens` (after a successful token exchange). Read the `issuer` stamp on stored tokens, or `ctx.issuer`, instead.
1645+
1646+
The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and `CrossAppAccessProvider` gain an `expectedIssuer` option that stamps the constructor-supplied credential; when set, `auth()` against any other authorization server fails before the credential leaves the process. These providers no longer define `saveClientInformation()`.
1647+
16361648
### Conformance obligations for `OAuthClientProvider` implementers
16371649

16381650
<!-- Filled in as the SEP-2352/2350/837/2207 behavior PRs land. -->
16391651

1652+
#### SEP-2352 — per-authorization-server credential isolation
1653+
1654+
**No code change required for the common case.** If your `saveTokens()` / `saveClientInformation()` persist the value passed to them verbatim and your `tokens()` / `clientInformation()` return it verbatim, the SDK-stamped `issuer` round-trips and the binding holds.
1655+
1656+
If you serialise to a custom format, persist the `issuer` field alongside the rest of the value. If you key storage by `ctx.issuer`, return `undefined` for an issuer you have no entry for, and treat **`ctx === undefined` as "return the most-recently-saved token set"** — the transport's per-request `Authorization: Bearer` read (`adaptOAuthProvider().token()`) calls `tokens()` with no `ctx`.
1657+
16401658
## Using an LLM to migrate your code
16411659

16421660
An LLM-optimized version of this guide is available at [`docs/migration-SKILL.md`](migration-SKILL.md). It contains dense mapping tables designed for tools like Claude Code to mechanically apply all the changes described above. You can paste it into your LLM context or load it as

examples/oauth/simpleOAuthClientProvider.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
1-
import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens } from '@modelcontextprotocol/client';
1+
import type {
2+
OAuthClientInformationMixed,
3+
OAuthClientMetadata,
4+
OAuthClientProvider,
5+
OAuthDiscoveryState,
6+
OAuthTokens
7+
} from '@modelcontextprotocol/client';
28
import { validateClientMetadataUrl } from '@modelcontextprotocol/client';
39

410
/**
5-
* In-memory OAuth client provider for demonstration purposes
6-
* In production, you should persist tokens securely
11+
* In-memory OAuth client provider for demonstration purposes.
12+
* In production, you should persist tokens and client credentials securely.
13+
*
14+
* Tokens and client credentials are stored as single-slot blobs. The SDK stamps an
15+
* `issuer` field onto every value it saves; round-tripping the blob unchanged means
16+
* a credential issued by one authorization server is never reused at another (the
17+
* SDK reads the stamp back as a key-not-found and re-registers / re-authorizes).
18+
* To hold credentials for several authorization servers at once, key your storage
19+
* on the `ctx.issuer` argument instead.
720
*/
821
export class InMemoryOAuthClientProvider implements OAuthClientProvider {
922
private _clientInformation?: OAuthClientInformationMixed;
1023
private _tokens?: OAuthTokens;
1124
private _codeVerifier?: string;
25+
private _discoveryState?: OAuthDiscoveryState;
1226

1327
constructor(
1428
private readonly _redirectUrl: string | URL,
@@ -66,4 +80,19 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider {
6680
}
6781
return this._codeVerifier;
6882
}
83+
84+
saveDiscoveryState(state: OAuthDiscoveryState): void {
85+
this._discoveryState = state;
86+
}
87+
88+
discoveryState(): OAuthDiscoveryState | undefined {
89+
return this._discoveryState;
90+
}
91+
92+
invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void {
93+
if (scope === 'all' || scope === 'client') this._clientInformation = undefined;
94+
if (scope === 'all' || scope === 'tokens') this._tokens = undefined;
95+
if (scope === 'all' || scope === 'verifier') this._codeVerifier = undefined;
96+
if (scope === 'all' || scope === 'discovery') this._discoveryState = undefined;
97+
}
6998
}

0 commit comments

Comments
 (0)