Skip to content

Commit 81bc563

Browse files
chore: auth conformance closeout — SEP-990 fixture, migration.md + client docs
Wires the existing CrossAppAccessProvider into the auth/enterprise-managed-authorization conformance fixture (8/8 checks pass, zero SDK code change). Consolidates the per-PR migration.md sections into a single 2026-authorization conformance contract aligned with the issuer-stamp model, and refreshes the clientGuide auth snippets + docs/client.md. Reconciles expected-failures baselines with the published 0.2.0-alpha.5 referee. The MyOAuthProvider guide example now implements state() and discoveryState()/ saveDiscoveryState() so the documented state-check is reachable and the callback-leg AS-binding is demonstrated; the deprecated saveAuthorizationServerUrl pair is dropped. The auth_finishAuth snippet now wires the CSRF check to provider.lastState and reconnects on a fresh StreamableHTTPClientTransport (a started transport cannot be restarted). Unused Prompt/Resource/Tool type imports dropped from the synced imports region. auth/* is fully green on both legs at the tip. Two client baseline entries remain at the alpha.5 pin: json-schema-ref-no-deref (SEP-2106 territory, unrelated) and auth/2025-03-26-oauth-metadata-backcompat (referee mock still serves a §3.3-violating issuer at this pin; tracked for a referee-side fix or removal at the next conformance pin bump). Claude-Session: https://claude.ai/code/session_01XBib5gRe8AMPPJhySCz3EJ
1 parent 71b2687 commit 81bc563

6 files changed

Lines changed: 273 additions & 20 deletions

File tree

docs/client.md

Lines changed: 117 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,111 @@ 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+
if (!(error instanceof UnauthorizedError)) throw error;
282+
// The transport called redirectToAuthorization(); fall through to the browser callback.
283+
}
284+
285+
const callbackUrl = await waitForCallback();
286+
const params = new URL(callbackUrl).searchParams;
287+
288+
// The SDK does not validate `state` — compare it to the value your provider generated.
289+
if (params.get('state') !== provider.lastState) throw new Error('state mismatch');
290+
291+
try {
292+
// Preferred: hand over the whole query — the SDK extracts `code` and
293+
// `iss`, validates `iss` (RFC 9207), and never surfaces callback-derived
294+
// `error`/`error_description` text on mismatch.
295+
await transport.finishAuth(params);
296+
} catch (error) {
297+
if (error instanceof IssuerMismatchError) {
298+
// Mix-up attack: do NOT render params.get('error_description') to the user.
299+
throw new Error('Authorization failed: issuer mismatch');
300+
}
301+
throw error;
302+
}
303+
304+
// Reconnect on a FRESH transport — a started transport cannot be restarted;
305+
// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport.
306+
await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider }));
307+
return client;
308+
```
197309

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

docs/migration.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1511,10 +1511,11 @@ The 2026-07-28 protocol revision adds client-side authorization requirements (RF
15111511

15121512
### `auth()` options are now `AuthOptions`
15131513

1514-
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):
1514+
The inline options object on `auth()` is now the named `AuthOptions` type, exported from `@modelcontextprotocol/client`. Existing call sites need no change. New fields:
15151515

1516-
- `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.
1517-
- `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`.
1516+
- `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.
1517+
- `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`.
1518+
- `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).
15181519

15191520
### `OAuthClientProvider` credential methods receive an `issuer` context
15201521

@@ -1574,13 +1575,21 @@ The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivate
15741575

15751576
### Conformance obligations for `OAuthClientProvider` implementers
15761577

1577-
<!-- Filled in as the SEP-2352/2350/837/2207 behavior PRs land. -->
1578+
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.
15781579

1579-
#### SEP-2352 — per-authorization-server credential isolation
1580+
- **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. 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.
15801581

1581-
**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.
1582+
- **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.
15821583

1583-
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`.
1584+
- **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.
1585+
1586+
- **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.
1587+
1588+
- **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).
1589+
1590+
- **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 [`examples/scoped-tools/client.ts`](../examples/scoped-tools/client.ts) for the per-request step-up flow.
1591+
1592+
- **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.
15841593

15851594
## Using an LLM to migrate your code
15861595

0 commit comments

Comments
 (0)