Skip to content

Commit 107184c

Browse files
chore: auth conformance closeout — SEP-990 fixture, migration.md + client docs (#2359)
1 parent 9ee8468 commit 107184c

8 files changed

Lines changed: 294 additions & 39 deletions

File tree

docs/client.md

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,32 @@ A client connects to a server, discovers what it offers — tools, resources, pr
1313
The examples below use these imports. Adjust based on which features and transport you need:
1414

1515
```ts source="../examples/guides/clientGuide.examples.ts#imports"
16-
import type { AuthProvider } from '@modelcontextprotocol/client';
16+
import type {
17+
AuthProvider,
18+
OAuthClientInformationContext,
19+
OAuthClientInformationMixed,
20+
OAuthClientMetadata,
21+
OAuthClientProvider,
22+
OAuthDiscoveryState,
23+
OAuthTokens
24+
} from '@modelcontextprotocol/client';
1725
import {
1826
applyMiddlewares,
1927
Client,
2028
ClientCredentialsProvider,
2129
createMiddleware,
2230
CrossAppAccessProvider,
2331
discoverAndRequestJwtAuthGrant,
32+
IssuerMismatchError,
2433
PrivateKeyJwtProvider,
2534
ProtocolError,
2635
SdkError,
2736
SdkErrorCode,
2837
SSEClientTransport,
2938
StreamableHTTPClientTransport,
3039
TRACEPARENT_META_KEY,
31-
TRACESTATE_META_KEY
40+
TRACESTATE_META_KEY,
41+
UnauthorizedError
3242
} from '@modelcontextprotocol/client';
3343
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
3444
```
@@ -191,9 +201,114 @@ Server only implements `client_secret_basic`/`client_secret_post`, so there is n
191201

192202
### Full OAuth with user authorization
193203

194-
For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode
195-
@modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, pass the redirect URL's query to {@linkcode
196-
@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(url.searchParams)} (so the SDK can validate the RFC 9207 `iss` parameter), and reconnect.
204+
For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). Key persisted
205+
client credentials by the `ctx.issuer` passed to `clientInformation()` / `saveClientInformation()` so credentials registered with one authorization server are never sent to another:
206+
207+
```ts source="../examples/guides/clientGuide.examples.ts#auth_oauthClientProvider"
208+
class MyOAuthProvider implements OAuthClientProvider {
209+
// Key DCR-obtained credentials by issuer so a client_id registered with one
210+
// authorization server is never returned for another (SEP-2352).
211+
private creds = new Map<string, OAuthClientInformationMixed>();
212+
private storedTokens?: OAuthTokens;
213+
private verifier?: string;
214+
private discovery?: OAuthDiscoveryState;
215+
lastState?: string;
216+
217+
readonly redirectUrl = 'http://localhost:8090/callback';
218+
readonly clientMetadata: OAuthClientMetadata = {
219+
client_name: 'My MCP Client',
220+
redirect_uris: ['http://localhost:8090/callback'],
221+
// Loopback redirect → the SDK would default this to 'native'; set
222+
// explicitly when the heuristic is wrong for your deployment (SEP-837).
223+
application_type: 'native'
224+
};
225+
226+
clientInformation(ctx?: OAuthClientInformationContext) {
227+
return ctx ? this.creds.get(ctx.issuer) : undefined;
228+
}
229+
saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) {
230+
if (ctx) this.creds.set(ctx.issuer, info);
231+
}
232+
tokens() {
233+
return this.storedTokens;
234+
}
235+
saveTokens(tokens: OAuthTokens) {
236+
// In production, persist to OS keychain / secure storage — never plain files.
237+
this.storedTokens = tokens;
238+
}
239+
// CSRF binding for the redirect — the SDK puts this on the authorize URL;
240+
// your callback handler compares it before calling `finishAuth`.
241+
state() {
242+
this.lastState = crypto.randomUUID();
243+
return this.lastState;
244+
}
245+
// Callback-leg AS-binding (SEP-2352): record what discovery resolved before
246+
// the redirect so the SDK can verify the code is exchanged at the same AS.
247+
saveDiscoveryState(state: OAuthDiscoveryState) {
248+
this.discovery = state;
249+
}
250+
discoveryState() {
251+
return this.discovery;
252+
}
253+
redirectToAuthorization(url: URL) {
254+
onRedirect(url);
255+
}
256+
saveCodeVerifier(v: string) {
257+
this.verifier = v;
258+
}
259+
codeVerifier() {
260+
if (!this.verifier) throw new Error('no code verifier');
261+
return this.verifier;
262+
}
263+
}
264+
265+
const provider = new MyOAuthProvider();
266+
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
267+
authProvider: provider
268+
});
269+
```
270+
271+
The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, hand the callback query
272+
to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth()}, and reconnect. Passing the whole `URLSearchParams` lets the SDK extract `code` and validate the RFC 9207 `iss` parameter for you:
273+
274+
```ts source="../examples/guides/clientGuide.examples.ts#auth_finishAuth"
275+
const client = new Client({ name: 'my-client', version: '1.0.0' });
276+
const transport = new StreamableHTTPClientTransport(url, { authProvider: provider });
277+
try {
278+
await client.connect(transport);
279+
return client;
280+
} catch (error) {
281+
// With version negotiation, the connect-time 401 may surface wrapped as
282+
// SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError.
283+
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
284+
if (!(root instanceof UnauthorizedError)) throw error;
285+
// The transport called redirectToAuthorization(); fall through to the browser callback.
286+
}
287+
288+
const callbackUrl = await waitForCallback();
289+
const params = new URL(callbackUrl).searchParams;
290+
291+
// The SDK does not validate `state` — compare it to the value your provider generated.
292+
if (params.get('state') !== provider.lastState) throw new Error('state mismatch');
293+
294+
try {
295+
// Preferred: hand over the whole query — the SDK extracts `code` and
296+
// `iss`, validates `iss` (RFC 9207), and never surfaces callback-derived
297+
// `error`/`error_description` text on mismatch.
298+
await transport.finishAuth(params);
299+
} catch (error) {
300+
if (error instanceof IssuerMismatchError) {
301+
// Mix-up attack: do NOT render params.get('error_description') to the user.
302+
throw new Error('Authorization failed: issuer mismatch');
303+
}
304+
throw error;
305+
}
306+
307+
// Reconnect on a FRESH transport — a started transport cannot be restarted;
308+
// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport.
309+
await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider }));
310+
return client;
311+
```
197312

198313
For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClient.ts) and
199314
[`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClientProvider.ts).

docs/migration.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,20 +1580,21 @@ client logic; key off the HTTP `404` status instead.
15801580

15811581
## Authorization (2026-07-28 spec)
15821582

1583-
The 2026-07-28 protocol revision adds client-side authorization requirements (RFC 9207 `iss` validation, RFC 8414 §3.3 issuer-echo, per-authorization-server credential isolation, scope step-up, DCR `application_type`, and refresh-token guidance). The SDK adds the public surface for these now and will implement the parts that land in SDK code (defaulting them on) as the SEP-2468/2352/2350/837/2207 behavior changes land; the parts that live in your `OAuthClientProvider` implementation, your `clientMetadata`, or your host UI are listed under [Conformance obligations for `OAuthClientProvider` implementers](#conformance-obligations-for-oauthclientprovider-implementers).
1583+
The 2026-07-28 protocol revision adds client-side authorization requirements (RFC 9207 `iss` validation, RFC 8414 §3.3 issuer-echo, per-authorization-server credential isolation, scope step-up, DCR `application_type`, and refresh-token guidance). The SDK implements the parts that land in SDK code and defaults them on; the parts that live in your `OAuthClientProvider` implementation, your `clientMetadata`, or your host UI are listed under [Conformance obligations for `OAuthClientProvider` implementers](#conformance-obligations-for-oauthclientprovider-implementers).
15841584

15851585
### `auth()` options are now `AuthOptions`
15861586

1587-
The inline options object on `auth()` is now the named `AuthOptions` type, exported from `@modelcontextprotocol/client`. Existing call sites need no change. New fields (both currently inert — the validation behavior they feed lands in the follow-up changes tracked by SEP-2468):
1587+
The inline options object on `auth()` is now the named `AuthOptions` type, exported from `@modelcontextprotocol/client`. Existing call sites need no change. New fields:
15881588

1589-
- `iss?: string` — the form-urldecoded `iss` query parameter from the authorization callback. Pass it alongside `authorizationCode`; it is forwarded to RFC 9207 issuer validation once that lands.
1590-
- `skipIssuerMetadataValidation?: boolean` — opt-out for the RFC 8414 §3.3 issuer-echo check during discovery. **Security-weakening**; use only with authorization servers known to publish a mismatched `issuer`.
1589+
- `iss?: string` — the form-urldecoded `iss` query parameter from the authorization callback. Pass it alongside `authorizationCode` so the SDK can validate it per RFC 9207 before redeeming the code.
1590+
- `skipIssuerMetadataValidation?: boolean` — opt-out of the RFC 8414 §3.3 issuer-echo check during discovery. **Security-weakening**; use only with authorization servers known to publish a mismatched `issuer`.
1591+
- `forceReauthorization?: boolean` — skip the refresh-token branch and force a fresh authorization request. Set by the transport's step-up path when the required scope strictly exceeds the current token's; hosts driving step-up themselves set it under the same condition. See [Scope step-up](#scope-step-up-on-403-insufficient_scope-sep-2350).
15911592

15921593
### `OAuthClientProvider` credential methods receive an `issuer` context
15931594

1594-
`clientInformation(ctx?)`, `saveClientInformation(info, ctx?)`, `tokens(ctx?)`, and `saveTokens(tokens, ctx?)` now receive an optional `OAuthClientInformationContext` parameter carrying `{ issuer: string }` — the authorization server's `issuer` identifier. Providers that persist credentials should key storage by this value so that credentials registered with one authorization server are never sent to another. Providers with a single credential set may ignore the parameter; existing implementations compile unchanged. The SDK does not yet pass this argument; it begins doing so when the SEP-2352 behavior change lands.
1595+
`clientInformation(ctx?)`, `saveClientInformation(info, ctx?)`, `tokens(ctx?)`, and `saveTokens(tokens, ctx?)` now receive an optional `OAuthClientInformationContext` parameter carrying `{ issuer: string }` — the authorization server's `issuer` identifier. Providers that persist credentials should key storage by this value so that credentials registered with one authorization server are never sent to another. Providers with a single credential set may ignore the parameter; existing implementations compile unchanged.
15951596

1596-
New TypeScript-only aliases `StoredOAuthTokens` and `StoredOAuthClientInformation` add an optional `issuer?: string` field on top of the wire types and are used as the parameter/return types of `tokens()` / `saveTokens()` and `clientInformation()` / `saveClientInformation()`. The `issuer` field is **not** part of the RFC 6749/7591 wire responses and is intentionally absent from `OAuthTokensSchema` / `OAuthClientInformationSchema` so an authorization server cannot populate it; once the SEP-2352 behavior change lands the SDK will stamp it onto credentials before calling `saveTokens` / `saveClientInformation`. Provider implementations should round-trip it unchanged. The field is currently inert.
1597+
New TypeScript-only aliases `StoredOAuthTokens` and `StoredOAuthClientInformation` add an optional `issuer?: string` field on top of the wire types and are used as the parameter/return types of `tokens()` / `saveTokens()` and `clientInformation()` / `saveClientInformation()`. The `issuer` field is **not** part of the RFC 6749/7591 wire responses and is intentionally absent from `OAuthTokensSchema` / `OAuthClientInformationSchema` so an authorization server cannot populate it; the SDK stamps it onto credentials before calling `saveTokens` / `saveClientInformation`. Provider implementations should round-trip it unchanged. See [Per-authorization-server credential isolation](#per-authorization-server-credential-isolation-sep-2352) for how the stamp is used.
15971598

15981599
### Authorization-server mix-up defense (RFC 9207 / RFC 8414 §3.3)
15991600

@@ -1647,13 +1648,21 @@ The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivate
16471648

16481649
### Conformance obligations for `OAuthClientProvider` implementers
16491650

1650-
<!-- Filled in as the SEP-2352/2350/837/2207 behavior PRs land. -->
1651+
The SDK enforces every 2026-07-28 authorization MUST that lands in SDK code. The obligations below live in **your** `OAuthClientProvider` implementation, your `clientMetadata`, your host UI, or your resource-server configuration — the SDK structurally cannot enforce them. Each links to the example that demonstrates the conformant pattern.
16511652

1652-
#### SEP-2352 — per-authorization-server credential isolation
1653+
- **SEP-2352 — round-trip the `issuer` stamp on persisted credentials.** `saveTokens()` and `saveClientInformation()` receive values with an SDK-stamped `issuer` field; persist the value verbatim and return it verbatim from `tokens()` / `clientInformation()` and the binding holds — the SDK discards a stored value whose stamp names a different authorization server. If you serialise to a custom format, persist `issuer` alongside the rest. To hold credentials for several authorization servers at once, key your storage on `ctx.issuer` and return `undefined` for an issuer you have no entry for — but when `ctx === undefined` (the transport's per-request bearer read), return the most-recently-saved token set. You **SHOULD** implement `discoveryState()` / `saveDiscoveryState()` so the callback leg can verify it is exchanging the authorization code at the same AS the redirect targeted; without them the SDK `console.warn`s once per callback (RFC 9207 `iss` validation independently protects this leg when the AS emits `iss`). See [`examples/oauth/simpleOAuthClientProvider.ts`](../examples/oauth/simpleOAuthClientProvider.ts) for the reference pattern.
16531654

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+
- **SEP-2352 — pass `expectedIssuer` when supplying static client credentials.** Hosts that construct `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, or `CrossAppAccessProvider` with a constructor-supplied `client_secret` (or pre-signed assertion) **SHOULD** pass the new `expectedIssuer` option naming the authorization server those credentials were registered with. Without it, the credential is sent to whatever authorization server the protected resource advertises on first contact; with it, a mismatch fails before the credential leaves the process.
16551656

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+
- **SEP-2207 — keep refresh tokens confidential in storage.** The SDK enforces in-transit confidentiality via the [`https:` token-endpoint guard](#token-endpoint-must-use-tls-sep-2207); in-storage confidentiality is your `saveTokens()` implementation. Use platform-appropriate secure storage (OS keychain, encrypted-at-rest store) — never persist `refresh_token` to plain files, `localStorage`, or logs.
1658+
1659+
- **SEP-2468 — extract `iss` from the callback URL and pass it to `finishAuth`.** Your callback handler must read the `iss` query parameter alongside `code` and call `transport.finishAuth(code, iss)` — or hand the whole `URLSearchParams` to the [overload](#authorization-server-mix-up-defense-rfc-9207--rfc-8414-33). The SDK validates the value but cannot extract it from a URL it never sees. When `IssuerMismatchError` is thrown, **do not** render the callback's raw `error` / `error_description` / `error_uri` in your UI — those values are attacker-controlled in a mix-up attack. See [`examples/oauth/simpleOAuthClient.ts`](../examples/oauth/simpleOAuthClient.ts) for the extraction pattern.
1660+
1661+
- **SEP-837 — set `application_type` correctly when overriding the heuristic.** The SDK defaults `clientMetadata.application_type` from your `redirect_uris` (loopback / custom scheme → `'native'`, else `'web'`). When the heuristic is wrong for your deployment — a web app dev-served on `localhost`, a native app with an `https:` claimed redirect — set the field explicitly; the SDK never overwrites a value you set but cannot know your deployment shape. See [Dynamic Client Registration defaults](#dynamic-client-registration-application_type-and-grant_types-defaults-sep-837-sep-2207).
1662+
1663+
- **SEP-2350 — track cross-request step-up failures yourself.** The SDK caps step-up retries **per request** (`maxStepUpRetries`). Tracking "this (resource, operation) has already failed step-up _N_ times across the session" — to back off, surface an error, or stop prompting the user — is host state the SDK has no visibility into. See the `client-auth:stepup:*` scenarios in [`test/e2e/scenarios/client-auth.test.ts`](../test/e2e/scenarios/client-auth.test.ts) for the transport-driven step-up flow.
1664+
1665+
- **SEP-2207 (resource-server operators) — do not advertise `offline_access` from the RS.** A resource server SHOULD NOT include `offline_access` in its `WWW-Authenticate` `scope` challenge or in its protected-resource metadata `scopes_supported` — refresh-token issuance is between the client and the authorization server. This is operator configuration of whatever serves your `WWW-Authenticate` header and PRM document, not SDK code.
16571666

16581667
## Using an LLM to migrate your code
16591668

0 commit comments

Comments
 (0)