diff --git a/.changeset/dpop-client-tokens.md b/.changeset/dpop-client-tokens.md new file mode 100644 index 0000000000..86477bbc42 --- /dev/null +++ b/.changeset/dpop-client-tokens.md @@ -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 ` 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`. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 7b25c01fe8..2086f0677d 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -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; + /** * 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 { @@ -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 => { + 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 { @@ -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 { @@ -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 }); } diff --git a/packages/client/src/client/dpop.ts b/packages/client/src/client/dpop.ts new file mode 100644 index 0000000000..e7ad624b4c --- /dev/null +++ b/packages/client/src/client/dpop.ts @@ -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 { + 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 { + 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 { + 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(); + + 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 { + 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 { + const jose = await importJose(); + const htu = stripQueryAndFragment(request.htu); + const nonce = request.nonce ?? this.nonceFor(request.htu); + + const payload: Record = { + 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); +} diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index c86db72d2c..179a1643eb 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -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 => { 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; + +/** + * 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); + +/** + * 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 ` + * 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 => { + 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 */ diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index 0cc77d8f4f..965bb91daa 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -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 { - 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, diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 4380aad1cd..c6eaef46d8 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -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 = 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; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index cfe9f88389..0b5b6e86ea 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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, diff --git a/packages/client/test/client/auth.dpop.test.ts b/packages/client/test/client/auth.dpop.test.ts new file mode 100644 index 0000000000..c8c84dab27 --- /dev/null +++ b/packages/client/test/client/auth.dpop.test.ts @@ -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 { + 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({}); + }); +}); diff --git a/packages/client/test/client/dpop.test.ts b/packages/client/test/client/dpop.test.ts new file mode 100644 index 0000000000..8906ef5255 --- /dev/null +++ b/packages/client/test/client/dpop.test.ts @@ -0,0 +1,195 @@ +import { accessTokenHash, DPOP_SUPPORTED_ALGS, DpopSession, generateDpopKeyPair, isDpopNonceChallenge } from '../../src/client/dpop'; + +function decodeJwtPart(part: string): Record { + 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).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); + }); +}); diff --git a/packages/client/test/client/middleware.dpop.test.ts b/packages/client/test/client/middleware.dpop.test.ts new file mode 100644 index 0000000000..427f27e16f --- /dev/null +++ b/packages/client/test/client/middleware.dpop.test.ts @@ -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('../../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; + +function decodeJwtPart(part: string): Record { + return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); +} + +describe('withDpop', () => { + let session: DpopSession; + let getToken: MockedFunction<() => string | undefined | Promise>; + let mockFetch: MockedFunction; + + beforeEach(async () => { + session = await DpopSession.create(); + getToken = vi.fn().mockResolvedValue('the-access-token'); + mockFetch = vi.fn(); + }); + + it('presents Authorization: DPoP 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().mockResolvedValue(new Response(null, { status: 200 })); + const init = { method: 'POST', headers: { Authorization: 'Bearer tok' } }; + + const noSession = vi.fn<() => Promise>().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().mockResolvedValue(new Response(null, { status: 200 })); + const init = { method: 'POST', headers: { Authorization: 'Bearer tok' } }; + + const noToken = vi.fn<() => Promise>().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().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; + let mockFetch: MockedFunction; + 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().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; + let mockFetch: MockedFunction; + 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); + }); +}); diff --git a/packages/client/test/client/sse.dpop.test.ts b/packages/client/test/client/sse.dpop.test.ts new file mode 100644 index 0000000000..04c231e4c1 --- /dev/null +++ b/packages/client/test/client/sse.dpop.test.ts @@ -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 { + return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); +} + +interface Seen { + method: string; + path: string; + authorization?: string; + proof?: Record; +} + +/** + * 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(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(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((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(); + }); +}); diff --git a/packages/client/test/client/streamableHttp.dpop.test.ts b/packages/client/test/client/streamableHttp.dpop.test.ts new file mode 100644 index 0000000000..d3445f234b --- /dev/null +++ b/packages/client/test/client/streamableHttp.dpop.test.ts @@ -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 { + return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); +} + +function proofOf(call: unknown[]): Record { + 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; + + 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 })._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().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(); + }); +}); diff --git a/packages/core-internal/src/auth/errors.ts b/packages/core-internal/src/auth/errors.ts index 13f0558fad..395051aa98 100644 --- a/packages/core-internal/src/auth/errors.ts +++ b/packages/core-internal/src/auth/errors.ts @@ -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' } /** diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index e21076d817..cf98edd716 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -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() }); /** diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 1e256bec6d..99045c1023 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -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: diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index cf33555a4b..450f1676b3 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -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 { + 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 // ============================================================================ diff --git a/test/conformance/src/helpers/dpopClient.ts b/test/conformance/src/helpers/dpopClient.ts new file mode 100644 index 0000000000..d14495d52b --- /dev/null +++ b/test/conformance/src/helpers/dpopClient.ts @@ -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; + + constructor(redirectUrl: string | URL, clientMetadata: OAuthClientMetadata, clientMetadataUrl?: string | URL) { + super(redirectUrl, clientMetadata, clientMetadataUrl); + this._dpopSession = DpopSession.create(); + } + + async dpop(): Promise { + return this._dpopSession; + } +} diff --git a/test/conformance/src/helpers/withOAuthRetry.ts b/test/conformance/src/helpers/withOAuthRetry.ts index 4f037ea454..44dd429d81 100644 --- a/test/conformance/src/helpers/withOAuthRetry.ts +++ b/test/conformance/src/helpers/withOAuthRetry.ts @@ -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 => { const makeRequest = async (): Promise => { 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(); }