feat(client): add DPoP (RFC 9449) sender-constrained token support (#2629)
Co-authored-by: Paul Carleton <paulc@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
70de0c8b56
commit
dcc01028ff
18 changed files with 1753 additions and 30 deletions
10
.changeset/dpop-client-tokens.md
Normal file
10
.changeset/dpop-client-tokens.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
'@modelcontextprotocol/client': minor
|
||||
'@modelcontextprotocol/core': minor
|
||||
---
|
||||
|
||||
Add DPoP (RFC 9449 / SEP-1932) sender-constrained access token support to the client.
|
||||
|
||||
- Opt in by implementing `OAuthClientProvider.dpop()` returning a `DpopSession` (new, along with `generateDpopKeyPair`, `accessTokenHash`, `isDpopNonceChallenge`). `auth()` / `exchangeAuthorization` / `refreshAuthorization` / `fetchToken` then sign a DPoP proof into token requests (retrying once on an authorization-server `use_dpop_nonce` challenge, with client authentication re-applied per attempt), and `StreamableHTTPClientTransport`, `SSEClientTransport` and `withOAuth` present a `token_type: "DPoP"` access token as `Authorization: DPoP <token>` plus a fresh per-request proof, retry a resource-server `use_dpop_nonce` challenge once, and pick up a `DPoP-Nonce` delivered on any response. Tokens the AS issued as `Bearer` are still presented as Bearer.
|
||||
- DPoP is applied at the fetch layer: the transports wrap their resource-server `fetch` (including a caller-supplied `fetch` / `eventSourceInit.fetch`) with the new `withDpopFromProvider(provider)` middleware, so proofs are always bound to the request actually sent. `withDpop(session, getToken)` is exported for callers that manage tokens themselves (e.g. alongside a minimal `AuthProvider`); the `AuthProvider` interface itself is unchanged.
|
||||
- `auth()` now recovers from `invalid_dpop_proof` on refresh (e.g. a refresh token bound to a key that is no longer held) by discarding the tokens and re-authorizing, like `invalid_grant`. `OAuthErrorCode` gains `InvalidDpopProof` and `UseDpopNonce`; `extractWWWAuthenticateParams` recognizes the `DPoP` challenge scheme; `OAuthMetadataSchema` gains `dpop_signing_alg_values_supported`.
|
||||
|
|
@ -30,6 +30,9 @@ import {
|
|||
import pkceChallenge from 'pkce-challenge';
|
||||
|
||||
import { AuthorizationServerMismatchError, InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors';
|
||||
import type { DpopSession } from './dpop';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode}
|
||||
import type { withDpopFromProvider, withOAuth } from './middleware';
|
||||
|
||||
// Re-exported for back-compat — the canonical home is ./authErrors.js.
|
||||
export { AuthorizationServerMismatchError, InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors';
|
||||
|
|
@ -206,6 +209,12 @@ export async function handleOAuthUnauthorized(
|
|||
* not in scope. Providers that key storage on `ctx.issuer` MUST treat `ctx === undefined`
|
||||
* as "return the most-recently-saved token set" (the only consumer is the resource server
|
||||
* the token was minted for); providers that round-trip a single blob need no change.
|
||||
*
|
||||
* SEP-1932 (DPoP) note: DPoP request-signing is deliberately *not* done here. When the provider
|
||||
* implements {@linkcode OAuthClientProvider.dpop | dpop()}, the transports wrap their
|
||||
* resource-server `fetch` with {@linkcode withDpopFromProvider}, which upgrades the `Bearer` header
|
||||
* this adapter produces to `DPoP` + proof for DPoP-bound tokens and handles nonce challenges — at
|
||||
* the one layer that sees the real method, URL and response of every request.
|
||||
*/
|
||||
export function adaptOAuthProvider(
|
||||
provider: OAuthClientProvider,
|
||||
|
|
@ -341,6 +350,28 @@ export interface OAuthClientProvider {
|
|||
*/
|
||||
addClientAuthentication?: AddClientAuthentication;
|
||||
|
||||
/**
|
||||
* Enables DPoP (RFC 9449 / SEP-1932) sender-constrained tokens when implemented. When this
|
||||
* resolves to a {@linkcode DpopSession}, {@linkcode auth} signs a DPoP proof into the token
|
||||
* request, and the transports (and {@linkcode withOAuth}) present a resulting `token_type: DPoP`
|
||||
* access token with the `DPoP` Authorization scheme plus a fresh per-request proof instead of
|
||||
* `Bearer`, by wrapping their resource-server `fetch` with {@linkcode withDpopFromProvider}.
|
||||
*
|
||||
* Return the *same* session across calls — the AS/RS nonce state and signing key it holds are
|
||||
* meant to persist for the life of this client registration. A minimal implementation:
|
||||
* ```typescript
|
||||
* class MyProvider implements OAuthClientProvider {
|
||||
* private _dpop = DpopSession.create();
|
||||
* dpop() { return this._dpop; }
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Left undefined (the default), the provider behaves exactly as before this option existed:
|
||||
* plain Bearer tokens throughout.
|
||||
*/
|
||||
dpop?(): DpopSession | undefined | Promise<DpopSession | undefined>;
|
||||
|
||||
/**
|
||||
* If defined, overrides the selection and validation of the
|
||||
* RFC 8707 Resource Indicator. If left undefined, default
|
||||
|
|
@ -1025,7 +1056,11 @@ export async function auth(provider: OAuthClientProvider, options: AuthOptions):
|
|||
await provider.invalidateCredentials?.('client');
|
||||
await provider.invalidateCredentials?.('tokens');
|
||||
return await authInternal(provider, options);
|
||||
} else if (error.code === OAuthErrorCode.InvalidGrant) {
|
||||
} else if (error.code === OAuthErrorCode.InvalidGrant || error.code === OAuthErrorCode.InvalidDpopProof) {
|
||||
// invalid_dpop_proof on refresh typically means the stored refresh token is bound
|
||||
// (RFC 9449 §5) to a DPoP key this process no longer holds — e.g. a non-extractable
|
||||
// key regenerated across a restart. Like invalid_grant, the token set is unusable;
|
||||
// drop it and fall through to a fresh authorization.
|
||||
warnCredentialInvalidation(provider, error, 'tokens');
|
||||
await provider.invalidateCredentials?.('tokens');
|
||||
return await authInternal(provider, options);
|
||||
|
|
@ -1335,6 +1370,7 @@ async function authInternal(
|
|||
refreshToken: tokens.refresh_token,
|
||||
resource,
|
||||
addClientAuthentication: provider.addClientAuthentication,
|
||||
dpop: await provider.dpop?.(),
|
||||
fetchFn
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -1449,9 +1485,17 @@ export async function selectResourceURL(
|
|||
return new URL(resourceMetadata.resource);
|
||||
}
|
||||
|
||||
/** Auth-scheme challenge tokens {@linkcode extractWWWAuthenticateParams} recognizes. */
|
||||
const RECOGNIZED_CHALLENGE_SCHEMES = new Set(['bearer', 'dpop']);
|
||||
|
||||
/**
|
||||
* Extract `resource_metadata`, `scope`, `error`, and `error_description` from a
|
||||
* `WWW-Authenticate` header.
|
||||
*
|
||||
* Recognizes both the `Bearer` scheme (RFC 6750) and the `DPoP` scheme (RFC 9449 §7.1,
|
||||
* SEP-1932) — a DPoP-protected resource's challenge carries the same parameters under `DPoP`
|
||||
* instead of `Bearer`, and this must still surface `resource_metadata`/`scope` from it for
|
||||
* discovery and SEP-2350 step-up to work against such a resource.
|
||||
*/
|
||||
export function extractWWWAuthenticateParams(res: Response): {
|
||||
resourceMetadataUrl?: URL;
|
||||
|
|
@ -1465,7 +1509,7 @@ export function extractWWWAuthenticateParams(res: Response): {
|
|||
}
|
||||
|
||||
const [type, scheme] = authenticateHeader.split(' ');
|
||||
if (type?.toLowerCase() !== 'bearer' || !scheme) {
|
||||
if (!type || !RECOGNIZED_CHALLENGE_SCHEMES.has(type.toLowerCase()) || !scheme) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
@ -2096,6 +2140,7 @@ export async function executeTokenRequest(
|
|||
clientInformation,
|
||||
addClientAuthentication,
|
||||
resource,
|
||||
dpop,
|
||||
fetchFn
|
||||
}: {
|
||||
metadata?: AuthorizationServerMetadata;
|
||||
|
|
@ -2103,6 +2148,13 @@ export async function executeTokenRequest(
|
|||
clientInformation?: OAuthClientInformationMixed;
|
||||
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
|
||||
resource?: URL;
|
||||
/**
|
||||
* SEP-1932 / RFC 9449 §5: when set, signs a DPoP proof into the token request's `DPoP`
|
||||
* header — the prerequisite for obtaining a DPoP-bound access token. On a `400
|
||||
* use_dpop_nonce` challenge (RFC 9449 §8) the request is retried exactly once with a
|
||||
* fresh proof carrying the server-supplied nonce.
|
||||
*/
|
||||
dpop?: DpopSession;
|
||||
fetchFn?: FetchLike;
|
||||
}
|
||||
): Promise<OAuthTokens> {
|
||||
|
|
@ -2117,19 +2169,50 @@ export async function executeTokenRequest(
|
|||
tokenRequestParams.set('resource', resource.href);
|
||||
}
|
||||
|
||||
if (addClientAuthentication) {
|
||||
await addClientAuthentication(headers, tokenRequestParams, tokenUrl, metadata);
|
||||
} else if (clientInformation) {
|
||||
if (!addClientAuthentication && clientInformation) {
|
||||
const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? [];
|
||||
const authMethod = selectClientAuthMethod(clientInformation, supportedMethods);
|
||||
applyClientAuthentication(authMethod, clientInformation as OAuthClientInformation, headers, tokenRequestParams);
|
||||
}
|
||||
|
||||
const response = await (fetchFn ?? fetch)(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: tokenRequestParams
|
||||
});
|
||||
const requestOnce = async (): Promise<Response> => {
|
||||
const requestHeaders = new Headers(headers);
|
||||
// Per attempt, not once up front: a `private_key_jwt` client_assertion carries a one-time
|
||||
// `jti` (RFC 7521 §5.2), so the DPoP nonce retry below must mint a fresh one, not replay it.
|
||||
if (addClientAuthentication) {
|
||||
await addClientAuthentication(requestHeaders, tokenRequestParams, tokenUrl, metadata);
|
||||
}
|
||||
if (dpop) {
|
||||
// No `ath`: RFC 9449 §4.3 step 12a only binds a proof to an access token when one is
|
||||
// presented, and the token request is presenting credentials to *obtain* one.
|
||||
requestHeaders.set('DPoP', await dpop.buildProof({ htm: 'POST', htu: tokenUrl }));
|
||||
}
|
||||
return (fetchFn ?? fetch)(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: tokenRequestParams
|
||||
});
|
||||
};
|
||||
|
||||
let response = await requestOnce();
|
||||
|
||||
// RFC 9449 §8: the AS may answer with `400 { error: "use_dpop_nonce" }` + `DPoP-Nonce`; a
|
||||
// conformant client retries the token request once with a fresh proof carrying that nonce
|
||||
// (buildProof picks it up automatically via the session's remembered nonce for this origin).
|
||||
// Peek the body via a clone so a non-nonce 400 still flows into parseErrorResponse below with
|
||||
// an unconsumed body.
|
||||
if (dpop && response.status === 400) {
|
||||
const challenge = (await response
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => {})) as { error?: string } | undefined;
|
||||
if (challenge?.error === OAuthErrorCode.UseDpopNonce) {
|
||||
dpop.observeNonce(response, tokenUrl);
|
||||
response = await requestOnce();
|
||||
}
|
||||
}
|
||||
// RFC 9449 §8.2: newest-wins nonce capture applies to any response, success included.
|
||||
dpop?.observeNonce(response, tokenUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseErrorResponse(response);
|
||||
|
|
@ -2172,6 +2255,7 @@ export async function exchangeAuthorization(
|
|||
redirectUri,
|
||||
resource,
|
||||
addClientAuthentication,
|
||||
dpop,
|
||||
fetchFn
|
||||
}: {
|
||||
metadata?: AuthorizationServerMetadata;
|
||||
|
|
@ -2187,6 +2271,8 @@ export async function exchangeAuthorization(
|
|||
redirectUri: string | URL;
|
||||
resource?: URL;
|
||||
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
|
||||
/** SEP-1932 / RFC 9449: see {@linkcode executeTokenRequest}'s `dpop` option. */
|
||||
dpop?: DpopSession;
|
||||
fetchFn?: FetchLike;
|
||||
}
|
||||
): Promise<OAuthTokens> {
|
||||
|
|
@ -2204,6 +2290,7 @@ export async function exchangeAuthorization(
|
|||
clientInformation,
|
||||
addClientAuthentication,
|
||||
resource,
|
||||
dpop,
|
||||
fetchFn
|
||||
});
|
||||
}
|
||||
|
|
@ -2228,6 +2315,7 @@ export async function refreshAuthorization(
|
|||
refreshToken,
|
||||
resource,
|
||||
addClientAuthentication,
|
||||
dpop,
|
||||
fetchFn
|
||||
}: {
|
||||
metadata?: AuthorizationServerMetadata;
|
||||
|
|
@ -2235,6 +2323,8 @@ export async function refreshAuthorization(
|
|||
refreshToken: string;
|
||||
resource?: URL;
|
||||
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
|
||||
/** SEP-1932 / RFC 9449: see {@linkcode executeTokenRequest}'s `dpop` option. */
|
||||
dpop?: DpopSession;
|
||||
fetchFn?: FetchLike;
|
||||
}
|
||||
): Promise<OAuthTokens> {
|
||||
|
|
@ -2249,6 +2339,7 @@ export async function refreshAuthorization(
|
|||
clientInformation,
|
||||
addClientAuthentication,
|
||||
resource,
|
||||
dpop,
|
||||
fetchFn
|
||||
});
|
||||
|
||||
|
|
@ -2346,6 +2437,7 @@ export async function fetchToken(
|
|||
clientInformation: clientInformation ?? undefined,
|
||||
addClientAuthentication: provider.addClientAuthentication,
|
||||
resource,
|
||||
dpop: await provider.dpop?.(),
|
||||
fetchFn
|
||||
});
|
||||
}
|
||||
|
|
|
|||
230
packages/client/src/client/dpop.ts
Normal file
230
packages/client/src/client/dpop.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* DPoP (Demonstrating Proof of Possession) client support.
|
||||
*
|
||||
* Implements the client half of {@link https://datatracker.ietf.org/doc/html/rfc9449 | RFC 9449},
|
||||
* adopted by MCP as the draft extension
|
||||
* {@link https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/dpop-extension.mdx | SEP-1932}.
|
||||
* DPoP binds an OAuth access token to a client-held asymmetric key: every request carries a
|
||||
* signed proof JWT over the key, so a stolen bearer token alone cannot be replayed elsewhere.
|
||||
*
|
||||
* This module is opt-in — nothing here runs unless a caller creates a {@linkcode DpopSession}
|
||||
* and wires it into {@linkcode OAuthClientProvider.dpop} (see `auth.ts`) or a {@linkcode withDpop}
|
||||
* middleware (see `middleware.ts`). Existing Bearer-token flows are unaffected.
|
||||
*
|
||||
* `jose` is loaded lazily so the (larger) WebCrypto-key-management code path is only pulled in
|
||||
* when a caller actually constructs a {@linkcode DpopSession} — mirroring the lazy `jose` import
|
||||
* in {@linkcode createPrivateKeyJwtAuth} (`authExtensions.ts`).
|
||||
*/
|
||||
|
||||
import type { CryptoKey, JWK } from 'jose';
|
||||
|
||||
/** Asymmetric JWS algorithms usable for a DPoP proof (RFC 9449 §11.6 forbids symmetric algs and `none`). */
|
||||
export const DPOP_SUPPORTED_ALGS = ['ES256', 'ES384', 'ES512', 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'EdDSA'] as const;
|
||||
|
||||
/** A DPoP JWS algorithm this SDK can sign proofs with. */
|
||||
export type DpopAlg = (typeof DPOP_SUPPORTED_ALGS)[number];
|
||||
|
||||
const DEFAULT_DPOP_ALG: DpopAlg = 'ES256';
|
||||
const DPOP_TYP = 'dpop+jwt';
|
||||
|
||||
async function importJose(): Promise<typeof import('jose')> {
|
||||
if (globalThis.crypto === undefined) {
|
||||
throw new TypeError(
|
||||
'crypto is not available, please ensure you have Web Crypto API support for older Node.js versions (see https://github.com/modelcontextprotocol/typescript-sdk#nodejs-web-crypto-globalthiscrypto-compatibility)'
|
||||
);
|
||||
}
|
||||
return import('jose');
|
||||
}
|
||||
|
||||
/** A DPoP signing key pair: the private key signs proofs, the public JWK is embedded in them. */
|
||||
export interface DpopKeyPair {
|
||||
/** Signs proofs. Non-extractable unless {@linkcode GenerateDpopKeyPairOptions.extractable} was set. */
|
||||
privateKey: CryptoKey;
|
||||
/** Matches {@linkcode publicJwk}; rarely needed directly. */
|
||||
publicKey: CryptoKey;
|
||||
/** Embedded in each proof's `jwk` header parameter (RFC 9449 §4.2). */
|
||||
publicJwk: JWK;
|
||||
/** RFC 7638 JWK SHA-256 thumbprint — the value an authorization server binds as the token's `cnf.jkt`. */
|
||||
thumbprint: string;
|
||||
/** The JWS algorithm this key pair signs with. */
|
||||
alg: DpopAlg;
|
||||
}
|
||||
|
||||
export interface GenerateDpopKeyPairOptions {
|
||||
/** Signing algorithm. @default 'ES256' */
|
||||
alg?: DpopAlg;
|
||||
/**
|
||||
* Allow the private key to be exported (e.g. for persistence across process restarts).
|
||||
*
|
||||
* @default false — RFC 9449 §11.1 and §11.7 recommend non-extractable keys (hardware-backed
|
||||
* where available) so the private key cannot be exfiltrated by XSS or a compromised dependency.
|
||||
* Only set this when the host has its own plan for protecting the exported key material.
|
||||
*/
|
||||
extractable?: boolean;
|
||||
}
|
||||
|
||||
/** Generate an asymmetric DPoP signing key pair. Non-extractable by default (RFC 9449 §11). */
|
||||
export async function generateDpopKeyPair(options: GenerateDpopKeyPairOptions = {}): Promise<DpopKeyPair> {
|
||||
const alg = options.alg ?? DEFAULT_DPOP_ALG;
|
||||
const jose = await importJose();
|
||||
const { publicKey, privateKey } = await jose.generateKeyPair(alg, { extractable: options.extractable ?? false });
|
||||
const publicJwk = await jose.exportJWK(publicKey);
|
||||
const thumbprint = await jose.calculateJwkThumbprint(publicJwk, 'sha256');
|
||||
return { privateKey, publicKey, publicJwk, thumbprint, alg };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the `ath` claim for a DPoP proof presented alongside an access token: the
|
||||
* base64url-encoded SHA-256 digest of the ASCII access-token value (RFC 9449 §4.1).
|
||||
*
|
||||
* Uses Web Crypto (`crypto.subtle`) rather than a Node-only hashing API so this stays usable
|
||||
* in browser and edge runtimes.
|
||||
*/
|
||||
export async function accessTokenHash(accessToken: string): Promise<string> {
|
||||
if (globalThis.crypto?.subtle === undefined) {
|
||||
throw new TypeError(
|
||||
'crypto.subtle is not available, please ensure you have Web Crypto API support for older Node.js versions (see https://github.com/modelcontextprotocol/typescript-sdk#nodejs-web-crypto-globalthiscrypto-compatibility)'
|
||||
);
|
||||
}
|
||||
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken));
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCodePoint(byte);
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
||||
}
|
||||
|
||||
/** Strip the query and fragment from a URL, per RFC 9449 §4.2 (`htu` MUST NOT contain either). */
|
||||
function stripQueryAndFragment(url: string | URL): string {
|
||||
const u = new URL(url.toString());
|
||||
return `${u.origin}${u.pathname}`;
|
||||
}
|
||||
|
||||
/** A fresh, cryptographically random `jti` (RFC 9449 §4.2 — MUST be unique per proof). */
|
||||
function randomJti(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
/** Inputs for a single DPoP proof (RFC 9449 §4.2). */
|
||||
export interface DpopProofRequest {
|
||||
/** HTTP method of the target request (`htm` claim). Case-normalized to upper-case. */
|
||||
htm: string;
|
||||
/** HTTP target URI of the request (`htu` claim) — query/fragment are stripped automatically. */
|
||||
htu: string | URL;
|
||||
/** When set, binds the proof to this access token via the `ath` claim (RFC 9449 §4.1). */
|
||||
accessToken?: string;
|
||||
/**
|
||||
* Explicit server-provided nonce to embed. When omitted, the session's remembered nonce for
|
||||
* `htu`'s origin (if any) is used automatically — see {@linkcode DpopSession.rememberNonce}.
|
||||
*/
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A DPoP signing identity plus the small amount of state RFC 9449 requires across requests:
|
||||
* the key pair and, per origin, the most recently server-supplied nonce (RFC 9449 §8/§9).
|
||||
*
|
||||
* One `DpopSession` is meant to live for the lifetime of a single OAuth client registration —
|
||||
* the token endpoint and the resource server are different origins and get independent nonce
|
||||
* slots, so a nonce challenge from one never leaks into proofs sent to the other.
|
||||
*/
|
||||
export class DpopSession {
|
||||
private readonly nonces = new Map<string, string>();
|
||||
|
||||
private constructor(private readonly keyPair: DpopKeyPair) {}
|
||||
|
||||
/** Create a session with a fresh key pair, or reuse a caller-supplied one. */
|
||||
static async create(options: { alg?: DpopAlg; keyPair?: DpopKeyPair } = {}): Promise<DpopSession> {
|
||||
const keyPair = options.keyPair ?? (await generateDpopKeyPair({ alg: options.alg }));
|
||||
return new DpopSession(keyPair);
|
||||
}
|
||||
|
||||
/** RFC 7638 JWK SHA-256 thumbprint of the signing key — matches the token's `cnf.jkt` once bound. */
|
||||
get thumbprint(): string {
|
||||
return this.keyPair.thumbprint;
|
||||
}
|
||||
|
||||
/** The JWS algorithm this session signs proofs with. */
|
||||
get alg(): DpopAlg {
|
||||
return this.keyPair.alg;
|
||||
}
|
||||
|
||||
/** The public JWK embedded in every proof's `jwk` header parameter. */
|
||||
get publicJwk(): JWK {
|
||||
return this.keyPair.publicJwk;
|
||||
}
|
||||
|
||||
/** The remembered nonce for `url`'s origin, if the server has issued one (RFC 9449 §8/§9). */
|
||||
nonceFor(url: string | URL): string | undefined {
|
||||
return this.nonces.get(new URL(url.toString()).origin);
|
||||
}
|
||||
|
||||
/** Record a server-supplied nonce for `url`'s origin (newest-wins, RFC 9449 §8.2). */
|
||||
rememberNonce(url: string | URL, nonce: string): void {
|
||||
this.nonces.set(new URL(url.toString()).origin, nonce);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a `DPoP-Nonce` response header, if present, for `url`'s origin. RFC 9449 §8.2 says a
|
||||
* fresh nonce may ride on *any* response (success or failure), so call this unconditionally
|
||||
* after every request, not only on a `use_dpop_nonce` challenge.
|
||||
*/
|
||||
observeNonce(response: Response, url: string | URL): void {
|
||||
const nonce = response.headers.get('dpop-nonce');
|
||||
if (nonce) this.rememberNonce(url, nonce);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh DPoP proof JWT. Always mints a new `jti` — proofs are never cached or reused,
|
||||
* since RFC 9449 §4.3 step 9 requires each to be presented at most once.
|
||||
*/
|
||||
async buildProof(request: DpopProofRequest): Promise<string> {
|
||||
const jose = await importJose();
|
||||
const htu = stripQueryAndFragment(request.htu);
|
||||
const nonce = request.nonce ?? this.nonceFor(request.htu);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
jti: randomJti(),
|
||||
htm: request.htm.toUpperCase(),
|
||||
htu,
|
||||
iat: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
if (request.accessToken !== undefined) {
|
||||
payload.ath = await accessTokenHash(request.accessToken);
|
||||
}
|
||||
if (nonce !== undefined) {
|
||||
payload.nonce = nonce;
|
||||
}
|
||||
|
||||
return new jose.SignJWT(payload)
|
||||
.setProtectedHeader({ alg: this.keyPair.alg, typ: DPOP_TYP, jwk: this.keyPair.publicJwk })
|
||||
.sign(this.keyPair.privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `wwwAuthenticate` advertises a `DPoP` auth-scheme challenge (matched at the start of the
|
||||
* header or after a comma, per RFC 9110 §11.6.1 — a server may list several schemes together, e.g.
|
||||
* `Bearer …, DPoP …`).
|
||||
*/
|
||||
function hasDpopChallenge(wwwAuthenticate: string): boolean {
|
||||
return /(?:^|,)\s*dpop(?:\s|$|,)/i.test(wwwAuthenticate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `response` is a resource-server `use_dpop_nonce` challenge (RFC 9449 §9): a `401` whose
|
||||
* `WWW-Authenticate` header carries a `DPoP` challenge with `error="use_dpop_nonce"`.
|
||||
*
|
||||
* A conformant retry re-signs the proof with the nonce {@linkcode DpopSession.observeNonce} just
|
||||
* captured — never resend the original proof: RFC 9449 §4.2 requires a unique `jti` per proof, and
|
||||
* replaying one across the challenge/retry boundary is itself a violation.
|
||||
*/
|
||||
export function isDpopNonceChallenge(response: Response): boolean {
|
||||
if (response.status !== 401) return false;
|
||||
const wwwAuthenticate = response.headers.get('www-authenticate') ?? '';
|
||||
return hasDpopChallenge(wwwAuthenticate) && /use_dpop_nonce/i.test(wwwAuthenticate);
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import type { FetchLike } from '@modelcontextprotocol/core-internal';
|
||||
|
||||
import type { OAuthClientProvider } from './auth';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- AuthProvider referenced in JSDoc {@linkcode}
|
||||
import type { AuthProvider, OAuthClientProvider } from './auth';
|
||||
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth';
|
||||
import { markAuthSeamEscape } from './authSeam';
|
||||
import type { DpopSession } from './dpop';
|
||||
import { isDpopNonceChallenge } from './dpop';
|
||||
|
||||
/**
|
||||
* Middleware function that wraps and enhances fetch functionality.
|
||||
|
|
@ -17,6 +21,10 @@ export type Middleware = (next: FetchLike) => FetchLike;
|
|||
* - Handle 401 responses by attempting re-authentication
|
||||
* - Retry the original request after successful auth
|
||||
* - Handle OAuth errors appropriately ({@linkcode index.OAuthErrorCode.InvalidClient | OAuthErrorCode.InvalidClient}, etc.)
|
||||
* - When {@linkcode OAuthClientProvider.dpop | provider.dpop()} is implemented, present DPoP-bound
|
||||
* tokens with the `DPoP` scheme plus a per-request proof (RFC 9449 / SEP-1932) by composing
|
||||
* {@linkcode withDpopFromProvider} underneath — so a `use_dpop_nonce` challenge is retried
|
||||
* inline on every attempt, independently of the single re-authentication retry here
|
||||
*
|
||||
* The `baseUrl` parameter is optional and defaults to using the domain from the request URL.
|
||||
* However, you should explicitly provide `baseUrl` when:
|
||||
|
|
@ -37,7 +45,12 @@ export type Middleware = (next: FetchLike) => FetchLike;
|
|||
*/
|
||||
export const withOAuth =
|
||||
(provider: OAuthClientProvider, baseUrl?: string | URL): Middleware =>
|
||||
next => {
|
||||
baseNext => {
|
||||
// DPoP request-signing (and its nonce retry) sits *below* the Bearer/re-auth layer so it
|
||||
// sees the final method/URL of every attempt and every response. `auth()` keeps the
|
||||
// unwrapped fetch: token-endpoint DPoP is handled inside executeTokenRequest.
|
||||
const next = provider.dpop ? withDpopFromProvider(provider)(baseNext) : baseNext;
|
||||
|
||||
return async (input, init) => {
|
||||
const makeRequest = async (): Promise<Response> => {
|
||||
const headers = new Headers(init?.headers);
|
||||
|
|
@ -65,7 +78,7 @@ export const withOAuth =
|
|||
serverUrl,
|
||||
resourceMetadataUrl,
|
||||
scope,
|
||||
fetchFn: next
|
||||
fetchFn: baseNext
|
||||
});
|
||||
|
||||
if (result === 'REDIRECT') {
|
||||
|
|
@ -96,6 +109,113 @@ export const withOAuth =
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A function returning the current access token, or `undefined` if none is available yet. See
|
||||
* {@linkcode withDpop}.
|
||||
*/
|
||||
export type DpopTokenSource = () => string | undefined | Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* A {@linkcode DpopSession}, or a function resolving to one (or to `undefined` to leave the request
|
||||
* untouched). The function form lets the session be created lazily or come from
|
||||
* {@linkcode OAuthClientProvider.dpop}. See {@linkcode withDpop}.
|
||||
*/
|
||||
export type DpopSessionSource = DpopSession | (() => DpopSession | undefined | Promise<DpopSession | undefined>);
|
||||
|
||||
/**
|
||||
* Creates a fetch wrapper that presents an access token using the `DPoP` Authorization scheme
|
||||
* (RFC 9449 / SEP-1932) instead of `Bearer`: every request carries `Authorization: DPoP <token>`
|
||||
* plus a fresh `DPoP` proof bound to that request's method and URL, a resource-server
|
||||
* `use_dpop_nonce` challenge (RFC 9449 §9) is retried once, inline, with the server-supplied nonce,
|
||||
* and a `DPoP-Nonce` delivered on any response is remembered for the next proof (RFC 9449 §8.2).
|
||||
*
|
||||
* Because it wraps `fetch` itself, the proof is always bound to the request actually sent and every
|
||||
* response is observed — which is why the MCP transports apply this wrapper internally (via
|
||||
* {@linkcode withDpopFromProvider}) when their `authProvider` implements
|
||||
* {@linkcode OAuthClientProvider.dpop | dpop()}, rather than threading request context through
|
||||
* {@linkcode AuthProvider}.
|
||||
*
|
||||
* Use this directly when you already manage the access token yourself (a non-OAuth token source,
|
||||
* or credentials obtained out-of-band) and only need DPoP's request-signing behavior — e.g.
|
||||
* `fetch: withDpop(session, getToken)(fetch)` alongside a minimal `authProvider: { token }`.
|
||||
*
|
||||
* @param session - The DPoP signing session (key pair + nonce state), or a function resolving to
|
||||
* it. Reuse the same session across requests to the same server so its nonce state persists.
|
||||
* When the function resolves to `undefined` the request passes through unchanged.
|
||||
* @param getToken - Returns the current access token, or `undefined` if none is available (the
|
||||
* request passes through unchanged — any `Authorization` header already on it is left as is).
|
||||
* @returns A fetch middleware function
|
||||
*/
|
||||
export const withDpop =
|
||||
(session: DpopSessionSource, getToken: DpopTokenSource): Middleware =>
|
||||
next => {
|
||||
const resolveSession = typeof session === 'function' ? session : () => session;
|
||||
|
||||
return async (input, init) => {
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const url = new URL(input.toString());
|
||||
const activeSession = await resolveSession();
|
||||
if (!activeSession) return next(input, init);
|
||||
|
||||
const makeRequest = async (): Promise<Response> => {
|
||||
const accessToken = await getToken();
|
||||
if (!accessToken) return next(input, init);
|
||||
const headers = new Headers(init?.headers);
|
||||
const proof = await activeSession.buildProof({ htm: method, htu: url, accessToken });
|
||||
headers.set('Authorization', `DPoP ${accessToken}`);
|
||||
headers.set('DPoP', proof);
|
||||
return next(input, { ...init, headers });
|
||||
};
|
||||
|
||||
let response = await makeRequest();
|
||||
|
||||
// Only retry when the challenge carries a fresh DPoP-Nonce — otherwise the retry
|
||||
// would re-send the nonce the server just rejected. RFC 9449 §4.2: the retry gets a
|
||||
// freshly signed proof (new jti); the original is never replayed.
|
||||
if (isDpopNonceChallenge(response) && response.headers.has('dpop-nonce')) {
|
||||
activeSession.observeNonce(response, url);
|
||||
await response.text?.().catch(() => {});
|
||||
response = await makeRequest();
|
||||
}
|
||||
// RFC 9449 §8.2: a fresh nonce may ride on any response, success included.
|
||||
activeSession.observeNonce(response, url);
|
||||
|
||||
return response;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* {@linkcode withDpop} driven by an {@linkcode OAuthClientProvider}: the session comes from
|
||||
* {@linkcode OAuthClientProvider.dpop | provider.dpop()} and the token from
|
||||
* {@linkcode OAuthClientProvider.tokens | provider.tokens()} — presented with the DPoP scheme only
|
||||
* when the AS actually issued `token_type: "DPoP"` (RFC 9449 §7.1); a Bearer token passes through
|
||||
* untouched.
|
||||
*
|
||||
* This is what the MCP transports and {@linkcode withOAuth} compose internally when the provider
|
||||
* implements `dpop()`. Use it directly only when building your own fetch pipeline around an
|
||||
* `OAuthClientProvider` (e.g. a custom re-authorization middleware) — place it *innermost*, below
|
||||
* whatever sets `Authorization: Bearer` and handles 401 re-authentication.
|
||||
*/
|
||||
export const withDpopFromProvider = (provider: OAuthClientProvider): Middleware =>
|
||||
withDpop(
|
||||
async () => {
|
||||
try {
|
||||
return await provider.dpop?.();
|
||||
} catch (error) {
|
||||
throw markAuthSeamEscape(error);
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
let tokens;
|
||||
try {
|
||||
tokens = await provider.tokens();
|
||||
} catch (error) {
|
||||
throw markAuthSeamEscape(error);
|
||||
}
|
||||
return tokens?.token_type?.toLowerCase() === 'dpop' ? tokens.access_token : undefined;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Logger function type for HTTP requests
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import {
|
|||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode}
|
||||
import type { IssuerMismatchError } from './authErrors';
|
||||
import { markAuthSeamEscape } from './authSeam';
|
||||
import type { Middleware } from './middleware';
|
||||
import { withDpopFromProvider } from './middleware';
|
||||
|
||||
export class SseError extends Error {
|
||||
static {
|
||||
|
|
@ -132,6 +134,7 @@ export class SSEClientTransport implements Transport {
|
|||
private _skipIssuerMetadataValidation?: boolean;
|
||||
private _fetch?: FetchLike;
|
||||
private _fetchWithInit: FetchLike;
|
||||
private _dpop?: Middleware;
|
||||
private _protocolVersion?: string;
|
||||
|
||||
onclose?: () => void;
|
||||
|
|
@ -145,15 +148,22 @@ export class SSEClientTransport implements Transport {
|
|||
this._eventSourceInit = opts?.eventSourceInit;
|
||||
this._requestInit = opts?.requestInit;
|
||||
this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation;
|
||||
this._fetch = opts?.fetch;
|
||||
if (isOAuthClientProvider(opts?.authProvider)) {
|
||||
this._oauthProvider = opts.authProvider;
|
||||
this._authProvider = adaptOAuthProvider(opts.authProvider, {
|
||||
skipIssuerMetadataValidation: opts.skipIssuerMetadataValidation
|
||||
});
|
||||
// SEP-1932 / RFC 9449: see the matching comment in StreamableHTTPClientTransport. The
|
||||
// EventSource stream's fetch is wrapped at use, since `eventSourceInit.fetch` may
|
||||
// override `_fetch` there.
|
||||
if (opts.authProvider.dpop) {
|
||||
this._dpop = withDpopFromProvider(opts.authProvider);
|
||||
this._fetch = this._dpop(opts.fetch ?? fetch);
|
||||
}
|
||||
} else {
|
||||
this._authProvider = opts?.authProvider;
|
||||
}
|
||||
this._fetch = opts?.fetch;
|
||||
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
|
||||
}
|
||||
|
||||
|
|
@ -185,7 +195,10 @@ export class SSEClientTransport implements Transport {
|
|||
}
|
||||
|
||||
private _startOrAuth(): Promise<void> {
|
||||
const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch;
|
||||
const eventSourceFetch = this._eventSourceInit?.fetch;
|
||||
const fetchImpl = (
|
||||
eventSourceFetch ? (this._dpop?.(eventSourceFetch as FetchLike) ?? eventSourceFetch) : (this._fetch ?? fetch)
|
||||
) as typeof fetch;
|
||||
return new Promise((resolve, reject) => {
|
||||
this._eventSource = new EventSource(this._url.href, {
|
||||
...this._eventSourceInit,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
import type { IssuerMismatchError } from './authErrors';
|
||||
import { InsufficientScopeError } from './authErrors';
|
||||
import { markAuthSeamEscape } from './authSeam';
|
||||
import { withDpopFromProvider } from './middleware';
|
||||
|
||||
/** Default cap on step-up re-authorization retries within a single send/stream-open. */
|
||||
const DEFAULT_MAX_STEP_UP_RETRIES = 1;
|
||||
|
|
@ -260,6 +261,7 @@ export type StreamableHTTPClientTransportOptions = {
|
|||
*/
|
||||
const RESERVED_REQUEST_HEADER_NAMES: ReadonlySet<string> = new Set([
|
||||
'authorization',
|
||||
'dpop',
|
||||
'content-type',
|
||||
'mcp-protocol-version',
|
||||
'mcp-method',
|
||||
|
|
@ -347,15 +349,22 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
this._scope = undefined;
|
||||
this._requestInit = opts?.requestInit;
|
||||
this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation;
|
||||
this._fetch = opts?.fetch;
|
||||
if (isOAuthClientProvider(opts?.authProvider)) {
|
||||
this._oauthProvider = opts.authProvider;
|
||||
this._authProvider = adaptOAuthProvider(opts.authProvider, {
|
||||
skipIssuerMetadataValidation: opts.skipIssuerMetadataValidation
|
||||
});
|
||||
// SEP-1932 / RFC 9449: sign resource-server requests with DPoP at the fetch layer, where
|
||||
// the real method/URL/response of every request (POST, GET stream, DELETE) is in hand.
|
||||
// `_fetchWithInit` below (handed to `auth()`) stays unwrapped — token-endpoint DPoP is
|
||||
// executeTokenRequest's job.
|
||||
if (opts.authProvider.dpop) {
|
||||
this._fetch = withDpopFromProvider(opts.authProvider)(opts.fetch ?? fetch);
|
||||
}
|
||||
} else {
|
||||
this._authProvider = opts?.authProvider;
|
||||
}
|
||||
this._fetch = opts?.fetch;
|
||||
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
|
||||
this._sessionId = opts?.sessionId;
|
||||
this._protocolVersion = opts?.protocolVersion;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
246
packages/client/test/client/auth.dpop.test.ts
Normal file
246
packages/client/test/client/auth.dpop.test.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import type { Mock } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { OAuthClientProvider } from '../../src/client/auth';
|
||||
import { auth, executeTokenRequest, extractWWWAuthenticateParams } from '../../src/client/auth';
|
||||
import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions';
|
||||
import { DpopSession } from '../../src/client/dpop';
|
||||
|
||||
function decodeJwtPart(part: string): Record<string, unknown> {
|
||||
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
describe('executeTokenRequest — DPoP', () => {
|
||||
let session: DpopSession;
|
||||
let fetchFn: Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
session = await DpopSession.create();
|
||||
fetchFn = vi.fn();
|
||||
});
|
||||
|
||||
it('signs a DPoP proof into the token request DPoP header', async () => {
|
||||
fetchFn.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ access_token: 'tok', token_type: 'DPoP' })
|
||||
});
|
||||
|
||||
await executeTokenRequest('https://as.example.com', {
|
||||
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
|
||||
dpop: session,
|
||||
fetchFn
|
||||
});
|
||||
|
||||
const [, init] = fetchFn.mock.calls[0]!;
|
||||
const headers = init.headers as Headers;
|
||||
const proof = headers.get('DPoP');
|
||||
expect(proof).toBeTruthy();
|
||||
const payload = decodeJwtPart(proof!.split('.')[1]!);
|
||||
expect(payload.htm).toBe('POST');
|
||||
expect(payload.htu).toBe('https://as.example.com/token');
|
||||
// No `ath`: the token request presents credentials to *obtain* a token, not an existing one.
|
||||
expect(payload.ath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retries once with a fresh nonce-carrying proof on a 400 use_dpop_nonce challenge', async () => {
|
||||
fetchFn
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
headers: new Headers({ 'DPoP-Nonce': 'as-nonce-1' }),
|
||||
clone() {
|
||||
return this;
|
||||
},
|
||||
json: async () => ({ error: 'use_dpop_nonce' })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ access_token: 'tok', token_type: 'DPoP' })
|
||||
});
|
||||
|
||||
const tokens = await executeTokenRequest('https://as.example.com', {
|
||||
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
|
||||
dpop: session,
|
||||
fetchFn
|
||||
});
|
||||
|
||||
expect(tokens.access_token).toBe('tok');
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
const secondProof = (fetchFn.mock.calls[1]![1].headers as Headers).get('DPoP');
|
||||
const firstProof = (fetchFn.mock.calls[0]![1].headers as Headers).get('DPoP');
|
||||
expect(secondProof).not.toBe(firstProof); // fresh jti — never resend the identical proof
|
||||
expect(decodeJwtPart(secondProof!.split('.')[1]!).nonce).toBe('as-nonce-1');
|
||||
// The session also remembers the nonce for future requests to this origin.
|
||||
expect(session.nonceFor('https://as.example.com')).toBe('as-nonce-1');
|
||||
});
|
||||
|
||||
it('does not retry a second time (surfaces the error) when the nonce challenge repeats', async () => {
|
||||
// A real Response (not a plain mock object): parseErrorResponse — reached once the single
|
||||
// retry is exhausted and the challenge repeats — branches on `instanceof Response`.
|
||||
fetchFn.mockImplementation(async () =>
|
||||
Response.json(
|
||||
{ error: 'use_dpop_nonce' },
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'DPoP-Nonce': 'as-nonce-1' }
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeTokenRequest('https://as.example.com', {
|
||||
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
|
||||
dpop: session,
|
||||
fetchFn
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'use_dpop_nonce' });
|
||||
// Exactly one retry: the initial nonce-less attempt, and one retry carrying the nonce.
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('re-runs addClientAuthentication for the nonce retry so a private_key_jwt client_assertion is not replayed', async () => {
|
||||
const addClientAuthentication = createPrivateKeyJwtAuth({
|
||||
issuer: 'client-1',
|
||||
subject: 'client-1',
|
||||
privateKey: 'a-string-secret-at-least-256-bits-long',
|
||||
alg: 'HS256'
|
||||
});
|
||||
const assertions: string[] = [];
|
||||
fetchFn.mockImplementation(async (_url: URL, init: RequestInit) => {
|
||||
assertions.push((init.body as URLSearchParams).get('client_assertion')!);
|
||||
return assertions.length === 1
|
||||
? Response.json({ error: 'use_dpop_nonce' }, { status: 400, headers: { 'DPoP-Nonce': 'as-nonce-1' } })
|
||||
: Response.json({ access_token: 'tok', token_type: 'DPoP' });
|
||||
});
|
||||
|
||||
await executeTokenRequest('https://as.example.com', {
|
||||
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
|
||||
addClientAuthentication,
|
||||
dpop: session,
|
||||
fetchFn
|
||||
});
|
||||
|
||||
expect(assertions).toHaveLength(2);
|
||||
// RFC 7521 §5.2 / RFC 7523 §3: an AS MAY enforce one-time use of assertions keyed on jti.
|
||||
const [first, second] = assertions.map(a => decodeJwtPart(a.split('.')[1]!));
|
||||
expect(second!.jti).not.toBe(first!.jti);
|
||||
});
|
||||
|
||||
it('is unaffected when no dpop session is supplied (plain OAuth token requests keep working)', async () => {
|
||||
fetchFn.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ access_token: 'tok', token_type: 'Bearer' })
|
||||
});
|
||||
|
||||
await executeTokenRequest('https://as.example.com', {
|
||||
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
|
||||
fetchFn
|
||||
});
|
||||
|
||||
const [, init] = fetchFn.mock.calls[0]!;
|
||||
expect((init.headers as Headers).has('DPoP')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth() refresh — DPoP', () => {
|
||||
const mockFetch = vi.fn();
|
||||
let session: DpopSession;
|
||||
let provider: OAuthClientProvider;
|
||||
let tokenResponse: () => Response;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
session = await DpopSession.create();
|
||||
const tokens = vi.fn().mockResolvedValue({ access_token: 'old', token_type: 'DPoP', refresh_token: 'rt-1' });
|
||||
provider = {
|
||||
get redirectUrl() {
|
||||
return 'http://localhost/callback';
|
||||
},
|
||||
get clientMetadata() {
|
||||
return { redirect_uris: ['http://localhost/callback'] };
|
||||
},
|
||||
clientInformation: vi.fn().mockResolvedValue({ client_id: 'client-1' }),
|
||||
tokens,
|
||||
saveTokens: vi.fn(),
|
||||
redirectToAuthorization: vi.fn(),
|
||||
saveCodeVerifier: vi.fn(),
|
||||
codeVerifier: vi.fn(),
|
||||
invalidateCredentials: vi.fn(async scope => {
|
||||
if (scope === 'tokens' || scope === 'all') tokens.mockResolvedValue(undefined);
|
||||
}),
|
||||
dpop: () => session
|
||||
};
|
||||
mockFetch.mockImplementation(async (url: URL | string) => {
|
||||
const urlString = url.toString();
|
||||
if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||||
return Response.json({
|
||||
issuer: 'https://api.example.com',
|
||||
authorization_endpoint: 'https://api.example.com/authorize',
|
||||
token_endpoint: 'https://api.example.com/token',
|
||||
response_types_supported: ['code'],
|
||||
dpop_signing_alg_values_supported: ['ES256']
|
||||
});
|
||||
}
|
||||
if (urlString === 'https://api.example.com/token') return tokenResponse();
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('signs a DPoP proof into the refresh_token grant request', async () => {
|
||||
tokenResponse = () => Response.json({ access_token: 'new', token_type: 'DPoP', refresh_token: 'rt-2' });
|
||||
|
||||
await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp' })).resolves.toBe('AUTHORIZED');
|
||||
|
||||
const [, init] = mockFetch.mock.calls.find(c => c[0].toString() === 'https://api.example.com/token')!;
|
||||
expect((init.body as URLSearchParams).get('grant_type')).toBe('refresh_token');
|
||||
const proof = new Headers(init.headers).get('DPoP');
|
||||
expect(decodeJwtPart(proof!.split('.')[1]!).htu).toBe('https://api.example.com/token');
|
||||
});
|
||||
|
||||
it('falls back to a fresh authorization when the AS rejects the refresh with invalid_dpop_proof', async () => {
|
||||
// e.g. the (non-extractable) DPoP key was regenerated across a restart while the persisted
|
||||
// refresh token is still bound to the old key (RFC 9449 §5) — refreshing can never succeed.
|
||||
tokenResponse = () => Response.json({ error: 'invalid_dpop_proof', error_description: 'key mismatch' }, { status: 400 });
|
||||
|
||||
await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp' })).resolves.toBe('REDIRECT');
|
||||
expect(provider.invalidateCredentials).toHaveBeenCalledWith('tokens');
|
||||
expect(provider.redirectToAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractWWWAuthenticateParams — DPoP scheme', () => {
|
||||
it('extracts resource_metadata from a DPoP challenge (not just Bearer)', () => {
|
||||
const response = new Response(null, {
|
||||
headers: {
|
||||
'WWW-Authenticate':
|
||||
'DPoP error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource"'
|
||||
}
|
||||
});
|
||||
const { resourceMetadataUrl, error } = extractWWWAuthenticateParams(response);
|
||||
expect(resourceMetadataUrl?.toString()).toBe('https://example.com/.well-known/oauth-protected-resource');
|
||||
expect(error).toBe('invalid_token');
|
||||
});
|
||||
|
||||
it('extracts scope from a DPoP insufficient_scope challenge (SEP-2350 step-up for DPoP resources)', () => {
|
||||
const response = new Response(null, {
|
||||
headers: { 'WWW-Authenticate': 'DPoP error="insufficient_scope", scope="admin"' }
|
||||
});
|
||||
expect(extractWWWAuthenticateParams(response).scope).toBe('admin');
|
||||
});
|
||||
|
||||
it('still returns {} for an unrecognized scheme', () => {
|
||||
const response = new Response(null, { headers: { 'WWW-Authenticate': 'Digest realm="x"' } });
|
||||
expect(extractWWWAuthenticateParams(response)).toEqual({});
|
||||
});
|
||||
});
|
||||
195
packages/client/test/client/dpop.test.ts
Normal file
195
packages/client/test/client/dpop.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { accessTokenHash, DPOP_SUPPORTED_ALGS, DpopSession, generateDpopKeyPair, isDpopNonceChallenge } from '../../src/client/dpop';
|
||||
|
||||
function decodeJwtPart(part: string): Record<string, unknown> {
|
||||
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
describe('generateDpopKeyPair', () => {
|
||||
it('defaults to ES256 and produces a matching thumbprint', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(kp.alg).toBe('ES256');
|
||||
expect(kp.publicJwk.kty).toBe('EC');
|
||||
expect(kp.publicJwk.crv).toBe('P-256');
|
||||
expect(kp.publicJwk.d).toBeUndefined();
|
||||
expect(typeof kp.thumbprint).toBe('string');
|
||||
expect(kp.thumbprint.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('generates a non-extractable private key by default', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(kp.privateKey.extractable).toBe(false);
|
||||
});
|
||||
|
||||
it('honors extractable: true for callers that need to export the key', async () => {
|
||||
const kp = await generateDpopKeyPair({ extractable: true });
|
||||
expect(kp.privateKey.extractable).toBe(true);
|
||||
});
|
||||
|
||||
it('produces distinct key pairs (and thumbprints) across calls', async () => {
|
||||
const a = await generateDpopKeyPair();
|
||||
const b = await generateDpopKeyPair();
|
||||
expect(a.thumbprint).not.toBe(b.thumbprint);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessTokenHash', () => {
|
||||
it('is the base64url SHA-256 digest of the token (RFC 9449 §4.1)', async () => {
|
||||
const hash = await accessTokenHash('some-access-token');
|
||||
// Independently computed expected digest (Node's webcrypto, not this module's code path).
|
||||
const expected = Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode('some-access-token'))).toString(
|
||||
'base64url'
|
||||
);
|
||||
expect(hash).toBe(expected);
|
||||
});
|
||||
|
||||
it('is base64url, not base64 (no +, /, or = padding)', async () => {
|
||||
const hash = await accessTokenHash('token-that-might-produce-padding-or-special-chars');
|
||||
expect(hash).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DpopSession.buildProof', () => {
|
||||
it('produces a well-formed dpop+jwt proof with the expected header shape', async () => {
|
||||
const session = await DpopSession.create();
|
||||
const proof = await session.buildProof({ htm: 'post', htu: 'https://mcp.example.com/mcp' });
|
||||
const [headerPart, payloadPart, signaturePart] = proof.split('.');
|
||||
expect(headerPart).toBeDefined();
|
||||
expect(payloadPart).toBeDefined();
|
||||
expect(signaturePart!.length).toBeGreaterThan(0);
|
||||
|
||||
const header = decodeJwtPart(headerPart!);
|
||||
expect(header.typ).toBe('dpop+jwt');
|
||||
expect(header.alg).toBe('ES256');
|
||||
expect(header.jwk).toMatchObject({ kty: 'EC', crv: 'P-256' });
|
||||
expect((header.jwk as Record<string, unknown>).d).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes htm to upper-case and strips query/fragment from htu', async () => {
|
||||
const session = await DpopSession.create();
|
||||
const proof = await session.buildProof({ htm: 'post', htu: 'https://mcp.example.com/mcp?foo=bar#frag' });
|
||||
const payload = decodeJwtPart(proof.split('.')[1]!);
|
||||
expect(payload.htm).toBe('POST');
|
||||
expect(payload.htu).toBe('https://mcp.example.com/mcp');
|
||||
});
|
||||
|
||||
it('mints a fresh jti on every call — never caches or reuses a proof', async () => {
|
||||
const session = await DpopSession.create();
|
||||
const first = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp' });
|
||||
const second = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp' });
|
||||
expect(first).not.toBe(second);
|
||||
const jti1 = decodeJwtPart(first.split('.')[1]!).jti;
|
||||
const jti2 = decodeJwtPart(second.split('.')[1]!).jti;
|
||||
expect(jti1).not.toBe(jti2);
|
||||
});
|
||||
|
||||
it('includes ath only when an accessToken is provided', async () => {
|
||||
const session = await DpopSession.create();
|
||||
const proofWithToken = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp', accessToken: 'tok' });
|
||||
const withToken = decodeJwtPart(proofWithToken.split('.')[1]!);
|
||||
expect(withToken.ath).toBe(await accessTokenHash('tok'));
|
||||
|
||||
const proofWithoutToken = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp' });
|
||||
const withoutToken = decodeJwtPart(proofWithoutToken.split('.')[1]!);
|
||||
expect(withoutToken.ath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('automatically carries the remembered nonce for a URL once one has been observed', async () => {
|
||||
const session = await DpopSession.create();
|
||||
const url = 'https://mcp.example.com/mcp';
|
||||
session.rememberNonce(url, 'server-nonce-1');
|
||||
const proof = await session.buildProof({ htm: 'POST', htu: url });
|
||||
const payload = decodeJwtPart(proof.split('.')[1]!);
|
||||
expect(payload.nonce).toBe('server-nonce-1');
|
||||
});
|
||||
|
||||
it('an explicit nonce option overrides the remembered one', async () => {
|
||||
const session = await DpopSession.create();
|
||||
const url = 'https://mcp.example.com/mcp';
|
||||
session.rememberNonce(url, 'remembered');
|
||||
const proof = await session.buildProof({ htm: 'POST', htu: url, nonce: 'explicit' });
|
||||
const payload = decodeJwtPart(proof.split('.')[1]!);
|
||||
expect(payload.nonce).toBe('explicit');
|
||||
});
|
||||
|
||||
it('keeps nonce state independent per origin', async () => {
|
||||
const session = await DpopSession.create();
|
||||
session.rememberNonce('https://as.example.com/token', 'as-nonce');
|
||||
session.rememberNonce('https://rs.example.com/mcp', 'rs-nonce');
|
||||
expect(session.nonceFor('https://as.example.com/token')).toBe('as-nonce');
|
||||
expect(session.nonceFor('https://rs.example.com/mcp')).toBe('rs-nonce');
|
||||
expect(session.nonceFor('https://other.example.com/x')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('supports every DPOP_SUPPORTED_ALGS entry as a construction option', async () => {
|
||||
for (const alg of DPOP_SUPPORTED_ALGS) {
|
||||
const session = await DpopSession.create({ alg });
|
||||
expect(session.alg).toBe(alg);
|
||||
const proof = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp' });
|
||||
expect(decodeJwtPart(proof.split('.')[0]!).alg).toBe(alg);
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses a caller-supplied key pair instead of generating a new one', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const session = await DpopSession.create({ keyPair: kp });
|
||||
expect(session.thumbprint).toBe(kp.thumbprint);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DpopSession.observeNonce', () => {
|
||||
it('remembers a DPoP-Nonce response header for the given url', async () => {
|
||||
const session = await DpopSession.create();
|
||||
const url = 'https://mcp.example.com/mcp';
|
||||
const response = new Response(null, { headers: { 'DPoP-Nonce': 'fresh-nonce' } });
|
||||
session.observeNonce(response, url);
|
||||
expect(session.nonceFor(url)).toBe('fresh-nonce');
|
||||
});
|
||||
|
||||
it('is a no-op when the response carries no DPoP-Nonce header', async () => {
|
||||
const session = await DpopSession.create();
|
||||
const url = 'https://mcp.example.com/mcp';
|
||||
session.rememberNonce(url, 'existing');
|
||||
session.observeNonce(new Response(null), url);
|
||||
expect(session.nonceFor(url)).toBe('existing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDpopNonceChallenge', () => {
|
||||
it('is true for a 401 with a DPoP use_dpop_nonce challenge', () => {
|
||||
const response = new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce", resource_metadata="https://example.com/prm"' }
|
||||
});
|
||||
expect(isDpopNonceChallenge(response)).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes a DPoP challenge listed alongside other schemes', () => {
|
||||
const response = new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'Bearer error="invalid_token", DPoP error="use_dpop_nonce"' }
|
||||
});
|
||||
expect(isDpopNonceChallenge(response)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a plain invalid_dpop_proof challenge (not a nonce challenge)', () => {
|
||||
const response = new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'DPoP error="invalid_dpop_proof"' }
|
||||
});
|
||||
expect(isDpopNonceChallenge(response)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a non-401 status', () => {
|
||||
const response = new Response(null, { status: 400, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"' } });
|
||||
expect(isDpopNonceChallenge(response)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when there is no WWW-Authenticate header at all', () => {
|
||||
expect(isDpopNonceChallenge(new Response(null, { status: 401 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a Bearer-only challenge', () => {
|
||||
const response = new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'Bearer error="use_dpop_nonce"' } });
|
||||
expect(isDpopNonceChallenge(response)).toBe(false);
|
||||
});
|
||||
});
|
||||
346
packages/client/test/client/middleware.dpop.test.ts
Normal file
346
packages/client/test/client/middleware.dpop.test.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import type { FetchLike } from '@modelcontextprotocol/core-internal';
|
||||
import type { Mock, Mocked, MockedFunction } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { OAuthClientProvider } from '../../src/client/auth';
|
||||
import { DpopSession } from '../../src/client/dpop';
|
||||
|
||||
// `auth` is mocked (not exercised for real — it would attempt real network discovery) so the
|
||||
// "credential retry happens before a nonce challenge is discovered" test below can simulate a
|
||||
// successful re-authorization without a real OAuth flow. Everything else — including
|
||||
// `withDpopFromProvider`, which is what makes `withOAuth` DPoP-aware — stays the real implementation.
|
||||
vi.mock('../../src/client/auth', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/client/auth')>('../../src/client/auth');
|
||||
return { ...actual, auth: vi.fn() };
|
||||
});
|
||||
|
||||
import { auth } from '../../src/client/auth';
|
||||
import { withDpop, withDpopFromProvider, withOAuth } from '../../src/client/middleware';
|
||||
|
||||
const mockAuth = auth as MockedFunction<typeof auth>;
|
||||
|
||||
function decodeJwtPart(part: string): Record<string, unknown> {
|
||||
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
describe('withDpop', () => {
|
||||
let session: DpopSession;
|
||||
let getToken: MockedFunction<() => string | undefined | Promise<string | undefined>>;
|
||||
let mockFetch: MockedFunction<FetchLike>;
|
||||
|
||||
beforeEach(async () => {
|
||||
session = await DpopSession.create();
|
||||
getToken = vi.fn().mockResolvedValue('the-access-token');
|
||||
mockFetch = vi.fn();
|
||||
});
|
||||
|
||||
it('presents Authorization: DPoP <token> plus a fresh proof bound to the request', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const enhancedFetch = withDpop(session, getToken)(mockFetch);
|
||||
|
||||
await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0]!;
|
||||
const headers = init!.headers as Headers;
|
||||
expect(headers.get('Authorization')).toBe('DPoP the-access-token');
|
||||
const proof = headers.get('DPoP');
|
||||
expect(proof).toBeTruthy();
|
||||
const payload = decodeJwtPart(proof!.split('.')[1]!);
|
||||
expect(payload.htm).toBe('POST');
|
||||
expect(payload.htu).toBe('https://mcp.example.com/mcp');
|
||||
expect(payload.ath).toBeDefined();
|
||||
});
|
||||
|
||||
it('defaults to GET when no method is given', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const enhancedFetch = withDpop(session, getToken)(mockFetch);
|
||||
await enhancedFetch('https://mcp.example.com/mcp');
|
||||
const payload = decodeJwtPart(((mockFetch.mock.calls[0]![1]!.headers as Headers).get('DPoP') as string).split('.')[1]!);
|
||||
expect(payload.htm).toBe('GET');
|
||||
});
|
||||
|
||||
it('sends no Authorization/DPoP headers when getToken resolves to undefined', async () => {
|
||||
getToken.mockResolvedValue(undefined);
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const enhancedFetch = withDpop(session, getToken)(mockFetch);
|
||||
|
||||
await enhancedFetch('https://mcp.example.com/mcp', { headers: { Accept: 'application/json' } });
|
||||
|
||||
const headers = new Headers(mockFetch.mock.calls[0]![1]!.headers);
|
||||
expect(headers.has('Authorization')).toBe(false);
|
||||
expect(headers.has('DPoP')).toBe(false);
|
||||
expect(headers.get('Accept')).toBe('application/json');
|
||||
});
|
||||
|
||||
it('retries once, with a fresh proof carrying the nonce, on a use_dpop_nonce challenge', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'rs-nonce-1' }
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
const enhancedFetch = withDpop(session, getToken)(mockFetch);
|
||||
|
||||
const response = await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
const firstProof = mockFetch.mock.calls[0]![1]!.headers as Headers;
|
||||
const secondProof = mockFetch.mock.calls[1]![1]!.headers as Headers;
|
||||
expect(secondProof.get('DPoP')).not.toBe(firstProof.get('DPoP')); // fresh jti, never resent
|
||||
expect(decodeJwtPart((secondProof.get('DPoP') as string).split('.')[1]!).nonce).toBe('rs-nonce-1');
|
||||
});
|
||||
|
||||
it('carries a DPoP-Nonce received on a 2xx response in the next request’s proof (RFC 9449 §8.2)', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200, headers: { 'DPoP-Nonce': 'rs-nonce-1' } }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
const enhancedFetch = withDpop(session, getToken)(mockFetch);
|
||||
|
||||
await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
|
||||
await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
const secondProof = (mockFetch.mock.calls[1]![1]!.headers as Headers).get('DPoP') as string;
|
||||
expect(decodeJwtPart(secondProof.split('.')[1]!).nonce).toBe('rs-nonce-1');
|
||||
});
|
||||
|
||||
it('does not retry on an ordinary (non-nonce) 401', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="invalid_token"' } }));
|
||||
const enhancedFetch = withDpop(session, getToken)(mockFetch);
|
||||
|
||||
const response = await enhancedFetch('https://mcp.example.com/mcp');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry a use_dpop_nonce challenge that carries no fresh DPoP-Nonce (nothing new to retry with)', async () => {
|
||||
session.rememberNonce('https://mcp.example.com/mcp', 'stale-nonce');
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"' } }));
|
||||
const enhancedFetch = withDpop(session, getToken)(mockFetch);
|
||||
|
||||
const response = await enhancedFetch('https://mcp.example.com/mcp');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withDpop — session/token sources', () => {
|
||||
it('passes the request through untouched when the session source resolves to undefined', async () => {
|
||||
const mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const init = { method: 'POST', headers: { Authorization: 'Bearer tok' } };
|
||||
|
||||
const noSession = vi.fn<() => Promise<DpopSession | undefined>>().mockResolvedValue(undefined);
|
||||
await withDpop(noSession, async () => 'tok')(mockFetch)('https://mcp.example.com/mcp', init);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://mcp.example.com/mcp', init);
|
||||
});
|
||||
|
||||
it('leaves an existing Authorization header alone when getToken resolves to undefined', async () => {
|
||||
const session = await DpopSession.create();
|
||||
const mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const init = { method: 'POST', headers: { Authorization: 'Bearer tok' } };
|
||||
|
||||
const noToken = vi.fn<() => Promise<string | undefined>>().mockResolvedValue(undefined);
|
||||
await withDpop(session, noToken)(mockFetch)('https://mcp.example.com/mcp', init);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://mcp.example.com/mcp', init);
|
||||
});
|
||||
|
||||
it('accepts a lazily-resolved session', async () => {
|
||||
const session = await DpopSession.create();
|
||||
const mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
|
||||
await withDpop(
|
||||
async () => session,
|
||||
async () => 'tok'
|
||||
)(mockFetch)('https://mcp.example.com/mcp', { method: 'POST' });
|
||||
|
||||
expect((mockFetch.mock.calls[0]![1]!.headers as Headers).get('Authorization')).toBe('DPoP tok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withDpopFromProvider', () => {
|
||||
let session: DpopSession;
|
||||
let provider: Mocked<OAuthClientProvider>;
|
||||
let mockFetch: MockedFunction<FetchLike>;
|
||||
const bearerInit = { method: 'POST', headers: { Authorization: 'Bearer tok-1' } };
|
||||
|
||||
beforeEach(async () => {
|
||||
session = await DpopSession.create();
|
||||
provider = {
|
||||
get redirectUrl() {
|
||||
return 'http://localhost/callback';
|
||||
},
|
||||
get clientMetadata() {
|
||||
return { redirect_uris: ['http://localhost/callback'] };
|
||||
},
|
||||
tokens: vi.fn().mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' }),
|
||||
saveTokens: vi.fn(),
|
||||
clientInformation: vi.fn(),
|
||||
redirectToAuthorization: vi.fn(),
|
||||
saveCodeVerifier: vi.fn(),
|
||||
codeVerifier: vi.fn(),
|
||||
dpop: vi.fn().mockResolvedValue(session)
|
||||
};
|
||||
mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
});
|
||||
|
||||
it('upgrades a token_type=DPoP token to the DPoP scheme with a proof bound to the request', async () => {
|
||||
await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp?x=1', bearerInit);
|
||||
|
||||
const headers = mockFetch.mock.calls[0]![1]!.headers as Headers;
|
||||
expect(headers.get('Authorization')).toBe('DPoP tok-1');
|
||||
const payload = decodeJwtPart(headers.get('DPoP')!.split('.')[1]!);
|
||||
expect(payload).toMatchObject({ htm: 'POST', htu: 'https://mcp.example.com/mcp' });
|
||||
expect(payload.ath).toBeDefined();
|
||||
});
|
||||
|
||||
it('leaves a token_type=Bearer token on the Bearer scheme with no proof, even though dpop() resolves (RFC 9449 §7.1)', async () => {
|
||||
// An AS that ignored the DPoP proof (or does not support DPoP) issues token_type=Bearer;
|
||||
// presenting that with the DPoP scheme to a Bearer-only resource server is a guaranteed 401.
|
||||
provider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'Bearer' });
|
||||
|
||||
await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp', bearerInit);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://mcp.example.com/mcp', bearerInit);
|
||||
});
|
||||
|
||||
it('is a pass-through when dpop() resolves to undefined', async () => {
|
||||
(provider.dpop as Mock).mockResolvedValue(undefined);
|
||||
|
||||
await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp', bearerInit);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://mcp.example.com/mcp', bearerInit);
|
||||
});
|
||||
|
||||
it('retries once with the server nonce on a use_dpop_nonce challenge, and not when the challenge carries no fresh DPoP-Nonce', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'n1' } })
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"' } }));
|
||||
|
||||
const response = await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp', bearerInit);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(decodeJwtPart((mockFetch.mock.calls[1]![1]!.headers as Headers).get('DPoP')!.split('.')[1]!).nonce).toBe('n1');
|
||||
});
|
||||
|
||||
it('stamps a throwing tokens()/dpop() as an auth-seam failure (not a network error)', async () => {
|
||||
const { isAuthSeamEscape } = await import('../../src/client/authSeam');
|
||||
provider.tokens.mockRejectedValue(new Error('storage unavailable'));
|
||||
|
||||
const error = await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp', bearerInit).catch(error_ => error_);
|
||||
|
||||
expect(isAuthSeamEscape(error)).toBe(true);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withOAuth — DPoP-aware', () => {
|
||||
let mockProvider: Mocked<OAuthClientProvider>;
|
||||
let mockFetch: MockedFunction<FetchLike>;
|
||||
let session: DpopSession;
|
||||
|
||||
beforeEach(async () => {
|
||||
session = await DpopSession.create();
|
||||
mockProvider = {
|
||||
get redirectUrl() {
|
||||
return 'http://localhost/callback';
|
||||
},
|
||||
get clientMetadata() {
|
||||
return { redirect_uris: ['http://localhost/callback'] };
|
||||
},
|
||||
tokens: vi.fn(),
|
||||
saveTokens: vi.fn(),
|
||||
clientInformation: vi.fn(),
|
||||
redirectToAuthorization: vi.fn(),
|
||||
saveCodeVerifier: vi.fn(),
|
||||
codeVerifier: vi.fn(),
|
||||
invalidateCredentials: vi.fn(),
|
||||
dpop: vi.fn().mockResolvedValue(session)
|
||||
};
|
||||
mockFetch = vi.fn();
|
||||
});
|
||||
|
||||
it('presents DPoP Authorization + proof (not Bearer) when provider.dpop() resolves to a session', async () => {
|
||||
mockProvider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' });
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const enhancedFetch = withOAuth(mockProvider, 'https://mcp.example.com')(mockFetch);
|
||||
|
||||
await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
|
||||
|
||||
const headers = mockFetch.mock.calls[0]![1]!.headers as Headers;
|
||||
expect(headers.get('Authorization')).toBe('DPoP tok-1');
|
||||
expect(headers.get('DPoP')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps presenting plain Bearer when the provider has no dpop() (existing behavior unaffected)', async () => {
|
||||
mockProvider.dpop = undefined;
|
||||
mockProvider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'Bearer' });
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const enhancedFetch = withOAuth(mockProvider, 'https://mcp.example.com')(mockFetch);
|
||||
|
||||
await enhancedFetch('https://mcp.example.com/mcp');
|
||||
|
||||
const headers = mockFetch.mock.calls[0]![1]!.headers as Headers;
|
||||
expect(headers.get('Authorization')).toBe('Bearer tok-1');
|
||||
expect(headers.has('DPoP')).toBe(false);
|
||||
});
|
||||
|
||||
it('retries once on a use_dpop_nonce challenge without invoking the auth() re-authorization flow', async () => {
|
||||
mockProvider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' });
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'rs-nonce-1' }
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
const enhancedFetch = withOAuth(mockProvider, 'https://mcp.example.com')(mockFetch);
|
||||
|
||||
const response = await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
// The nonce leg is a self-contained retry — it never calls provider.saveTokens or any
|
||||
// other re-authorization side effect.
|
||||
expect(mockProvider.saveTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes credential re-authorization first, then a nonce challenge discovered on the retried request (auth/dpop-nonce shape)', async () => {
|
||||
// No token yet -> the first 401 carries no DPoP-Nonce header at all, so it is NOT a nonce
|
||||
// challenge; only after auth() succeeds and a real token is presented does the resource
|
||||
// server reveal its nonce requirement. Regression test for a bug where the nonce check
|
||||
// only ran *before* the credential retry, never after it — real conformance servers
|
||||
// (auth/dpop-nonce) hit exactly this ordering.
|
||||
mockProvider.tokens.mockResolvedValue(undefined);
|
||||
mockAuth.mockImplementation(async () => {
|
||||
mockProvider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' });
|
||||
return 'AUTHORIZED';
|
||||
});
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401 })) // no token, no nonce header
|
||||
.mockResolvedValueOnce(
|
||||
new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'rs-nonce-1' }
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
const enhancedFetch = withOAuth(mockProvider, 'https://mcp.example.com')(mockFetch);
|
||||
|
||||
const response = await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
expect(mockAuth).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
153
packages/client/test/client/sse.dpop.test.ts
Normal file
153
packages/client/test/client/sse.dpop.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
||||
import { createServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
import type { FetchLike } from '@modelcontextprotocol/core-internal';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth';
|
||||
import { DpopSession } from '../../src/client/dpop';
|
||||
import { SSEClientTransport } from '../../src/client/sse';
|
||||
|
||||
function decodeJwtPart(part: string): Record<string, unknown> {
|
||||
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
interface Seen {
|
||||
method: string;
|
||||
path: string;
|
||||
authorization?: string;
|
||||
proof?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the transport against a real HTTP+SSE server with a real {@linkcode DpopSession} behind an
|
||||
* {@linkcode OAuthClientProvider}, asserting on what the server receives. The announced message
|
||||
* endpoint is deliberately a different path from the SSE URL (the normal shape for this transport)
|
||||
* so the POST proof's `htu` binding is actually exercised.
|
||||
*/
|
||||
describe('SSEClientTransport — DPoP', () => {
|
||||
let server: Server;
|
||||
let baseUrl: URL;
|
||||
let session: DpopSession;
|
||||
let provider: OAuthClientProvider;
|
||||
let transport: SSEClientTransport;
|
||||
let seen: Seen[];
|
||||
let postHandler: (req: IncomingMessage, res: ServerResponse) => void;
|
||||
|
||||
beforeEach(async () => {
|
||||
seen = [];
|
||||
postHandler = (_req, res) => res.writeHead(202).end();
|
||||
server = createServer((req, res) => {
|
||||
const proofHeader = req.headers.dpop as string | undefined;
|
||||
seen.push({
|
||||
method: req.method!,
|
||||
path: req.url!.split('?')[0]!,
|
||||
authorization: req.headers.authorization,
|
||||
proof: proofHeader ? decodeJwtPart(proofHeader.split('.')[1]!) : undefined
|
||||
});
|
||||
if (req.method === 'GET') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive'
|
||||
});
|
||||
res.write('event: endpoint\n');
|
||||
res.write(`data: ${baseUrl.origin}/messages?sessionId=s1\n\n`);
|
||||
return;
|
||||
}
|
||||
req.resume().on('end', () => postHandler(req, res));
|
||||
});
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
baseUrl = new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}/sse`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
session = await DpopSession.create();
|
||||
provider = {
|
||||
get redirectUrl() {
|
||||
return 'http://localhost/callback';
|
||||
},
|
||||
get clientMetadata() {
|
||||
return { redirect_uris: ['http://localhost/callback'] };
|
||||
},
|
||||
clientInformation: vi.fn(),
|
||||
tokens: vi.fn().mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' }),
|
||||
saveTokens: vi.fn(),
|
||||
redirectToAuthorization: vi.fn(),
|
||||
saveCodeVerifier: vi.fn(),
|
||||
codeVerifier: vi.fn(),
|
||||
dpop: () => session
|
||||
};
|
||||
transport = new SSEClientTransport(baseUrl, { authProvider: provider });
|
||||
await transport.start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await transport.close().catch(() => {});
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('presents DPoP Authorization + a proof bound to GET and the SSE URL on the event stream', () => {
|
||||
expect(seen[0]).toMatchObject({ method: 'GET', authorization: 'DPoP tok-1', proof: { htm: 'GET', htu: baseUrl.href } });
|
||||
});
|
||||
|
||||
it('presents DPoP Authorization + a proof on POST, bound to the announced message endpoint (not the SSE URL)', async () => {
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
|
||||
|
||||
// RFC 9449 §4.2: htu is the URI of *this* request, minus query — the /messages endpoint.
|
||||
expect(seen[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
path: '/messages',
|
||||
authorization: 'DPoP tok-1',
|
||||
proof: { htm: 'POST', htu: `${baseUrl.origin}/messages` }
|
||||
});
|
||||
});
|
||||
|
||||
it('retries the POST once on a use_dpop_nonce challenge, with a fresh proof carrying the nonce, without re-authorizing', async () => {
|
||||
const onUnauthorized = vi.spyOn((transport as unknown as { _authProvider: AuthProvider })._authProvider, 'onUnauthorized');
|
||||
let postCalls = 0;
|
||||
postHandler = (_req, res) => {
|
||||
postCalls++;
|
||||
if (postCalls === 1) {
|
||||
res.writeHead(401, { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'rs-nonce-1' }).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(202).end();
|
||||
};
|
||||
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
|
||||
|
||||
const posts = seen.filter(s => s.method === 'POST');
|
||||
expect(posts).toHaveLength(2);
|
||||
expect(posts[1]!.proof).toMatchObject({ htu: `${baseUrl.origin}/messages`, nonce: 'rs-nonce-1' });
|
||||
expect(posts[1]!.proof!.jti).not.toBe(posts[0]!.proof!.jti);
|
||||
expect(onUnauthorized).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps a caller-supplied eventSourceInit.fetch too (it still runs, and the stream request is DPoP-signed)', async () => {
|
||||
await transport.close();
|
||||
const esFetch = vi.fn<FetchLike>((url, init) => fetch(url, init));
|
||||
const t = new SSEClientTransport(baseUrl, { authProvider: provider, eventSourceInit: { fetch: esFetch as typeof fetch } });
|
||||
|
||||
await t.start();
|
||||
|
||||
expect(esFetch).toHaveBeenCalledTimes(1);
|
||||
expect(new Headers(esFetch.mock.calls[0]![1]!.headers).get('Authorization')).toBe('DPoP tok-1');
|
||||
expect(seen.at(-1)).toMatchObject({ method: 'GET', proof: { htm: 'GET' } });
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it('leaves a plain AuthProvider untouched (Bearer via token())', async () => {
|
||||
await transport.close();
|
||||
const bearer = new SSEClientTransport(baseUrl, { authProvider: { token: async () => 'bearer-tok' } });
|
||||
await bearer.start();
|
||||
|
||||
await bearer.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
|
||||
|
||||
expect(seen.at(-1)).toMatchObject({ method: 'POST', authorization: 'Bearer bearer-tok', proof: undefined });
|
||||
await bearer.close();
|
||||
});
|
||||
});
|
||||
206
packages/client/test/client/streamableHttp.dpop.test.ts
Normal file
206
packages/client/test/client/streamableHttp.dpop.test.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import type { FetchLike } from '@modelcontextprotocol/core-internal';
|
||||
import type { Mock } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth';
|
||||
import { DpopSession } from '../../src/client/dpop';
|
||||
import { withDpop } from '../../src/client/middleware';
|
||||
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp';
|
||||
|
||||
function decodeJwtPart(part: string): Record<string, unknown> {
|
||||
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
||||
}
|
||||
|
||||
function proofOf(call: unknown[]): Record<string, unknown> {
|
||||
return decodeJwtPart(new Headers((call[1] as RequestInit).headers).get('DPoP')!.split('.')[1]!);
|
||||
}
|
||||
|
||||
const nonceChallenge = (nonce?: string) =>
|
||||
new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', ...(nonce ? { 'DPoP-Nonce': nonce } : {}) }
|
||||
});
|
||||
|
||||
/**
|
||||
* These tests drive the transport with a real {@linkcode DpopSession} behind an
|
||||
* {@linkcode OAuthClientProvider} and assert on what reaches `fetch` — the transport applies DPoP by
|
||||
* wrapping its resource-server fetch with `withDpopFromProvider`, so the wire is the contract.
|
||||
*/
|
||||
describe('StreamableHTTPClientTransport — DPoP', () => {
|
||||
const url = new URL('http://localhost:1234/mcp');
|
||||
let session: DpopSession;
|
||||
let provider: OAuthClientProvider & { tokens: Mock };
|
||||
let transport: StreamableHTTPClientTransport;
|
||||
let fetchSpy: Mock<typeof fetch>;
|
||||
|
||||
const authProviderOf = (t: StreamableHTTPClientTransport) => (t as unknown as { _authProvider: AuthProvider })._authProvider;
|
||||
|
||||
beforeEach(async () => {
|
||||
session = await DpopSession.create();
|
||||
provider = {
|
||||
get redirectUrl() {
|
||||
return 'http://localhost/callback';
|
||||
},
|
||||
get clientMetadata() {
|
||||
return { redirect_uris: ['http://localhost/callback'] };
|
||||
},
|
||||
clientInformation: vi.fn(),
|
||||
tokens: vi.fn().mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' }),
|
||||
saveTokens: vi.fn(),
|
||||
redirectToAuthorization: vi.fn(),
|
||||
saveCodeVerifier: vi.fn(),
|
||||
codeVerifier: vi.fn(),
|
||||
dpop: () => session
|
||||
};
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
transport = new StreamableHTTPClientTransport(url, { authProvider: provider });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await transport.close().catch(() => {});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('presents Authorization: DPoP + a proof bound to POST and the MCP URL', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 202 }));
|
||||
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
|
||||
|
||||
const headers = new Headers((fetchSpy.mock.calls[0]![1] as RequestInit).headers);
|
||||
expect(headers.get('Authorization')).toBe('DPoP tok-1');
|
||||
expect(proofOf(fetchSpy.mock.calls[0]!)).toMatchObject({ htm: 'POST', htu: url.href });
|
||||
expect(proofOf(fetchSpy.mock.calls[0]!).ath).toBeDefined();
|
||||
});
|
||||
|
||||
it('binds the GET SSE stream proof to GET', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 405 }));
|
||||
|
||||
await (transport as unknown as { _startOrAuthSse: (o: object) => Promise<void> })._startOrAuthSse({});
|
||||
|
||||
expect(proofOf(fetchSpy.mock.calls[0]!)).toMatchObject({ htm: 'GET', htu: url.href });
|
||||
});
|
||||
|
||||
it('binds the session-termination proof to DELETE, and retries DELETE once on a use_dpop_nonce challenge', async () => {
|
||||
(transport as unknown as { _sessionId?: string })._sessionId = 'sess-1';
|
||||
fetchSpy.mockResolvedValueOnce(nonceChallenge('rs-nonce-1')).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await expect(transport.terminateSession()).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(proofOf(fetchSpy.mock.calls[0]!)).toMatchObject({ htm: 'DELETE', htu: url.href });
|
||||
expect(proofOf(fetchSpy.mock.calls[1]!)).toMatchObject({ htm: 'DELETE', nonce: 'rs-nonce-1' });
|
||||
});
|
||||
|
||||
it('presents a token_type=Bearer token with the Bearer scheme and no proof, even though dpop() resolves (RFC 9449 §7.1)', async () => {
|
||||
provider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'Bearer' });
|
||||
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 202 }));
|
||||
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
|
||||
|
||||
const headers = new Headers((fetchSpy.mock.calls[0]![1] as RequestInit).headers);
|
||||
expect(headers.get('Authorization')).toBe('Bearer tok-1');
|
||||
expect(headers.has('DPoP')).toBe(false);
|
||||
});
|
||||
|
||||
it("a caller-supplied per-request 'dpop' header cannot override the transport's own proof (reserved header name)", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 202 }));
|
||||
|
||||
await transport.send(
|
||||
{ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' },
|
||||
{ headers: { dpop: 'attacker-supplied-proof', DPoP: 'attacker-supplied-proof-2' } }
|
||||
);
|
||||
|
||||
expect(proofOf(fetchSpy.mock.calls[0]!).htm).toBe('POST');
|
||||
});
|
||||
|
||||
it('retries once on a use_dpop_nonce challenge with a fresh proof carrying the nonce, without re-authorizing', async () => {
|
||||
const onUnauthorized = vi.spyOn(authProviderOf(transport), 'onUnauthorized');
|
||||
fetchSpy.mockResolvedValueOnce(nonceChallenge('rs-nonce-1')).mockResolvedValueOnce(new Response(null, { status: 202 }));
|
||||
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
const [first, second] = fetchSpy.mock.calls.map(c => proofOf(c));
|
||||
expect(second!.nonce).toBe('rs-nonce-1');
|
||||
expect(second!.jti).not.toBe(first!.jti); // RFC 9449 §4.2: never replay the original proof
|
||||
expect(onUnauthorized).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not spend the nonce retry on a use_dpop_nonce challenge that carries no fresh DPoP-Nonce', async () => {
|
||||
session.rememberNonce(url, 'stale-nonce');
|
||||
const onUnauthorized = vi.spyOn(authProviderOf(transport), 'onUnauthorized').mockResolvedValue();
|
||||
fetchSpy.mockResolvedValueOnce(nonceChallenge()).mockResolvedValueOnce(new Response(null, { status: 202 }));
|
||||
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
|
||||
|
||||
// Straight to the credential path: challenge → onUnauthorized → one retry.
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bounds retries when the nonce challenge never clears: a nonce retry per attempt, one credential retry, then throws', async () => {
|
||||
const onUnauthorized = vi.spyOn(authProviderOf(transport), 'onUnauthorized').mockResolvedValue();
|
||||
fetchSpy.mockImplementation(async () => nonceChallenge('rs-nonce-1'));
|
||||
|
||||
await expect(transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' })).rejects.toThrow();
|
||||
|
||||
// attempt 1 (+ its nonce retry) → onUnauthorized → attempt 2 (+ its nonce retry) → give up.
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles a credential 401 first and a nonce challenge on the re-authorized retry (auth/dpop-nonce shape)', async () => {
|
||||
const onUnauthorized = vi.spyOn(authProviderOf(transport), 'onUnauthorized').mockResolvedValue();
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="invalid_token"' } }))
|
||||
.mockResolvedValueOnce(nonceChallenge('rs-nonce-1'))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 202 }));
|
||||
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
||||
expect(proofOf(fetchSpy.mock.calls[2]!).nonce).toBe('rs-nonce-1');
|
||||
});
|
||||
|
||||
it('carries a DPoP-Nonce received on a 2xx response into the next request’s proof (RFC 9449 §8.2)', async () => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(new Response(null, { status: 202, headers: { 'DPoP-Nonce': 'rs-nonce-1' } }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 202 }));
|
||||
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-2' });
|
||||
|
||||
expect(proofOf(fetchSpy.mock.calls[1]!).nonce).toBe('rs-nonce-1');
|
||||
});
|
||||
|
||||
it('wraps a caller-supplied fetch (it still runs, and sees the DPoP headers)', async () => {
|
||||
const customFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 202 }));
|
||||
const t = new StreamableHTTPClientTransport(url, { authProvider: provider, fetch: customFetch });
|
||||
|
||||
await t.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(new Headers(customFetch.mock.calls[0]![1]!.headers).get('Authorization')).toBe('DPoP tok-1');
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it('leaves a plain AuthProvider untouched (Bearer via token()); DPoP there is opt-in via fetch: withDpop(...)', async () => {
|
||||
fetchSpy.mockResolvedValue(new Response(null, { status: 202 }));
|
||||
|
||||
const bearer = new StreamableHTTPClientTransport(url, { authProvider: { token: async () => 'bearer-tok' } });
|
||||
await bearer.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
|
||||
expect(new Headers((fetchSpy.mock.calls[0]![1] as RequestInit).headers).get('Authorization')).toBe('Bearer bearer-tok');
|
||||
expect(new Headers((fetchSpy.mock.calls[0]![1] as RequestInit).headers).has('DPoP')).toBe(false);
|
||||
await bearer.close();
|
||||
|
||||
const explicit = new StreamableHTTPClientTransport(url, {
|
||||
authProvider: { token: async () => 'pop-tok' },
|
||||
fetch: withDpop(session, () => 'pop-tok')(fetch)
|
||||
});
|
||||
await explicit.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-2' });
|
||||
expect(new Headers((fetchSpy.mock.calls[1]![1] as RequestInit).headers).get('Authorization')).toBe('DPoP pop-tok');
|
||||
expect(proofOf(fetchSpy.mock.calls[1]!)).toMatchObject({ htm: 'POST', htu: url.href });
|
||||
await explicit.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import * as z from 'zod/v4';
|
||||
|
||||
import { ConformanceOAuthProvider } from './helpers/conformanceOAuthProvider';
|
||||
import { DpopOAuthProvider } from './helpers/dpopClient';
|
||||
import { logger } from './helpers/logger';
|
||||
import { handle401, withOAuthRetry } from './helpers/withOAuthRetry';
|
||||
|
||||
|
|
@ -505,6 +506,51 @@ registerScenarios(
|
|||
runAuthClient
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// DPoP sender-constrained tokens (SEP-1932 / RFC 9449, draft extension)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Identical to {@linkcode runAuthClient} except the provider carries a DPoP session
|
||||
* ({@linkcode DpopOAuthProvider}) — every DPoP-specific behavior (token-request proof, the
|
||||
* `DPoP` Authorization scheme, a fresh per-request proof, AS/RS `use_dpop_nonce` retry) is the
|
||||
* SDK's own (`@modelcontextprotocol/client`'s `dpop.ts` / `auth.ts` / `streamableHttp.ts`),
|
||||
* exercised end-to-end here rather than re-implemented. One handler drives both `auth/dpop`
|
||||
* (nonce-less) and `auth/dpop-nonce` — which posture runs depends only on whether the referee's
|
||||
* authorization server / MCP server issue a nonce challenge, which the SDK reacts to automatically.
|
||||
*/
|
||||
async function runDpopAuthClient(serverUrl: string): Promise<void> {
|
||||
const client = new Client(
|
||||
{ name: 'test-dpop-auth-client', version: '1.0.0' },
|
||||
{ capabilities: {}, versionNegotiation: { mode: 'auto' } }
|
||||
);
|
||||
|
||||
const provider = new DpopOAuthProvider(
|
||||
'http://localhost:3000/callback',
|
||||
{ client_name: 'test-dpop-auth-client', redirect_uris: ['http://localhost:3000/callback'] },
|
||||
CIMD_CLIENT_METADATA_URL
|
||||
);
|
||||
const dpopFetch = withOAuthRetry('test-dpop-auth-client', new URL(serverUrl), handle401, CIMD_CLIENT_METADATA_URL, provider)(fetch);
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
fetch: dpopFetch
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected to MCP server (DPoP)');
|
||||
|
||||
await client.listTools();
|
||||
logger.debug('Successfully listed tools');
|
||||
|
||||
await client.callTool({ name: 'test-tool', arguments: {} });
|
||||
logger.debug('Successfully called tool');
|
||||
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
}
|
||||
|
||||
registerScenarios(['auth/dpop', 'auth/dpop-nonce'], runDpopAuthClient);
|
||||
|
||||
// ============================================================================
|
||||
// Client Credentials scenarios
|
||||
// ============================================================================
|
||||
|
|
|
|||
29
test/conformance/src/helpers/dpopClient.ts
Normal file
29
test/conformance/src/helpers/dpopClient.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { OAuthClientMetadata } from '@modelcontextprotocol/client';
|
||||
import { DpopSession } from '@modelcontextprotocol/client';
|
||||
|
||||
import { ConformanceOAuthProvider } from './conformanceOAuthProvider';
|
||||
|
||||
/**
|
||||
* {@linkcode ConformanceOAuthProvider} plus a DPoP signing session (SEP-1932 / RFC 9449).
|
||||
*
|
||||
* Adding `dpop()` is the *only* thing this class does — every DPoP-specific behavior (a proof at
|
||||
* the token endpoint, presenting the token with the `DPoP` scheme, a fresh proof per MCP request,
|
||||
* retrying on an AS/RS `use_dpop_nonce` challenge) lives in `@modelcontextprotocol/client` itself
|
||||
* (`dpop.ts` / `auth.ts` / `streamableHttp.ts`) and is exercised end-to-end through this provider,
|
||||
* not re-implemented here. The same handler drives both the `auth/dpop` (nonce-less) and
|
||||
* `auth/dpop-nonce` postures — which one is exercised depends entirely on whether the test
|
||||
* authorization server / MCP server issue a `use_dpop_nonce` challenge, which the SDK reacts to
|
||||
* automatically.
|
||||
*/
|
||||
export class DpopOAuthProvider extends ConformanceOAuthProvider {
|
||||
private readonly _dpopSession: Promise<DpopSession>;
|
||||
|
||||
constructor(redirectUrl: string | URL, clientMetadata: OAuthClientMetadata, clientMetadataUrl?: string | URL) {
|
||||
super(redirectUrl, clientMetadata, clientMetadataUrl);
|
||||
this._dpopSession = DpopSession.create();
|
||||
}
|
||||
|
||||
async dpop(): Promise<DpopSession> {
|
||||
return this._dpopSession;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@ import {
|
|||
computeScopeUnion,
|
||||
extractWWWAuthenticateParams,
|
||||
isStrictScopeSuperset,
|
||||
UnauthorizedError
|
||||
UnauthorizedError,
|
||||
withDpopFromProvider
|
||||
} from '@modelcontextprotocol/client';
|
||||
|
||||
import { ConformanceOAuthProvider } from './conformanceOAuthProvider';
|
||||
|
|
@ -94,7 +95,14 @@ export const withOAuthRetry = (
|
|||
},
|
||||
clientMetadataUrl
|
||||
);
|
||||
return (next: FetchLike) => {
|
||||
return (baseNext: FetchLike) => {
|
||||
// Same composition as the SDK's withOAuth: DPoP request-signing sits *below* this
|
||||
// Bearer/re-auth layer so it binds proofs to the real request and retries use_dpop_nonce
|
||||
// challenges on every attempt. It is a pass-through unless the provider implements dpop()
|
||||
// (helpers/dpopClient.ts). auth() keeps the unwrapped fetch — token-endpoint DPoP is the
|
||||
// SDK's executeTokenRequest's job.
|
||||
const next = withDpopFromProvider(provider)(baseNext);
|
||||
|
||||
return async (input: string | URL, init?: RequestInit): Promise<Response> => {
|
||||
const makeRequest = async (): Promise<Response> => {
|
||||
const headers = new Headers(init?.headers);
|
||||
|
|
@ -113,7 +121,7 @@ export const withOAuthRetry = (
|
|||
// Handle 401/403 responses by attempting re-authentication
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin);
|
||||
await handle401Fn(response, provider, next, serverUrl);
|
||||
await handle401Fn(response, provider, baseNext, serverUrl);
|
||||
|
||||
response = await makeRequest();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue