feat(client): add DPoP (RFC 9449) sender-constrained token support (#2629)

Co-authored-by: Paul Carleton <paulc@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Bhavani Shankar Garikapati 2026-08-31 10:00:18 -07:00 • committed by GitHub
commit dcc01028ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1753 additions and 30 deletions

View file

@ -0,0 +1,10 @@
---
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/core': minor
---
Add DPoP (RFC 9449 / SEP-1932) sender-constrained access token support to the client.
- Opt in by implementing `OAuthClientProvider.dpop()` returning a `DpopSession` (new, along with `generateDpopKeyPair`, `accessTokenHash`, `isDpopNonceChallenge`). `auth()` / `exchangeAuthorization` / `refreshAuthorization` / `fetchToken` then sign a DPoP proof into token requests (retrying once on an authorization-server `use_dpop_nonce` challenge, with client authentication re-applied per attempt), and `StreamableHTTPClientTransport`, `SSEClientTransport` and `withOAuth` present a `token_type: "DPoP"` access token as `Authorization: DPoP <token>` plus a fresh per-request proof, retry a resource-server `use_dpop_nonce` challenge once, and pick up a `DPoP-Nonce` delivered on any response. Tokens the AS issued as `Bearer` are still presented as Bearer.
- DPoP is applied at the fetch layer: the transports wrap their resource-server `fetch` (including a caller-supplied `fetch` / `eventSourceInit.fetch`) with the new `withDpopFromProvider(provider)` middleware, so proofs are always bound to the request actually sent. `withDpop(session, getToken)` is exported for callers that manage tokens themselves (e.g. alongside a minimal `AuthProvider`); the `AuthProvider` interface itself is unchanged.
- `auth()` now recovers from `invalid_dpop_proof` on refresh (e.g. a refresh token bound to a key that is no longer held) by discarding the tokens and re-authorizing, like `invalid_grant`. `OAuthErrorCode` gains `InvalidDpopProof` and `UseDpopNonce`; `extractWWWAuthenticateParams` recognizes the `DPoP` challenge scheme; `OAuthMetadataSchema` gains `dpop_signing_alg_values_supported`.

View file

@ -30,6 +30,9 @@ import {
import pkceChallenge from 'pkce-challenge';
import { AuthorizationServerMismatchError, InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors';
import type { DpopSession } from './dpop';
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode}
import type { withDpopFromProvider, withOAuth } from './middleware';
// Re-exported for back-compat — the canonical home is ./authErrors.js.
export { AuthorizationServerMismatchError, InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors';
@ -206,6 +209,12 @@ export async function handleOAuthUnauthorized(
* not in scope. Providers that key storage on `ctx.issuer` MUST treat `ctx === undefined`
* as "return the most-recently-saved token set" (the only consumer is the resource server
* the token was minted for); providers that round-trip a single blob need no change.
*
* SEP-1932 (DPoP) note: DPoP request-signing is deliberately *not* done here. When the provider
* implements {@linkcode OAuthClientProvider.dpop | dpop()}, the transports wrap their
* resource-server `fetch` with {@linkcode withDpopFromProvider}, which upgrades the `Bearer` header
* this adapter produces to `DPoP` + proof for DPoP-bound tokens and handles nonce challenges — at
* the one layer that sees the real method, URL and response of every request.
*/
export function adaptOAuthProvider(
provider: OAuthClientProvider,
@ -341,6 +350,28 @@ export interface OAuthClientProvider {
*/
addClientAuthentication?: AddClientAuthentication;
/**
* Enables DPoP (RFC 9449 / SEP-1932) sender-constrained tokens when implemented. When this
* resolves to a {@linkcode DpopSession}, {@linkcode auth} signs a DPoP proof into the token
* request, and the transports (and {@linkcode withOAuth}) present a resulting `token_type: DPoP`
* access token with the `DPoP` Authorization scheme plus a fresh per-request proof instead of
* `Bearer`, by wrapping their resource-server `fetch` with {@linkcode withDpopFromProvider}.
*
* Return the *same* session across calls — the AS/RS nonce state and signing key it holds are
* meant to persist for the life of this client registration. A minimal implementation:
* ```typescript
* class MyProvider implements OAuthClientProvider {
* private _dpop = DpopSession.create();
* dpop() { return this._dpop; }
* // ...
* }
* ```
*
* Left undefined (the default), the provider behaves exactly as before this option existed:
* plain Bearer tokens throughout.
*/
dpop?(): DpopSession | undefined | Promise<DpopSession | undefined>;
/**
* If defined, overrides the selection and validation of the
* RFC 8707 Resource Indicator. If left undefined, default
@ -1025,7 +1056,11 @@ export async function auth(provider: OAuthClientProvider, options: AuthOptions):
await provider.invalidateCredentials?.('client');
await provider.invalidateCredentials?.('tokens');
return await authInternal(provider, options);
} else if (error.code === OAuthErrorCode.InvalidGrant) {
} else if (error.code === OAuthErrorCode.InvalidGrant || error.code === OAuthErrorCode.InvalidDpopProof) {
// invalid_dpop_proof on refresh typically means the stored refresh token is bound
// (RFC 9449 §5) to a DPoP key this process no longer holds — e.g. a non-extractable
// key regenerated across a restart. Like invalid_grant, the token set is unusable;
// drop it and fall through to a fresh authorization.
warnCredentialInvalidation(provider, error, 'tokens');
await provider.invalidateCredentials?.('tokens');
return await authInternal(provider, options);
@ -1335,6 +1370,7 @@ async function authInternal(
refreshToken: tokens.refresh_token,
resource,
addClientAuthentication: provider.addClientAuthentication,
dpop: await provider.dpop?.(),
fetchFn
});
} catch (error) {
@ -1449,9 +1485,17 @@ export async function selectResourceURL(
return new URL(resourceMetadata.resource);
}
/** Auth-scheme challenge tokens {@linkcode extractWWWAuthenticateParams} recognizes. */
const RECOGNIZED_CHALLENGE_SCHEMES = new Set(['bearer', 'dpop']);
/**
* Extract `resource_metadata`, `scope`, `error`, and `error_description` from a
* `WWW-Authenticate` header.
*
* Recognizes both the `Bearer` scheme (RFC 6750) and the `DPoP` scheme (RFC 9449 §7.1,
* SEP-1932) — a DPoP-protected resource's challenge carries the same parameters under `DPoP`
* instead of `Bearer`, and this must still surface `resource_metadata`/`scope` from it for
* discovery and SEP-2350 step-up to work against such a resource.
*/
export function extractWWWAuthenticateParams(res: Response): {
resourceMetadataUrl?: URL;
@ -1465,7 +1509,7 @@ export function extractWWWAuthenticateParams(res: Response): {
}
const [type, scheme] = authenticateHeader.split(' ');
if (type?.toLowerCase() !== 'bearer' || !scheme) {
if (!type || !RECOGNIZED_CHALLENGE_SCHEMES.has(type.toLowerCase()) || !scheme) {
return {};
}
@ -2096,6 +2140,7 @@ export async function executeTokenRequest(
clientInformation,
addClientAuthentication,
resource,
dpop,
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
@ -2103,6 +2148,13 @@ export async function executeTokenRequest(
clientInformation?: OAuthClientInformationMixed;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
resource?: URL;
/**
* SEP-1932 / RFC 9449 §5: when set, signs a DPoP proof into the token request's `DPoP`
* header — the prerequisite for obtaining a DPoP-bound access token. On a `400
* use_dpop_nonce` challenge (RFC 9449 §8) the request is retried exactly once with a
* fresh proof carrying the server-supplied nonce.
*/
dpop?: DpopSession;
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
@ -2117,19 +2169,50 @@ export async function executeTokenRequest(
tokenRequestParams.set('resource', resource.href);
}
if (addClientAuthentication) {
await addClientAuthentication(headers, tokenRequestParams, tokenUrl, metadata);
} else if (clientInformation) {
if (!addClientAuthentication && clientInformation) {
const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? [];
const authMethod = selectClientAuthMethod(clientInformation, supportedMethods);
applyClientAuthentication(authMethod, clientInformation as OAuthClientInformation, headers, tokenRequestParams);
}
const response = await (fetchFn ?? fetch)(tokenUrl, {
method: 'POST',
headers,
body: tokenRequestParams
});
const requestOnce = async (): Promise<Response> => {
const requestHeaders = new Headers(headers);
// Per attempt, not once up front: a `private_key_jwt` client_assertion carries a one-time
// `jti` (RFC 7521 §5.2), so the DPoP nonce retry below must mint a fresh one, not replay it.
if (addClientAuthentication) {
await addClientAuthentication(requestHeaders, tokenRequestParams, tokenUrl, metadata);
}
if (dpop) {
// No `ath`: RFC 9449 §4.3 step 12a only binds a proof to an access token when one is
// presented, and the token request is presenting credentials to *obtain* one.
requestHeaders.set('DPoP', await dpop.buildProof({ htm: 'POST', htu: tokenUrl }));
}
return (fetchFn ?? fetch)(tokenUrl, {
method: 'POST',
headers: requestHeaders,
body: tokenRequestParams
});
};
let response = await requestOnce();
// RFC 9449 §8: the AS may answer with `400 { error: "use_dpop_nonce" }` + `DPoP-Nonce`; a
// conformant client retries the token request once with a fresh proof carrying that nonce
// (buildProof picks it up automatically via the session's remembered nonce for this origin).
// Peek the body via a clone so a non-nonce 400 still flows into parseErrorResponse below with
// an unconsumed body.
if (dpop && response.status === 400) {
const challenge = (await response
.clone()
.json()
.catch(() => {})) as { error?: string } | undefined;
if (challenge?.error === OAuthErrorCode.UseDpopNonce) {
dpop.observeNonce(response, tokenUrl);
response = await requestOnce();
}
}
// RFC 9449 §8.2: newest-wins nonce capture applies to any response, success included.
dpop?.observeNonce(response, tokenUrl);
if (!response.ok) {
throw await parseErrorResponse(response);
@ -2172,6 +2255,7 @@ export async function exchangeAuthorization(
redirectUri,
resource,
addClientAuthentication,
dpop,
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
@ -2187,6 +2271,8 @@ export async function exchangeAuthorization(
redirectUri: string | URL;
resource?: URL;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
/** SEP-1932 / RFC 9449: see {@linkcode executeTokenRequest}'s `dpop` option. */
dpop?: DpopSession;
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
@ -2204,6 +2290,7 @@ export async function exchangeAuthorization(
clientInformation,
addClientAuthentication,
resource,
dpop,
fetchFn
});
}
@ -2228,6 +2315,7 @@ export async function refreshAuthorization(
refreshToken,
resource,
addClientAuthentication,
dpop,
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
@ -2235,6 +2323,8 @@ export async function refreshAuthorization(
refreshToken: string;
resource?: URL;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
/** SEP-1932 / RFC 9449: see {@linkcode executeTokenRequest}'s `dpop` option. */
dpop?: DpopSession;
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
@ -2249,6 +2339,7 @@ export async function refreshAuthorization(
clientInformation,
addClientAuthentication,
resource,
dpop,
fetchFn
});
@ -2346,6 +2437,7 @@ export async function fetchToken(
clientInformation: clientInformation ?? undefined,
addClientAuthentication: provider.addClientAuthentication,
resource,
dpop: await provider.dpop?.(),
fetchFn
});
}

View file

@ -0,0 +1,230 @@
/**
* DPoP (Demonstrating Proof of Possession) client support.
*
* Implements the client half of {@link https://datatracker.ietf.org/doc/html/rfc9449 | RFC 9449},
* adopted by MCP as the draft extension
* {@link https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/dpop-extension.mdx | SEP-1932}.
* DPoP binds an OAuth access token to a client-held asymmetric key: every request carries a
* signed proof JWT over the key, so a stolen bearer token alone cannot be replayed elsewhere.
*
* This module is opt-in — nothing here runs unless a caller creates a {@linkcode DpopSession}
* and wires it into {@linkcode OAuthClientProvider.dpop} (see `auth.ts`) or a {@linkcode withDpop}
* middleware (see `middleware.ts`). Existing Bearer-token flows are unaffected.
*
* `jose` is loaded lazily so the (larger) WebCrypto-key-management code path is only pulled in
* when a caller actually constructs a {@linkcode DpopSession} — mirroring the lazy `jose` import
* in {@linkcode createPrivateKeyJwtAuth} (`authExtensions.ts`).
*/
import type { CryptoKey, JWK } from 'jose';
/** Asymmetric JWS algorithms usable for a DPoP proof (RFC 9449 §11.6 forbids symmetric algs and `none`). */
export const DPOP_SUPPORTED_ALGS = ['ES256', 'ES384', 'ES512', 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'EdDSA'] as const;
/** A DPoP JWS algorithm this SDK can sign proofs with. */
export type DpopAlg = (typeof DPOP_SUPPORTED_ALGS)[number];
const DEFAULT_DPOP_ALG: DpopAlg = 'ES256';
const DPOP_TYP = 'dpop+jwt';
async function importJose(): Promise<typeof import('jose')> {
if (globalThis.crypto === undefined) {
throw new TypeError(
'crypto is not available, please ensure you have Web Crypto API support for older Node.js versions (see https://github.com/modelcontextprotocol/typescript-sdk#nodejs-web-crypto-globalthiscrypto-compatibility)'
);
}
return import('jose');
}
/** A DPoP signing key pair: the private key signs proofs, the public JWK is embedded in them. */
export interface DpopKeyPair {
/** Signs proofs. Non-extractable unless {@linkcode GenerateDpopKeyPairOptions.extractable} was set. */
privateKey: CryptoKey;
/** Matches {@linkcode publicJwk}; rarely needed directly. */
publicKey: CryptoKey;
/** Embedded in each proof's `jwk` header parameter (RFC 9449 §4.2). */
publicJwk: JWK;
/** RFC 7638 JWK SHA-256 thumbprint — the value an authorization server binds as the token's `cnf.jkt`. */
thumbprint: string;
/** The JWS algorithm this key pair signs with. */
alg: DpopAlg;
}
export interface GenerateDpopKeyPairOptions {
/** Signing algorithm. @default 'ES256' */
alg?: DpopAlg;
/**
* Allow the private key to be exported (e.g. for persistence across process restarts).
*
* @default false — RFC 9449 §11.1 and §11.7 recommend non-extractable keys (hardware-backed
* where available) so the private key cannot be exfiltrated by XSS or a compromised dependency.
* Only set this when the host has its own plan for protecting the exported key material.
*/
extractable?: boolean;
}
/** Generate an asymmetric DPoP signing key pair. Non-extractable by default (RFC 9449 §11). */
export async function generateDpopKeyPair(options: GenerateDpopKeyPairOptions = {}): Promise<DpopKeyPair> {
const alg = options.alg ?? DEFAULT_DPOP_ALG;
const jose = await importJose();
const { publicKey, privateKey } = await jose.generateKeyPair(alg, { extractable: options.extractable ?? false });
const publicJwk = await jose.exportJWK(publicKey);
const thumbprint = await jose.calculateJwkThumbprint(publicJwk, 'sha256');
return { privateKey, publicKey, publicJwk, thumbprint, alg };
}
/**
* Compute the `ath` claim for a DPoP proof presented alongside an access token: the
* base64url-encoded SHA-256 digest of the ASCII access-token value (RFC 9449 §4.1).
*
* Uses Web Crypto (`crypto.subtle`) rather than a Node-only hashing API so this stays usable
* in browser and edge runtimes.
*/
export async function accessTokenHash(accessToken: string): Promise<string> {
if (globalThis.crypto?.subtle === undefined) {
throw new TypeError(
'crypto.subtle is not available, please ensure you have Web Crypto API support for older Node.js versions (see https://github.com/modelcontextprotocol/typescript-sdk#nodejs-web-crypto-globalthiscrypto-compatibility)'
);
}
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken));
return base64UrlEncode(new Uint8Array(digest));
}
function base64UrlEncode(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) binary += String.fromCodePoint(byte);
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}
/** Strip the query and fragment from a URL, per RFC 9449 §4.2 (`htu` MUST NOT contain either). */
function stripQueryAndFragment(url: string | URL): string {
const u = new URL(url.toString());
return `${u.origin}${u.pathname}`;
}
/** A fresh, cryptographically random `jti` (RFC 9449 §4.2 — MUST be unique per proof). */
function randomJti(): string {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
/** Inputs for a single DPoP proof (RFC 9449 §4.2). */
export interface DpopProofRequest {
/** HTTP method of the target request (`htm` claim). Case-normalized to upper-case. */
htm: string;
/** HTTP target URI of the request (`htu` claim) — query/fragment are stripped automatically. */
htu: string | URL;
/** When set, binds the proof to this access token via the `ath` claim (RFC 9449 §4.1). */
accessToken?: string;
/**
* Explicit server-provided nonce to embed. When omitted, the session's remembered nonce for
* `htu`'s origin (if any) is used automatically — see {@linkcode DpopSession.rememberNonce}.
*/
nonce?: string;
}
/**
* A DPoP signing identity plus the small amount of state RFC 9449 requires across requests:
* the key pair and, per origin, the most recently server-supplied nonce (RFC 9449 §8/§9).
*
* One `DpopSession` is meant to live for the lifetime of a single OAuth client registration —
* the token endpoint and the resource server are different origins and get independent nonce
* slots, so a nonce challenge from one never leaks into proofs sent to the other.
*/
export class DpopSession {
private readonly nonces = new Map<string, string>();
private constructor(private readonly keyPair: DpopKeyPair) {}
/** Create a session with a fresh key pair, or reuse a caller-supplied one. */
static async create(options: { alg?: DpopAlg; keyPair?: DpopKeyPair } = {}): Promise<DpopSession> {
const keyPair = options.keyPair ?? (await generateDpopKeyPair({ alg: options.alg }));
return new DpopSession(keyPair);
}
/** RFC 7638 JWK SHA-256 thumbprint of the signing key — matches the token's `cnf.jkt` once bound. */
get thumbprint(): string {
return this.keyPair.thumbprint;
}
/** The JWS algorithm this session signs proofs with. */
get alg(): DpopAlg {
return this.keyPair.alg;
}
/** The public JWK embedded in every proof's `jwk` header parameter. */
get publicJwk(): JWK {
return this.keyPair.publicJwk;
}
/** The remembered nonce for `url`'s origin, if the server has issued one (RFC 9449 §8/§9). */
nonceFor(url: string | URL): string | undefined {
return this.nonces.get(new URL(url.toString()).origin);
}
/** Record a server-supplied nonce for `url`'s origin (newest-wins, RFC 9449 §8.2). */
rememberNonce(url: string | URL, nonce: string): void {
this.nonces.set(new URL(url.toString()).origin, nonce);
}
/**
* Capture a `DPoP-Nonce` response header, if present, for `url`'s origin. RFC 9449 §8.2 says a
* fresh nonce may ride on *any* response (success or failure), so call this unconditionally
* after every request, not only on a `use_dpop_nonce` challenge.
*/
observeNonce(response: Response, url: string | URL): void {
const nonce = response.headers.get('dpop-nonce');
if (nonce) this.rememberNonce(url, nonce);
}
/**
* Build a fresh DPoP proof JWT. Always mints a new `jti` — proofs are never cached or reused,
* since RFC 9449 §4.3 step 9 requires each to be presented at most once.
*/
async buildProof(request: DpopProofRequest): Promise<string> {
const jose = await importJose();
const htu = stripQueryAndFragment(request.htu);
const nonce = request.nonce ?? this.nonceFor(request.htu);
const payload: Record<string, unknown> = {
jti: randomJti(),
htm: request.htm.toUpperCase(),
htu,
iat: Math.floor(Date.now() / 1000)
};
if (request.accessToken !== undefined) {
payload.ath = await accessTokenHash(request.accessToken);
}
if (nonce !== undefined) {
payload.nonce = nonce;
}
return new jose.SignJWT(payload)
.setProtectedHeader({ alg: this.keyPair.alg, typ: DPOP_TYP, jwk: this.keyPair.publicJwk })
.sign(this.keyPair.privateKey);
}
}
/**
* Whether `wwwAuthenticate` advertises a `DPoP` auth-scheme challenge (matched at the start of the
* header or after a comma, per RFC 9110 §11.6.1 — a server may list several schemes together, e.g.
* `Bearer …, DPoP …`).
*/
function hasDpopChallenge(wwwAuthenticate: string): boolean {
return /(?:^|,)\s*dpop(?:\s|$|,)/i.test(wwwAuthenticate);
}
/**
* Whether `response` is a resource-server `use_dpop_nonce` challenge (RFC 9449 §9): a `401` whose
* `WWW-Authenticate` header carries a `DPoP` challenge with `error="use_dpop_nonce"`.
*
* A conformant retry re-signs the proof with the nonce {@linkcode DpopSession.observeNonce} just
* captured — never resend the original proof: RFC 9449 §4.2 requires a unique `jti` per proof, and
* replaying one across the challenge/retry boundary is itself a violation.
*/
export function isDpopNonceChallenge(response: Response): boolean {
if (response.status !== 401) return false;
const wwwAuthenticate = response.headers.get('www-authenticate') ?? '';
return hasDpopChallenge(wwwAuthenticate) && /use_dpop_nonce/i.test(wwwAuthenticate);
}

View file

@ -1,7 +1,11 @@
import type { FetchLike } from '@modelcontextprotocol/core-internal';
import type { OAuthClientProvider } from './auth';
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- AuthProvider referenced in JSDoc {@linkcode}
import type { AuthProvider, OAuthClientProvider } from './auth';
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth';
import { markAuthSeamEscape } from './authSeam';
import type { DpopSession } from './dpop';
import { isDpopNonceChallenge } from './dpop';
/**
* Middleware function that wraps and enhances fetch functionality.
@ -17,6 +21,10 @@ export type Middleware = (next: FetchLike) => FetchLike;
* - Handle 401 responses by attempting re-authentication
* - Retry the original request after successful auth
* - Handle OAuth errors appropriately ({@linkcode index.OAuthErrorCode.InvalidClient | OAuthErrorCode.InvalidClient}, etc.)
* - When {@linkcode OAuthClientProvider.dpop | provider.dpop()} is implemented, present DPoP-bound
* tokens with the `DPoP` scheme plus a per-request proof (RFC 9449 / SEP-1932) by composing
* {@linkcode withDpopFromProvider} underneath — so a `use_dpop_nonce` challenge is retried
* inline on every attempt, independently of the single re-authentication retry here
*
* The `baseUrl` parameter is optional and defaults to using the domain from the request URL.
* However, you should explicitly provide `baseUrl` when:
@ -37,7 +45,12 @@ export type Middleware = (next: FetchLike) => FetchLike;
*/
export const withOAuth =
(provider: OAuthClientProvider, baseUrl?: string | URL): Middleware =>
next => {
baseNext => {
// DPoP request-signing (and its nonce retry) sits *below* the Bearer/re-auth layer so it
// sees the final method/URL of every attempt and every response. `auth()` keeps the
// unwrapped fetch: token-endpoint DPoP is handled inside executeTokenRequest.
const next = provider.dpop ? withDpopFromProvider(provider)(baseNext) : baseNext;
return async (input, init) => {
const makeRequest = async (): Promise<Response> => {
const headers = new Headers(init?.headers);
@ -65,7 +78,7 @@ export const withOAuth =
serverUrl,
resourceMetadataUrl,
scope,
fetchFn: next
fetchFn: baseNext
});
if (result === 'REDIRECT') {
@ -96,6 +109,113 @@ export const withOAuth =
};
};
/**
* A function returning the current access token, or `undefined` if none is available yet. See
* {@linkcode withDpop}.
*/
export type DpopTokenSource = () => string | undefined | Promise<string | undefined>;
/**
* A {@linkcode DpopSession}, or a function resolving to one (or to `undefined` to leave the request
* untouched). The function form lets the session be created lazily or come from
* {@linkcode OAuthClientProvider.dpop}. See {@linkcode withDpop}.
*/
export type DpopSessionSource = DpopSession | (() => DpopSession | undefined | Promise<DpopSession | undefined>);
/**
* Creates a fetch wrapper that presents an access token using the `DPoP` Authorization scheme
* (RFC 9449 / SEP-1932) instead of `Bearer`: every request carries `Authorization: DPoP <token>`
* plus a fresh `DPoP` proof bound to that request's method and URL, a resource-server
* `use_dpop_nonce` challenge (RFC 9449 §9) is retried once, inline, with the server-supplied nonce,
* and a `DPoP-Nonce` delivered on any response is remembered for the next proof (RFC 9449 §8.2).
*
* Because it wraps `fetch` itself, the proof is always bound to the request actually sent and every
* response is observed — which is why the MCP transports apply this wrapper internally (via
* {@linkcode withDpopFromProvider}) when their `authProvider` implements
* {@linkcode OAuthClientProvider.dpop | dpop()}, rather than threading request context through
* {@linkcode AuthProvider}.
*
* Use this directly when you already manage the access token yourself (a non-OAuth token source,
* or credentials obtained out-of-band) and only need DPoP's request-signing behavior — e.g.
* `fetch: withDpop(session, getToken)(fetch)` alongside a minimal `authProvider: { token }`.
*
* @param session - The DPoP signing session (key pair + nonce state), or a function resolving to
* it. Reuse the same session across requests to the same server so its nonce state persists.
* When the function resolves to `undefined` the request passes through unchanged.
* @param getToken - Returns the current access token, or `undefined` if none is available (the
* request passes through unchanged — any `Authorization` header already on it is left as is).
* @returns A fetch middleware function
*/
export const withDpop =
(session: DpopSessionSource, getToken: DpopTokenSource): Middleware =>
next => {
const resolveSession = typeof session === 'function' ? session : () => session;
return async (input, init) => {
const method = (init?.method ?? 'GET').toUpperCase();
const url = new URL(input.toString());
const activeSession = await resolveSession();
if (!activeSession) return next(input, init);
const makeRequest = async (): Promise<Response> => {
const accessToken = await getToken();
if (!accessToken) return next(input, init);
const headers = new Headers(init?.headers);
const proof = await activeSession.buildProof({ htm: method, htu: url, accessToken });
headers.set('Authorization', `DPoP ${accessToken}`);
headers.set('DPoP', proof);
return next(input, { ...init, headers });
};
let response = await makeRequest();
// Only retry when the challenge carries a fresh DPoP-Nonce — otherwise the retry
// would re-send the nonce the server just rejected. RFC 9449 §4.2: the retry gets a
// freshly signed proof (new jti); the original is never replayed.
if (isDpopNonceChallenge(response) && response.headers.has('dpop-nonce')) {
activeSession.observeNonce(response, url);
await response.text?.().catch(() => {});
response = await makeRequest();
}
// RFC 9449 §8.2: a fresh nonce may ride on any response, success included.
activeSession.observeNonce(response, url);
return response;
};
};
/**
* {@linkcode withDpop} driven by an {@linkcode OAuthClientProvider}: the session comes from
* {@linkcode OAuthClientProvider.dpop | provider.dpop()} and the token from
* {@linkcode OAuthClientProvider.tokens | provider.tokens()} — presented with the DPoP scheme only
* when the AS actually issued `token_type: "DPoP"` (RFC 9449 §7.1); a Bearer token passes through
* untouched.
*
* This is what the MCP transports and {@linkcode withOAuth} compose internally when the provider
* implements `dpop()`. Use it directly only when building your own fetch pipeline around an
* `OAuthClientProvider` (e.g. a custom re-authorization middleware) — place it *innermost*, below
* whatever sets `Authorization: Bearer` and handles 401 re-authentication.
*/
export const withDpopFromProvider = (provider: OAuthClientProvider): Middleware =>
withDpop(
async () => {
try {
return await provider.dpop?.();
} catch (error) {
throw markAuthSeamEscape(error);
}
},
async () => {
let tokens;
try {
tokens = await provider.tokens();
} catch (error) {
throw markAuthSeamEscape(error);
}
return tokens?.token_type?.toLowerCase() === 'dpop' ? tokens.access_token : undefined;
}
);
/**
* Logger function type for HTTP requests
*/

View file

@ -24,6 +24,8 @@ import {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode}
import type { IssuerMismatchError } from './authErrors';
import { markAuthSeamEscape } from './authSeam';
import type { Middleware } from './middleware';
import { withDpopFromProvider } from './middleware';
export class SseError extends Error {
static {
@ -132,6 +134,7 @@ export class SSEClientTransport implements Transport {
private _skipIssuerMetadataValidation?: boolean;
private _fetch?: FetchLike;
private _fetchWithInit: FetchLike;
private _dpop?: Middleware;
private _protocolVersion?: string;
onclose?: () => void;
@ -145,15 +148,22 @@ export class SSEClientTransport implements Transport {
this._eventSourceInit = opts?.eventSourceInit;
this._requestInit = opts?.requestInit;
this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation;
this._fetch = opts?.fetch;
if (isOAuthClientProvider(opts?.authProvider)) {
this._oauthProvider = opts.authProvider;
this._authProvider = adaptOAuthProvider(opts.authProvider, {
skipIssuerMetadataValidation: opts.skipIssuerMetadataValidation
});
// SEP-1932 / RFC 9449: see the matching comment in StreamableHTTPClientTransport. The
// EventSource stream's fetch is wrapped at use, since `eventSourceInit.fetch` may
// override `_fetch` there.
if (opts.authProvider.dpop) {
this._dpop = withDpopFromProvider(opts.authProvider);
this._fetch = this._dpop(opts.fetch ?? fetch);
}
} else {
this._authProvider = opts?.authProvider;
}
this._fetch = opts?.fetch;
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
}
@ -185,7 +195,10 @@ export class SSEClientTransport implements Transport {
}
private _startOrAuth(): Promise<void> {
const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch;
const eventSourceFetch = this._eventSourceInit?.fetch;
const fetchImpl = (
eventSourceFetch ? (this._dpop?.(eventSourceFetch as FetchLike) ?? eventSourceFetch) : (this._fetch ?? fetch)
) as typeof fetch;
return new Promise((resolve, reject) => {
this._eventSource = new EventSource(this._url.href, {
...this._eventSourceInit,

View file

@ -36,6 +36,7 @@ import {
import type { IssuerMismatchError } from './authErrors';
import { InsufficientScopeError } from './authErrors';
import { markAuthSeamEscape } from './authSeam';
import { withDpopFromProvider } from './middleware';
/** Default cap on step-up re-authorization retries within a single send/stream-open. */
const DEFAULT_MAX_STEP_UP_RETRIES = 1;
@ -260,6 +261,7 @@ export type StreamableHTTPClientTransportOptions = {
*/
const RESERVED_REQUEST_HEADER_NAMES: ReadonlySet<string> = new Set([
'authorization',
'dpop',
'content-type',
'mcp-protocol-version',
'mcp-method',
@ -347,15 +349,22 @@ export class StreamableHTTPClientTransport implements Transport {
this._scope = undefined;
this._requestInit = opts?.requestInit;
this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation;
this._fetch = opts?.fetch;
if (isOAuthClientProvider(opts?.authProvider)) {
this._oauthProvider = opts.authProvider;
this._authProvider = adaptOAuthProvider(opts.authProvider, {
skipIssuerMetadataValidation: opts.skipIssuerMetadataValidation
});
// SEP-1932 / RFC 9449: sign resource-server requests with DPoP at the fetch layer, where
// the real method/URL/response of every request (POST, GET stream, DELETE) is in hand.
// `_fetchWithInit` below (handed to `auth()`) stays unwrapped — token-endpoint DPoP is
// executeTokenRequest's job.
if (opts.authProvider.dpop) {
this._fetch = withDpopFromProvider(opts.authProvider)(opts.fetch ?? fetch);
}
} else {
this._authProvider = opts?.authProvider;
}
this._fetch = opts?.fetch;
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
this._sessionId = opts?.sessionId;
this._protocolVersion = opts?.protocolVersion;

View file

@ -72,8 +72,13 @@ export { Client } from './client/client';
export { getSupportedElicitationModes } from './client/client';
export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess';
export { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from './client/crossAppAccess';
export type { LoggingOptions, Middleware, RequestLogger } from './client/middleware';
export { applyMiddlewares, createMiddleware, withLogging, withOAuth } from './client/middleware';
// DPoP (RFC 9449 / SEP-1932) sender-constrained tokens: the signing session plus key-pair
// primitives. Wire a DpopSession into OAuthClientProvider.dpop() for full OAuth+DPoP via `auth`/
// the transports' authProvider option, or use `withDpop` directly when you manage tokens yourself.
export type { DpopAlg, DpopKeyPair, DpopProofRequest, GenerateDpopKeyPairOptions } from './client/dpop';
export { accessTokenHash, DPOP_SUPPORTED_ALGS, DpopSession, generateDpopKeyPair, isDpopNonceChallenge } from './client/dpop';
export type { DpopSessionSource, DpopTokenSource, LoggingOptions, Middleware, RequestLogger } from './client/middleware';
export { applyMiddlewares, createMiddleware, withDpop, withDpopFromProvider, withLogging, withOAuth } from './client/middleware';
export type { PriorDiscovery } from './client/probeClassifier';
export type {
CacheEntry,

View file

@ -0,0 +1,246 @@
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { OAuthClientProvider } from '../../src/client/auth';
import { auth, executeTokenRequest, extractWWWAuthenticateParams } from '../../src/client/auth';
import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions';
import { DpopSession } from '../../src/client/dpop';
function decodeJwtPart(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
describe('executeTokenRequest — DPoP', () => {
let session: DpopSession;
let fetchFn: Mock;
beforeEach(async () => {
session = await DpopSession.create();
fetchFn = vi.fn();
});
it('signs a DPoP proof into the token request DPoP header', async () => {
fetchFn.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({ access_token: 'tok', token_type: 'DPoP' })
});
await executeTokenRequest('https://as.example.com', {
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
dpop: session,
fetchFn
});
const [, init] = fetchFn.mock.calls[0]!;
const headers = init.headers as Headers;
const proof = headers.get('DPoP');
expect(proof).toBeTruthy();
const payload = decodeJwtPart(proof!.split('.')[1]!);
expect(payload.htm).toBe('POST');
expect(payload.htu).toBe('https://as.example.com/token');
// No `ath`: the token request presents credentials to *obtain* a token, not an existing one.
expect(payload.ath).toBeUndefined();
});
it('retries once with a fresh nonce-carrying proof on a 400 use_dpop_nonce challenge', async () => {
fetchFn
.mockResolvedValueOnce({
ok: false,
status: 400,
headers: new Headers({ 'DPoP-Nonce': 'as-nonce-1' }),
clone() {
return this;
},
json: async () => ({ error: 'use_dpop_nonce' })
})
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({ access_token: 'tok', token_type: 'DPoP' })
});
const tokens = await executeTokenRequest('https://as.example.com', {
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
dpop: session,
fetchFn
});
expect(tokens.access_token).toBe('tok');
expect(fetchFn).toHaveBeenCalledTimes(2);
const secondProof = (fetchFn.mock.calls[1]![1].headers as Headers).get('DPoP');
const firstProof = (fetchFn.mock.calls[0]![1].headers as Headers).get('DPoP');
expect(secondProof).not.toBe(firstProof); // fresh jti — never resend the identical proof
expect(decodeJwtPart(secondProof!.split('.')[1]!).nonce).toBe('as-nonce-1');
// The session also remembers the nonce for future requests to this origin.
expect(session.nonceFor('https://as.example.com')).toBe('as-nonce-1');
});
it('does not retry a second time (surfaces the error) when the nonce challenge repeats', async () => {
// A real Response (not a plain mock object): parseErrorResponse — reached once the single
// retry is exhausted and the challenge repeats — branches on `instanceof Response`.
fetchFn.mockImplementation(async () =>
Response.json(
{ error: 'use_dpop_nonce' },
{
status: 400,
headers: { 'DPoP-Nonce': 'as-nonce-1' }
}
)
);
await expect(
executeTokenRequest('https://as.example.com', {
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
dpop: session,
fetchFn
})
).rejects.toMatchObject({ code: 'use_dpop_nonce' });
// Exactly one retry: the initial nonce-less attempt, and one retry carrying the nonce.
expect(fetchFn).toHaveBeenCalledTimes(2);
});
it('re-runs addClientAuthentication for the nonce retry so a private_key_jwt client_assertion is not replayed', async () => {
const addClientAuthentication = createPrivateKeyJwtAuth({
issuer: 'client-1',
subject: 'client-1',
privateKey: 'a-string-secret-at-least-256-bits-long',
alg: 'HS256'
});
const assertions: string[] = [];
fetchFn.mockImplementation(async (_url: URL, init: RequestInit) => {
assertions.push((init.body as URLSearchParams).get('client_assertion')!);
return assertions.length === 1
? Response.json({ error: 'use_dpop_nonce' }, { status: 400, headers: { 'DPoP-Nonce': 'as-nonce-1' } })
: Response.json({ access_token: 'tok', token_type: 'DPoP' });
});
await executeTokenRequest('https://as.example.com', {
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
addClientAuthentication,
dpop: session,
fetchFn
});
expect(assertions).toHaveLength(2);
// RFC 7521 §5.2 / RFC 7523 §3: an AS MAY enforce one-time use of assertions keyed on jti.
const [first, second] = assertions.map(a => decodeJwtPart(a.split('.')[1]!));
expect(second!.jti).not.toBe(first!.jti);
});
it('is unaffected when no dpop session is supplied (plain OAuth token requests keep working)', async () => {
fetchFn.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({ access_token: 'tok', token_type: 'Bearer' })
});
await executeTokenRequest('https://as.example.com', {
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
fetchFn
});
const [, init] = fetchFn.mock.calls[0]!;
expect((init.headers as Headers).has('DPoP')).toBe(false);
});
});
describe('auth() refresh — DPoP', () => {
const mockFetch = vi.fn();
let session: DpopSession;
let provider: OAuthClientProvider;
let tokenResponse: () => Response;
beforeEach(async () => {
mockFetch.mockReset();
vi.stubGlobal('fetch', mockFetch);
session = await DpopSession.create();
const tokens = vi.fn().mockResolvedValue({ access_token: 'old', token_type: 'DPoP', refresh_token: 'rt-1' });
provider = {
get redirectUrl() {
return 'http://localhost/callback';
},
get clientMetadata() {
return { redirect_uris: ['http://localhost/callback'] };
},
clientInformation: vi.fn().mockResolvedValue({ client_id: 'client-1' }),
tokens,
saveTokens: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
invalidateCredentials: vi.fn(async scope => {
if (scope === 'tokens' || scope === 'all') tokens.mockResolvedValue(undefined);
}),
dpop: () => session
};
mockFetch.mockImplementation(async (url: URL | string) => {
const urlString = url.toString();
if (urlString.includes('/.well-known/oauth-authorization-server')) {
return Response.json({
issuer: 'https://api.example.com',
authorization_endpoint: 'https://api.example.com/authorize',
token_endpoint: 'https://api.example.com/token',
response_types_supported: ['code'],
dpop_signing_alg_values_supported: ['ES256']
});
}
if (urlString === 'https://api.example.com/token') return tokenResponse();
return new Response(null, { status: 404 });
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('signs a DPoP proof into the refresh_token grant request', async () => {
tokenResponse = () => Response.json({ access_token: 'new', token_type: 'DPoP', refresh_token: 'rt-2' });
await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp' })).resolves.toBe('AUTHORIZED');
const [, init] = mockFetch.mock.calls.find(c => c[0].toString() === 'https://api.example.com/token')!;
expect((init.body as URLSearchParams).get('grant_type')).toBe('refresh_token');
const proof = new Headers(init.headers).get('DPoP');
expect(decodeJwtPart(proof!.split('.')[1]!).htu).toBe('https://api.example.com/token');
});
it('falls back to a fresh authorization when the AS rejects the refresh with invalid_dpop_proof', async () => {
// e.g. the (non-extractable) DPoP key was regenerated across a restart while the persisted
// refresh token is still bound to the old key (RFC 9449 §5) — refreshing can never succeed.
tokenResponse = () => Response.json({ error: 'invalid_dpop_proof', error_description: 'key mismatch' }, { status: 400 });
await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp' })).resolves.toBe('REDIRECT');
expect(provider.invalidateCredentials).toHaveBeenCalledWith('tokens');
expect(provider.redirectToAuthorization).toHaveBeenCalledTimes(1);
});
});
describe('extractWWWAuthenticateParams — DPoP scheme', () => {
it('extracts resource_metadata from a DPoP challenge (not just Bearer)', () => {
const response = new Response(null, {
headers: {
'WWW-Authenticate':
'DPoP error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource"'
}
});
const { resourceMetadataUrl, error } = extractWWWAuthenticateParams(response);
expect(resourceMetadataUrl?.toString()).toBe('https://example.com/.well-known/oauth-protected-resource');
expect(error).toBe('invalid_token');
});
it('extracts scope from a DPoP insufficient_scope challenge (SEP-2350 step-up for DPoP resources)', () => {
const response = new Response(null, {
headers: { 'WWW-Authenticate': 'DPoP error="insufficient_scope", scope="admin"' }
});
expect(extractWWWAuthenticateParams(response).scope).toBe('admin');
});
it('still returns {} for an unrecognized scheme', () => {
const response = new Response(null, { headers: { 'WWW-Authenticate': 'Digest realm="x"' } });
expect(extractWWWAuthenticateParams(response)).toEqual({});
});
});

View file

@ -0,0 +1,195 @@
import { accessTokenHash, DPOP_SUPPORTED_ALGS, DpopSession, generateDpopKeyPair, isDpopNonceChallenge } from '../../src/client/dpop';
function decodeJwtPart(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
describe('generateDpopKeyPair', () => {
it('defaults to ES256 and produces a matching thumbprint', async () => {
const kp = await generateDpopKeyPair();
expect(kp.alg).toBe('ES256');
expect(kp.publicJwk.kty).toBe('EC');
expect(kp.publicJwk.crv).toBe('P-256');
expect(kp.publicJwk.d).toBeUndefined();
expect(typeof kp.thumbprint).toBe('string');
expect(kp.thumbprint.length).toBeGreaterThan(0);
});
it('generates a non-extractable private key by default', async () => {
const kp = await generateDpopKeyPair();
expect(kp.privateKey.extractable).toBe(false);
});
it('honors extractable: true for callers that need to export the key', async () => {
const kp = await generateDpopKeyPair({ extractable: true });
expect(kp.privateKey.extractable).toBe(true);
});
it('produces distinct key pairs (and thumbprints) across calls', async () => {
const a = await generateDpopKeyPair();
const b = await generateDpopKeyPair();
expect(a.thumbprint).not.toBe(b.thumbprint);
});
});
describe('accessTokenHash', () => {
it('is the base64url SHA-256 digest of the token (RFC 9449 §4.1)', async () => {
const hash = await accessTokenHash('some-access-token');
// Independently computed expected digest (Node's webcrypto, not this module's code path).
const expected = Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode('some-access-token'))).toString(
'base64url'
);
expect(hash).toBe(expected);
});
it('is base64url, not base64 (no +, /, or = padding)', async () => {
const hash = await accessTokenHash('token-that-might-produce-padding-or-special-chars');
expect(hash).toMatch(/^[A-Za-z0-9_-]+$/);
});
});
describe('DpopSession.buildProof', () => {
it('produces a well-formed dpop+jwt proof with the expected header shape', async () => {
const session = await DpopSession.create();
const proof = await session.buildProof({ htm: 'post', htu: 'https://mcp.example.com/mcp' });
const [headerPart, payloadPart, signaturePart] = proof.split('.');
expect(headerPart).toBeDefined();
expect(payloadPart).toBeDefined();
expect(signaturePart!.length).toBeGreaterThan(0);
const header = decodeJwtPart(headerPart!);
expect(header.typ).toBe('dpop+jwt');
expect(header.alg).toBe('ES256');
expect(header.jwk).toMatchObject({ kty: 'EC', crv: 'P-256' });
expect((header.jwk as Record<string, unknown>).d).toBeUndefined();
});
it('normalizes htm to upper-case and strips query/fragment from htu', async () => {
const session = await DpopSession.create();
const proof = await session.buildProof({ htm: 'post', htu: 'https://mcp.example.com/mcp?foo=bar#frag' });
const payload = decodeJwtPart(proof.split('.')[1]!);
expect(payload.htm).toBe('POST');
expect(payload.htu).toBe('https://mcp.example.com/mcp');
});
it('mints a fresh jti on every call — never caches or reuses a proof', async () => {
const session = await DpopSession.create();
const first = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp' });
const second = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp' });
expect(first).not.toBe(second);
const jti1 = decodeJwtPart(first.split('.')[1]!).jti;
const jti2 = decodeJwtPart(second.split('.')[1]!).jti;
expect(jti1).not.toBe(jti2);
});
it('includes ath only when an accessToken is provided', async () => {
const session = await DpopSession.create();
const proofWithToken = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp', accessToken: 'tok' });
const withToken = decodeJwtPart(proofWithToken.split('.')[1]!);
expect(withToken.ath).toBe(await accessTokenHash('tok'));
const proofWithoutToken = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp' });
const withoutToken = decodeJwtPart(proofWithoutToken.split('.')[1]!);
expect(withoutToken.ath).toBeUndefined();
});
it('automatically carries the remembered nonce for a URL once one has been observed', async () => {
const session = await DpopSession.create();
const url = 'https://mcp.example.com/mcp';
session.rememberNonce(url, 'server-nonce-1');
const proof = await session.buildProof({ htm: 'POST', htu: url });
const payload = decodeJwtPart(proof.split('.')[1]!);
expect(payload.nonce).toBe('server-nonce-1');
});
it('an explicit nonce option overrides the remembered one', async () => {
const session = await DpopSession.create();
const url = 'https://mcp.example.com/mcp';
session.rememberNonce(url, 'remembered');
const proof = await session.buildProof({ htm: 'POST', htu: url, nonce: 'explicit' });
const payload = decodeJwtPart(proof.split('.')[1]!);
expect(payload.nonce).toBe('explicit');
});
it('keeps nonce state independent per origin', async () => {
const session = await DpopSession.create();
session.rememberNonce('https://as.example.com/token', 'as-nonce');
session.rememberNonce('https://rs.example.com/mcp', 'rs-nonce');
expect(session.nonceFor('https://as.example.com/token')).toBe('as-nonce');
expect(session.nonceFor('https://rs.example.com/mcp')).toBe('rs-nonce');
expect(session.nonceFor('https://other.example.com/x')).toBeUndefined();
});
it('supports every DPOP_SUPPORTED_ALGS entry as a construction option', async () => {
for (const alg of DPOP_SUPPORTED_ALGS) {
const session = await DpopSession.create({ alg });
expect(session.alg).toBe(alg);
const proof = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp' });
expect(decodeJwtPart(proof.split('.')[0]!).alg).toBe(alg);
}
});
it('reuses a caller-supplied key pair instead of generating a new one', async () => {
const kp = await generateDpopKeyPair();
const session = await DpopSession.create({ keyPair: kp });
expect(session.thumbprint).toBe(kp.thumbprint);
});
});
describe('DpopSession.observeNonce', () => {
it('remembers a DPoP-Nonce response header for the given url', async () => {
const session = await DpopSession.create();
const url = 'https://mcp.example.com/mcp';
const response = new Response(null, { headers: { 'DPoP-Nonce': 'fresh-nonce' } });
session.observeNonce(response, url);
expect(session.nonceFor(url)).toBe('fresh-nonce');
});
it('is a no-op when the response carries no DPoP-Nonce header', async () => {
const session = await DpopSession.create();
const url = 'https://mcp.example.com/mcp';
session.rememberNonce(url, 'existing');
session.observeNonce(new Response(null), url);
expect(session.nonceFor(url)).toBe('existing');
});
});
describe('isDpopNonceChallenge', () => {
it('is true for a 401 with a DPoP use_dpop_nonce challenge', () => {
const response = new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce", resource_metadata="https://example.com/prm"' }
});
expect(isDpopNonceChallenge(response)).toBe(true);
});
it('recognizes a DPoP challenge listed alongside other schemes', () => {
const response = new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'Bearer error="invalid_token", DPoP error="use_dpop_nonce"' }
});
expect(isDpopNonceChallenge(response)).toBe(true);
});
it('is false for a plain invalid_dpop_proof challenge (not a nonce challenge)', () => {
const response = new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'DPoP error="invalid_dpop_proof"' }
});
expect(isDpopNonceChallenge(response)).toBe(false);
});
it('is false for a non-401 status', () => {
const response = new Response(null, { status: 400, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"' } });
expect(isDpopNonceChallenge(response)).toBe(false);
});
it('is false when there is no WWW-Authenticate header at all', () => {
expect(isDpopNonceChallenge(new Response(null, { status: 401 }))).toBe(false);
});
it('is false for a Bearer-only challenge', () => {
const response = new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'Bearer error="use_dpop_nonce"' } });
expect(isDpopNonceChallenge(response)).toBe(false);
});
});

View file

@ -0,0 +1,346 @@
import type { FetchLike } from '@modelcontextprotocol/core-internal';
import type { Mock, Mocked, MockedFunction } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { OAuthClientProvider } from '../../src/client/auth';
import { DpopSession } from '../../src/client/dpop';
// `auth` is mocked (not exercised for real — it would attempt real network discovery) so the
// "credential retry happens before a nonce challenge is discovered" test below can simulate a
// successful re-authorization without a real OAuth flow. Everything else — including
// `withDpopFromProvider`, which is what makes `withOAuth` DPoP-aware — stays the real implementation.
vi.mock('../../src/client/auth', async () => {
const actual = await vi.importActual<typeof import('../../src/client/auth')>('../../src/client/auth');
return { ...actual, auth: vi.fn() };
});
import { auth } from '../../src/client/auth';
import { withDpop, withDpopFromProvider, withOAuth } from '../../src/client/middleware';
const mockAuth = auth as MockedFunction<typeof auth>;
function decodeJwtPart(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
describe('withDpop', () => {
let session: DpopSession;
let getToken: MockedFunction<() => string | undefined | Promise<string | undefined>>;
let mockFetch: MockedFunction<FetchLike>;
beforeEach(async () => {
session = await DpopSession.create();
getToken = vi.fn().mockResolvedValue('the-access-token');
mockFetch = vi.fn();
});
it('presents Authorization: DPoP <token> plus a fresh proof bound to the request', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
const [, init] = mockFetch.mock.calls[0]!;
const headers = init!.headers as Headers;
expect(headers.get('Authorization')).toBe('DPoP the-access-token');
const proof = headers.get('DPoP');
expect(proof).toBeTruthy();
const payload = decodeJwtPart(proof!.split('.')[1]!);
expect(payload.htm).toBe('POST');
expect(payload.htu).toBe('https://mcp.example.com/mcp');
expect(payload.ath).toBeDefined();
});
it('defaults to GET when no method is given', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
await enhancedFetch('https://mcp.example.com/mcp');
const payload = decodeJwtPart(((mockFetch.mock.calls[0]![1]!.headers as Headers).get('DPoP') as string).split('.')[1]!);
expect(payload.htm).toBe('GET');
});
it('sends no Authorization/DPoP headers when getToken resolves to undefined', async () => {
getToken.mockResolvedValue(undefined);
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
await enhancedFetch('https://mcp.example.com/mcp', { headers: { Accept: 'application/json' } });
const headers = new Headers(mockFetch.mock.calls[0]![1]!.headers);
expect(headers.has('Authorization')).toBe(false);
expect(headers.has('DPoP')).toBe(false);
expect(headers.get('Accept')).toBe('application/json');
});
it('retries once, with a fresh proof carrying the nonce, on a use_dpop_nonce challenge', async () => {
mockFetch
.mockResolvedValueOnce(
new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'rs-nonce-1' }
})
)
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
const response = await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(2);
const firstProof = mockFetch.mock.calls[0]![1]!.headers as Headers;
const secondProof = mockFetch.mock.calls[1]![1]!.headers as Headers;
expect(secondProof.get('DPoP')).not.toBe(firstProof.get('DPoP')); // fresh jti, never resent
expect(decodeJwtPart((secondProof.get('DPoP') as string).split('.')[1]!).nonce).toBe('rs-nonce-1');
});
it('carries a DPoP-Nonce received on a 2xx response in the next request’s proof (RFC 9449 §8.2)', async () => {
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 200, headers: { 'DPoP-Nonce': 'rs-nonce-1' } }))
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
expect(mockFetch).toHaveBeenCalledTimes(2);
const secondProof = (mockFetch.mock.calls[1]![1]!.headers as Headers).get('DPoP') as string;
expect(decodeJwtPart(secondProof.split('.')[1]!).nonce).toBe('rs-nonce-1');
});
it('does not retry on an ordinary (non-nonce) 401', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="invalid_token"' } }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
const response = await enhancedFetch('https://mcp.example.com/mcp');
expect(response.status).toBe(401);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('does not retry a use_dpop_nonce challenge that carries no fresh DPoP-Nonce (nothing new to retry with)', async () => {
session.rememberNonce('https://mcp.example.com/mcp', 'stale-nonce');
mockFetch.mockResolvedValue(new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"' } }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
const response = await enhancedFetch('https://mcp.example.com/mcp');
expect(response.status).toBe(401);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
describe('withDpop — session/token sources', () => {
it('passes the request through untouched when the session source resolves to undefined', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }));
const init = { method: 'POST', headers: { Authorization: 'Bearer tok' } };
const noSession = vi.fn<() => Promise<DpopSession | undefined>>().mockResolvedValue(undefined);
await withDpop(noSession, async () => 'tok')(mockFetch)('https://mcp.example.com/mcp', init);
expect(mockFetch).toHaveBeenCalledWith('https://mcp.example.com/mcp', init);
});
it('leaves an existing Authorization header alone when getToken resolves to undefined', async () => {
const session = await DpopSession.create();
const mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }));
const init = { method: 'POST', headers: { Authorization: 'Bearer tok' } };
const noToken = vi.fn<() => Promise<string | undefined>>().mockResolvedValue(undefined);
await withDpop(session, noToken)(mockFetch)('https://mcp.example.com/mcp', init);
expect(mockFetch).toHaveBeenCalledWith('https://mcp.example.com/mcp', init);
});
it('accepts a lazily-resolved session', async () => {
const session = await DpopSession.create();
const mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }));
await withDpop(
async () => session,
async () => 'tok'
)(mockFetch)('https://mcp.example.com/mcp', { method: 'POST' });
expect((mockFetch.mock.calls[0]![1]!.headers as Headers).get('Authorization')).toBe('DPoP tok');
});
});
describe('withDpopFromProvider', () => {
let session: DpopSession;
let provider: Mocked<OAuthClientProvider>;
let mockFetch: MockedFunction<FetchLike>;
const bearerInit = { method: 'POST', headers: { Authorization: 'Bearer tok-1' } };
beforeEach(async () => {
session = await DpopSession.create();
provider = {
get redirectUrl() {
return 'http://localhost/callback';
},
get clientMetadata() {
return { redirect_uris: ['http://localhost/callback'] };
},
tokens: vi.fn().mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' }),
saveTokens: vi.fn(),
clientInformation: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
dpop: vi.fn().mockResolvedValue(session)
};
mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }));
});
it('upgrades a token_type=DPoP token to the DPoP scheme with a proof bound to the request', async () => {
await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp?x=1', bearerInit);
const headers = mockFetch.mock.calls[0]![1]!.headers as Headers;
expect(headers.get('Authorization')).toBe('DPoP tok-1');
const payload = decodeJwtPart(headers.get('DPoP')!.split('.')[1]!);
expect(payload).toMatchObject({ htm: 'POST', htu: 'https://mcp.example.com/mcp' });
expect(payload.ath).toBeDefined();
});
it('leaves a token_type=Bearer token on the Bearer scheme with no proof, even though dpop() resolves (RFC 9449 §7.1)', async () => {
// An AS that ignored the DPoP proof (or does not support DPoP) issues token_type=Bearer;
// presenting that with the DPoP scheme to a Bearer-only resource server is a guaranteed 401.
provider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'Bearer' });
await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp', bearerInit);
expect(mockFetch).toHaveBeenCalledWith('https://mcp.example.com/mcp', bearerInit);
});
it('is a pass-through when dpop() resolves to undefined', async () => {
(provider.dpop as Mock).mockResolvedValue(undefined);
await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp', bearerInit);
expect(mockFetch).toHaveBeenCalledWith('https://mcp.example.com/mcp', bearerInit);
});
it('retries once with the server nonce on a use_dpop_nonce challenge, and not when the challenge carries no fresh DPoP-Nonce', async () => {
mockFetch
.mockResolvedValueOnce(
new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'n1' } })
)
.mockResolvedValueOnce(new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"' } }));
const response = await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp', bearerInit);
expect(response.status).toBe(401);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(decodeJwtPart((mockFetch.mock.calls[1]![1]!.headers as Headers).get('DPoP')!.split('.')[1]!).nonce).toBe('n1');
});
it('stamps a throwing tokens()/dpop() as an auth-seam failure (not a network error)', async () => {
const { isAuthSeamEscape } = await import('../../src/client/authSeam');
provider.tokens.mockRejectedValue(new Error('storage unavailable'));
const error = await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp', bearerInit).catch(error_ => error_);
expect(isAuthSeamEscape(error)).toBe(true);
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('withOAuth — DPoP-aware', () => {
let mockProvider: Mocked<OAuthClientProvider>;
let mockFetch: MockedFunction<FetchLike>;
let session: DpopSession;
beforeEach(async () => {
session = await DpopSession.create();
mockProvider = {
get redirectUrl() {
return 'http://localhost/callback';
},
get clientMetadata() {
return { redirect_uris: ['http://localhost/callback'] };
},
tokens: vi.fn(),
saveTokens: vi.fn(),
clientInformation: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
invalidateCredentials: vi.fn(),
dpop: vi.fn().mockResolvedValue(session)
};
mockFetch = vi.fn();
});
it('presents DPoP Authorization + proof (not Bearer) when provider.dpop() resolves to a session', async () => {
mockProvider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' });
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const enhancedFetch = withOAuth(mockProvider, 'https://mcp.example.com')(mockFetch);
await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
const headers = mockFetch.mock.calls[0]![1]!.headers as Headers;
expect(headers.get('Authorization')).toBe('DPoP tok-1');
expect(headers.get('DPoP')).toBeTruthy();
});
it('keeps presenting plain Bearer when the provider has no dpop() (existing behavior unaffected)', async () => {
mockProvider.dpop = undefined;
mockProvider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'Bearer' });
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const enhancedFetch = withOAuth(mockProvider, 'https://mcp.example.com')(mockFetch);
await enhancedFetch('https://mcp.example.com/mcp');
const headers = mockFetch.mock.calls[0]![1]!.headers as Headers;
expect(headers.get('Authorization')).toBe('Bearer tok-1');
expect(headers.has('DPoP')).toBe(false);
});
it('retries once on a use_dpop_nonce challenge without invoking the auth() re-authorization flow', async () => {
mockProvider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' });
mockFetch
.mockResolvedValueOnce(
new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'rs-nonce-1' }
})
)
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const enhancedFetch = withOAuth(mockProvider, 'https://mcp.example.com')(mockFetch);
const response = await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(2);
// The nonce leg is a self-contained retry — it never calls provider.saveTokens or any
// other re-authorization side effect.
expect(mockProvider.saveTokens).not.toHaveBeenCalled();
});
it('completes credential re-authorization first, then a nonce challenge discovered on the retried request (auth/dpop-nonce shape)', async () => {
// No token yet -> the first 401 carries no DPoP-Nonce header at all, so it is NOT a nonce
// challenge; only after auth() succeeds and a real token is presented does the resource
// server reveal its nonce requirement. Regression test for a bug where the nonce check
// only ran *before* the credential retry, never after it — real conformance servers
// (auth/dpop-nonce) hit exactly this ordering.
mockProvider.tokens.mockResolvedValue(undefined);
mockAuth.mockImplementation(async () => {
mockProvider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' });
return 'AUTHORIZED';
});
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 401 })) // no token, no nonce header
.mockResolvedValueOnce(
new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'rs-nonce-1' }
})
)
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const enhancedFetch = withOAuth(mockProvider, 'https://mcp.example.com')(mockFetch);
const response = await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(3);
expect(mockAuth).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,153 @@
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { FetchLike } from '@modelcontextprotocol/core-internal';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth';
import { DpopSession } from '../../src/client/dpop';
import { SSEClientTransport } from '../../src/client/sse';
function decodeJwtPart(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
interface Seen {
method: string;
path: string;
authorization?: string;
proof?: Record<string, unknown>;
}
/**
* Drives the transport against a real HTTP+SSE server with a real {@linkcode DpopSession} behind an
* {@linkcode OAuthClientProvider}, asserting on what the server receives. The announced message
* endpoint is deliberately a different path from the SSE URL (the normal shape for this transport)
* so the POST proof's `htu` binding is actually exercised.
*/
describe('SSEClientTransport — DPoP', () => {
let server: Server;
let baseUrl: URL;
let session: DpopSession;
let provider: OAuthClientProvider;
let transport: SSEClientTransport;
let seen: Seen[];
let postHandler: (req: IncomingMessage, res: ServerResponse) => void;
beforeEach(async () => {
seen = [];
postHandler = (_req, res) => res.writeHead(202).end();
server = createServer((req, res) => {
const proofHeader = req.headers.dpop as string | undefined;
seen.push({
method: req.method!,
path: req.url!.split('?')[0]!,
authorization: req.headers.authorization,
proof: proofHeader ? decodeJwtPart(proofHeader.split('.')[1]!) : undefined
});
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive'
});
res.write('event: endpoint\n');
res.write(`data: ${baseUrl.origin}/messages?sessionId=s1\n\n`);
return;
}
req.resume().on('end', () => postHandler(req, res));
});
await new Promise<void>(resolve => {
server.listen(0, '127.0.0.1', () => {
baseUrl = new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}/sse`);
resolve();
});
});
session = await DpopSession.create();
provider = {
get redirectUrl() {
return 'http://localhost/callback';
},
get clientMetadata() {
return { redirect_uris: ['http://localhost/callback'] };
},
clientInformation: vi.fn(),
tokens: vi.fn().mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' }),
saveTokens: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
dpop: () => session
};
transport = new SSEClientTransport(baseUrl, { authProvider: provider });
await transport.start();
});
afterEach(async () => {
await transport.close().catch(() => {});
await new Promise<void>(resolve => server.close(() => resolve()));
});
it('presents DPoP Authorization + a proof bound to GET and the SSE URL on the event stream', () => {
expect(seen[0]).toMatchObject({ method: 'GET', authorization: 'DPoP tok-1', proof: { htm: 'GET', htu: baseUrl.href } });
});
it('presents DPoP Authorization + a proof on POST, bound to the announced message endpoint (not the SSE URL)', async () => {
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
// RFC 9449 §4.2: htu is the URI of *this* request, minus query — the /messages endpoint.
expect(seen[1]).toMatchObject({
method: 'POST',
path: '/messages',
authorization: 'DPoP tok-1',
proof: { htm: 'POST', htu: `${baseUrl.origin}/messages` }
});
});
it('retries the POST once on a use_dpop_nonce challenge, with a fresh proof carrying the nonce, without re-authorizing', async () => {
const onUnauthorized = vi.spyOn((transport as unknown as { _authProvider: AuthProvider })._authProvider, 'onUnauthorized');
let postCalls = 0;
postHandler = (_req, res) => {
postCalls++;
if (postCalls === 1) {
res.writeHead(401, { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'rs-nonce-1' }).end();
return;
}
res.writeHead(202).end();
};
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
const posts = seen.filter(s => s.method === 'POST');
expect(posts).toHaveLength(2);
expect(posts[1]!.proof).toMatchObject({ htu: `${baseUrl.origin}/messages`, nonce: 'rs-nonce-1' });
expect(posts[1]!.proof!.jti).not.toBe(posts[0]!.proof!.jti);
expect(onUnauthorized).not.toHaveBeenCalled();
});
it('wraps a caller-supplied eventSourceInit.fetch too (it still runs, and the stream request is DPoP-signed)', async () => {
await transport.close();
const esFetch = vi.fn<FetchLike>((url, init) => fetch(url, init));
const t = new SSEClientTransport(baseUrl, { authProvider: provider, eventSourceInit: { fetch: esFetch as typeof fetch } });
await t.start();
expect(esFetch).toHaveBeenCalledTimes(1);
expect(new Headers(esFetch.mock.calls[0]![1]!.headers).get('Authorization')).toBe('DPoP tok-1');
expect(seen.at(-1)).toMatchObject({ method: 'GET', proof: { htm: 'GET' } });
await t.close();
});
it('leaves a plain AuthProvider untouched (Bearer via token())', async () => {
await transport.close();
const bearer = new SSEClientTransport(baseUrl, { authProvider: { token: async () => 'bearer-tok' } });
await bearer.start();
await bearer.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
expect(seen.at(-1)).toMatchObject({ method: 'POST', authorization: 'Bearer bearer-tok', proof: undefined });
await bearer.close();
});
});

View file

@ -0,0 +1,206 @@
import type { FetchLike } from '@modelcontextprotocol/core-internal';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth';
import { DpopSession } from '../../src/client/dpop';
import { withDpop } from '../../src/client/middleware';
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp';
function decodeJwtPart(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
function proofOf(call: unknown[]): Record<string, unknown> {
return decodeJwtPart(new Headers((call[1] as RequestInit).headers).get('DPoP')!.split('.')[1]!);
}
const nonceChallenge = (nonce?: string) =>
new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', ...(nonce ? { 'DPoP-Nonce': nonce } : {}) }
});
/**
* These tests drive the transport with a real {@linkcode DpopSession} behind an
* {@linkcode OAuthClientProvider} and assert on what reaches `fetch` — the transport applies DPoP by
* wrapping its resource-server fetch with `withDpopFromProvider`, so the wire is the contract.
*/
describe('StreamableHTTPClientTransport — DPoP', () => {
const url = new URL('http://localhost:1234/mcp');
let session: DpopSession;
let provider: OAuthClientProvider & { tokens: Mock };
let transport: StreamableHTTPClientTransport;
let fetchSpy: Mock<typeof fetch>;
const authProviderOf = (t: StreamableHTTPClientTransport) => (t as unknown as { _authProvider: AuthProvider })._authProvider;
beforeEach(async () => {
session = await DpopSession.create();
provider = {
get redirectUrl() {
return 'http://localhost/callback';
},
get clientMetadata() {
return { redirect_uris: ['http://localhost/callback'] };
},
clientInformation: vi.fn(),
tokens: vi.fn().mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' }),
saveTokens: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
dpop: () => session
};
fetchSpy = vi.spyOn(globalThis, 'fetch');
transport = new StreamableHTTPClientTransport(url, { authProvider: provider });
});
afterEach(async () => {
await transport.close().catch(() => {});
vi.restoreAllMocks();
});
it('presents Authorization: DPoP + a proof bound to POST and the MCP URL', async () => {
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
const headers = new Headers((fetchSpy.mock.calls[0]![1] as RequestInit).headers);
expect(headers.get('Authorization')).toBe('DPoP tok-1');
expect(proofOf(fetchSpy.mock.calls[0]!)).toMatchObject({ htm: 'POST', htu: url.href });
expect(proofOf(fetchSpy.mock.calls[0]!).ath).toBeDefined();
});
it('binds the GET SSE stream proof to GET', async () => {
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 405 }));
await (transport as unknown as { _startOrAuthSse: (o: object) => Promise<void> })._startOrAuthSse({});
expect(proofOf(fetchSpy.mock.calls[0]!)).toMatchObject({ htm: 'GET', htu: url.href });
});
it('binds the session-termination proof to DELETE, and retries DELETE once on a use_dpop_nonce challenge', async () => {
(transport as unknown as { _sessionId?: string })._sessionId = 'sess-1';
fetchSpy.mockResolvedValueOnce(nonceChallenge('rs-nonce-1')).mockResolvedValueOnce(new Response(null, { status: 200 }));
await expect(transport.terminateSession()).resolves.toBeUndefined();
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(proofOf(fetchSpy.mock.calls[0]!)).toMatchObject({ htm: 'DELETE', htu: url.href });
expect(proofOf(fetchSpy.mock.calls[1]!)).toMatchObject({ htm: 'DELETE', nonce: 'rs-nonce-1' });
});
it('presents a token_type=Bearer token with the Bearer scheme and no proof, even though dpop() resolves (RFC 9449 §7.1)', async () => {
provider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'Bearer' });
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
const headers = new Headers((fetchSpy.mock.calls[0]![1] as RequestInit).headers);
expect(headers.get('Authorization')).toBe('Bearer tok-1');
expect(headers.has('DPoP')).toBe(false);
});
it("a caller-supplied per-request 'dpop' header cannot override the transport's own proof (reserved header name)", async () => {
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send(
{ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' },
{ headers: { dpop: 'attacker-supplied-proof', DPoP: 'attacker-supplied-proof-2' } }
);
expect(proofOf(fetchSpy.mock.calls[0]!).htm).toBe('POST');
});
it('retries once on a use_dpop_nonce challenge with a fresh proof carrying the nonce, without re-authorizing', async () => {
const onUnauthorized = vi.spyOn(authProviderOf(transport), 'onUnauthorized');
fetchSpy.mockResolvedValueOnce(nonceChallenge('rs-nonce-1')).mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
expect(fetchSpy).toHaveBeenCalledTimes(2);
const [first, second] = fetchSpy.mock.calls.map(c => proofOf(c));
expect(second!.nonce).toBe('rs-nonce-1');
expect(second!.jti).not.toBe(first!.jti); // RFC 9449 §4.2: never replay the original proof
expect(onUnauthorized).not.toHaveBeenCalled();
});
it('does not spend the nonce retry on a use_dpop_nonce challenge that carries no fresh DPoP-Nonce', async () => {
session.rememberNonce(url, 'stale-nonce');
const onUnauthorized = vi.spyOn(authProviderOf(transport), 'onUnauthorized').mockResolvedValue();
fetchSpy.mockResolvedValueOnce(nonceChallenge()).mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
// Straight to the credential path: challenge → onUnauthorized → one retry.
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(onUnauthorized).toHaveBeenCalledTimes(1);
});
it('bounds retries when the nonce challenge never clears: a nonce retry per attempt, one credential retry, then throws', async () => {
const onUnauthorized = vi.spyOn(authProviderOf(transport), 'onUnauthorized').mockResolvedValue();
fetchSpy.mockImplementation(async () => nonceChallenge('rs-nonce-1'));
await expect(transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' })).rejects.toThrow();
// attempt 1 (+ its nonce retry) → onUnauthorized → attempt 2 (+ its nonce retry) → give up.
expect(fetchSpy).toHaveBeenCalledTimes(4);
expect(onUnauthorized).toHaveBeenCalledTimes(1);
});
it('handles a credential 401 first and a nonce challenge on the re-authorized retry (auth/dpop-nonce shape)', async () => {
const onUnauthorized = vi.spyOn(authProviderOf(transport), 'onUnauthorized').mockResolvedValue();
fetchSpy
.mockResolvedValueOnce(new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="invalid_token"' } }))
.mockResolvedValueOnce(nonceChallenge('rs-nonce-1'))
.mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
expect(fetchSpy).toHaveBeenCalledTimes(3);
expect(onUnauthorized).toHaveBeenCalledTimes(1);
expect(proofOf(fetchSpy.mock.calls[2]!).nonce).toBe('rs-nonce-1');
});
it('carries a DPoP-Nonce received on a 2xx response into the next request’s proof (RFC 9449 §8.2)', async () => {
fetchSpy
.mockResolvedValueOnce(new Response(null, { status: 202, headers: { 'DPoP-Nonce': 'rs-nonce-1' } }))
.mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-2' });
expect(proofOf(fetchSpy.mock.calls[1]!).nonce).toBe('rs-nonce-1');
});
it('wraps a caller-supplied fetch (it still runs, and sees the DPoP headers)', async () => {
const customFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 202 }));
const t = new StreamableHTTPClientTransport(url, { authProvider: provider, fetch: customFetch });
await t.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
expect(fetchSpy).not.toHaveBeenCalled();
expect(new Headers(customFetch.mock.calls[0]![1]!.headers).get('Authorization')).toBe('DPoP tok-1');
await t.close();
});
it('leaves a plain AuthProvider untouched (Bearer via token()); DPoP there is opt-in via fetch: withDpop(...)', async () => {
fetchSpy.mockResolvedValue(new Response(null, { status: 202 }));
const bearer = new StreamableHTTPClientTransport(url, { authProvider: { token: async () => 'bearer-tok' } });
await bearer.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
expect(new Headers((fetchSpy.mock.calls[0]![1] as RequestInit).headers).get('Authorization')).toBe('Bearer bearer-tok');
expect(new Headers((fetchSpy.mock.calls[0]![1] as RequestInit).headers).has('DPoP')).toBe(false);
await bearer.close();
const explicit = new StreamableHTTPClientTransport(url, {
authProvider: { token: async () => 'pop-tok' },
fetch: withDpop(session, () => 'pop-tok')(fetch)
});
await explicit.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-2' });
expect(new Headers((fetchSpy.mock.calls[1]![1] as RequestInit).headers).get('Authorization')).toBe('DPoP pop-tok');
expect(proofOf(fetchSpy.mock.calls[1]!)).toMatchObject({ htm: 'POST', htu: url.href });
await explicit.close();
});
});

View file

@ -97,7 +97,19 @@ export enum OAuthErrorCode {
/**
* The requested resource is invalid, missing, unknown, or malformed. (Custom error for resource indicators - RFC 8707)
*/
InvalidTarget = 'invalid_target'
InvalidTarget = 'invalid_target',
/**
* The DPoP proof accompanying the request is invalid, or does not match the key the grant
* (e.g. a refresh token) is bound to. (RFC 9449 §5 / §7.1)
*/
InvalidDpopProof = 'invalid_dpop_proof',
/**
* The server requires a server-supplied nonce in the DPoP proof; retry with the `DPoP-Nonce`
* it returned. (RFC 9449 §8 / §9)
*/
UseDpopNonce = 'use_dpop_nonce'
}
/**

View file

@ -68,7 +68,12 @@ export const OAuthMetadataSchema = z.looseObject({
code_challenge_methods_supported: z.array(z.string()).optional(),
client_id_metadata_document_supported: z.boolean().optional(),
// eslint-disable-next-line unicorn/prefer-top-level-await -- Zod .catch(), not a Promise chain
authorization_response_iss_parameter_supported: z.boolean().optional().catch(undefined)
authorization_response_iss_parameter_supported: z.boolean().optional().catch(undefined),
/**
* RFC 9449 §5.1 (DPoP, SEP-1932): the JWS `alg` values this authorization server accepts for
* DPoP proofs at the token endpoint. A server supporting DPoP MUST publish this field.
*/
dpop_signing_alg_values_supported: z.array(z.string()).optional()
});
/**

View file

@ -29,14 +29,12 @@ client:
# (none: SEP-2468/2352/2350/837/2207/990 burned by the auth bundle; the
# last referee-side gap — conformance#361 callback-iss — closed at alpha.6)
#
# --- SEP-1932 (DPoP) / SEP-1933 (WIF) — extension-tagged auth scenarios, new in the alpha.10 referee ---
# The OAuth client implements neither DPoP proofs (RFC 9449) nor the
# urn:ietf:params:oauth:grant-type:jwt-bearer grant, so every check in
# these scenarios fails. Client-side extension scenarios are selected only
# by `--suite all`; the 2026 leg cannot flag them stale (extension
# --- SEP-1933 (WIF) — extension-tagged auth scenario, new in the alpha.10 referee ---
# The OAuth client does not implement the urn:ietf:params:oauth:grant-type:jwt-bearer
# grant, so every check in this scenario fails. Client-side extension scenarios are
# selected only by `--suite all`; the 2026 leg cannot flag it stale (extension
# scenarios never match a --spec-version filter).
- auth/dpop
- auth/dpop-nonce
# (SEP-1932 DPoP burned by the auth/dpop + auth/dpop-nonce client implementation.)
- auth/wif-jwt-bearer
server:

View file

@ -23,6 +23,7 @@ import {
import * as z from 'zod/v4';
import { ConformanceOAuthProvider } from './helpers/conformanceOAuthProvider';
import { DpopOAuthProvider } from './helpers/dpopClient';
import { logger } from './helpers/logger';
import { handle401, withOAuthRetry } from './helpers/withOAuthRetry';
@ -505,6 +506,51 @@ registerScenarios(
runAuthClient
);
// ============================================================================
// DPoP sender-constrained tokens (SEP-1932 / RFC 9449, draft extension)
// ============================================================================
/**
* Identical to {@linkcode runAuthClient} except the provider carries a DPoP session
* ({@linkcode DpopOAuthProvider}) — every DPoP-specific behavior (token-request proof, the
* `DPoP` Authorization scheme, a fresh per-request proof, AS/RS `use_dpop_nonce` retry) is the
* SDK's own (`@modelcontextprotocol/client`'s `dpop.ts` / `auth.ts` / `streamableHttp.ts`),
* exercised end-to-end here rather than re-implemented. One handler drives both `auth/dpop`
* (nonce-less) and `auth/dpop-nonce` — which posture runs depends only on whether the referee's
* authorization server / MCP server issue a nonce challenge, which the SDK reacts to automatically.
*/
async function runDpopAuthClient(serverUrl: string): Promise<void> {
const client = new Client(
{ name: 'test-dpop-auth-client', version: '1.0.0' },
{ capabilities: {}, versionNegotiation: { mode: 'auto' } }
);
const provider = new DpopOAuthProvider(
'http://localhost:3000/callback',
{ client_name: 'test-dpop-auth-client', redirect_uris: ['http://localhost:3000/callback'] },
CIMD_CLIENT_METADATA_URL
);
const dpopFetch = withOAuthRetry('test-dpop-auth-client', new URL(serverUrl), handle401, CIMD_CLIENT_METADATA_URL, provider)(fetch);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: dpopFetch
});
await client.connect(transport);
logger.debug('Successfully connected to MCP server (DPoP)');
await client.listTools();
logger.debug('Successfully listed tools');
await client.callTool({ name: 'test-tool', arguments: {} });
logger.debug('Successfully called tool');
await transport.close();
logger.debug('Connection closed successfully');
}
registerScenarios(['auth/dpop', 'auth/dpop-nonce'], runDpopAuthClient);
// ============================================================================
// Client Credentials scenarios
// ============================================================================

View file

@ -0,0 +1,29 @@
import type { OAuthClientMetadata } from '@modelcontextprotocol/client';
import { DpopSession } from '@modelcontextprotocol/client';
import { ConformanceOAuthProvider } from './conformanceOAuthProvider';
/**
* {@linkcode ConformanceOAuthProvider} plus a DPoP signing session (SEP-1932 / RFC 9449).
*
* Adding `dpop()` is the *only* thing this class does — every DPoP-specific behavior (a proof at
* the token endpoint, presenting the token with the `DPoP` scheme, a fresh proof per MCP request,
* retrying on an AS/RS `use_dpop_nonce` challenge) lives in `@modelcontextprotocol/client` itself
* (`dpop.ts` / `auth.ts` / `streamableHttp.ts`) and is exercised end-to-end through this provider,
* not re-implemented here. The same handler drives both the `auth/dpop` (nonce-less) and
* `auth/dpop-nonce` postures — which one is exercised depends entirely on whether the test
* authorization server / MCP server issue a `use_dpop_nonce` challenge, which the SDK reacts to
* automatically.
*/
export class DpopOAuthProvider extends ConformanceOAuthProvider {
private readonly _dpopSession: Promise<DpopSession>;
constructor(redirectUrl: string | URL, clientMetadata: OAuthClientMetadata, clientMetadataUrl?: string | URL) {
super(redirectUrl, clientMetadata, clientMetadataUrl);
this._dpopSession = DpopSession.create();
}
async dpop(): Promise<DpopSession> {
return this._dpopSession;
}
}

View file

@ -4,7 +4,8 @@ import {
computeScopeUnion,
extractWWWAuthenticateParams,
isStrictScopeSuperset,
UnauthorizedError
UnauthorizedError,
withDpopFromProvider
} from '@modelcontextprotocol/client';
import { ConformanceOAuthProvider } from './conformanceOAuthProvider';
@ -94,7 +95,14 @@ export const withOAuthRetry = (
},
clientMetadataUrl
);
return (next: FetchLike) => {
return (baseNext: FetchLike) => {
// Same composition as the SDK's withOAuth: DPoP request-signing sits *below* this
// Bearer/re-auth layer so it binds proofs to the real request and retries use_dpop_nonce
// challenges on every attempt. It is a pass-through unless the provider implements dpop()
// (helpers/dpopClient.ts). auth() keeps the unwrapped fetch — token-endpoint DPoP is the
// SDK's executeTokenRequest's job.
const next = withDpopFromProvider(provider)(baseNext);
return async (input: string | URL, init?: RequestInit): Promise<Response> => {
const makeRequest = async (): Promise<Response> => {
const headers = new Headers(init?.headers);
@ -113,7 +121,7 @@ export const withOAuthRetry = (
// Handle 401/403 responses by attempting re-authentication
if (response.status === 401 || response.status === 403) {
const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin);
await handle401Fn(response, provider, next, serverUrl);
await handle401Fn(response, provider, baseNext, serverUrl);
response = await makeRequest();
}