From e7e2d485ef281041b56a9f84d99486fe8f891469 Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:26:15 +0100 Subject: [PATCH 1/8] Add DPoP client conformance scenario + shared DPoP helpers (SEP-1932, #368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-anchored packaging: carries the shared DPoP foundation (dpopProof / dpopToken helpers, the createAuthServer DPoP core, and sep-1932.yaml) together with the client scenario, so it can merge first; the server (#369) and authorization-server (#370) scenarios are follow-ups that depend on it. DPoP is treated as a draft/not-yet-official feature (mirroring the WIF SEP-1933 scenario): the scenario's source is `introducedIn: DRAFT_PROTOCOL_VERSION` and it is registered in draftScenariosList (informational, not scored). It is NOT registered in EXTENSION_IDS, since the ext-auth DPoP extension is still on a branch rather than merged. Client checks (the two extension MUSTs, RFC 9449 §7.1 / §4.2-4.3): - sep-1932-client-dpop-auth-scheme — token presented with the DPoP scheme. - sep-1932-client-fresh-proof — a fresh, well-formed DPoP proof per request. The test MCP server judges the client via an opt-in DPoP middleware (dpopResourceAuth) passed through createServer's authMiddleware hook; a compliant example client (covered by the Client Draft Scenarios loop) plus deliberately- broken bearer/replay variants drive the pass/fail acceptance tests. Co-Authored-By: Claude Opus 4.8 --- .../typescript/auth-test-dpop-bearer.ts | 21 ++ .../typescript/auth-test-dpop-no-as-nonce.ts | 22 ++ .../typescript/auth-test-dpop-no-rs-nonce.ts | 22 ++ .../auth-test-dpop-no-token-proof.ts | 27 ++ .../typescript/auth-test-dpop-replay.ts | 21 ++ examples/clients/typescript/auth-test-dpop.ts | 20 ++ .../clients/typescript/everything-client.ts | 7 + .../typescript/helpers/dpopClientFlow.ts | 224 +++++++++++++ src/scenarios/client/auth/dpop.ts | 287 ++++++++++++++++ .../client/auth/helpers/createAuthServer.ts | 305 +++++++++++++++++- .../client/auth/helpers/dpopProof.test.ts | 180 +++++++++++ .../client/auth/helpers/dpopProof.ts | 178 ++++++++++ .../auth/helpers/dpopResourceAuth.test.ts | 239 ++++++++++++++ .../client/auth/helpers/dpopResourceAuth.ts | 271 ++++++++++++++++ .../client/auth/helpers/dpopToken.test.ts | 158 +++++++++ .../client/auth/helpers/dpopToken.ts | 110 +++++++ .../validateDpopProofAtTokenEndpoint.test.ts | 193 +++++++++++ src/scenarios/client/auth/index.test.ts | 51 +++ src/scenarios/client/auth/index.ts | 4 +- src/scenarios/client/auth/spec-references.ts | 33 ++ src/seps/sep-1932.yaml | 48 +++ 21 files changed, 2419 insertions(+), 2 deletions(-) create mode 100644 examples/clients/typescript/auth-test-dpop-bearer.ts create mode 100644 examples/clients/typescript/auth-test-dpop-no-as-nonce.ts create mode 100644 examples/clients/typescript/auth-test-dpop-no-rs-nonce.ts create mode 100644 examples/clients/typescript/auth-test-dpop-no-token-proof.ts create mode 100644 examples/clients/typescript/auth-test-dpop-replay.ts create mode 100644 examples/clients/typescript/auth-test-dpop.ts create mode 100644 examples/clients/typescript/helpers/dpopClientFlow.ts create mode 100644 src/scenarios/client/auth/dpop.ts create mode 100644 src/scenarios/client/auth/helpers/dpopProof.test.ts create mode 100644 src/scenarios/client/auth/helpers/dpopProof.ts create mode 100644 src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts create mode 100644 src/scenarios/client/auth/helpers/dpopResourceAuth.ts create mode 100644 src/scenarios/client/auth/helpers/dpopToken.test.ts create mode 100644 src/scenarios/client/auth/helpers/dpopToken.ts create mode 100644 src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts create mode 100644 src/seps/sep-1932.yaml diff --git a/examples/clients/typescript/auth-test-dpop-bearer.ts b/examples/clients/typescript/auth-test-dpop-bearer.ts new file mode 100644 index 00000000..6ddd4ea8 --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-bearer.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: builds correct per-request proofs but presents the + * DPoP-bound token with the `Bearer` scheme instead of `DPoP`. Isolates a + * failure of sep-1932-client-dpop-auth-scheme (RFC 9449 §7.1). + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'Bearer', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: true + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-bearer '); diff --git a/examples/clients/typescript/auth-test-dpop-no-as-nonce.ts b/examples/clients/typescript/auth-test-dpop-no-as-nonce.ts new file mode 100644 index 00000000..0c395c4f --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-no-as-nonce.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: ignores the authorization server's `use_dpop_nonce` + * challenge at the token endpoint (RFC 9449 §8) — it does not retry the token + * request with the supplied nonce. Isolates a failure of + * sep-1932-client-as-nonce. + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: false, + handleRsNonce: true + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-as-nonce '); diff --git a/examples/clients/typescript/auth-test-dpop-no-rs-nonce.ts b/examples/clients/typescript/auth-test-dpop-no-rs-nonce.ts new file mode 100644 index 00000000..35f1991d --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-no-rs-nonce.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: obtains the token correctly (handles the AS nonce) but + * ignores the MCP server's `use_dpop_nonce` challenge at the resource + * (RFC 9449 §9) — it does not retry the request with the supplied nonce. + * Isolates a failure of sep-1932-client-rs-nonce. + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: false + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-rs-nonce '); diff --git a/examples/clients/typescript/auth-test-dpop-no-token-proof.ts b/examples/clients/typescript/auth-test-dpop-no-token-proof.ts new file mode 100644 index 00000000..ab72c5fc --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-no-token-proof.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: never sends a DPoP proof at the token endpoint, so it + * never asks for a sender-constrained token — the AS falls back to an unbound + * Bearer token. Isolates a failure of sep-1932-client-token-request-proof + * (RFC 9449 §5). Because the resulting token is unbound, the resource-side + * binding check (sep-1932-client-fresh-proof) also fails as a consequence. + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: false, + handleAsNonce: true, + handleRsNonce: true + }); +} + +runAsCli( + runClient, + import.meta.url, + 'auth-test-dpop-no-token-proof ' +); diff --git a/examples/clients/typescript/auth-test-dpop-replay.ts b/examples/clients/typescript/auth-test-dpop-replay.ts new file mode 100644 index 00000000..50b7efcf --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-replay.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: uses the DPoP scheme correctly but reuses a single proof + * (same `jti`) across requests instead of a fresh one each time. Isolates a + * failure of sep-1932-client-fresh-proof. + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: false, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: true + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-replay '); diff --git a/examples/clients/typescript/auth-test-dpop.ts b/examples/clients/typescript/auth-test-dpop.ts new file mode 100644 index 00000000..feabfa74 --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Well-behaved DPoP client (SEP-1932 / RFC 9449): presents the DPoP-bound token + * with the `DPoP` Authorization scheme and a fresh proof on every MCP request. + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: true + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop '); diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index ff6dd35f..94afab31 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -42,6 +42,7 @@ import { } from './helpers/withOAuthRetry.js'; import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; import { runClient as issValidationClient } from './auth-test-iss-validation.js'; +import { runClient as dpopClient } from './auth-test-dpop.js'; import { logger } from './helpers/logger.js'; /** @@ -854,6 +855,12 @@ registerScenario( runEnterpriseManagedAuthorization ); +// ============================================================================ +// DPoP client conformance (SEP-1932) +// ============================================================================ + +registerScenario('auth/dpop', dpopClient); + // ============================================================================ // MRTR client conformance (SEP-2322) // ============================================================================ diff --git a/examples/clients/typescript/helpers/dpopClientFlow.ts b/examples/clients/typescript/helpers/dpopClientFlow.ts new file mode 100644 index 00000000..c8fc0b18 --- /dev/null +++ b/examples/clients/typescript/helpers/dpopClientFlow.ts @@ -0,0 +1,224 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { request } from 'undici'; +import { createHash, randomBytes } from 'crypto'; +import { + generateDpopKeyPair, + buildDpopProof +} from '../../../../src/scenarios/client/auth/helpers/dpopProof'; +import { logger } from './logger'; + +/** + * Shared DPoP client flow (SEP-1932 / RFC 9449). Acquires a DPoP-bound access + * token via the authorization_code + PKCE grant (with a DPoP proof at the token + * endpoint), then opens an MCP session presenting the token to the resource. + * + * The OAuth flow is hand-rolled rather than reusing ConformanceOAuthProvider / + * withOAuthRetry because the SDK's OAuth provider offers no hook to attach a + * DPoP proof to the token-endpoint request, which is required to obtain a bound + * token. The MCP session itself does use the SDK Client (via a fetch wrapper). + * + * Parameterized so the compliant client and the deliberately-broken variants + * differ by exactly one behaviour: + * - `scheme: 'Bearer'` → fails sep-1932-client-dpop-auth-scheme + * - `freshProofPerRequest:false` → reuses one proof, fails sep-1932-client-fresh-proof + * - `sendTokenRequestProof:false`→ omits the token-endpoint proof, so the AS + * issues an unbound Bearer token; fails sep-1932-client-token-request-proof + * (and, downstream, the resource binding check, since the token isn't bound) + * - `handleAsNonce:false` → ignores the token endpoint's `use_dpop_nonce` + * challenge (RFC 9449 §8); fails sep-1932-client-as-nonce + * - `handleRsNonce:false` → ignores the MCP server's `use_dpop_nonce` + * challenge (RFC 9449 §9); fails sep-1932-client-rs-nonce + */ +export interface DpopClientOptions { + scheme: 'DPoP' | 'Bearer'; + freshProofPerRequest: boolean; + sendTokenRequestProof: boolean; + /** Retry the token request with the AS-supplied nonce on a use_dpop_nonce challenge (RFC 9449 §8). */ + handleAsNonce: boolean; + /** Retry an MCP request with the server-supplied nonce on a use_dpop_nonce challenge (RFC 9449 §9). */ + handleRsNonce: boolean; +} + +const REDIRECT_URI = 'http://127.0.0.1:9876/callback'; + +export async function runDpopClient( + serverUrl: string, + options: DpopClientOptions +): Promise { + const keyPair = await generateDpopKeyPair(); + + // 1. Discover the authorization server via Protected Resource Metadata. + const prmUrl = new URL( + '/.well-known/oauth-protected-resource/mcp', + serverUrl + ); + const prm = await (await fetch(prmUrl.toString())).json(); + const authServerUrl: string = prm.authorization_servers[0]; + + // 2. Authorization server metadata. + const asMeta = await ( + await fetch( + new URL( + '/.well-known/oauth-authorization-server', + authServerUrl + ).toString() + ) + ).json(); + const authorizationEndpoint: string = asMeta.authorization_endpoint; + const tokenEndpoint: string = asMeta.token_endpoint; + const registrationEndpoint: string = asMeta.registration_endpoint; + + // 3. Dynamic client registration. + const reg = await ( + await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: 'conformance-dpop-client', + redirect_uris: [REDIRECT_URI], + application_type: 'native' + }) + }) + ).json(); + const clientId: string = reg.client_id; + + // 4. Authorization request (PKCE). The test AS redirects straight to the + // redirect_uri, so read the code from the Location header (no callback needed). + const state = randomBytes(16).toString('base64url'); + const codeVerifier = randomBytes(32).toString('base64url'); + const codeChallenge = createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + const authorizeUrl = `${authorizationEndpoint}?${new URLSearchParams({ + response_type: 'code', + client_id: clientId, + state, + redirect_uri: REDIRECT_URI, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }).toString()}`; + const authorizeResponse = await request(authorizeUrl, { method: 'GET' }); + await authorizeResponse.body.text().catch(() => undefined); + const location = authorizeResponse.headers['location']; + const locationStr = Array.isArray(location) ? location[0] : location; + if (!locationStr) { + throw new Error('Authorization endpoint did not redirect with a code'); + } + const code = new URL(locationStr).searchParams.get('code'); + if (!code) throw new Error('No authorization code in redirect'); + + // 5. Token request with a DPoP proof → DPoP-bound access token. The broken + // `sendTokenRequestProof:false` variant omits the proof, so the AS issues an + // unbound Bearer token instead. + const tokenReqBody = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + code_verifier: codeVerifier, + client_id: clientId + }).toString(); + const requestToken = async (nonce?: string): Promise => { + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded' + }; + if (options.sendTokenRequestProof) { + headers.dpop = await buildDpopProof({ + keyPair, + htm: 'POST', + // RFC 9449 §4.2: htu carries no query/fragment (the token endpoint URL + // may legally have a query, so strip it here). + htu: stripQuery(tokenEndpoint), + ...(nonce ? { nonce } : {}) + }); + } + return fetch(tokenEndpoint, { + method: 'POST', + headers, + body: tokenReqBody + }); + }; + let tokenResponse = await requestToken(); + // RFC 9449 §8: the AS may answer with `use_dpop_nonce` (HTTP 400 + DPoP-Nonce); + // a conformant client retries the token request with the supplied nonce. + const asNonce = tokenResponse.headers.get('DPoP-Nonce'); + if (tokenResponse.status === 400 && asNonce && options.handleAsNonce) { + tokenResponse = await requestToken(asNonce); + } + if (!tokenResponse.ok) { + throw new Error(`Token request failed: HTTP ${tokenResponse.status}`); + } + const tokenBody = await tokenResponse.json(); + const accessToken: string = tokenBody.access_token; + logger.debug(`Obtained ${tokenBody.token_type} access token`); + + // 6. MCP session — present the token to the resource with a per-request proof. + // On a `use_dpop_nonce` challenge (RFC 9449 §9) a conformant client retries + // with the server-supplied nonce embedded in the proof, and carries it on + // subsequent requests. + const mcpUrl = `${serverUrl}`; + let reusableProof: string | undefined; + let rsNonce: string | undefined; + const dpopFetch = async ( + input: string | URL, + init?: RequestInit + ): Promise => { + const method = (init?.method ?? 'POST').toUpperCase(); + const htu = stripQuery( + typeof input === 'string' ? input : input.toString() + ); + const attempt = async (): Promise => { + const headers = new Headers(init?.headers); + headers.set('Authorization', `${options.scheme} ${accessToken}`); + let proof: string; + if (options.freshProofPerRequest || !reusableProof) { + proof = await buildDpopProof({ + keyPair, + htm: method, + htu, + accessToken, + ...(rsNonce ? { nonce: rsNonce } : {}) + }); + if (!options.freshProofPerRequest) reusableProof = proof; + } else { + proof = reusableProof; + } + headers.set('DPoP', proof); + return fetch(input, { ...init, headers }); + }; + let res = await attempt(); + const nonce = res.headers.get('DPoP-Nonce'); + if ( + res.status === 401 && + nonce && + options.handleRsNonce && + (res.headers.get('WWW-Authenticate') ?? '').includes('use_dpop_nonce') + ) { + rsNonce = nonce; + reusableProof = undefined; // rebuild the proof carrying the nonce + res = await attempt(); + } + return res; + }; + + const client = new Client( + { name: 'conformance-dpop-client', version: '1.0.0' }, + { capabilities: {} } + ); + const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), { + fetch: dpopFetch as typeof fetch + }); + + await client.connect(transport); + logger.debug('Connected to MCP server'); + await client.listTools(); + logger.debug('Listed tools'); + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Called tool'); + await transport.close(); +} + +function stripQuery(url: string): string { + const u = new URL(url); + return `${u.origin}${u.pathname}`; +} diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts new file mode 100644 index 00000000..559a666e --- /dev/null +++ b/src/scenarios/client/auth/dpop.ts @@ -0,0 +1,287 @@ +import type { ScenarioContext } from '../../../mock-server'; +import type { + Scenario, + ConformanceCheck, + CheckStatus, + SpecReference +} from '../../../types'; +import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types'; +import { + createAuthServer, + type DpopTokenRequestObservation +} from './helpers/createAuthServer'; +import { createServer } from './helpers/createServer'; +import { ServerLifecycle } from './helpers/serverLifecycle'; +import { + createDpopResourceAuth, + newDpopClientObservations, + type DpopClientObservations +} from './helpers/dpopResourceAuth'; +import { SpecReferences } from './spec-references'; + +const PRM_PATH = '/.well-known/oauth-protected-resource/mcp'; + +/** Static id → (name, description, spec references) for each emitted check. */ +const CHECK_DEFS: Record< + string, + { name: string; description: string; specReferences: SpecReference[] } +> = { + 'sep-1932-client-dpop-auth-scheme': { + name: 'DpopAuthScheme', + description: + 'Client presents the access token with the DPoP Authorization scheme (not Bearer)', + specReferences: [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_AUTH_SCHEME + ] + }, + 'sep-1932-client-fresh-proof': { + name: 'DpopFreshProof', + description: + 'Client includes a fresh, well-formed DPoP proof in the DPoP header on each MCP POST request', + specReferences: [ + SpecReferences.RFC_9449_CHECKING_PROOFS, + SpecReferences.RFC_9449_PROOF_SYNTAX, + SpecReferences.DPOP_EXTENSION + ] + }, + 'sep-1932-client-token-request-proof': { + name: 'DpopTokenRequestProof', + description: + 'Client includes a valid DPoP proof in the DPoP header of its token request, obtaining a DPoP-bound access token (RFC 9449 §5)', + specReferences: [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_TOKEN_REQUEST + ] + }, + 'sep-1932-client-as-nonce': { + name: 'DpopAsNonce', + description: + 'On a use_dpop_nonce challenge from the token endpoint, the client retries the token request with the server-supplied nonce (RFC 9449 §8)', + specReferences: [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_AS_NONCE + ] + }, + 'sep-1932-client-rs-nonce': { + name: 'DpopRsNonce', + description: + 'On a use_dpop_nonce challenge from the MCP server, the client retries the request with the server-supplied nonce (RFC 9449 §9)', + specReferences: [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_RS_NONCE + ] + } +}; + +/** + * Scenario: DPoP sender-constrained tokens — MCP client (SEP-1932 / RFC 9449). + * + * The test authorization server (DPoP-capable `createAuthServer`) issues a + * DPoP-bound token; the test MCP server judges how the client presents it. The + * test AS and MCP server both require a server-provided nonce (RFC 9449 §8/§9), + * so the client's nonce handling is exercised too. Five checks: + * - token acquisition — the client sends a valid DPoP proof at the token + * request, obtaining a sender-constrained token (RFC 9449 §5); + * - the client retries the token request with the AS-supplied nonce (§8); + * - the client retries the MCP request with the server-supplied nonce (§9); + * - the token is presented with the `DPoP` Authorization scheme (RFC 9449 §7.1); + * - a fresh, well-formed DPoP proof accompanies each request (unique `jti`). + */ +function newTokenReqObs(): DpopTokenRequestObservation { + return { + recorded: false, + validProof: false, + asNonceChallengeIssued: false, + asNonceHonored: false + }; +} + +export class DPoPClientScenario implements Scenario { + name = 'auth/dpop'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests that an MCP client requests a DPoP-bound access token (a valid DPoP proof at the token request) and then presents it using the DPoP Authorization scheme (not Bearer) with a fresh, well-formed DPoP proof on each POST /mcp request (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private obs: DpopClientObservations = newDpopClientObservations(); + private tokenReqObs: DpopTokenRequestObservation = newTokenReqObs(); + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + this.obs = newDpopClientObservations(); + this.tokenReqObs = newTokenReqObs(); + + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + dpopSigningAlgValuesSupported: ['ES256'], + dpopTokenRequestObs: this.tokenReqObs, + dpopRequireNonce: true + }); + await this.authServer.start(authApp); + + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + authMiddleware: createDpopResourceAuth( + this.obs, + () => `${this.server.getUrl()}/mcp`, + () => `${this.server.getUrl()}${PRM_PATH}`, + true + ) + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop(): Promise { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + return [ + ...this.checks, + this.tokenRequestProofCheck(), + this.asNonceCheck(), + this.rsNonceCheck(), + this.authSchemeCheck(), + this.freshProofCheck() + ]; + } + + private asNonceCheck(): ConformanceCheck { + const honored = this.tokenReqObs.asNonceHonored; + return this.build( + 'sep-1932-client-as-nonce', + honored ? 'SUCCESS' : 'FAILURE', + { + errorMessage: honored + ? undefined + : this.tokenReqObs.asNonceChallengeIssued + ? 'Client did not retry the token request with the server-supplied nonce after a use_dpop_nonce challenge' + : 'Client never completed a token request that could be nonce-challenged', + details: { + challengeIssued: this.tokenReqObs.asNonceChallengeIssued, + nonceHonored: honored + } + } + ); + } + + private rsNonceCheck(): ConformanceCheck { + const honored = this.obs.rsNonceHonored; + return this.build( + 'sep-1932-client-rs-nonce', + honored ? 'SUCCESS' : 'FAILURE', + { + errorMessage: honored + ? undefined + : this.obs.rsNonceChallengeIssued + ? 'Client did not retry the MCP request with the server-supplied nonce after a use_dpop_nonce challenge' + : 'Client never made an MCP request that could be nonce-challenged', + details: { + challengeIssued: this.obs.rsNonceChallengeIssued, + nonceHonored: honored + } + } + ); + } + + private tokenRequestProofCheck(): ConformanceCheck { + let status: CheckStatus; + let errorMessage: string | undefined; + if (!this.tokenReqObs.recorded) { + status = 'FAILURE'; + errorMessage = + 'Client never completed an authorization_code token request, so it obtained no DPoP-bound token'; + } else if (!this.tokenReqObs.validProof) { + status = 'FAILURE'; + errorMessage = `Client did not present a valid DPoP proof at the token endpoint: ${this.tokenReqObs.error}`; + } else { + status = 'SUCCESS'; + } + return this.build('sep-1932-client-token-request-proof', status, { + errorMessage, + details: { + tokenRequestObserved: this.tokenReqObs.recorded, + validProofPresented: this.tokenReqObs.validProof + } + }); + } + + private authSchemeCheck(): ConformanceCheck { + let status: CheckStatus; + let errorMessage: string | undefined; + if (this.obs.authenticatedRequests === 0) { + status = 'FAILURE'; + errorMessage = 'Client never presented an access token to the MCP server'; + } else if (this.obs.nonDpopSchemeSeen) { + status = 'FAILURE'; + errorMessage = `Client presented the token with a non-DPoP Authorization scheme (${[...new Set(this.obs.observedSchemes)].join(', ')})`; + } else { + status = 'SUCCESS'; + } + return this.build('sep-1932-client-dpop-auth-scheme', status, { + errorMessage, + details: { + authenticatedRequests: this.obs.authenticatedRequests, + observedSchemes: [...new Set(this.obs.observedSchemes)] + } + }); + } + + private freshProofCheck(): ConformanceCheck { + let status: CheckStatus; + let errorMessage: string | undefined; + if (this.obs.authenticatedRequests === 0) { + status = 'FAILURE'; + errorMessage = 'Client never presented an access token to the MCP server'; + } else if (!this.obs.allProofsWellFormed) { + status = 'FAILURE'; + errorMessage = `DPoP proof was missing or malformed: ${this.obs.proofError}`; + } else if (this.obs.replayDetected) { + status = 'FAILURE'; + errorMessage = + 'Client reused a DPoP proof (duplicate jti) across requests instead of sending a fresh one'; + } else { + status = 'SUCCESS'; + } + return this.build('sep-1932-client-fresh-proof', status, { + errorMessage, + details: { + authenticatedRequests: this.obs.authenticatedRequests, + distinctJtis: this.obs.jtisSeen.length, + replayDetected: this.obs.replayDetected + } + }); + } + + private build( + id: string, + status: CheckStatus, + opts: { errorMessage?: string; details?: Record } = {} + ): ConformanceCheck { + const def = CHECK_DEFS[id]; + return { + id, + name: def.name, + description: def.description, + status, + timestamp: new Date().toISOString(), + specReferences: def.specReferences, + ...(opts.errorMessage ? { errorMessage: opts.errorMessage } : {}), + ...(opts.details ? { details: opts.details } : {}) + }; + } +} diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index cbcbd707..971b28cb 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -6,6 +6,12 @@ import { isStatefulVersion } from '../../../../connection/select'; import { createRequestLogger } from '../../../request-logger'; import { SpecReferences } from '../spec-references'; import { MockTokenVerifier } from './mockTokenVerifier'; +import * as jose from 'jose'; +import { + generateIssuerKey, + mintDpopBoundToken, + type TokenIssuerKey +} from './dpopToken'; /** * Compute S256 code challenge from a code verifier. @@ -20,6 +26,105 @@ function computeS256Challenge(codeVerifier: string): string { .replace(/=+$/, ''); } +// Fixed nonce the test AS hands out when `dpopRequireNonce` is set (RFC 9449 §8). +const AS_DPOP_NONCE = 'conformance-as-dpop-nonce'; + +// Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3, §11.6). +const DPOP_ASYMMETRIC_ALGS = [ + 'ES256', + 'ES384', + 'ES512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA' +]; + +/** + * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based + * normalization): lowercase scheme/host, drop the default port, ignore a + * trailing slash. Query/fragment are rejected by the caller (RFC 9449 §4.2), + * not silently stripped here. + */ +function normalizeHtu(u: string): string { + try { + const url = new URL(u); + // Tolerate a single trailing slash only; `/mcp//` is a distinct path. + return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}`; + } catch { + return u; + } +} + +/** + * Validate a DPoP proof presented at the token endpoint (the token-request + * subset of RFC 9449 §4.3). Hand-rolled with jose primitives — deliberately an + * INDEPENDENT code path from the suite's proof builder, so a shared bug + * surfaces rather than hides. Returns the JWK thumbprint to bind on success. + */ +export async function validateDpopProofAtTokenEndpoint( + proof: string, + tokenEndpointUrl: string +): Promise<{ ok: true; jkt: string } | { ok: false; error: string }> { + let header: jose.ProtectedHeaderParameters; + try { + header = jose.decodeProtectedHeader(proof); + } catch { + return { ok: false, error: 'DPoP proof is not a well-formed JWT' }; + } + if (header.typ !== 'dpop+jwt') { + return { ok: false, error: 'typ must be dpop+jwt' }; + } + if (!header.alg || !DPOP_ASYMMETRIC_ALGS.includes(header.alg)) { + return { ok: false, error: 'alg must be a supported asymmetric algorithm' }; + } + const jwk = header.jwk as jose.JWK | undefined; + if (!jwk) { + return { ok: false, error: 'missing jwk header parameter' }; + } + if ((jwk as Record).d !== undefined) { + return { ok: false, error: 'jwk must not contain a private key' }; + } + let claims: jose.JWTPayload; + try { + const key = await jose.importJWK(jwk, header.alg); + claims = ( + await jose.jwtVerify(proof, key, { algorithms: DPOP_ASYMMETRIC_ALGS }) + ).payload; + } catch { + return { ok: false, error: 'DPoP proof signature does not verify' }; + } + if (typeof claims.jti !== 'string') { + return { ok: false, error: 'missing jti claim' }; + } + if (claims.htm !== 'POST') { + return { ok: false, error: 'htm does not match POST' }; + } + if (typeof claims.htu !== 'string') { + return { ok: false, error: 'htu does not match the token endpoint' }; + } + if (claims.htu.includes('?') || claims.htu.includes('#')) { + return { + ok: false, + error: 'htu MUST NOT contain a query or fragment (RFC 9449 §4.2)' + }; + } + if (normalizeHtu(claims.htu) !== normalizeHtu(tokenEndpointUrl)) { + return { ok: false, error: 'htu does not match the token endpoint' }; + } + if ( + typeof claims.iat !== 'number' || + Math.abs(Math.floor(Date.now() / 1000) - claims.iat) > 300 + ) { + return { ok: false, error: 'iat outside the acceptable window' }; + } + const jkt = await jose.calculateJwkThumbprint(jwk, 'sha256'); + return { ok: true, jkt }; +} + export interface TokenRequestResult { token: string; scopes: string[]; @@ -31,6 +136,23 @@ export interface TokenRequestError { statusCode?: number; } +/** + * Sink for the DPoP token-request observation (RFC 9449 §5). The AS writes to + * it on the authorization_code exchange (last write wins; refresh grants are + * ignored) so the client scenario can emit sep-1932-client-token-request-proof + * unconditionally — FAILURE by default when the client never reaches the token + * endpoint, matching its sibling checks. + */ +export interface DpopTokenRequestObservation { + recorded: boolean; + validProof: boolean; + error?: string; + /** The token endpoint issued a `use_dpop_nonce` challenge (RFC 9449 §8). */ + asNonceChallengeIssued: boolean; + /** The client retried the token request carrying the correct nonce. */ + asNonceHonored: boolean; +} + export interface AuthServerOptions { metadataPath?: string; isOpenIdConfiguration?: boolean; @@ -63,6 +185,34 @@ export interface AuthServerOptions { * after `start()`. */ metadataIssuer?: string | (() => string); + /** + * DPoP (SEP-1932 / RFC 9449) — opt-in; absent ⇒ no DPoP behaviour at all. + * When set, the AS advertises `dpop_signing_alg_values_supported` in its + * metadata and validates+binds a DPoP proof presented at the token endpoint. + */ + dpopSigningAlgValuesSupported?: string[]; + /** + * Negative-test mode: make the AS misbehave in one specific way so a check + * can be proven to FAIL. + * - 'omit-alg-values' — drop `dpop_signing_alg_values_supported` entirely + * - 'empty-alg-values' — advertise the field as an empty array + * - 'include-none' — list `none` among the supported proof algs + * - 'unbound-token' — issue a Bearer token ignoring a valid proof + */ + dpopMisbehavior?: + | 'omit-alg-values' + | 'empty-alg-values' + | 'include-none' + | 'unbound-token'; + /** Sink for the DPoP token-request observation; see the interface docstring. */ + dpopTokenRequestObs?: DpopTokenRequestObservation; + /** + * When true, the token endpoint requires a DPoP nonce (RFC 9449 §8): a + * proof-bearing request without the correct `nonce` claim is answered with + * `400 use_dpop_nonce` + a `DPoP-Nonce` header, and the retry carrying the + * nonce is accepted. Used to exercise the client's nonce handling. + */ + dpopRequireNonce?: boolean; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { scope?: string; @@ -110,6 +260,10 @@ export function createAuthServer( issParameterSupported = true, issInRedirect = 'correct', metadataIssuer, + dpopSigningAlgValuesSupported, + dpopMisbehavior, + dpopTokenRequestObs, + dpopRequireNonce = false, tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -120,6 +274,34 @@ export function createAuthServer( let lastAuthorizationScopes: string[] = []; // Track PKCE code_challenge for verification in token request let storedCodeChallenge: string | undefined; + // Lazily-created issuer key for minting DPoP-bound JWT access tokens. + let dpopIssuerKey: TokenIssuerKey | undefined; + // DPoP behaviour is active only when the caller opts in (any DPoP option). + const dpopEnabled = + dpopSigningAlgValuesSupported !== undefined || + dpopMisbehavior !== undefined; + + // Records whether the client presented a valid DPoP proof at its + // authorization_code token request (RFC 9449 §5) into the caller's observation + // sink. Sticky-failure: FAILURE if ANY exchange lacked a valid proof, SUCCESS + // only if every one had one — so a later proof-less exchange (e.g. a refresh + // done as authorization_code) can't overwrite an earlier failure, nor vice + // versa. Refresh grants are ignored entirely. The client scenario reads this + // to emit sep-1932-client-token-request-proof unconditionally (FAILURE by + // default when the client never reaches the token endpoint). + const recordTokenRequestProof = ( + grantType: string, + ok: boolean, + detail?: string + ): void => { + if (grantType !== 'authorization_code' || !dpopTokenRequestObs) return; + const valid = dpopTokenRequestObs.recorded + ? dpopTokenRequestObs.validProof && ok + : ok; + dpopTokenRequestObs.recorded = true; + dpopTokenRequestObs.validProof = valid; + if (!valid && detail) dpopTokenRequestObs.error ??= detail; + }; const authRoutes = { authorization_endpoint: `${routePrefix}/authorize`, @@ -185,7 +367,21 @@ export function createAuthServer( ...(tokenEndpointAuthSigningAlgValuesSupported && { token_endpoint_auth_signing_alg_values_supported: tokenEndpointAuthSigningAlgValuesSupported - }) + }), + // DPoP AS-metadata support signal (SEP-1932 / RFC 9449 §5.1). Opt-in; + // misbehaviour modes alter it: 'omit-alg-values' drops the field, + // 'empty-alg-values' advertises an empty array, 'include-none' adds the + // forbidden `none` alg. (dpop_bound_access_tokens is client-registration + // metadata per RFC 9449 §5.2, so it is deliberately NOT advertised here.) + ...(dpopMisbehavior !== 'omit-alg-values' && + dpopSigningAlgValuesSupported !== undefined && { + dpop_signing_alg_values_supported: + dpopMisbehavior === 'empty-alg-values' + ? [] + : dpopMisbehavior === 'include-none' + ? [...dpopSigningAlgValuesSupported, 'none'] + : dpopSigningAlgValuesSupported + }) }; // Add scopes_supported if provided @@ -370,6 +566,113 @@ export function createAuthServer( }); } + // ---- DPoP token binding (SEP-1932 / RFC 9449) — opt-in ---- + if (dpopEnabled) { + const proofHeader = req.headers['dpop']; + const proofValue = Array.isArray(proofHeader) + ? proofHeader.join(', ') + : proofHeader; + // RFC 9449 §4.2: at most one DPoP header field. Node collapses duplicate + // request headers into one comma-joined value; a valid proof JWT contains + // no comma, so a comma means more than one proof was sent. + if (typeof proofValue === 'string' && proofValue.includes(',')) { + recordTokenRequestProof( + grantType, + false, + 'multiple DPoP proof headers' + ); + res.status(400).json({ + error: 'invalid_dpop_proof', + error_description: 'Multiple DPoP proof headers' + }); + return; + } + const proof = proofValue; + const tokenEndpointUrl = `${getAuthBaseUrl()}${authRoutes.token_endpoint}`; + const grantedScopes = requestedScope + ? requestedScope.split(' ') + : lastAuthorizationScopes; + + // No proof ⇒ DPoP is not being exercised here; fall through to a Bearer + // token. (Requiring a proof is a per-client `dpop_bound_access_tokens` + // registration policy — RFC 9449 §5.2 — not a global AS behaviour.) The + // client-side check still records that the client failed to ask for a + // bound token. + if (proof) { + const result = await validateDpopProofAtTokenEndpoint( + proof, + tokenEndpointUrl + ); + if (!result.ok) { + recordTokenRequestProof(grantType, false, result.error); + res.status(400).json({ + error: 'invalid_dpop_proof', + error_description: result.error + }); + return; + } + // RFC 9449 §8: require a server-provided nonce. A proof without the + // correct nonce is challenged (400 use_dpop_nonce + DPoP-Nonce); the + // client is expected to retry with it. + if (dpopRequireNonce) { + let proofNonce: unknown; + try { + proofNonce = jose.decodeJwt(proof).nonce; + } catch { + proofNonce = undefined; + } + if (proofNonce !== AS_DPOP_NONCE) { + if (dpopTokenRequestObs) + dpopTokenRequestObs.asNonceChallengeIssued = true; + res.status(400).set('DPoP-Nonce', AS_DPOP_NONCE).json({ + error: 'use_dpop_nonce', + error_description: 'Authorization server requires a DPoP nonce' + }); + return; + } + if (dpopTokenRequestObs) dpopTokenRequestObs.asNonceHonored = true; + } + recordTokenRequestProof(grantType, true); + + if (dpopMisbehavior === 'unbound-token') { + // Misbehaviour: ignore the binding and issue a plain Bearer token. + const bearer = `test-token-${Date.now()}`; + if (tokenVerifier) tokenVerifier.registerToken(bearer, grantedScopes); + res.json({ + access_token: bearer, + token_type: 'Bearer', + expires_in: 3600 + }); + return; + } + + if (!dpopIssuerKey) { + dpopIssuerKey = await generateIssuerKey(); + } + const boundToken = await mintDpopBoundToken({ + issuerKey: dpopIssuerKey, + issuer: resolveIssuer(), + audience: + (req.body.resource as string) || 'urn:conformance-test-resource', + jkt: result.jkt, + ...(requestedScope && { scope: requestedScope }) + }); + res.json({ + access_token: boundToken, + token_type: 'DPoP', + expires_in: 3600, + ...(requestedScope && { scope: requestedScope }) + }); + return; + } + // dpopEnabled but the token request carried no DPoP proof. + recordTokenRequestProof( + grantType, + false, + 'no DPoP proof in the token request' + ); + } + let token = `test-token-${Date.now()}`; let scopes: string[] = lastAuthorizationScopes; diff --git a/src/scenarios/client/auth/helpers/dpopProof.test.ts b/src/scenarios/client/auth/helpers/dpopProof.test.ts new file mode 100644 index 00000000..e0d1efcd --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopProof.test.ts @@ -0,0 +1,180 @@ +import { webcrypto } from 'node:crypto'; +import * as jose from 'jose'; +import type { JWK } from 'jose'; +import { + generateDpopKeyPair, + accessTokenHash, + jwkThumbprint, + buildDpopProof +} from './dpopProof'; + +/** + * Independent ES256 signature verification using Node's native WebCrypto — + * a different code path from jose's signer, so a signing bug can't be masked + * by symmetric use of one library (validation Layer 2). + */ +async function verifyEs256Independently( + jwt: string, + publicJwk: JWK +): Promise { + const [h, p, s] = jwt.split('.'); + const key = await webcrypto.subtle.importKey( + 'jwk', + publicJwk as JsonWebKey, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'] + ); + const signature = new Uint8Array(Buffer.from(s, 'base64url')); + const data = new TextEncoder().encode(`${h}.${p}`); + return webcrypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + signature, + data + ); +} + +describe('DPoP proof helper — RFC 9449 published vectors (Layer 1)', () => { + // RFC 9449 §4 / §6.1 example: this EC public key's JWK thumbprint is the + // bound `cnf.jkt` value shown in the spec. + const RFC9449_EC_JWK: JWK = { + kty: 'EC', + x: 'l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs', + y: '9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA', + crv: 'P-256' + }; + const RFC9449_EXPECTED_JKT = '0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I'; + + // RFC 9449 §4.1 example: ath = base64url(SHA-256(ASCII access token)). + const RFC9449_ACCESS_TOKEN = 'Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU'; + const RFC9449_EXPECTED_ATH = 'fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo'; + + it('reproduces the RFC 9449 JWK thumbprint vector', async () => { + expect(await jwkThumbprint(RFC9449_EC_JWK)).toBe(RFC9449_EXPECTED_JKT); + }); + + it('reproduces the RFC 9449 access-token-hash (ath) vector', () => { + expect(accessTokenHash(RFC9449_ACCESS_TOKEN)).toBe(RFC9449_EXPECTED_ATH); + }); +}); + +describe('DPoP proof helper — key pair', () => { + it('generates an extractable P-256 public JWK with a consistent thumbprint', async () => { + const kp = await generateDpopKeyPair(); + expect(kp.publicJwk.kty).toBe('EC'); + expect(kp.publicJwk.crv).toBe('P-256'); + expect(kp.publicJwk.d).toBeUndefined(); // public JWK must not carry the private scalar + expect(kp.thumbprint).toBe( + await jose.calculateJwkThumbprint(kp.publicJwk, 'sha256') + ); + }); +}); + +describe('DPoP proof helper — valid proof', () => { + it('builds a well-formed dpop+jwt with the required header and claims', async () => { + const kp = await generateDpopKeyPair(); + const accessToken = 'example-access-token-value'; + const jwt = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: 'https://mcp.example.com/mcp', + accessToken + }); + + const header = jose.decodeProtectedHeader(jwt); + expect(header.typ).toBe('dpop+jwt'); + expect(header.alg).toBe('ES256'); + expect((header.jwk as JWK).kty).toBe('EC'); + expect((header.jwk as JWK).d).toBeUndefined(); + + const claims = jose.decodeJwt(jwt); + expect(typeof claims.jti).toBe('string'); + expect(claims.htm).toBe('POST'); + expect(claims.htu).toBe('https://mcp.example.com/mcp'); + expect(typeof claims.iat).toBe('number'); + expect(claims.ath).toBe(accessTokenHash(accessToken)); + }); + + it('signature verifies under an independent WebCrypto verifier (Layer 2)', async () => { + const kp = await generateDpopKeyPair(); + const jwt = await buildDpopProof({ + keyPair: kp, + htm: 'GET', + htu: 'https://mcp.example.com/mcp' + }); + expect(await verifyEs256Independently(jwt, kp.publicJwk)).toBe(true); + }); +}); + +describe('DPoP proof helper — invalid variants isolate exactly one defect (Layer 3)', () => { + const base = { htm: 'POST', htu: 'https://mcp.example.com/mcp' }; + + it('a tampered signature fails verification while header and claims are unchanged', async () => { + const kp = await generateDpopKeyPair(); + const good = await buildDpopProof({ keyPair: kp, ...base }); + // Derive the bad proof from the good one so ONLY the signature differs + // (each build has a fresh jti and ECDSA signatures are randomized, so two + // independent builds would differ in their claims too). + const [h, p, s] = good.split('.'); + // Flip the FIRST signature char (fully significant); the last char carries + // base64url padding bits that can decode identically and stay valid. + const bad = [h, p, (s[0] === 'A' ? 'B' : 'A') + s.slice(1)].join('.'); + + expect(bad.split('.').slice(0, 2)).toEqual([h, p]); + expect(await verifyEs256Independently(good, kp.publicJwk)).toBe(true); + expect(await verifyEs256Independently(bad, kp.publicJwk)).toBe(false); + }); + + it('the builder tamperSignature option yields a proof that fails verification', async () => { + const kp = await generateDpopKeyPair(); + const jwt = await buildDpopProof({ + keyPair: kp, + ...base, + tamperSignature: true + }); + expect(await verifyEs256Independently(jwt, kp.publicJwk)).toBe(false); + }); + + it('omitting jti drops only the jti claim', async () => { + const kp = await generateDpopKeyPair(); + const jwt = await buildDpopProof({ keyPair: kp, ...base, omit: ['jti'] }); + const claims = jose.decodeJwt(jwt); + expect(claims.jti).toBeUndefined(); + expect(claims.htm).toBe('POST'); + expect(claims.htu).toBe('https://mcp.example.com/mcp'); + expect(typeof claims.iat).toBe('number'); + }); + + it('unsigned variant uses alg=none with an empty signature segment', async () => { + const kp = await generateDpopKeyPair(); + const jwt = await buildDpopProof({ keyPair: kp, ...base, unsigned: true }); + const parts = jwt.split('.'); + expect(parts).toHaveLength(3); + expect(parts[2]).toBe(''); + expect(jose.decodeProtectedHeader(jwt).alg).toBe('none'); + }); + + it('symmetric variant signs with HS256 (must be rejected by servers)', async () => { + const kp = await generateDpopKeyPair(); + const jwt = await buildDpopProof({ keyPair: kp, ...base, symmetric: true }); + expect(jose.decodeProtectedHeader(jwt).alg).toBe('HS256'); + }); + + it('embedPrivateKey leaks the private scalar into the jwk header', async () => { + const kp = await generateDpopKeyPair(); + const jwt = await buildDpopProof({ + keyPair: kp, + ...base, + embedPrivateKey: true + }); + expect((jose.decodeProtectedHeader(jwt).jwk as JWK).d).toBeDefined(); + }); + + it('iat override produces a stale proof', async () => { + const kp = await generateDpopKeyPair(); + const staleIat = Math.floor(Date.now() / 1000) - 3600; + const jwt = await buildDpopProof({ keyPair: kp, ...base, iat: staleIat }); + expect(jose.decodeJwt(jwt).iat).toBe(staleIat); + }); +}); diff --git a/src/scenarios/client/auth/helpers/dpopProof.ts b/src/scenarios/client/auth/helpers/dpopProof.ts new file mode 100644 index 00000000..5ebc8687 --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopProof.ts @@ -0,0 +1,178 @@ +import * as jose from 'jose'; +import type { JWK, CryptoKey } from 'jose'; +import { createHash, randomBytes } from 'node:crypto'; + +/** + * DPoP proof construction helpers (RFC 9449). + * + * Shared across the DPoP client, server, and authorization-server conformance + * scenarios. The framework uses these to act as a DPoP client: it generates a + * key pair, builds a well-formed `dpop+jwt` proof for the happy path, and builds + * deliberately-malformed variants (one defect at a time) for the negative checks. + * + * Correctness of this module is anchored to published RFC test vectors in + * `proof.test.ts` (RFC 9449 §4 examples), not to the conformance servers that + * consume it — see the validation strategy in the project notes. + */ + +const DEFAULT_ALG = 'ES256'; +const DPOP_TYP = 'dpop+jwt'; + +export interface DpopKeyPair { + /** Private key used to sign proofs. */ + privateKey: CryptoKey; + /** Public key matching {@link publicJwk}. */ + publicKey: CryptoKey; + /** Public JWK embedded in the proof's `jwk` header parameter. */ + publicJwk: JWK; + /** RFC 7638 JWK SHA-256 thumbprint (the value bound as `cnf.jkt`). */ + thumbprint: string; +} + +/** Generate an asymmetric key pair for DPoP proofs (default ES256 / P-256). */ +export async function generateDpopKeyPair( + alg: string = DEFAULT_ALG +): Promise { + const { publicKey, privateKey } = await jose.generateKeyPair(alg, { + extractable: true + }); + const publicJwk = await jose.exportJWK(publicKey); + const thumbprint = await jose.calculateJwkThumbprint(publicJwk, 'sha256'); + return { privateKey, publicKey, publicJwk, thumbprint }; +} + +/** + * Compute the `ath` claim for a DPoP proof presented with an access token: + * the base64url-encoded SHA-256 of the ASCII access-token value (RFC 9449 §4.1). + */ +export function accessTokenHash(accessToken: string): string { + return createHash('sha256').update(accessToken, 'ascii').digest('base64url'); +} + +/** RFC 7638 JWK SHA-256 thumbprint (base64url). */ +export async function jwkThumbprint(jwk: JWK): Promise { + return jose.calculateJwkThumbprint(jwk, 'sha256'); +} + +export interface DpopProofOptions { + /** Key pair whose private key signs the proof and whose public JWK is embedded. */ + keyPair: DpopKeyPair; + /** HTTP method of the target request (`htm` claim). */ + htm: string; + /** HTTP target URI of the request, no query/fragment (`htu` claim). */ + htu: string; + /** When set, adds an `ath` claim bound to this access token. */ + accessToken?: string; + /** When set, adds a `nonce` claim (server-supplied nonce). */ + nonce?: string; + /** `iat` in epoch seconds. Defaults to now. Override to craft stale/future proofs. */ + iat?: number; + /** `jti` value. Defaults to a fresh random id. */ + jti?: string; + + // --- override knobs for crafting deliberately-invalid variants --- + /** Override the `typ` header (default `dpop+jwt`). */ + typ?: string; + /** Override the signing algorithm (default ES256). */ + alg?: string; + /** Embed the PRIVATE JWK in the header instead of the public one (invalid). */ + embedPrivateKey?: boolean; + /** Replace the embedded `jwk` header entirely. */ + jwkOverride?: JWK; + /** Omit specific header params / claims to craft "missing field" variants. */ + omit?: Array<'jti' | 'htm' | 'htu' | 'iat' | 'typ' | 'jwk'>; + /** Force a specific (wrong) `ath` value. */ + athOverride?: string; + /** Sign with a symmetric key (HS256) — must be rejected (asymmetric-only). */ + symmetric?: boolean; + /** Produce an unsigned `alg: none` proof — must be rejected. */ + unsigned?: boolean; + /** Corrupt the signature after signing so verification fails. */ + tamperSignature?: boolean; +} + +function base64urlJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +/** + * Build a DPoP proof JWT. With no override knobs set, produces a well-formed, + * RFC 9449-conformant proof. The override knobs each introduce exactly one + * defect so a negative check can attribute a rejection to that single cause. + */ +export async function buildDpopProof( + options: DpopProofOptions +): Promise { + const omit = new Set(options.omit ?? []); + const iat = options.iat ?? Math.floor(Date.now() / 1000); + const jti = options.jti ?? randomBytes(16).toString('base64url'); + + // ----- claims (payload) ----- + const payload: Record = {}; + if (!omit.has('jti')) payload.jti = jti; + if (!omit.has('htm')) payload.htm = options.htm; + if (!omit.has('htu')) payload.htu = options.htu; + if (!omit.has('iat')) payload.iat = iat; + if (options.athOverride !== undefined) { + payload.ath = options.athOverride; + } else if (options.accessToken !== undefined) { + payload.ath = accessTokenHash(options.accessToken); + } + if (options.nonce !== undefined) payload.nonce = options.nonce; + + // ----- protected header ----- + const unsigned = options.unsigned === true || options.alg === 'none'; + const alg = unsigned + ? 'none' + : options.symmetric + ? 'HS256' + : (options.alg ?? DEFAULT_ALG); + const header: Record = { alg }; + if (!omit.has('typ')) header.typ = options.typ ?? DPOP_TYP; + if (!omit.has('jwk')) { + if (options.jwkOverride !== undefined) { + header.jwk = options.jwkOverride; + } else if (options.embedPrivateKey === true) { + header.jwk = await jose.exportJWK(options.keyPair.privateKey); + } else { + header.jwk = options.keyPair.publicJwk; + } + } + + // ----- assembly / signing ----- + if (unsigned) { + // `alg: none` — no signature segment. + return `${base64urlJson(header)}.${base64urlJson(payload)}.`; + } + + if (options.symmetric) { + const secret = new Uint8Array(randomBytes(32)); + return new jose.SignJWT(payload) + .setProtectedHeader(header as jose.JWTHeaderParameters) + .sign(secret); + } + + const jwt = await new jose.SignJWT(payload) + .setProtectedHeader(header as jose.JWTHeaderParameters) + .sign(options.keyPair.privateKey); + + if (options.tamperSignature) { + const parts = jwt.split('.'); + parts[2] = corruptSegment(parts[2]); + return parts.join('.'); + } + + return jwt; +} + +/** + * Corrupt a base64url signature so it no longer verifies. We flip the FIRST + * character (all 6 of its bits are significant): the LAST character of a + * 64-byte ECDSA signature carries 4 zero padding bits, so flipping it (e.g. + * `A`→`B`) can decode to the same bytes and leave the signature valid. + */ +function corruptSegment(segment: string): string { + const first = segment[0]; + const replacement = first === 'A' ? 'B' : 'A'; + return replacement + segment.slice(1); +} diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts new file mode 100644 index 00000000..8d20cf7a --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from 'vitest'; +import { + generateDpopKeyPair, + buildDpopProof, + type DpopKeyPair +} from './dpopProof'; +import { generateIssuerKey, mintDpopBoundToken } from './dpopToken'; +import { validateResourceProof } from './dpopResourceAuth'; + +const ISSUER = 'https://auth.example.com'; +const RESOURCE = 'https://mcp.example.com/mcp'; + +/** Mint a DPoP-bound token for `kp`'s key, optionally bound to a foreign key. */ +async function boundToken( + kp: DpopKeyPair, + jktOverride?: string +): Promise { + const issuerKey = await generateIssuerKey(); + return mintDpopBoundToken({ + issuerKey, + issuer: ISSUER, + audience: RESOURCE, + jkt: kp.thumbprint, + ...(jktOverride ? { jktOverride } : {}) + }); +} + +describe('validateResourceProof — accepts a well-formed resource proof', () => { + it('accepts a valid proof bound to the presented token', async () => { + const kp = await generateDpopKeyPair(); + const token = await boundToken(kp); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + accessToken: token + }); + const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + expect(result.ok).toBe(true); + }); + + it('accepts an htu that differs only by normalization (trailing slash)', async () => { + const kp = await generateDpopKeyPair(); + const token = await boundToken(kp); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: `${RESOURCE}/`, + accessToken: token + }); + const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + expect(result.ok).toBe(true); + }); +}); + +describe('validateResourceProof — rejects each single defect', () => { + async function expectRejected( + build: Parameters[0], + method = 'POST', + tokenJktOverride?: string + ): Promise { + const kp = (build.keyPair as DpopKeyPair) ?? (await generateDpopKeyPair()); + const token = await boundToken(kp, tokenJktOverride); + const proof = await buildDpopProof({ ...build, keyPair: kp }); + const result = await validateResourceProof(proof, token, method, RESOURCE); + expect(result.ok).toBe(false); + return result.ok ? '' : result.error; + } + + it('rejects a missing proof', async () => { + const kp = await generateDpopKeyPair(); + const token = await boundToken(kp); + const result = await validateResourceProof( + undefined, + token, + 'POST', + RESOURCE + ); + expect(result.ok).toBe(false); + }); + + it('rejects a wrong typ', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + typ: 'jwt' + }) + ).toMatch(/typ/); + }); + + it('rejects a symmetric algorithm', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + symmetric: true + }) + ).toMatch(/alg/); + }); + + it('rejects a private key embedded in the jwk header', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + embedPrivateKey: true + }) + ).toMatch(/private key/); + }); + + it('rejects a tampered signature', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + tamperSignature: true + }) + ).toMatch(/signature/); + }); + + it('rejects a missing jti', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + omit: ['jti'] + }) + ).toMatch(/jti/); + }); + + it('rejects an htm that does not match the request method', async () => { + const kp = await generateDpopKeyPair(); + // proof says GET, request is POST + expect( + await expectRejected({ keyPair: kp, htm: 'GET', htu: RESOURCE }, 'POST') + ).toMatch(/htm/); + }); + + it('rejects an htu that does not match the request URI', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: 'https://elsewhere.example.com/mcp' + }) + ).toMatch(/htu/); + }); + + it('rejects a stale iat', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + iat: Math.floor(Date.now() / 1000) - 3600 + }) + ).toMatch(/iat/); + }); + + it('rejects a wrong ath', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + athOverride: 'not-the-token-hash' + }) + ).toMatch(/ath/); + }); + + it('rejects a token bound to a different key (cnf.jkt mismatch)', async () => { + const kp = await generateDpopKeyPair(); + const foreign = await generateDpopKeyPair(); + const token = await boundToken(kp, foreign.thumbprint); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + accessToken: token + }); + const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch(/cnf\.jkt/); + }); + + it('rejects an htu containing a query string (RFC 9449 §4.2)', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ keyPair: kp, htm: 'POST', htu: `${RESOURCE}?x=1` }) + ).toMatch(/query or fragment/); + }); + + it('rejects an htu containing a fragment (RFC 9449 §4.2)', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: `${RESOURCE}#frag` + }) + ).toMatch(/query or fragment/); + }); + + it('rejects two comma-joined proofs — multiple DPoP headers (RFC 9449 §4.2)', async () => { + const kp = await generateDpopKeyPair(); + const token = await boundToken(kp); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + accessToken: token + }); + // Node joins duplicate DPoP headers into one comma-separated value. + const result = await validateResourceProof( + `${proof}, ${proof}`, + token, + 'POST', + RESOURCE + ); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch( + /multiple DPoP proof headers/ + ); + }); +}); diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts new file mode 100644 index 00000000..be645321 --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts @@ -0,0 +1,271 @@ +import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import * as jose from 'jose'; +import { createHash } from 'node:crypto'; + +/** Fixed nonce the judge hands out when `requireNonce` is set (RFC 9449 §9). */ +const RS_DPOP_NONCE = 'conformance-rs-dpop-nonce'; + +/** Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 / §11.6). */ +const DPOP_ASYMMETRIC_ALGS = [ + 'ES256', + 'ES384', + 'ES512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA' +]; + +/** + * Accumulated observations of how the client presented its access token and + * per-request DPoP proof. The scenario turns these into the two + * sep-1932-client-* checks in getChecks() — freshness needs cross-request + * state (unique `jti` per request), so we accumulate rather than emit inline. + */ +export interface DpopClientObservations { + authenticatedRequests: number; + nonDpopSchemeSeen: boolean; + observedSchemes: string[]; + jtisSeen: string[]; + replayDetected: boolean; + allProofsWellFormed: boolean; + proofError?: string; + /** The judge issued a `use_dpop_nonce` challenge (RFC 9449 §9). */ + rsNonceChallengeIssued: boolean; + /** The client retried the request carrying the correct nonce. */ + rsNonceHonored: boolean; +} + +export function newDpopClientObservations(): DpopClientObservations { + return { + authenticatedRequests: 0, + nonDpopSchemeSeen: false, + observedSchemes: [], + jtisSeen: [], + replayDetected: false, + allProofsWellFormed: true, + rsNonceChallengeIssued: false, + rsNonceHonored: false + }; +} + +/** + * Test MCP server DPoP judge (SEP-1932 / RFC 9449), passed to `createServer` + * via its `options.authMiddleware` hook. An unauthenticated request gets a + * `401 DPoP` discovery challenge; an authenticated one is observed (scheme + + * per-request proof) and allowed through so the MCP session can complete and + * multiple requests can be examined for proof freshness. + * + * Proof validation is hand-rolled with jose — deliberately an INDEPENDENT code + * path from the suite's proof builder, so a shared bug surfaces rather than hides. + */ +export function createDpopResourceAuth( + obs: DpopClientObservations, + getResourceUrl: () => string, + getPrmUrl: () => string, + requireNonce = false +): RequestHandler { + return async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { + const authorization = req.headers.authorization; + if (!authorization) { + res + .status(401) + .set('WWW-Authenticate', `DPoP resource_metadata="${getPrmUrl()}"`) + .json({ error: 'unauthorized' }); + return; + } + + obs.authenticatedRequests++; + const { scheme, token } = splitAuthorization(authorization); + obs.observedSchemes.push(scheme); + if (scheme.toLowerCase() !== 'dpop') { + obs.nonDpopSchemeSeen = true; + } + + const proofHeader = req.headers['dpop']; + // Node collapses duplicate DPoP request headers into one comma-joined value; + // validateResourceProof rejects a comma (RFC 9449 §4.2 — at most one proof). + const proofValue = Array.isArray(proofHeader) + ? proofHeader.join(', ') + : proofHeader; + const result = await validateResourceProof( + proofValue, + token, + req.method, + getResourceUrl() + ); + if (!result.ok) { + obs.allProofsWellFormed = false; + obs.proofError ??= result.error; + next(); + return; + } + + // RFC 9449 §9: require a server-provided nonce. A valid proof without the + // correct nonce is challenged (401 use_dpop_nonce + DPoP-Nonce); the client + // is expected to retry with it. Only nonce-honoured requests are recorded + // for the freshness check. + if (requireNonce) { + let proofNonce: unknown; + try { + proofNonce = proofValue ? jose.decodeJwt(proofValue).nonce : undefined; + } catch { + proofNonce = undefined; + } + if (proofNonce !== RS_DPOP_NONCE) { + obs.rsNonceChallengeIssued = true; + res + .status(401) + .set( + 'WWW-Authenticate', + `DPoP error="use_dpop_nonce", resource_metadata="${getPrmUrl()}"` + ) + .set('DPoP-Nonce', RS_DPOP_NONCE) + .json({ error: 'use_dpop_nonce' }); + return; + } + obs.rsNonceHonored = true; + } + + if (obs.jtisSeen.includes(result.jti)) { + obs.replayDetected = true; + } else { + obs.jtisSeen.push(result.jti); + } + + next(); + }; +} + +function splitAuthorization(authorization: string): { + scheme: string; + token: string; +} { + const idx = authorization.indexOf(' '); + if (idx === -1) return { scheme: authorization, token: '' }; + return { + scheme: authorization.slice(0, idx), + token: authorization.slice(idx + 1).trim() + }; +} + +/** + * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based + * normalization): lowercase scheme/host, drop the default port, ignore a + * trailing slash. Query/fragment are rejected by the caller (RFC 9449 §4.2), + * not silently stripped here. + */ +function normalizeHtu(u: string): string { + try { + const url = new URL(u); + // Tolerate a single trailing slash only; `/mcp//` is a distinct path. + return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}`; + } catch { + return u; + } +} + +/** + * Validate a per-request DPoP proof presented alongside an access token + * (RFC 9449 §4.3, resource-request subset): well-formed `dpop+jwt`, asymmetric + * alg, embedded public jwk, htm/htu match, `ath` binds the token, signature + * verifies, and the proof key's thumbprint matches the token's `cnf.jkt`. + */ +export async function validateResourceProof( + proof: string | undefined, + token: string, + method: string, + resourceUrl: string +): Promise<{ ok: true; jti: string } | { ok: false; error: string }> { + if (!proof) return { ok: false, error: 'missing DPoP proof header' }; + // RFC 9449 §4.2: at most one DPoP header. A single proof JWT has no comma, so + // a comma means duplicate headers were sent (Node joins them with ", "). + if (proof.includes(',')) { + return { ok: false, error: 'multiple DPoP proof headers' }; + } + + let header: jose.ProtectedHeaderParameters; + try { + header = jose.decodeProtectedHeader(proof); + } catch { + return { ok: false, error: 'DPoP proof is not a well-formed JWT' }; + } + if (header.typ !== 'dpop+jwt') + return { ok: false, error: 'typ must be dpop+jwt' }; + if (!header.alg || !DPOP_ASYMMETRIC_ALGS.includes(header.alg)) { + return { ok: false, error: 'alg must be a supported asymmetric algorithm' }; + } + const jwk = header.jwk as jose.JWK | undefined; + if (!jwk) return { ok: false, error: 'missing jwk header parameter' }; + if ((jwk as Record).d !== undefined) { + return { ok: false, error: 'jwk must not contain a private key' }; + } + + let claims: jose.JWTPayload; + try { + const key = await jose.importJWK(jwk, header.alg); + claims = ( + await jose.jwtVerify(proof, key, { algorithms: DPOP_ASYMMETRIC_ALGS }) + ).payload; + } catch { + return { ok: false, error: 'DPoP proof signature does not verify' }; + } + if (typeof claims.jti !== 'string') + return { ok: false, error: 'missing jti claim' }; + if (claims.htm !== method) + return { ok: false, error: 'htm does not match the request method' }; + if (typeof claims.htu !== 'string') { + return { ok: false, error: 'htu does not match the request URI' }; + } + if (claims.htu.includes('?') || claims.htu.includes('#')) { + return { + ok: false, + error: 'htu MUST NOT contain a query or fragment (RFC 9449 §4.2)' + }; + } + if (normalizeHtu(claims.htu) !== normalizeHtu(resourceUrl)) { + return { ok: false, error: 'htu does not match the request URI' }; + } + if ( + typeof claims.iat !== 'number' || + Math.abs(Math.floor(Date.now() / 1000) - claims.iat) > 300 + ) { + return { ok: false, error: 'iat outside the acceptable window' }; + } + // Recompute ath and the thumbprint inline (jose + node crypto) rather than + // via the proof builder's helpers, so this validator stays a fully + // independent path. + const expectedAth = createHash('sha256') + .update(token, 'ascii') + .digest('base64url'); + if (typeof claims.ath !== 'string' || claims.ath !== expectedAth) { + return { + ok: false, + error: 'ath does not match the presented access token' + }; + } + + // Possession: the proof key must be the key the token is bound to. + try { + const tokenClaims = jose.decodeJwt(token); + const cnf = tokenClaims.cnf as { jkt?: unknown } | undefined; + const thumbprint = await jose.calculateJwkThumbprint(jwk, 'sha256'); + if (!cnf || cnf.jkt !== thumbprint) { + return { + ok: false, + error: 'proof key thumbprint does not match the token cnf.jkt' + }; + } + } catch { + return { ok: false, error: 'access token is not a decodable JWT' }; + } + + return { ok: true, jti: claims.jti }; +} diff --git a/src/scenarios/client/auth/helpers/dpopToken.test.ts b/src/scenarios/client/auth/helpers/dpopToken.test.ts new file mode 100644 index 00000000..71038093 --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopToken.test.ts @@ -0,0 +1,158 @@ +import { webcrypto } from 'node:crypto'; +import * as jose from 'jose'; +import type { JWK } from 'jose'; +import { + generateDpopKeyPair, + buildDpopProof, + accessTokenHash +} from './dpopProof'; +import { generateIssuerKey, mintDpopBoundToken } from './dpopToken'; + +/** Independent ES256 verification via Node WebCrypto (a different path from jose). */ +async function verifyEs256Independently( + jwt: string, + publicJwk: JWK +): Promise { + const [h, p, s] = jwt.split('.'); + const key = await webcrypto.subtle.importKey( + 'jwk', + publicJwk as JsonWebKey, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'] + ); + const signature = new Uint8Array(Buffer.from(s, 'base64url')); + const data = new TextEncoder().encode(`${h}.${p}`); + return webcrypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + signature, + data + ); +} + +const ISSUER = 'https://auth.example.com'; +const AUDIENCE = 'https://mcp.example.com/mcp'; + +describe('DPoP token minter — valid bound token', () => { + it('mints a token bound to the given thumbprint with correct claims', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + issuer: ISSUER, + audience: AUDIENCE, + jkt: kp.thumbprint + }); + + const claims = jose.decodeJwt(token); + expect(claims.iss).toBe(ISSUER); + expect(claims.aud).toBe(AUDIENCE); + expect(typeof claims.sub).toBe('string'); + expect(typeof claims.iat).toBe('number'); + expect(typeof claims.exp).toBe('number'); + expect((claims.exp as number) > (claims.iat as number)).toBe(true); + expect((claims.cnf as { jkt: string }).jkt).toBe(kp.thumbprint); + + expect(jose.decodeProtectedHeader(token).typ).toBe('at+jwt'); + }); + + it('is signed by the issuer key (independent WebCrypto verification, Layer 2)', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + issuer: ISSUER, + audience: AUDIENCE, + jkt: kp.thumbprint + }); + expect(await verifyEs256Independently(token, issuerKey.publicJwk)).toBe( + true + ); + }); +}); + +describe('DPoP token minter — proof/token binding agreement (integration)', () => { + it('proof.ath hashes the minted token and token.cnf.jkt equals the proof key thumbprint', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + + const token = await mintDpopBoundToken({ + issuerKey, + issuer: ISSUER, + audience: AUDIENCE, + jkt: kp.thumbprint + }); + + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: AUDIENCE, + accessToken: token + }); + + // The two halves of the sender-constraint must line up: + const tokenJkt = (jose.decodeJwt(token).cnf as { jkt: string }).jkt; + const proofJwkThumbprint = kp.thumbprint; + expect(tokenJkt).toBe(proofJwkThumbprint); // token bound to the proof's key + + const proofAth = jose.decodeJwt(proof).ath; + expect(proofAth).toBe(accessTokenHash(token)); // proof attests to this token + }); +}); + +describe('DPoP token minter — invalid variants', () => { + const base = { issuer: ISSUER, audience: AUDIENCE }; + + it('omitCnf produces an unbound token (no cnf)', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + ...base, + jkt: kp.thumbprint, + omitCnf: true + }); + expect(jose.decodeJwt(token).cnf).toBeUndefined(); + }); + + it('jktOverride binds to a foreign key (cnf.jkt mismatch case)', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const foreign = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + ...base, + jkt: kp.thumbprint, + jktOverride: foreign.thumbprint + }); + const boundJkt = (jose.decodeJwt(token).cnf as { jkt: string }).jkt; + expect(boundJkt).toBe(foreign.thumbprint); + expect(boundJkt).not.toBe(kp.thumbprint); + }); + + it('wrong-audience token still binds correctly (audience-validation case)', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + issuer: ISSUER, + audience: 'https://other.example.com/mcp', + jkt: kp.thumbprint + }); + expect(jose.decodeJwt(token).aud).toBe('https://other.example.com/mcp'); + }); + + it('expired produces a token whose exp is in the past', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + ...base, + jkt: kp.thumbprint, + expired: true + }); + const claims = jose.decodeJwt(token); + expect((claims.exp as number) < (claims.iat as number)).toBe(true); + }); +}); diff --git a/src/scenarios/client/auth/helpers/dpopToken.ts b/src/scenarios/client/auth/helpers/dpopToken.ts new file mode 100644 index 00000000..afe5632f --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopToken.ts @@ -0,0 +1,110 @@ +import * as jose from 'jose'; +import type { JWK, CryptoKey } from 'jose'; + +/** + * DPoP-bound access-token minting (RFC 9449 §6). + * + * Transport-free: this issues a signed JWT access token carrying the + * `cnf.jkt` confirmation that binds the token to a DPoP key. It is the + * token-issuing *core* shared across the DPoP scenarios: + * + * - #369 server: the scenario (acting as client) mints a bound token to + * PRESENT to the server under test. + * - #370 AS / #368 client: the compliant/test authorization-server fixtures + * call this to ISSUE bound tokens (wrapped by an HTTP token endpoint). + * + * `jkt` is a parameter (not derived here) so the same function works whether + * the bound key is ours (#369/#370) or extracted from a client's proof (#368). + */ + +const DEFAULT_ALG = 'ES256'; + +export interface TokenIssuerKey { + privateKey: CryptoKey; + publicKey: CryptoKey; + publicJwk: JWK; + alg: string; +} + +/** Generate an asymmetric signing key for the (test) token issuer. */ +export async function generateIssuerKey( + alg: string = DEFAULT_ALG +): Promise { + const { publicKey, privateKey } = await jose.generateKeyPair(alg, { + extractable: true + }); + const publicJwk = await jose.exportJWK(publicKey); + return { privateKey, publicKey, publicJwk, alg }; +} + +/** + * Reconstruct a {@link TokenIssuerKey} from a private JWK (e.g. supplied to a + * scenario via env), deriving the public JWK by stripping the private scalar. + * EC/OKP keys only (sufficient for the ES256 test issuer). + */ +export async function importIssuerKey( + privateJwk: JWK, + alg: string = DEFAULT_ALG +): Promise { + const privateKey = (await jose.importJWK(privateJwk, alg)) as CryptoKey; + const publicJwk: JWK = { ...privateJwk }; + delete (publicJwk as Record).d; + const publicKey = (await jose.importJWK(publicJwk, alg)) as CryptoKey; + return { privateKey, publicKey, publicJwk, alg }; +} + +export interface MintDpopBoundTokenOptions { + /** Issuer signing key (the server under test must trust its public key). */ + issuerKey: TokenIssuerKey; + /** `iss` claim — the token issuer identifier. */ + issuer: string; + /** `aud` claim — the resource the token is for (the MCP server's canonical URI). */ + audience: string; + /** Thumbprint bound as `cnf.jkt` (RFC 7638 JWK thumbprint of the DPoP public key). */ + jkt: string; + /** `sub` claim. Defaults to a fixed test subject. */ + subject?: string; + /** `scope` claim, if any. */ + scope?: string; + /** `iat` in epoch seconds. Defaults to now. */ + iat?: number; + /** Token lifetime in seconds. Defaults to 3600. */ + expiresInSeconds?: number; + + // --- override knobs for crafting deliberately-invalid variants --- + /** Omit `cnf` entirely — an unbound (bearer-style) token. */ + omitCnf?: boolean; + /** Bind to a different (foreign/wrong) thumbprint than the presented proof. */ + jktOverride?: string; + /** Issue an already-expired token (`exp` in the past). */ + expired?: boolean; + /** Additional claims to merge into the payload. */ + extraClaims?: Record; +} + +/** + * Mint a signed JWT access token. With no override knobs set, produces a + * valid DPoP-bound token whose `cnf.jkt` equals {@link MintDpopBoundTokenOptions.jkt}. + */ +export async function mintDpopBoundToken( + options: MintDpopBoundTokenOptions +): Promise { + const iat = options.iat ?? Math.floor(Date.now() / 1000); + const lifetime = options.expiresInSeconds ?? 3600; + const exp = options.expired ? iat - 60 : iat + lifetime; + + const payload: Record = { ...(options.extraClaims ?? {}) }; + if (options.scope !== undefined) payload.scope = options.scope; + if (!options.omitCnf) { + payload.cnf = { jkt: options.jktOverride ?? options.jkt }; + } + + return new jose.SignJWT(payload) + .setProtectedHeader({ alg: options.issuerKey.alg, typ: 'at+jwt' }) + .setIssuer(options.issuer) + .setAudience(options.audience) + .setSubject(options.subject ?? 'conformance-test-subject') + .setIssuedAt(iat) + .setExpirationTime(exp) + .sign(options.issuerKey.privateKey); +} diff --git a/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts b/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts new file mode 100644 index 00000000..e4a23102 --- /dev/null +++ b/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from 'vitest'; +import { + generateDpopKeyPair, + buildDpopProof, + type DpopKeyPair +} from './dpopProof'; +import { validateDpopProofAtTokenEndpoint } from './createAuthServer'; + +const TOKEN_ENDPOINT = 'https://auth.example.com/token'; + +/** + * The token-endpoint validator is an INDEPENDENT copy of the resource-side + * validator (dpopResourceAuth). These tests keep the two from drifting: they + * cover the token-request subset of RFC 9449 §4.3 (no `ath`/`cnf` — there is no + * access token yet at the token request). + */ +describe('validateDpopProofAtTokenEndpoint — accepts a valid token-request proof', () => { + it('accepts a well-formed proof and returns the JWK thumbprint', async () => { + const kp = await generateDpopKeyPair(); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT + }); + const result = await validateDpopProofAtTokenEndpoint( + proof, + TOKEN_ENDPOINT + ); + expect(result.ok).toBe(true); + expect(result.ok ? result.jkt : '').toBe(kp.thumbprint); + }); + + it('accepts an htu differing only by a single trailing slash', async () => { + const kp = await generateDpopKeyPair(); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: `${TOKEN_ENDPOINT}/` + }); + const result = await validateDpopProofAtTokenEndpoint( + proof, + TOKEN_ENDPOINT + ); + expect(result.ok).toBe(true); + }); +}); + +describe('validateDpopProofAtTokenEndpoint — rejects each single defect', () => { + async function expectRejected( + build: Parameters[0] + ): Promise { + const kp = (build.keyPair as DpopKeyPair) ?? (await generateDpopKeyPair()); + const proof = await buildDpopProof({ ...build, keyPair: kp }); + const result = await validateDpopProofAtTokenEndpoint( + proof, + TOKEN_ENDPOINT + ); + expect(result.ok).toBe(false); + return result.ok ? '' : result.error; + } + + it('rejects a non-JWT', async () => { + const result = await validateDpopProofAtTokenEndpoint( + 'this-is-not-a-jwt', + TOKEN_ENDPOINT + ); + expect(result.ok).toBe(false); + }); + + it('rejects a wrong typ', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + typ: 'jwt' + }) + ).toMatch(/typ/); + }); + + it('rejects a symmetric algorithm', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + symmetric: true + }) + ).toMatch(/alg/); + }); + + it('rejects an unsigned (alg=none) proof', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + unsigned: true + }) + ).toMatch(/alg/); + }); + + it('rejects a private key embedded in the jwk header', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + embedPrivateKey: true + }) + ).toMatch(/private key/); + }); + + it('rejects a tampered signature', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + tamperSignature: true + }) + ).toMatch(/signature/); + }); + + it('rejects a missing jti', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + omit: ['jti'] + }) + ).toMatch(/jti/); + }); + + it('rejects an htm that is not POST', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ keyPair: kp, htm: 'GET', htu: TOKEN_ENDPOINT }) + ).toMatch(/htm/); + }); + + it('rejects an htu that does not match the token endpoint', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: 'https://wrong.example.com/token' + }) + ).toMatch(/htu/); + }); + + it('rejects an htu containing a query string (RFC 9449 §4.2)', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: `${TOKEN_ENDPOINT}?tenant=x` + }) + ).toMatch(/query or fragment/); + }); + + it('rejects an htu containing a fragment (RFC 9449 §4.2)', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: `${TOKEN_ENDPOINT}#frag` + }) + ).toMatch(/query or fragment/); + }); + + it('rejects a stale iat', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + iat: Math.floor(Date.now() / 1000) - 3600 + }) + ).toMatch(/iat/); + }); +}); diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index fbb50a28..12e7b038 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -28,6 +28,11 @@ import { runClient as noAppTypeClient } from '../../../../examples/clients/types import { runClient as noIssValidationClient } from '../../../../examples/clients/typescript/auth-test'; import { runClient as issNormalizeClient } from '../../../../examples/clients/typescript/auth-test-iss-normalize'; import { runClient as echoScopeClient } from '../../../../examples/clients/typescript/auth-test-echo-scope'; +import { runClient as dpopBearerClient } from '../../../../examples/clients/typescript/auth-test-dpop-bearer'; +import { runClient as dpopReplayClient } from '../../../../examples/clients/typescript/auth-test-dpop-replay'; +import { runClient as dpopNoTokenProofClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-token-proof'; +import { runClient as dpopNoAsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-as-nonce'; +import { runClient as dpopNoRsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-rs-nonce'; import { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; import { DRAFT_PROTOCOL_VERSION } from '../../../types'; @@ -339,3 +344,49 @@ describe('WIF JWT-bearer negative tests', () => { }); }); }); + +// DPoP (SEP-1932): the compliant path is covered by the Client Draft Scenarios +// loop above (auth/dpop is in draftScenariosList); these are the negative +// cases, via deliberately-broken example clients. +describe('DPoP client negative tests (SEP-1932)', () => { + test('auth/dpop: client presents the token with the Bearer scheme', async () => { + const runner = new InlineClientRunner(dpopBearerClient); + await runClientAgainstScenario(runner, 'auth/dpop', { + expectedFailureSlugs: ['sep-1932-client-dpop-auth-scheme'] + }); + }); + + test('auth/dpop: client reuses a DPoP proof across requests', async () => { + const runner = new InlineClientRunner(dpopReplayClient); + await runClientAgainstScenario(runner, 'auth/dpop', { + expectedFailureSlugs: ['sep-1932-client-fresh-proof'] + }); + }); + + test('auth/dpop: client never requests a sender-constrained token', async () => { + // No token-endpoint proof → the AS issues an unbound Bearer token, so both + // the token-request check and (as a consequence of the unbound token) the + // resource binding check fail. + const runner = new InlineClientRunner(dpopNoTokenProofClient); + await runClientAgainstScenario(runner, 'auth/dpop', { + expectedFailureSlugs: [ + 'sep-1932-client-token-request-proof', + 'sep-1932-client-fresh-proof' + ] + }); + }); + + test('auth/dpop: client ignores the authorization-server nonce challenge', async () => { + const runner = new InlineClientRunner(dpopNoAsNonceClient); + await runClientAgainstScenario(runner, 'auth/dpop', { + expectedFailureSlugs: ['sep-1932-client-as-nonce'] + }); + }); + + test('auth/dpop: client ignores the MCP-server nonce challenge', async () => { + const runner = new InlineClientRunner(dpopNoRsNonceClient); + await runClientAgainstScenario(runner, 'auth/dpop', { + expectedFailureSlugs: ['sep-1932-client-rs-nonce'] + }); + }); +}); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 11e6358b..21966e83 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -25,6 +25,7 @@ import { ResourceMismatchScenario } from './resource-mismatch'; import { PreRegistrationScenario } from './pre-registration'; import { EnterpriseManagedAuthorizationScenario } from './enterprise-managed-authorization'; import { WifJwtBearerScenario } from './wif-jwt-bearer'; +import { DPoPClientScenario } from './dpop'; import { OfflineAccessScopeScenario, OfflineAccessNotSupportedScenario @@ -81,5 +82,6 @@ export const draftScenariosList: Scenario[] = [ new IssParameterUnexpectedScenario(), new IssParameterNormalizedVariantScenario(), new MetadataIssuerMismatchScenario(), - new WifJwtBearerScenario() + new WifJwtBearerScenario(), + new DPoPClientScenario() ]; diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 0cc0e076..972417bd 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -113,5 +113,38 @@ export const SpecReferences: { [key: string]: SpecReference } = { SEP_1933_WIF: { id: 'SEP-1933-Workload-Identity-Federation', url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933' + }, + // DPoP (SEP-1932 / RFC 9449) — client concerns. + SEP_1932_DPOP: { + id: 'SEP-1932-DPoP', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932' + }, + DPOP_EXTENSION: { + id: 'MCP-DPoP-Extension', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx' + }, + RFC_9449_PROOF_SYNTAX: { + id: 'RFC-9449-dpop-proof-jwt-syntax', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2' + }, + RFC_9449_CHECKING_PROOFS: { + id: 'RFC-9449-checking-dpop-proofs', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-4.3' + }, + RFC_9449_AUTH_SCHEME: { + id: 'RFC-9449-dpop-authentication-scheme', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-7.1' + }, + RFC_9449_TOKEN_REQUEST: { + id: 'RFC-9449-dpop-access-token-request', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-5' + }, + RFC_9449_AS_NONCE: { + id: 'RFC-9449-authorization-server-provided-nonce', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-8' + }, + RFC_9449_RS_NONCE: { + id: 'RFC-9449-resource-server-provided-nonce', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-9' } }; diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml new file mode 100644 index 00000000..c3c650f3 --- /dev/null +++ b/src/seps/sep-1932.yaml @@ -0,0 +1,48 @@ +sep: 1932 +spec_url: https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx +requirements: + - check: sep-1932-client-dpop-auth-scheme + text: 'When making requests to protected MCP server resources, clients MUST include the access token using the `Authorization` header with the `DPoP` authentication scheme as specified in RFC 9449 Section 7.1' + - check: sep-1932-client-fresh-proof + text: 'When making requests to protected MCP server resources, clients MUST include a fresh DPoP proof in the `DPoP` header' + - check: sep-1932-client-token-request-proof + text: 'To obtain a DPoP-bound access token, the client MUST include a DPoP proof in the `DPoP` header of its token request (RFC 9449 Section 5)' + - check: sep-1932-client-as-nonce + text: 'When the authorization server responds with `use_dpop_nonce`, the client MUST retry the token request with a DPoP proof that includes the supplied `nonce` (RFC 9449 Section 8)' + - check: sep-1932-client-rs-nonce + text: 'When the MCP server responds with `use_dpop_nonce`, the client MUST retry the request with a DPoP proof that includes the supplied `nonce` (RFC 9449 Section 9)' + - check: sep-1932-server-validate-proof + text: 'MCP servers MUST validate DPoP proofs according to RFC 9449 Section 4.3' + - check: sep-1932-server-iat-window + text: "Implementations conforming to this specification MUST verify that the `iat` claim is within ±5 minutes of the server's current time / MCP servers that are not capable of keeping state or performing global `jti` tracking MUST provide DPoP proof replay protection by enforcing short `iat` acceptance windows of ±5 minutes and standard RFC 9449 claim validation" + - check: sep-1932-server-reject-401 + text: 'If any validation step fails, the MCP server MUST reject the request with an HTTP 401 response and include appropriate error information in the `WWW-Authenticate` header as specified in RFC 9449 Section 7.1' + - check: sep-1932-as-metadata-alg-values + text: 'Authorization servers supporting DPoP MUST include the `dpop_signing_alg_values_supported` field in their Authorization Server Metadata as defined in RFC 9449 Section 5.1. This field MUST contain a JSON array listing the JWS algorithm values supported for DPoP proof JWTs' + - check: sep-1932-as-no-none-alg + text: 'Only asymmetric signature algorithms are permitted; the `none` algorithm MUST NOT be included' + - check: sep-1932-as-dpop-bound-enforcement + text: 'When `dpop_bound_access_tokens` is set to `true`, the authorization server MUST reject token requests from the client that do not include a valid DPoP proof' + - check: sep-1932-as-token-binding + text: "When issuing a DPoP-bound access token, the authorization server MUST bind it to the client's DPoP public key by including a `cnf` claim carrying the JWK SHA-256 thumbprint (`jkt`) of that key (RFC 9449 Section 6) and MUST set the token response `token_type` to `DPoP` (RFC 9449 Section 5)" + - check: sep-1932-asymmetric-alg-only + text: 'Only asymmetric signature algorithms MUST be used for DPoP proofs. Symmetric algorithms and the `none` algorithm MUST NOT be permitted as specified in RFC 9449 Section 11.6' + - check: sep-1932-server-nonce + text: 'For high-security environments, MCP servers SHOULD implement server-provided nonces as described in RFC 9449 Section 9 to further limit proof lifetime, even if it is stateless' + - check: sep-1932-server-audience-validation + text: 'MCP servers MUST continue to validate that access tokens were specifically issued for them, even when DPoP is used' + + - text: 'Implementations MUST conform to all requirements specified in this extension' + excluded: 'Umbrella requirement satisfied by the specific checks above; not separately observable on the wire.' + - text: 'Implementations MUST also conform to the baseline authorization requirements' + excluded: 'Covered by the existing baseline MCP authorization conformance suite; not DPoP-specific.' + - text: 'Authorization servers and clients SHOULD support modern, secure algorithms such as ES256 (ECDSA using P-256 and SHA-256)' + excluded: 'No crisp wire-observable behaviour; the spec explicitly does not mandate specific algorithms.' + - text: 'Implementations adopting this extension MUST follow the security considerations outlined in RFC 9449 Section 11, the baseline MCP Authorization specification security requirements, and OAuth 2.1 security best practices' + excluded: 'Umbrella reference to the security considerations of other specifications; not a discrete observable behaviour.' + - text: 'Clients MUST protect DPoP private keys using appropriate security measures. If hardware security modules or secure enclaves are available, they SHOULD be used to protect private keys to prevent key extraction' + excluded: 'Key storage is an implementation/internal concern, not observable on the wire.' + - text: 'Browser-based clients SHOULD use the Web Crypto API with non-extractable keys to prevent key exfiltration via XSS attacks' + excluded: 'Client implementation detail, not observable on the wire.' + - text: 'MCP servers requiring maximum replay protection SHOULD implement `jti` tracking despite the operational complexity' + excluded: 'Explicitly optional (servers are NOT required to track jti) and stateful/internal; only indirectly observable via replay.' From b61b68db5125d40220a9c4da91f8236e753d8faa Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:22:55 +0100 Subject: [PATCH 2/8] client/dpop: split into nonce-less baseline + nonce-required scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-provided nonces are optional in RFC 9449 (AS §8 "MAY", RS §9 "can also choose"), and the nonce-less flow is the common case. The single auth/dpop scenario hard-wired nonce enforcement on both the test AS and MCP server, so a plain nonce-less proof was never accepted end-to-end and a client that only does plain proofs was never exercised on its happy path. Parameterize DPoPClientScenario with `requireNonce` and register it twice: - auth/dpop (requireNonce=false) — nonce-less baseline; emits the three baseline checks (token-request-proof, dpop-auth-scheme, fresh-proof). - auth/dpop-nonce (requireNonce=true) — prior behavior; adds as-nonce and rs-nonce (five checks). Check IDs are reused (no new IDs, no sep-1932.yaml change); the two nonce checks are emitted only by auth/dpop-nonce, so traceability stays intact. Add auth-test-dpop-no-nonce.ts (nonce-incapable but otherwise compliant) asserting SUCCESS against auth/dpop — proof that a client with no nonce handling still completes DPoP when the server does not require a nonce. Baseline negatives stay on auth/dpop (nonce-independent); the two nonce negatives are re-pointed to auth/dpop-nonce. Co-Authored-By: Claude Opus 4.8 --- .../typescript/auth-test-dpop-no-nonce.ts | 25 ++++++++ .../clients/typescript/everything-client.ts | 1 + src/scenarios/client/auth/dpop.ts | 61 +++++++++++++------ src/scenarios/client/auth/index.test.ts | 32 +++++++--- src/scenarios/client/auth/index.ts | 3 +- 5 files changed, 97 insertions(+), 25 deletions(-) create mode 100644 examples/clients/typescript/auth-test-dpop-no-nonce.ts diff --git a/examples/clients/typescript/auth-test-dpop-no-nonce.ts b/examples/clients/typescript/auth-test-dpop-no-nonce.ts new file mode 100644 index 00000000..232367f2 --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-no-nonce.ts @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Nonce-incapable but otherwise compliant DPoP client (SEP-1932 / RFC 9449): + * presents the DPoP-bound token with the `DPoP` Authorization scheme and a fresh + * proof on every request, but implements NO `use_dpop_nonce` handling. Against + * the nonce-less `auth/dpop` scenario (where neither server issues a challenge) + * it completes the flow successfully — proving that a client which does not + * support nonces still passes when the server does not require one (the common + * case, since server nonces are OPTIONAL in RFC 9449 §8/§9). + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: false, + handleRsNonce: false + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-nonce '); diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 94afab31..c91d0b8b 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -860,6 +860,7 @@ registerScenario( // ============================================================================ registerScenario('auth/dpop', dpopClient); +registerScenario('auth/dpop-nonce', dpopClient); // ============================================================================ // MRTR client conformance (SEP-2322) diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index 559a666e..9dcc7c26 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -82,15 +82,25 @@ const CHECK_DEFS: Record< * Scenario: DPoP sender-constrained tokens — MCP client (SEP-1932 / RFC 9449). * * The test authorization server (DPoP-capable `createAuthServer`) issues a - * DPoP-bound token; the test MCP server judges how the client presents it. The - * test AS and MCP server both require a server-provided nonce (RFC 9449 §8/§9), - * so the client's nonce handling is exercised too. Five checks: - * - token acquisition — the client sends a valid DPoP proof at the token - * request, obtaining a sender-constrained token (RFC 9449 §5); - * - the client retries the token request with the AS-supplied nonce (§8); - * - the client retries the MCP request with the server-supplied nonce (§9); - * - the token is presented with the `DPoP` Authorization scheme (RFC 9449 §7.1); - * - a fresh, well-formed DPoP proof accompanies each request (unique `jti`). + * DPoP-bound token; the test MCP server judges how the client presents it. + * + * Registered in two postures, because server-provided nonces are OPTIONAL in + * RFC 9449 (AS §8 "MAY", RS §9 "can also choose") and the two are mutually + * exclusive for a given run: + * + * - `auth/dpop` (`requireNonce = false`) — the common, nonce-less baseline. + * Neither the AS nor the MCP server issues a nonce challenge; the client + * completes the flow with plain proofs. Emits three checks: + * · token acquisition — a valid DPoP proof at the token request, obtaining + * a sender-constrained token (RFC 9449 §5); + * · the token is presented with the `DPoP` Authorization scheme (§7.1); + * · a fresh, well-formed DPoP proof accompanies each request (unique `jti`). + * + * - `auth/dpop-nonce` (`requireNonce = true`) — the AS and MCP server both + * require a server-provided nonce (§8/§9), exercising the client's nonce + * handling. Emits the three baseline checks plus two more: + * · the client retries the token request with the AS-supplied nonce (§8); + * · the client retries the MCP request with the server-supplied nonce (§9). */ function newTokenReqObs(): DpopTokenRequestObservation { return { @@ -102,10 +112,9 @@ function newTokenReqObs(): DpopTokenRequestObservation { } export class DPoPClientScenario implements Scenario { - name = 'auth/dpop'; + readonly name: string; readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; - description = - 'Tests that an MCP client requests a DPoP-bound access token (a valid DPoP proof at the token request) and then presents it using the DPoP Authorization scheme (not Bearer) with a fresh, well-formed DPoP proof on each POST /mcp request (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).'; + readonly description: string; private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); @@ -113,6 +122,19 @@ export class DPoPClientScenario implements Scenario { private obs: DpopClientObservations = newDpopClientObservations(); private tokenReqObs: DpopTokenRequestObservation = newTokenReqObs(); + /** + * @param requireNonce when true (`auth/dpop-nonce`) the test AS and MCP server + * both demand a server-provided nonce (RFC 9449 §8/§9); when false + * (`auth/dpop`) neither challenges and the client completes with plain + * proofs — the common, nonce-less baseline. + */ + constructor(private readonly requireNonce: boolean) { + this.name = requireNonce ? 'auth/dpop-nonce' : 'auth/dpop'; + this.description = requireNonce + ? 'Tests that an MCP client, when the authorization server and MCP server require a DPoP nonce, retries the token request and the MCP request with the server-supplied nonce (RFC 9449 §8/§9) — on top of requesting a DPoP-bound token and presenting it with the DPoP Authorization scheme and a fresh proof per request (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).' + : 'Tests that an MCP client requests a DPoP-bound access token (a valid DPoP proof at the token request) and presents it using the DPoP Authorization scheme (not Bearer) with a fresh, well-formed DPoP proof on each POST /mcp request, when the server does not require a nonce (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).'; + } + async start(ctx: ScenarioContext): Promise { this.checks = []; this.obs = newDpopClientObservations(); @@ -121,7 +143,7 @@ export class DPoPClientScenario implements Scenario { const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { dpopSigningAlgValuesSupported: ['ES256'], dpopTokenRequestObs: this.tokenReqObs, - dpopRequireNonce: true + dpopRequireNonce: this.requireNonce }); await this.authServer.start(authApp); @@ -135,7 +157,7 @@ export class DPoPClientScenario implements Scenario { this.obs, () => `${this.server.getUrl()}/mcp`, () => `${this.server.getUrl()}${PRM_PATH}`, - true + this.requireNonce ) } ); @@ -150,14 +172,19 @@ export class DPoPClientScenario implements Scenario { } getChecks(): ConformanceCheck[] { - return [ + const checks: ConformanceCheck[] = [ ...this.checks, this.tokenRequestProofCheck(), - this.asNonceCheck(), - this.rsNonceCheck(), this.authSchemeCheck(), this.freshProofCheck() ]; + // The nonce checks only apply to the nonce-requiring posture: in the + // baseline (`auth/dpop`) neither server issues a `use_dpop_nonce` + // challenge, so there is no nonce behaviour to assert. + if (this.requireNonce) { + checks.push(this.asNonceCheck(), this.rsNonceCheck()); + } + return checks; } private asNonceCheck(): ConformanceCheck { diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 12e7b038..20f5ee04 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -33,6 +33,7 @@ import { runClient as dpopReplayClient } from '../../../../examples/clients/type import { runClient as dpopNoTokenProofClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-token-proof'; import { runClient as dpopNoAsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-as-nonce'; import { runClient as dpopNoRsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-rs-nonce'; +import { runClient as dpopNoNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-nonce'; import { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; import { DRAFT_PROTOCOL_VERSION } from '../../../types'; @@ -345,9 +346,12 @@ describe('WIF JWT-bearer negative tests', () => { }); }); -// DPoP (SEP-1932): the compliant path is covered by the Client Draft Scenarios -// loop above (auth/dpop is in draftScenariosList); these are the negative -// cases, via deliberately-broken example clients. +// DPoP (SEP-1932): the compliant paths for both postures (auth/dpop and +// auth/dpop-nonce) are covered by the Client Draft Scenarios loop above; these +// are the negative cases, via deliberately-broken example clients. The baseline +// checks (scheme, replay, token-request-proof) are nonce-independent, so those +// negatives run against auth/dpop; the two nonce checks only fire when a +// challenge is issued, so their negatives run against auth/dpop-nonce. describe('DPoP client negative tests (SEP-1932)', () => { test('auth/dpop: client presents the token with the Bearer scheme', async () => { const runner = new InlineClientRunner(dpopBearerClient); @@ -376,17 +380,31 @@ describe('DPoP client negative tests (SEP-1932)', () => { }); }); - test('auth/dpop: client ignores the authorization-server nonce challenge', async () => { + test('auth/dpop-nonce: client ignores the authorization-server nonce challenge', async () => { const runner = new InlineClientRunner(dpopNoAsNonceClient); - await runClientAgainstScenario(runner, 'auth/dpop', { + await runClientAgainstScenario(runner, 'auth/dpop-nonce', { expectedFailureSlugs: ['sep-1932-client-as-nonce'] }); }); - test('auth/dpop: client ignores the MCP-server nonce challenge', async () => { + test('auth/dpop-nonce: client ignores the MCP-server nonce challenge', async () => { const runner = new InlineClientRunner(dpopNoRsNonceClient); - await runClientAgainstScenario(runner, 'auth/dpop', { + await runClientAgainstScenario(runner, 'auth/dpop-nonce', { expectedFailureSlugs: ['sep-1932-client-rs-nonce'] }); }); }); + +// DPoP nonce-less baseline (SEP-1932): a client that implements NO nonce +// handling still completes DPoP successfully when the server does not require a +// nonce (the common case — server nonces are OPTIONAL, RFC 9449 §8/§9). The +// nonce-capable compliant path is covered by the Client Draft Scenarios loop +// above (auth/dpop is in draftScenariosList). +describe('DPoP client nonce-less baseline (SEP-1932)', () => { + test('auth/dpop: nonce-incapable client passes the baseline', async () => { + const runner = new InlineClientRunner(dpopNoNonceClient); + // No expectedFailureSlugs → asserts every emitted check is SUCCESS (the + // three baseline checks; no as-nonce/rs-nonce checks are emitted here). + await runClientAgainstScenario(runner, 'auth/dpop'); + }); +}); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 21966e83..a46a872a 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -83,5 +83,6 @@ export const draftScenariosList: Scenario[] = [ new IssParameterNormalizedVariantScenario(), new MetadataIssuerMismatchScenario(), new WifJwtBearerScenario(), - new DPoPClientScenario() + new DPoPClientScenario(false), // auth/dpop — nonce-less baseline (common case) + new DPoPClientScenario(true) // auth/dpop-nonce — server-required nonce (§8/§9) ]; From 2030af2fe4388141fd488ae4a35c1bd26056253b Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:47:20 +0100 Subject: [PATCH 3/8] client/dpop: round-4 nonce-surface correctness fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings on the auth/dpop-nonce posture: - Record the token-request proof observation BEFORE the §8 nonce challenge, so a client that is challenged but does not retry is no longer mis-reported by token-request-proof as "never completed a token request" (its proof was just validated). (#2) - Record the resource-request jti/replay on ANY valid proof (before the §9 nonce gate), so fresh-proof is never asserted on zero evidence and a client that never honours the RS nonce fails ONLY rs-nonce. (#5) - Require challengeIssued && honored for as-nonce/rs-nonce SUCCESS, closing a vacuous-pass path (a client that pre-sends the nonce is never challenged). (#3a) - Gate the AS nonce observation on the authorization_code exchange, matching recordTokenRequestProof (a refresh exchange must not satisfy §8). (#3c) - Collapse duplicate shared token-flow check IDs (token-request, pkce-*) that the §8 round-trip re-POST would otherwise emit twice. (#4) - Example client: retry the token request only on a use_dpop_nonce error code, not any 400 carrying a DPoP-Nonce header (consistent with the RS path). (#6) - Fix the DpopTokenRequestObservation docstring ("last write wins" → sticky-failure). (#7) Adds an expectedSuccessSlugs option to the client test helper and tightens the two nonce negative tests to pin the now-clean behavior (token-request-proof SUCCESS for no-as-nonce; only rs-nonce fails for no-rs-nonce). Co-Authored-By: Claude Opus 4.8 --- .../typescript/helpers/dpopClientFlow.ts | 14 +++++- src/scenarios/client/auth/dpop.ts | 48 ++++++++++++++----- .../client/auth/helpers/createAuthServer.ts | 26 ++++++---- .../client/auth/helpers/dpopResourceAuth.ts | 19 ++++---- src/scenarios/client/auth/index.test.ts | 24 +++++++++- .../client/auth/test_helpers/testClient.ts | 17 +++++++ 6 files changed, 117 insertions(+), 31 deletions(-) diff --git a/examples/clients/typescript/helpers/dpopClientFlow.ts b/examples/clients/typescript/helpers/dpopClientFlow.ts index c8fc0b18..f4cb81ff 100644 --- a/examples/clients/typescript/helpers/dpopClientFlow.ts +++ b/examples/clients/typescript/helpers/dpopClientFlow.ts @@ -140,10 +140,20 @@ export async function runDpopClient( }; let tokenResponse = await requestToken(); // RFC 9449 §8: the AS may answer with `use_dpop_nonce` (HTTP 400 + DPoP-Nonce); - // a conformant client retries the token request with the supplied nonce. + // a conformant client retries the token request with the supplied nonce. Match + // on the `use_dpop_nonce` error code (not merely any 400 carrying a nonce), so + // an unrelated error (e.g. invalid_grant) that an AS proactively decorates with + // a DPoP-Nonce header does not burn the retry and mask the real failure — + // consistent with the resource-side check below. const asNonce = tokenResponse.headers.get('DPoP-Nonce'); if (tokenResponse.status === 400 && asNonce && options.handleAsNonce) { - tokenResponse = await requestToken(asNonce); + const challenge = await tokenResponse + .clone() + .json() + .catch(() => ({}) as { error?: string }); + if (challenge?.error === 'use_dpop_nonce') { + tokenResponse = await requestToken(asNonce); + } } if (!tokenResponse.ok) { throw new Error(`Token request failed: HTTP ${tokenResponse.status}`); diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index 9dcc7c26..d48476d1 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -173,7 +173,7 @@ export class DPoPClientScenario implements Scenario { getChecks(): ConformanceCheck[] { const checks: ConformanceCheck[] = [ - ...this.checks, + ...this.dedupeSharedChecks(), this.tokenRequestProofCheck(), this.authSchemeCheck(), this.freshProofCheck() @@ -187,19 +187,41 @@ export class DPoPClientScenario implements Scenario { return checks; } + /** + * The RFC 9449 §8/§9 nonce round-trip re-POSTs /token (challenge → retry), so + * the shared token-flow conformance checks (`token-request`, `pkce-*`) are + * appended twice. Collapse duplicate non-INFO shared-check IDs to the last + * occurrence so each is reported once; per-request INFO log entries are left + * intact. Our own sep-1932-client-* checks are unique by construction. + */ + private dedupeSharedChecks(): ConformanceCheck[] { + const lastIndex = new Map(); + this.checks.forEach((c, i) => { + if (c.status !== 'INFO') lastIndex.set(c.id, i); + }); + return this.checks.filter( + (c, i) => c.status === 'INFO' || lastIndex.get(c.id) === i + ); + } + private asNonceCheck(): ConformanceCheck { + const challenged = this.tokenReqObs.asNonceChallengeIssued; const honored = this.tokenReqObs.asNonceHonored; + // SUCCESS requires that a challenge was actually issued AND then honored — + // grading `honored` alone would let a client that pre-sends the nonce + // (never challenged) pass vacuously. + const pass = challenged && honored; return this.build( 'sep-1932-client-as-nonce', - honored ? 'SUCCESS' : 'FAILURE', + pass ? 'SUCCESS' : 'FAILURE', { - errorMessage: honored + errorMessage: pass ? undefined - : this.tokenReqObs.asNonceChallengeIssued + : challenged ? 'Client did not retry the token request with the server-supplied nonce after a use_dpop_nonce challenge' - : 'Client never completed a token request that could be nonce-challenged', + : 'Client never presented a valid proof that was answered with a use_dpop_nonce challenge', details: { - challengeIssued: this.tokenReqObs.asNonceChallengeIssued, + challengeIssued: challenged, nonceHonored: honored } } @@ -207,18 +229,22 @@ export class DPoPClientScenario implements Scenario { } private rsNonceCheck(): ConformanceCheck { + const challenged = this.obs.rsNonceChallengeIssued; const honored = this.obs.rsNonceHonored; + // SUCCESS requires that a challenge was actually issued AND then honored — + // see asNonceCheck; grading `honored` alone permits a vacuous pass. + const pass = challenged && honored; return this.build( 'sep-1932-client-rs-nonce', - honored ? 'SUCCESS' : 'FAILURE', + pass ? 'SUCCESS' : 'FAILURE', { - errorMessage: honored + errorMessage: pass ? undefined - : this.obs.rsNonceChallengeIssued + : challenged ? 'Client did not retry the MCP request with the server-supplied nonce after a use_dpop_nonce challenge' - : 'Client never made an MCP request that could be nonce-challenged', + : 'Client never made an MCP request with a valid proof that was answered with a use_dpop_nonce challenge', details: { - challengeIssued: this.obs.rsNonceChallengeIssued, + challengeIssued: challenged, nonceHonored: honored } } diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 971b28cb..162f93aa 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -138,10 +138,11 @@ export interface TokenRequestError { /** * Sink for the DPoP token-request observation (RFC 9449 §5). The AS writes to - * it on the authorization_code exchange (last write wins; refresh grants are - * ignored) so the client scenario can emit sep-1932-client-token-request-proof - * unconditionally — FAILURE by default when the client never reaches the token - * endpoint, matching its sibling checks. + * it on the authorization_code exchange (sticky-failure: once any exchange + * lacks a valid proof it stays FAILURE; refresh grants are ignored) so the + * client scenario can emit sep-1932-client-token-request-proof unconditionally + * — FAILURE by default when the client never reaches the token endpoint, + * matching its sibling checks. */ export interface DpopTokenRequestObservation { recorded: boolean; @@ -611,9 +612,18 @@ export function createAuthServer( }); return; } + // The proof itself is valid — record that now, BEFORE any nonce + // challenge, so a client that is challenged but does not retry is not + // mis-reported by token-request-proof as "never completed a token + // request" (its proof was just verified). Sticky + gated on + // authorization_code inside recordTokenRequestProof. + recordTokenRequestProof(grantType, true); + // RFC 9449 §8: require a server-provided nonce. A proof without the // correct nonce is challenged (400 use_dpop_nonce + DPoP-Nonce); the - // client is expected to retry with it. + // client is expected to retry with it. The nonce observation is gated + // on the authorization_code exchange, matching recordTokenRequestProof + // (honoring a challenge on a refresh exchange must not satisfy §8). if (dpopRequireNonce) { let proofNonce: unknown; try { @@ -622,7 +632,7 @@ export function createAuthServer( proofNonce = undefined; } if (proofNonce !== AS_DPOP_NONCE) { - if (dpopTokenRequestObs) + if (grantType === 'authorization_code' && dpopTokenRequestObs) dpopTokenRequestObs.asNonceChallengeIssued = true; res.status(400).set('DPoP-Nonce', AS_DPOP_NONCE).json({ error: 'use_dpop_nonce', @@ -630,9 +640,9 @@ export function createAuthServer( }); return; } - if (dpopTokenRequestObs) dpopTokenRequestObs.asNonceHonored = true; + if (grantType === 'authorization_code' && dpopTokenRequestObs) + dpopTokenRequestObs.asNonceHonored = true; } - recordTokenRequestProof(grantType, true); if (dpopMisbehavior === 'unbound-token') { // Misbehaviour: ignore the binding and issue a plain Bearer token. diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts index be645321..ed8ae658 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts @@ -108,10 +108,19 @@ export function createDpopResourceAuth( return; } + // Record proof freshness on ANY valid proof, before the nonce gate. A + // nonce-challenged (but otherwise valid) proof still demonstrates freshness, + // so fresh-proof is never asserted on zero `jti` evidence, and a client that + // never honours the nonce fails only rs-nonce (not fresh-proof as well). + if (obs.jtisSeen.includes(result.jti)) { + obs.replayDetected = true; + } else { + obs.jtisSeen.push(result.jti); + } + // RFC 9449 §9: require a server-provided nonce. A valid proof without the // correct nonce is challenged (401 use_dpop_nonce + DPoP-Nonce); the client - // is expected to retry with it. Only nonce-honoured requests are recorded - // for the freshness check. + // is expected to retry with it. if (requireNonce) { let proofNonce: unknown; try { @@ -134,12 +143,6 @@ export function createDpopResourceAuth( obs.rsNonceHonored = true; } - if (obs.jtisSeen.includes(result.jti)) { - obs.replayDetected = true; - } else { - obs.jtisSeen.push(result.jti); - } - next(); }; } diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 20f5ee04..6e9a39fa 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -382,15 +382,35 @@ describe('DPoP client negative tests (SEP-1932)', () => { test('auth/dpop-nonce: client ignores the authorization-server nonce challenge', async () => { const runner = new InlineClientRunner(dpopNoAsNonceClient); + // The client presents a valid proof but never retries with the nonce, so it + // never obtains a token — as-nonce fails and the downstream token-dependent + // checks legitimately cascade. But token-request-proof MUST stay SUCCESS: + // the client DID present a valid proof at the token endpoint (regression + // guard for the "never completed a token request" misattribution). await runClientAgainstScenario(runner, 'auth/dpop-nonce', { - expectedFailureSlugs: ['sep-1932-client-as-nonce'] + expectedFailureSlugs: [ + 'sep-1932-client-as-nonce', + 'sep-1932-client-dpop-auth-scheme', + 'sep-1932-client-fresh-proof', + 'sep-1932-client-rs-nonce' + ], + expectedSuccessSlugs: ['sep-1932-client-token-request-proof'] }); }); test('auth/dpop-nonce: client ignores the MCP-server nonce challenge', async () => { const runner = new InlineClientRunner(dpopNoRsNonceClient); + // Clean one-defect isolation: the client obtains a token and presents a + // valid fresh proof (recorded before the nonce gate), so only rs-nonce + // fails — everything else stays SUCCESS. await runClientAgainstScenario(runner, 'auth/dpop-nonce', { - expectedFailureSlugs: ['sep-1932-client-rs-nonce'] + expectedFailureSlugs: ['sep-1932-client-rs-nonce'], + expectedSuccessSlugs: [ + 'sep-1932-client-token-request-proof', + 'sep-1932-client-dpop-auth-scheme', + 'sep-1932-client-fresh-proof', + 'sep-1932-client-as-nonce' + ] }); }); }); diff --git a/src/scenarios/client/auth/test_helpers/testClient.ts b/src/scenarios/client/auth/test_helpers/testClient.ts index 8790a7bf..c5c487fc 100644 --- a/src/scenarios/client/auth/test_helpers/testClient.ts +++ b/src/scenarios/client/auth/test_helpers/testClient.ts @@ -87,6 +87,13 @@ export class InlineClientRunner implements ClientRunner { export interface RunClientOptions { expectedFailureSlugs?: string[]; + /** + * Slugs that MUST be present and SUCCESS. Use alongside expectedFailureSlugs + * to pin that a specific check is *not* collateral damage of the injected + * defect (e.g. a client that skips the nonce retry still passes + * token-request-proof because it did present a valid proof). + */ + expectedSuccessSlugs?: string[]; allowClientError?: boolean; /** * Spec version to run the scenario at. Defaults to the latest dated spec @@ -103,6 +110,7 @@ export async function runClientAgainstScenario( ): Promise { const { expectedFailureSlugs = [], + expectedSuccessSlugs = [], allowClientError = false, specVersion } = options; @@ -152,6 +160,15 @@ export async function runClientAgainstScenario( // Filter out INFO checks const nonInfoChecks = checks.filter((c) => c.status !== 'INFO'); + // Slugs that must be present and SUCCESS (independent of the failure set). + for (const slug of expectedSuccessSlugs) { + const check = checks.find((c) => c.id === slug); + if (!check) { + throw new Error(`Expected-success check ${slug} not found`); + } + expect(check.status).toBe('SUCCESS'); + } + // Check for expected failures if (expectedFailureSlugs.length > 0) { // Verify that the expected failures are present From e23223d29debc34448d2daf8c71ff06be8b76b8e Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:58:07 +0100 Subject: [PATCH 4/8] =?UTF-8?q?client/dpop:=20round-5=20fixes=20=E2=80=94?= =?UTF-8?q?=20dedupe=20masking,=20wording,=20replay=20decision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dedupeSharedChecks was masking failures: it kept the last occurrence of a duplicate check ID regardless of status and ran in BOTH postures, so a FAILURE on a challenged POST (or an earlier attempt in a restarted flow) was silently dropped. Replace with an exported, unit-tested collapseDuplicateChecks that prefers the MOST-SEVERE occurrence, and only apply it in the nonce posture (the baseline keeps genuinely distinct repeated attempts). (R5 #1) - Reword the token-request-proof description: it can be SUCCESS for a client that presented a valid proof but never obtained a token (challenged, no retry), so it no longer claims "obtaining a DPoP-bound access token". (R5 #2) - Reword the as-nonce/rs-nonce failure messages to cover a client that DID retry but whose retry proof was rejected (not only "did not retry"). (R5 #3) - Document the deliberate replay decision: recording jti on challenged requests means re-sending the identical proof across the challenge/retry boundary trips replay detection — a genuine RFC 9449 §4.2 jti-uniqueness violation. (R5 #5) Adds src/scenarios/client/auth/dpop.test.ts unit-testing collapseDuplicateChecks (prefers failures, keeps INFO, preserves order) — the dedupe fix is now pinned. Co-Authored-By: Claude Opus 4.8 --- src/scenarios/client/auth/dpop.test.ts | 85 +++++++++++++++++++ src/scenarios/client/auth/dpop.ts | 62 +++++++++----- .../client/auth/helpers/dpopResourceAuth.ts | 5 ++ 3 files changed, 131 insertions(+), 21 deletions(-) create mode 100644 src/scenarios/client/auth/dpop.test.ts diff --git a/src/scenarios/client/auth/dpop.test.ts b/src/scenarios/client/auth/dpop.test.ts new file mode 100644 index 00000000..5818e0d5 --- /dev/null +++ b/src/scenarios/client/auth/dpop.test.ts @@ -0,0 +1,85 @@ +import { collapseDuplicateChecks } from './dpop'; +import type { ConformanceCheck, CheckStatus } from '../../../types'; + +/** Minimal check factory for the dedupe unit tests. */ +function chk(id: string, status: CheckStatus, tag?: string): ConformanceCheck { + return { + id, + name: id, + description: tag ?? id, + status, + timestamp: '2026-07-06T00:00:00.000Z', + specReferences: [] + }; +} + +describe('collapseDuplicateChecks (DPoP nonce-posture shared-check dedupe)', () => { + it('collapses duplicate SUCCESS ids to a single entry', () => { + const out = collapseDuplicateChecks([ + chk('token-request', 'SUCCESS'), + chk('pkce-code-verifier-sent', 'SUCCESS'), + chk('token-request', 'SUCCESS') + ]); + expect(out.map((c) => c.id)).toEqual([ + 'pkce-code-verifier-sent', + 'token-request' + ]); + }); + + it('never masks a FAILURE — keeps the failing occurrence over a later SUCCESS', () => { + // The regression this guards: a client that fails a check on the challenged + // POST but passes on the nonce retry must still be reported FAILURE. + const out = collapseDuplicateChecks([ + chk('pkce-verifier-matches-challenge', 'FAILURE', 'first attempt'), + chk('pkce-verifier-matches-challenge', 'SUCCESS', 'retry') + ]); + expect(out).toHaveLength(1); + expect(out[0].status).toBe('FAILURE'); + expect(out[0].description).toBe('first attempt'); + }); + + it('keeps the failing occurrence regardless of order (SUCCESS then FAILURE)', () => { + const out = collapseDuplicateChecks([ + chk('token-request', 'SUCCESS'), + chk('token-request', 'FAILURE', 'later failure') + ]); + expect(out).toHaveLength(1); + expect(out[0].status).toBe('FAILURE'); + expect(out[0].description).toBe('later failure'); + }); + + it('prefers WARNING over SUCCESS but FAILURE over WARNING', () => { + expect( + collapseDuplicateChecks([chk('x', 'SUCCESS'), chk('x', 'WARNING')])[0] + .status + ).toBe('WARNING'); + expect( + collapseDuplicateChecks([chk('x', 'WARNING'), chk('x', 'FAILURE')])[0] + .status + ).toBe('FAILURE'); + }); + + it('keeps every INFO log entry, even duplicates', () => { + const out = collapseDuplicateChecks([ + chk('incoming-request', 'INFO'), + chk('incoming-request', 'INFO'), + chk('incoming-request', 'INFO') + ]); + expect(out).toHaveLength(3); + }); + + it('preserves order and leaves a duplicate-free list untouched', () => { + const input = [ + chk('a', 'SUCCESS'), + chk('b', 'FAILURE'), + chk('incoming', 'INFO'), + chk('c', 'SUCCESS') + ]; + expect(collapseDuplicateChecks(input).map((c) => c.id)).toEqual([ + 'a', + 'b', + 'incoming', + 'c' + ]); + }); +}); diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index d48476d1..d5622aba 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -49,7 +49,7 @@ const CHECK_DEFS: Record< 'sep-1932-client-token-request-proof': { name: 'DpopTokenRequestProof', description: - 'Client includes a valid DPoP proof in the DPoP header of its token request, obtaining a DPoP-bound access token (RFC 9449 §5)', + 'Client includes a valid DPoP proof in the DPoP header of its token request — the prerequisite for obtaining a DPoP-bound access token (RFC 9449 §5)', specReferences: [ SpecReferences.SEP_1932_DPOP, SpecReferences.DPOP_EXTENSION, @@ -111,6 +111,36 @@ function newTokenReqObs(): DpopTokenRequestObservation { }; } +/** + * Collapse duplicate non-INFO check IDs to a single entry, preferring the + * MOST-SEVERE occurrence (FAILURE > WARNING > SUCCESS; ties keep the last) so a + * real failure is never masked. Per-request INFO log entries are always kept. + * + * The RFC 9449 §8/§9 nonce round-trip re-POSTs /token (challenge → retry), so + * the shared token-flow conformance checks (`token-request`, `pkce-*`) are + * appended twice; this reports each once without hiding a failure recorded on + * either attempt. Exported so the behaviour is unit-tested directly. + */ +export function collapseDuplicateChecks( + checks: ConformanceCheck[] +): ConformanceCheck[] { + const severity = (s: CheckStatus): number => + s === 'FAILURE' ? 3 : s === 'WARNING' ? 2 : s === 'SUCCESS' ? 1 : 0; + // Winning index per non-INFO id: highest severity, ties → last occurrence. + const winner = new Map(); + checks.forEach((c, i) => { + if (c.status === 'INFO') return; + const cur = winner.get(c.id); + if ( + cur === undefined || + severity(c.status) >= severity(checks[cur].status) + ) { + winner.set(c.id, i); + } + }); + return checks.filter((c, i) => c.status === 'INFO' || winner.get(c.id) === i); +} + export class DPoPClientScenario implements Scenario { readonly name: string; readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; @@ -172,8 +202,15 @@ export class DPoPClientScenario implements Scenario { } getChecks(): ConformanceCheck[] { + // Only the nonce posture re-POSTs /token (challenge → retry), which + // duplicates the shared token-flow checks (token-request, pkce-*); collapse + // those. The baseline (`auth/dpop`) is left untouched so genuinely distinct + // repeated attempts (e.g. a restarted authorization flow) keep both entries. + const shared = this.requireNonce + ? collapseDuplicateChecks(this.checks) + : this.checks; const checks: ConformanceCheck[] = [ - ...this.dedupeSharedChecks(), + ...shared, this.tokenRequestProofCheck(), this.authSchemeCheck(), this.freshProofCheck() @@ -187,23 +224,6 @@ export class DPoPClientScenario implements Scenario { return checks; } - /** - * The RFC 9449 §8/§9 nonce round-trip re-POSTs /token (challenge → retry), so - * the shared token-flow conformance checks (`token-request`, `pkce-*`) are - * appended twice. Collapse duplicate non-INFO shared-check IDs to the last - * occurrence so each is reported once; per-request INFO log entries are left - * intact. Our own sep-1932-client-* checks are unique by construction. - */ - private dedupeSharedChecks(): ConformanceCheck[] { - const lastIndex = new Map(); - this.checks.forEach((c, i) => { - if (c.status !== 'INFO') lastIndex.set(c.id, i); - }); - return this.checks.filter( - (c, i) => c.status === 'INFO' || lastIndex.get(c.id) === i - ); - } - private asNonceCheck(): ConformanceCheck { const challenged = this.tokenReqObs.asNonceChallengeIssued; const honored = this.tokenReqObs.asNonceHonored; @@ -218,7 +238,7 @@ export class DPoPClientScenario implements Scenario { errorMessage: pass ? undefined : challenged - ? 'Client did not retry the token request with the server-supplied nonce after a use_dpop_nonce challenge' + ? 'Client did not complete the token request with the server-supplied nonce after a use_dpop_nonce challenge (it either did not retry or the retried proof was rejected)' : 'Client never presented a valid proof that was answered with a use_dpop_nonce challenge', details: { challengeIssued: challenged, @@ -241,7 +261,7 @@ export class DPoPClientScenario implements Scenario { errorMessage: pass ? undefined : challenged - ? 'Client did not retry the MCP request with the server-supplied nonce after a use_dpop_nonce challenge' + ? 'Client did not complete the MCP request with the server-supplied nonce after a use_dpop_nonce challenge (it either did not retry or the retried proof was rejected)' : 'Client never made an MCP request with a valid proof that was answered with a use_dpop_nonce challenge', details: { challengeIssued: challenged, diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts index ed8ae658..33f44be4 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts @@ -112,6 +112,11 @@ export function createDpopResourceAuth( // nonce-challenged (but otherwise valid) proof still demonstrates freshness, // so fresh-proof is never asserted on zero `jti` evidence, and a client that // never honours the nonce fails only rs-nonce (not fresh-proof as well). + // + // Deliberate consequence: a client that re-sends the *identical* proof + // across the challenge/retry boundary (same `jti`) trips replay detection. + // That is a genuine RFC 9449 §4.2 violation (`jti` MUST be unique per proof) + // — a conformant client mints a fresh proof, carrying the nonce, on retry. if (obs.jtisSeen.includes(result.jti)) { obs.replayDetected = true; } else { From 535adbd0075df78fdcf2f5022b9e546c05c9c93f Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:30:49 +0100 Subject: [PATCH 5/8] =?UTF-8?q?client/dpop:=20round-6=20polish=20=E2=80=94?= =?UTF-8?q?=20pin=20collapse=20wiring;=20docstring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add an integration assertion that the §8/§9 double-POST is collapsed to one token-request/pkce-* entry each in auth/dpop-nonce, so the requireNonce gating of collapseDuplicateChecks can't be silently deleted or inverted (previously mutation-survivable). runClientAgainstScenario now returns the emitted checks so callers can make occurrence-level assertions (backward-compatible). - Docstring: note the severity ladder ranks SKIPPED below SUCCESS, and that equal-severity ties keep the last occurrence. Co-Authored-By: Claude Opus 4.8 --- src/scenarios/client/auth/dpop.ts | 6 ++++-- src/scenarios/client/auth/index.test.ts | 15 +++++++++++++++ .../client/auth/test_helpers/testClient.ts | 11 +++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index d5622aba..1d7b37b0 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -113,8 +113,10 @@ function newTokenReqObs(): DpopTokenRequestObservation { /** * Collapse duplicate non-INFO check IDs to a single entry, preferring the - * MOST-SEVERE occurrence (FAILURE > WARNING > SUCCESS; ties keep the last) so a - * real failure is never masked. Per-request INFO log entries are always kept. + * MOST-SEVERE occurrence (FAILURE > WARNING > SUCCESS > any other status, e.g. + * SKIPPED) so a real failure is never masked. Equal-severity ties keep the LAST + * occurrence (for the nonce round-trip that is the retry's diagnostic, which is + * status-equivalent to the first). Per-request INFO log entries are always kept. * * The RFC 9449 §8/§9 nonce round-trip re-POSTs /token (challenge → retry), so * the shared token-flow conformance checks (`token-request`, `pkce-*`) are diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 6e9a39fa..f37bf926 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -34,6 +34,7 @@ import { runClient as dpopNoTokenProofClient } from '../../../../examples/client import { runClient as dpopNoAsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-as-nonce'; import { runClient as dpopNoRsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-rs-nonce'; import { runClient as dpopNoNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-nonce'; +import { runClient as dpopClient } from '../../../../examples/clients/typescript/auth-test-dpop'; import { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; import { DRAFT_PROTOCOL_VERSION } from '../../../types'; @@ -427,4 +428,18 @@ describe('DPoP client nonce-less baseline (SEP-1932)', () => { // three baseline checks; no as-nonce/rs-nonce checks are emitted here). await runClientAgainstScenario(runner, 'auth/dpop'); }); + + test('auth/dpop-nonce: the §8/§9 double-POST is collapsed to one shared check each', async () => { + // Pins that collapseDuplicateChecks is actually wired into getChecks for the + // nonce posture: the challenge→retry re-POST records token-request/pkce + // twice, so without the collapse these would appear 2×. Guards against the + // gating being deleted or inverted. + const runner = new InlineClientRunner(dpopClient); + const checks = await runClientAgainstScenario(runner, 'auth/dpop-nonce'); + const count = (id: string): number => + checks.filter((c) => c.id === id).length; + expect(count('token-request')).toBe(1); + expect(count('pkce-code-verifier-sent')).toBe(1); + expect(count('pkce-verifier-matches-challenge')).toBe(1); + }); }); diff --git a/src/scenarios/client/auth/test_helpers/testClient.ts b/src/scenarios/client/auth/test_helpers/testClient.ts index c5c487fc..e21459a7 100644 --- a/src/scenarios/client/auth/test_helpers/testClient.ts +++ b/src/scenarios/client/auth/test_helpers/testClient.ts @@ -1,6 +1,6 @@ import { getScenario } from '../../../index'; import { testScenarioContext } from '../../../../mock-server/testing'; -import type { SpecVersion } from '../../../../types'; +import type { SpecVersion, ConformanceCheck } from '../../../../types'; import { spawn } from 'child_process'; const CLIENT_TIMEOUT = 10000; // 10 seconds for client to complete @@ -107,7 +107,7 @@ export async function runClientAgainstScenario( clientRunner: ClientRunner, scenarioName: string, options: RunClientOptions = {} -): Promise { +): Promise { const { expectedFailureSlugs = [], expectedSuccessSlugs = [], @@ -127,6 +127,10 @@ export async function runClientAgainstScenario( const urls = await scenario.start(ctx); const serverUrl = urls.serverUrl; + // The emitted checks, returned to the caller for assertions the standard + // pass/fail options don't cover (e.g. counting occurrences of a shared check). + let collected: ConformanceCheck[] = []; + try { // Set environment variables for inline clients // These mirror what src/runner/client.ts does for spawned processes @@ -151,6 +155,7 @@ export async function runClientAgainstScenario( // Get checks from the scenario const checks = scenario.getChecks(); + collected = checks; // Verify checks were returned if (checks.length === 0) { @@ -215,4 +220,6 @@ export async function runClientAgainstScenario( // Stop the scenario server await scenario.stop(); } + + return collected; } From 49889e4669d034adf5f1b4071cee5164f3130fad Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:06:07 +0100 Subject: [PATCH 6/8] Add DPoP server proof-validation scenario (SEP-1932, #369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the DPoP client PR (shared foundation). Adds the server-role conformance for SEP-1932 / RFC 9449: the framework acts as a DPoP client against the MCP server under test and emits the sep-1932-server-* checks across the RFC 9449 §4.3 validation surface — proof validation, the ±5-minute iat window, asymmetric-only algorithms, the 401 + WWW-Authenticate challenge, token audience validation under DPoP, and the optional server-provided nonce. - src/scenarios/server/auth/dpop.ts (+ test, spec-references): the scenario. - examples/servers/typescript/sep-1932-{compliant,broken}-server.ts: passing and failing fixtures proving every check passes and fails. - Registered in the pending + all-client scenario lists. Depends only on the shared DPoP foundation (dpopProof/dpopToken); independent of the authorization-server PR. No DPoP-capable MCP SDK exists yet, so correctness rests on RFC vectors + an independent verifier + the fixtures. Co-Authored-By: Claude Opus 4.8 --- examples/servers/typescript/package.json | 1 + .../typescript/sep-1932-broken-server.ts | 66 ++ .../typescript/sep-1932-compliant-server.ts | 317 ++++++ .../typescript/sep-1932-reject-all-server.ts | 38 + src/scenarios/index.ts | 12 +- src/scenarios/server/auth/dpop.test.ts | 393 ++++++++ src/scenarios/server/auth/dpop.ts | 928 ++++++++++++++++++ src/scenarios/server/auth/spec-references.ts | 28 + 8 files changed, 1782 insertions(+), 1 deletion(-) create mode 100644 examples/servers/typescript/sep-1932-broken-server.ts create mode 100644 examples/servers/typescript/sep-1932-compliant-server.ts create mode 100644 examples/servers/typescript/sep-1932-reject-all-server.ts create mode 100644 src/scenarios/server/auth/dpop.test.ts create mode 100644 src/scenarios/server/auth/dpop.ts create mode 100644 src/scenarios/server/auth/spec-references.ts diff --git a/examples/servers/typescript/package.json b/examples/servers/typescript/package.json index 4356d425..b8bdbbd1 100644 --- a/examples/servers/typescript/package.json +++ b/examples/servers/typescript/package.json @@ -19,6 +19,7 @@ "@types/cors": "^2.8.19", "cors": "^2.8.5", "express": "^5.2.1", + "jose": "^6.1.2", "zod": "^4.3.6" }, "devDependencies": { diff --git a/examples/servers/typescript/sep-1932-broken-server.ts b/examples/servers/typescript/sep-1932-broken-server.ts new file mode 100644 index 00000000..d0ffb3e9 --- /dev/null +++ b/examples/servers/typescript/sep-1932-broken-server.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +/** + * MCP server that does NOT enforce DPoP — NEGATIVE test fixture. + * + * It accepts requests without validating the DPoP proof or the access-token + * binding at all, so it should FAIL the sep-1932-server-* negative checks + * (proving those checks actually detect non-conformance). DO NOT use in + * production. It is NOT what an SDK author runs against. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express, { type Request, type Response } from 'express'; +import { z } from 'zod'; + +function createMcpServer(): McpServer { + const server = new McpServer({ + name: 'sep-1932-broken-server', + version: '1.0.0' + }); + server.registerTool( + 'echo', + { + description: 'Echo the input back', + inputSchema: { message: z.string() } + }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Echo: ${message}` }] + }) + ); + return server; +} + +const app = express(); +app.use(express.json()); + +// NO DPoP validation: every request is handled regardless of the (missing, +// malformed, replayed, or unbound) DPoP proof or access token. +app.post('/mcp', async (req: Request, res: Response) => { + try { + const server = createMcpServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}` + }, + id: null + }); + } + } +}); + +const PORT = parseInt(process.env.PORT || '3011', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log(`DPoP broken server running on http://localhost:${PORT}/mcp`); + console.log('WARNING: No DPoP validation enabled!'); +}); diff --git a/examples/servers/typescript/sep-1932-compliant-server.ts b/examples/servers/typescript/sep-1932-compliant-server.ts new file mode 100644 index 00000000..8cece692 --- /dev/null +++ b/examples/servers/typescript/sep-1932-compliant-server.ts @@ -0,0 +1,317 @@ +#!/usr/bin/env node + +/** + * MCP server that correctly enforces DPoP (RFC 9449) — POSITIVE test fixture. + * + * Used only to validate the dpop server conformance scenario: it should PASS + * every sep-1932-server-* check. It is NOT what an SDK author runs against. + * + * The DPoP validation here is written from scratch against RFC 9449 §4.3 (using + * jose only for primitive verify/thumbprint) so it is an INDEPENDENT code path + * from the conformance suite's proof-builder/minter — a shared bug surfaces as a + * test failure rather than mutual agreement on a wrong answer. + * + * Trust config is supplied via env (the scenario mints tokens with the matching + * issuer private key): + * PORT, DPOP_ISSUER_JWK (public JWK JSON), DPOP_ISSUER, DPOP_AUDIENCE, + * DPOP_IAT_SKEW_SECONDS (default 300), DPOP_REQUIRE_NONCE ('1'), DPOP_NONCE. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express, { type Request, type Response } from 'express'; +import { z } from 'zod'; +import * as jose from 'jose'; +import { createHash } from 'node:crypto'; + +const ISSUER_JWK = JSON.parse(process.env.DPOP_ISSUER_JWK || '{}'); +const ISSUER = process.env.DPOP_ISSUER || 'https://auth.example.com'; +const AUDIENCE = process.env.DPOP_AUDIENCE || ''; +const IAT_SKEW = parseInt(process.env.DPOP_IAT_SKEW_SECONDS || '300', 10); +const REQUIRE_NONCE = process.env.DPOP_REQUIRE_NONCE === '1'; +const NONCE = process.env.DPOP_NONCE || 'conformance-test-nonce'; +// Buggy-nonce mode (negative-test fixture only): challenge when no nonce is +// present, but accept ANY nonce value without validating it. Used to prove the +// scenario's nonce check can report WARNING. +const NONCE_ACCEPT_ANY = process.env.DPOP_NONCE_ACCEPT_ANY === '1'; +// Nonce-first mode (negative-test fixture only): gate on the nonce BEFORE any +// structural proof validation, so every nonce-less request — even a malformed +// proof — is answered with use_dpop_nonce. Models a server that checks the +// nonce first; used to prove the scenario reports those rejections as +// not-testable rather than a vacuous SUCCESS. +const NONCE_FIRST = process.env.DPOP_NONCE_FIRST === '1'; +// Clock-offset mode (negative-test fixture only): shift the server's notion of +// "now" — and its `Date` header — by N seconds to simulate a skewed server +// clock. Used to prove the scenario anchors its iat probes to the server's Date. +const CLOCK_OFFSET = parseInt(process.env.DPOP_CLOCK_OFFSET_SECONDS || '0', 10); + +// Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 step 5). +const ASYMMETRIC_ALGS = [ + 'ES256', + 'ES384', + 'ES512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA' +]; + +type Failure = { error: string; description: string; nonce?: boolean }; +type Result = { ok: true } | ({ ok: false } & Failure); + +const fail = (error: string, description: string, nonce = false): Result => ({ + ok: false, + error, + description, + nonce +}); + +let issuerKey: jose.CryptoKey | Uint8Array; + +function reconstructHtu(req: Request): string { + const proto = (req.headers['x-forwarded-proto'] as string) || 'http'; + const host = req.headers.host; + return `${proto}://${host}${req.originalUrl.split('?')[0]}`; +} + +function countHeader(req: Request, name: string): number { + let n = 0; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + if (req.rawHeaders[i].toLowerCase() === name) n++; + } + return n; +} + +async function validateDpop(req: Request): Promise { + // --- Access token presentation: must use the DPoP scheme (RFC 9449 §7.1) --- + const authz = req.headers.authorization; + if (!authz) return fail('invalid_token', 'missing Authorization header'); + if (!authz.startsWith('DPoP ')) { + return fail( + 'invalid_token', + 'access token must be presented with the DPoP scheme' + ); + } + const accessToken = authz.slice('DPoP '.length).trim(); + + // --- Exactly one DPoP header (§4.3 step 1) --- + const dpopCount = countHeader(req, 'dpop'); + if (dpopCount === 0) + return fail('invalid_dpop_proof', 'missing DPoP proof header'); + if (dpopCount > 1) + return fail('invalid_dpop_proof', 'more than one DPoP header field'); + const proof = req.headers.dpop as string; + + // --- Nonce-first negative mode: challenge before structural validation --- + if (REQUIRE_NONCE && NONCE_FIRST) { + let nonce: unknown; + try { + nonce = jose.decodeJwt(proof).nonce; + } catch { + nonce = undefined; + } + const hasNonce = typeof nonce === 'string' && nonce.length > 0; + if (!hasNonce) { + return fail( + 'use_dpop_nonce', + 'a server-provided nonce is required', + true + ); + } + if (!NONCE_ACCEPT_ANY && nonce !== NONCE) { + return fail('use_dpop_nonce', 'the supplied nonce does not match', true); + } + } + + // --- Proof is a well-formed JWT with required header params (§4.3 steps 2,4,5,7) --- + let header: jose.ProtectedHeaderParameters; + try { + header = jose.decodeProtectedHeader(proof); + } catch { + return fail('invalid_dpop_proof', 'DPoP proof is not a well-formed JWT'); + } + if (header.typ !== 'dpop+jwt') + return fail('invalid_dpop_proof', 'typ must be dpop+jwt'); + if (!header.alg || !ASYMMETRIC_ALGS.includes(header.alg)) { + return fail( + 'invalid_dpop_proof', + 'alg must be a supported asymmetric algorithm' + ); + } + const jwk = header.jwk as jose.JWK | undefined; + if (!jwk) return fail('invalid_dpop_proof', 'missing jwk header parameter'); + if ((jwk as Record).d !== undefined) { + return fail('invalid_dpop_proof', 'jwk must not contain a private key'); + } + + // --- Signature verifies with the embedded public key (§4.3 step 6) --- + let claims: jose.JWTPayload; + try { + const proofKey = await jose.importJWK(jwk, header.alg); + const verified = await jose.jwtVerify(proof, proofKey, { + algorithms: ASYMMETRIC_ALGS + }); + claims = verified.payload; + } catch { + return fail('invalid_dpop_proof', 'DPoP proof signature does not verify'); + } + + // --- Required claims + htm/htu match (§4.3 steps 3,8,9) --- + if (typeof claims.jti !== 'string') + return fail('invalid_dpop_proof', 'missing jti claim'); + if (claims.htm !== req.method) + return fail('invalid_dpop_proof', 'htm does not match request method'); + if (claims.htu !== reconstructHtu(req)) + return fail('invalid_dpop_proof', 'htu does not match request URI'); + + // --- iat acceptance window of ±IAT_SKEW (§4.3 step 11; SEP ±5 min) --- + if (typeof claims.iat !== 'number') + return fail('invalid_dpop_proof', 'missing iat claim'); + const now = Math.floor(Date.now() / 1000) + CLOCK_OFFSET; + if (Math.abs(now - claims.iat) > IAT_SKEW) { + return fail('invalid_dpop_proof', 'iat outside the acceptable window'); + } + + // --- Access token validity: signature, issuer, audience, expiry --- + let tokenClaims: jose.JWTPayload; + try { + const verified = await jose.jwtVerify(accessToken, issuerKey, { + issuer: ISSUER, + audience: AUDIENCE + }); + tokenClaims = verified.payload; + } catch { + return fail( + 'invalid_token', + 'access token is invalid (signature/issuer/audience/expiry)' + ); + } + + // --- ath binds the proof to this access token (§4.3 step 12a) --- + const expectedAth = createHash('sha256') + .update(accessToken, 'ascii') + .digest('base64url'); + if (claims.ath !== expectedAth) + return fail('invalid_dpop_proof', 'ath does not match the access token'); + + // --- Token is bound to the proof key (§4.3 step 12b) --- + const cnf = tokenClaims.cnf as { jkt?: string } | undefined; + const thumbprint = await jose.calculateJwkThumbprint(jwk, 'sha256'); + if (!cnf || cnf.jkt !== thumbprint) { + return fail( + 'invalid_token', + 'access token is not bound to the DPoP proof key (cnf.jkt mismatch)' + ); + } + + // --- Optional server-provided nonce (§4.3 step 10; §9) --- + if (REQUIRE_NONCE) { + const hasNonce = + typeof claims.nonce === 'string' && claims.nonce.length > 0; + if (!hasNonce) { + return fail( + 'use_dpop_nonce', + 'a server-provided nonce is required', + true + ); + } + if (!NONCE_ACCEPT_ANY && claims.nonce !== NONCE) { + return fail('use_dpop_nonce', 'the supplied nonce does not match', true); + } + } + + return { ok: true }; +} + +function send401(res: Response, f: Failure): void { + const algs = ASYMMETRIC_ALGS.join(' '); + res.setHeader( + 'WWW-Authenticate', + `DPoP error="${f.error}", error_description="${f.description}", algs="${algs}"` + ); + if (f.nonce) res.setHeader('DPoP-Nonce', NONCE); + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null + }); +} + +function createMcpServer(): McpServer { + const server = new McpServer({ + name: 'sep-1932-compliant-server', + version: '1.0.0' + }); + server.registerTool( + 'echo', + { + description: 'Echo the input back', + inputSchema: { message: z.string() } + }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Echo: ${message}` }] + }) + ); + return server; +} + +const app = express(); +app.use(express.json()); + +// Clock-offset mode: reflect the skewed clock in the Date header too, so a +// client that anchors to it measures against the same clock the server uses. +if (CLOCK_OFFSET) { + app.use((_req: Request, res: Response, next) => { + res.setHeader( + 'Date', + new Date(Date.now() + CLOCK_OFFSET * 1000).toUTCString() + ); + next(); + }); +} + +app.post('/mcp', async (req: Request, res: Response) => { + const result = await validateDpop(req); + if (!result.ok) { + send401(res, result); + return; + } + try { + const server = createMcpServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}` + }, + id: null + }); + } + } +}); + +const PORT = parseInt(process.env.PORT || '3010', 10); + +async function main(): Promise { + issuerKey = await jose.importJWK(ISSUER_JWK, ISSUER_JWK.alg || 'ES256'); + app.listen(PORT, '127.0.0.1', () => { + console.log( + `DPoP compliant server running on http://localhost:${PORT}/mcp` + ); + }); +} + +main().catch((err) => { + console.error('Failed to start DPoP compliant server:', err); + process.exit(1); +}); diff --git a/examples/servers/typescript/sep-1932-reject-all-server.ts b/examples/servers/typescript/sep-1932-reject-all-server.ts new file mode 100644 index 00000000..061f48e4 --- /dev/null +++ b/examples/servers/typescript/sep-1932-reject-all-server.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +/** + * MCP server that rejects EVERY request with 401 + `WWW-Authenticate: DPoP` — + * NEGATIVE test fixture for the gating in the DPoP server-validation scenario. + * + * A naive rejection check ("did the server 401 the malformed proof?") passes + * vacuously here, because this server also 401s a perfectly valid DPoP request. + * The scenario must therefore gate its rejection checks on the positive + * baseline: against this fixture `AcceptsValidProof` FAILs, and every rejection + * check must report notTestable rather than SUCCESS. DO NOT use in production. + */ + +import express, { type Request, type Response } from 'express'; + +const ASYMMETRIC_ALGS = ['ES256', 'ES384', 'ES512', 'RS256', 'PS256', 'EdDSA']; + +const app = express(); +app.use(express.json()); + +// Reject unconditionally — a valid proof is refused exactly like a malformed one. +app.post('/mcp', (_req: Request, res: Response) => { + res.setHeader( + 'WWW-Authenticate', + `DPoP error="invalid_token", error_description="rejects everything", algs="${ASYMMETRIC_ALGS.join(' ')}"` + ); + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null + }); +}); + +const PORT = parseInt(process.env.PORT || '3012', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log(`DPoP reject-all server running on http://localhost:${PORT}/mcp`); + console.log('WARNING: Rejects every request, including valid ones!'); +}); diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index db1584ea..b905bff0 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -66,6 +66,7 @@ import { } from './server/prompts'; import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +import { DPoPServerValidationScenario } from './server/auth/dpop'; import { CachingScenario } from './server/caching'; // InputRequiredResult scenarios from (SEP-2322) @@ -149,7 +150,12 @@ const pendingClientScenariosList: ClientScenario[] = [ new TasksDispatchScenario(), new TasksStatusNotificationsScenario(), new TasksRequiredTaskErrorScenario(), - new TasksMrtrCompositionScenario() + new TasksMrtrCompositionScenario(), + + // DPoP server validation (SEP-1932): the everything-server is not DPoP-aware, + // so this runs against a dedicated fixture, e.g. + // `npm start -- server --scenario auth/dpop-server-validation --url `. + new DPoPServerValidationScenario() ]; // All client scenarios @@ -229,6 +235,10 @@ const allClientScenariosList: ClientScenario[] = [ new TasksRequiredTaskErrorScenario(), new TasksMrtrCompositionScenario(), + // DPoP server validation (SEP-1932). Pending against the everything-server + // (not DPoP-aware); targeted runs point at the sep-1932-compliant-server fixture. + new DPoPServerValidationScenario(), + // InputRequiredResult scenarios (SEP-2322) new InputRequiredResultBasicElicitationScenario(), new InputRequiredResultBasicSamplingScenario(), diff --git a/src/scenarios/server/auth/dpop.test.ts b/src/scenarios/server/auth/dpop.test.ts new file mode 100644 index 00000000..21f80a9e --- /dev/null +++ b/src/scenarios/server/auth/dpop.test.ts @@ -0,0 +1,393 @@ +import { spawn, ChildProcess } from 'child_process'; +import { createServer } from 'node:net'; +import path from 'path'; +import * as jose from 'jose'; +import { testContext } from '../../../connection/testing'; +import { DPoPServerValidationScenario } from './dpop'; +import type { ConformanceCheck } from '../../../types'; + +const WINDOWS = process.platform === 'win32'; + +/** Find an unused TCP port (small TOCTOU window, fine for tests). */ +function freePort(): Promise { + return new Promise((resolve, reject) => { + const s = createServer(); + s.once('error', reject); + s.listen(0, '127.0.0.1', () => { + const addr = s.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + s.close(() => resolve(port)); + }); + }); +} + +async function freePorts(n: number): Promise { + const ports = new Set(); + while (ports.size < n) ports.add(await freePort()); + return [...ports]; +} + +/** Kill the whole process group so the `tsx`/`node` child isn't orphaned. */ +function killTree(proc: ChildProcess, signal: NodeJS.Signals): void { + if (!WINDOWS && proc.pid !== undefined) { + process.kill(-proc.pid, signal); // negative pid → the detached group + } else { + proc.kill(signal); + } +} + +function startServer( + script: string, + port: number, + extraEnv: Record +): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('npx', ['tsx', script], { + env: { ...process.env, PORT: port.toString(), ...extraEnv }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: !WINDOWS, // own process group → killable as a unit + shell: WINDOWS + }); + let stderr = ''; + proc.stderr?.on('data', (d) => (stderr += d.toString())); + const timeout = setTimeout(() => { + killTree(proc, 'SIGKILL'); + reject( + new Error(`Server ${script} failed to start within 30s: ${stderr}`) + ); + }, 30000); + proc.stdout?.on('data', (data) => { + if (data.toString().includes('running on')) { + clearTimeout(timeout); + resolve(proc); + } + }); + proc.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +function stopServer(proc: ChildProcess | null): Promise { + return new Promise((resolve) => { + if (!proc || proc.killed || proc.pid === undefined) return resolve(); + const t = setTimeout(() => { + try { + killTree(proc, 'SIGKILL'); + } catch { + /* already gone */ + } + resolve(); + }, 5000); + proc.once('exit', () => { + clearTimeout(t); + resolve(); + }); + try { + killTree(proc, 'SIGTERM'); + } catch { + clearTimeout(t); + resolve(); + } + }); +} + +const COMPLIANT = path.join( + process.cwd(), + 'examples/servers/typescript/sep-1932-compliant-server.ts' +); +const BROKEN = path.join( + process.cwd(), + 'examples/servers/typescript/sep-1932-broken-server.ts' +); +const REJECT_ALL = path.join( + process.cwd(), + 'examples/servers/typescript/sep-1932-reject-all-server.ts' +); +const ISSUER = 'https://conformance-dpop-issuer.example.com'; +const url = (p: number) => `http://localhost:${p}/mcp`; + +function byId(checks: ConformanceCheck[], id: string): ConformanceCheck[] { + return checks.filter((c) => c.id === id); +} + +describe('DPoP server validation scenario', () => { + let compliant: ChildProcess | null = null; + let broken: ChildProcess | null = null; + let rejectAll: ChildProcess | null = null; + let nonceStrict: ChildProcess | null = null; + let nonceBuggy: ChildProcess | null = null; + let nonceFirst: ChildProcess | null = null; + let clockSkew: ChildProcess | null = null; + let ports: { + compliant: number; + broken: number; + rejectAll: number; + strict: number; + buggy: number; + nonceFirst: number; + clockSkew: number; + }; + let savedEnv: { jwk?: string; issuer?: string }; + + beforeAll(async () => { + // One issuer key, shared: the scenario mints with the private key (via env), + // the example servers trust the matching public key (via env). + const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { + extractable: true + }); + const publicJwk = { ...(await jose.exportJWK(publicKey)), alg: 'ES256' }; + const privateJwk = { ...(await jose.exportJWK(privateKey)), alg: 'ES256' }; + + savedEnv = { + jwk: process.env.DPOP_ISSUER_PRIVATE_JWK, + issuer: process.env.DPOP_ISSUER + }; + process.env.DPOP_ISSUER_PRIVATE_JWK = JSON.stringify(privateJwk); + process.env.DPOP_ISSUER = ISSUER; + + const [cp, bp, rp, sp, gp, nf, ck] = await freePorts(7); + ports = { + compliant: cp, + broken: bp, + rejectAll: rp, + strict: sp, + buggy: gp, + nonceFirst: nf, + clockSkew: ck + }; + + const issuerEnv = (port: number, extra: Record = {}) => ({ + DPOP_ISSUER_JWK: JSON.stringify(publicJwk), + DPOP_ISSUER: ISSUER, + DPOP_AUDIENCE: url(port), + ...extra + }); + + [ + compliant, + broken, + rejectAll, + nonceStrict, + nonceBuggy, + nonceFirst, + clockSkew + ] = await Promise.all([ + startServer(COMPLIANT, cp, issuerEnv(cp)), + startServer(BROKEN, bp, {}), + startServer(REJECT_ALL, rp, {}), + startServer(COMPLIANT, sp, issuerEnv(sp, { DPOP_REQUIRE_NONCE: '1' })), + startServer( + COMPLIANT, + gp, + issuerEnv(gp, { DPOP_REQUIRE_NONCE: '1', DPOP_NONCE_ACCEPT_ANY: '1' }) + ), + startServer( + COMPLIANT, + nf, + issuerEnv(nf, { DPOP_REQUIRE_NONCE: '1', DPOP_NONCE_FIRST: '1' }) + ), + startServer( + COMPLIANT, + ck, + issuerEnv(ck, { DPOP_CLOCK_OFFSET_SECONDS: '-30' }) + ) + ]); + }, 60000); + + afterAll(async () => { + await Promise.all([ + stopServer(compliant), + stopServer(broken), + stopServer(rejectAll), + stopServer(nonceStrict), + stopServer(nonceBuggy), + stopServer(nonceFirst), + stopServer(clockSkew) + ]); + process.env.DPOP_ISSUER_PRIVATE_JWK = savedEnv.jwk; + process.env.DPOP_ISSUER = savedEnv.issuer; + if (savedEnv.jwk === undefined) delete process.env.DPOP_ISSUER_PRIVATE_JWK; + if (savedEnv.issuer === undefined) delete process.env.DPOP_ISSUER; + }); + + it('passes every check against a compliant server (no FAILUREs)', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.compliant)) + ); + + const failures = checks.filter((c) => c.status === 'FAILURE'); + expect(failures.map((c) => `${c.id}/${c.name}: ${c.errorMessage}`)).toEqual( + [] + ); + + expect( + byId(checks, 'sep-1932-server-validate-proof').every( + (c) => c.status === 'SUCCESS' + ) + ).toBe(true); + expect( + byId(checks, 'sep-1932-server-iat-window').every( + (c) => c.status === 'SUCCESS' + ) + ).toBe(true); + expect( + byId(checks, 'sep-1932-asymmetric-alg-only').every( + (c) => c.status === 'SUCCESS' + ) + ).toBe(true); + expect( + byId(checks, 'sep-1932-server-audience-validation').every( + (c) => c.status === 'SUCCESS' + ) + ).toBe(true); + expect( + byId(checks, 'sep-1932-server-reject-401').every( + (c) => c.status === 'SUCCESS' + ) + ).toBe(true); + // Compliant server does not require a nonce → nonce flow is optional/SKIPPED. + expect(byId(checks, 'sep-1932-server-nonce')[0].status).toBe('SKIPPED'); + }, 30000); + + it('emits FAILURE against a server that does not validate DPoP', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.broken)) + ); + + const rejects = byId(checks, 'sep-1932-server-validate-proof').filter((c) => + c.name.startsWith('Rejects') + ); + expect(rejects.length).toBeGreaterThan(0); + expect(rejects.every((c) => c.status === 'FAILURE')).toBe(true); + + expect(byId(checks, 'sep-1932-server-reject-401')[0].status).toBe( + 'FAILURE' + ); + expect( + byId(checks, 'sep-1932-server-iat-window').every( + (c) => c.status === 'FAILURE' + ) + ).toBe(true); + expect( + byId(checks, 'sep-1932-asymmetric-alg-only').every( + (c) => c.status === 'FAILURE' + ) + ).toBe(true); + expect(byId(checks, 'sep-1932-server-audience-validation')[0].status).toBe( + 'FAILURE' + ); + }, 30000); + + it('reports rejection checks notTestable (not vacuous SUCCESS) against a reject-everything server', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.rejectAll)) + ); + + // The valid baseline is refused → the positive check fails. + const positive = byId(checks, 'sep-1932-server-validate-proof').find( + (c) => c.name === 'AcceptsValidProof' + ); + expect(positive?.status).toBe('FAILURE'); + + // Every rejection check must be gated to notTestable (#248), never SUCCESS: + // a 401 here cannot be attributed to validation when the server 401s + // everything — otherwise a reject-all server would pass the whole battery. + const gated = [ + ...byId(checks, 'sep-1932-server-validate-proof').filter((c) => + c.name.startsWith('Rejects') + ), + ...byId(checks, 'sep-1932-server-iat-window'), + ...byId(checks, 'sep-1932-asymmetric-alg-only'), + ...byId(checks, 'sep-1932-server-audience-validation'), + ...byId(checks, 'sep-1932-server-reject-401') + ]; + + expect(gated.length).toBeGreaterThan(0); + expect(gated.every((c) => c.details?.untestable === true)).toBe(true); + expect(gated.some((c) => c.status === 'SUCCESS')).toBe(false); + }, 30000); + + // The baseline completes the nonce handshake (acceptValid retries with the + // server-issued nonce), so it passes against a nonce-requiring server. A + // server that requires a nonce but checks it AFTER structural validation + // still rejects the malformed negatives on their real defect, so those checks + // stay meaningful; the nonce-FIRST case below covers the adversary that gates + // on the nonce before anything else. + it('reports the nonce check SUCCESS against a correct nonce server', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.strict)) + ); + expect(byId(checks, 'sep-1932-server-nonce')[0].status).toBe('SUCCESS'); + }, 30000); + + it('correctly tests a nonce-first server by retrying negatives with the nonce', async () => { + // Without the retry, a server that gates on the nonce before any structural + // check would answer every nonce-less negative with use_dpop_nonce → each + // check would go not-testable → a red run for a *conformant* server. The + // scenario instead folds the handshake nonce into the negatives, so the + // server rejects each on its real defect (SUCCESS). Only malformed-not-a-jwt, + // which can't carry a nonce, stays not-testable. + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.nonceFirst)) + ); + + // Baseline succeeds via the nonce handshake. + expect( + byId(checks, 'sep-1932-server-validate-proof').find( + (c) => c.name === 'AcceptsValidProof' + )?.status + ).toBe('SUCCESS'); + + const rejections = [ + ...byId(checks, 'sep-1932-server-validate-proof').filter((c) => + c.name.startsWith('Rejects') + ), + ...byId(checks, 'sep-1932-server-iat-window'), + ...byId(checks, 'sep-1932-asymmetric-alg-only'), + ...byId(checks, 'sep-1932-server-audience-validation'), + ...byId(checks, 'sep-1932-server-reject-401') + ]; + // No spurious FAILURE against a conformant server, and the negatives are + // genuinely exercised (SUCCESS) rather than all going not-testable. + // No *genuine* FAILURE against a conformant server (untestable checks carry + // FAILURE status but are flagged details.untestable — handled separately). + expect( + rejections + .filter((c) => c.status === 'FAILURE' && !c.details?.untestable) + .map((c) => `${c.name}: ${c.errorMessage}`) + ).toEqual([]); + // Every retryable negative is genuinely exercised (SUCCESS); exactly one — + // the malformed proof that can't carry a nonce — remains untestable. + expect(rejections.filter((c) => c.status === 'SUCCESS').length).toBe( + rejections.length - 1 + ); + const malformed = byId(checks, 'sep-1932-server-validate-proof').find( + (c) => c.name === 'RejectsMalformedProof' + ); + expect(malformed?.details?.untestable).toBe(true); + }, 30000); + + it('reports the nonce check WARNING against a server that accepts any nonce', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.buggy)) + ); + expect(byId(checks, 'sep-1932-server-nonce')[0].status).toBe('WARNING'); + }, 30000); + + it('anchors iat probes to the server clock (no false failure under skew)', async () => { + // The server's clock is 30s behind the framework. Without anchoring, the + // stale probe (now−301s by the framework clock) looks only ~271s old to the + // server → inside ±5 min → wrongly accepted → the iat check would FAIL a + // conformant server. Anchoring to the server's Date header keeps it rejected. + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.clockSkew)) + ); + expect( + byId(checks, 'sep-1932-server-iat-window').every( + (c) => c.status === 'SUCCESS' + ) + ).toBe(true); + }, 30000); +}); diff --git a/src/scenarios/server/auth/dpop.ts b/src/scenarios/server/auth/dpop.ts new file mode 100644 index 00000000..9cd31fb0 --- /dev/null +++ b/src/scenarios/server/auth/dpop.ts @@ -0,0 +1,928 @@ +/** + * DPoP server proof-validation scenario (SEP-1932 / RFC 9449). + * + * The framework acts as a DPoP client against the MCP server under test: it + * presents a valid DPoP-bound access token + proof (expect acceptance) and a + * battery of deliberately-malformed requests (expect 401), recording one check + * per case. Emits the sep-1932-server-* check IDs declared in + * src/seps/sep-1932.yaml. + * + * Token-issuer trust is supplied via env so the server under test can validate + * the access token (the compliant example server reads the matching public key): + * DPOP_ISSUER_PRIVATE_JWK (JSON), DPOP_ISSUER. Falls back to an ephemeral + * issuer if unset (only a server configured to trust it will then pass). + */ + +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../../types'; +import { + buildStandardHeaders, + withRequestMeta, + type RunContext +} from '../../../connection'; +import { request } from 'undici'; +import { untestableCheck } from '../../untestable'; +import { + generateDpopKeyPair, + buildDpopProof as baseBuildDpopProof +} from '../../client/auth/helpers/dpopProof'; +import { + generateIssuerKey, + importIssuerKey, + mintDpopBoundToken, + type TokenIssuerKey +} from '../../client/auth/helpers/dpopToken'; +import { SpecReferences } from './spec-references'; + +const SPEC_REFERENCES = [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_CHECKING_PROOFS, + SpecReferences.RFC_9449_AUTH_SCHEME, + SpecReferences.RFC_9449_NONCE, + SpecReferences.RFC_9449_ALGORITHMS +]; + +interface Probe { + jsonrpc: '2.0'; + id: number; + method: string; + params: Record; +} + +function probeBody(specVersion: string): Probe { + if (specVersion === DRAFT_PROTOCOL_VERSION) { + // Reuse the shared `_meta` envelope builder so the stateless probe carries + // exactly the required keys a strictly-conformant server expects. + return { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: withRequestMeta({}, specVersion) + }; + } + const clientInfo = { name: 'conformance-dpop-server-test', version: '1.0.0' }; + return { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: specVersion, capabilities: {}, clientInfo } + }; +} + +interface Response { + statusCode: number; + wwwAuthenticate: string; + dpopNonce: string | undefined; + /** The server's `Date` header as epoch seconds, if present (RFC 9110 §6.6.1). */ + date: number | undefined; +} + +function isAccepted(status: number): boolean { + return status >= 200 && status < 300; +} + +// A DPoP failure MUST be a 401 carrying a WWW-Authenticate: DPoP challenge +// (SEP-1932 / RFC 9449 §7.1) — not merely "some 4xx", so that an unrelated +// MCP-layer rejection cannot vacuously pass a negative check. +// +// The header may advertise several challenges (e.g. `Bearer ..., DPoP ...`), +// so match a `DPoP` auth-scheme token at the start or after a comma rather +// than requiring the header to *begin* with it (RFC 9110 §11.6.1). +function hasDpopChallenge(wwwAuthenticate: string): boolean { + return /(?:^|,)\s*dpop(?:\s|$|,)/i.test(wwwAuthenticate); +} + +function properlyRejected(res: Response): boolean { + return res.statusCode === 401 && hasDpopChallenge(res.wwwAuthenticate); +} + +// A `use_dpop_nonce` challenge means the server is demanding a nonce before it +// will look at the proof. The negative probes are sent without a nonce, so such +// a 401 is a rejection for the MISSING NONCE, not for the injected defect — it +// cannot be attributed to the defect and must not count as a proper rejection. +function isNonceChallenge(res: Response): boolean { + return ( + res.statusCode === 401 && + res.wwwAuthenticate.toLowerCase().includes('use_dpop_nonce') + ); +} + +function dpopCheck( + id: string, + name: string, + description: string, + status: ConformanceCheck['status'], + errorMessage?: string, + details?: Record +): ConformanceCheck { + return { + id, + name, + description, + timestamp: new Date().toISOString(), + specReferences: SPEC_REFERENCES, + status, + ...(errorMessage ? { errorMessage } : {}), + ...(details ? { details } : {}) + }; +} + +// Reason a rejection check cannot be attributed when the baseline is refused. +function gateReason(caseLabel: unknown): string { + return `server did not accept the valid baseline DPoP request, so a rejection of the ${String(caseLabel ?? 'malformed')} case cannot be distinguished from a server that rejects everything`; +} + +// A probe that throws (proof build / transport error) is a genuine FAILURE when +// the baseline was accepted, but — like the gated checks — not attributable when +// it wasn't, so the catch mirrors the gate rather than emitting a raw FAILURE. +function probeErrorCheck( + positiveAccepted: boolean, + id: string, + name: string, + description: string, + caseLabel: string, + error: unknown +): ConformanceCheck { + return positiveAccepted + ? dpopCheck(id, name, description, 'FAILURE', String(error), { + case: caseLabel + }) + : untestableCheck( + id, + name, + description, + gateReason(caseLabel), + SPEC_REFERENCES + ); +} + +// Build a check that passes when the server properly rejected a malformed +// request (401 + DPoP challenge) and fails otherwise. +// +// Gated on the positive baseline: a server that refuses even a valid DPoP +// request would 401 every negative probe too, making these checks pass +// vacuously — so when `positiveAccepted` is false we report them notTestable +// (#248) rather than SUCCESS. `predicate` lets a case relax what counts as a +// proper rejection (e.g. the Bearer-scheme case, where no DPoP challenge is +// required). +function rejectionCheck( + positiveAccepted: boolean, + id: string, + name: string, + description: string, + res: Response, + details: Record, + predicate: (res: Response) => boolean = properlyRejected +): ConformanceCheck { + if (!positiveAccepted) { + return untestableCheck( + id, + name, + description, + gateReason(details.case), + SPEC_REFERENCES + ); + } + if (isNonceChallenge(res)) { + return untestableCheck( + id, + name, + description, + `server answered with a DPoP nonce challenge (use_dpop_nonce), so this rejection cannot be attributed to the ${String(details.case ?? 'injected')} defect rather than the missing nonce`, + SPEC_REFERENCES + ); + } + const ok = predicate(res); + return dpopCheck( + id, + name, + description, + ok ? 'SUCCESS' : 'FAILURE', + ok + ? undefined + : `Expected the server to reject this request, got ${res.statusCode} / "${res.wwwAuthenticate}"`, + { + ...details, + statusCode: res.statusCode, + wwwAuthenticate: res.wwwAuthenticate + } + ); +} + +async function resolveIssuer(): Promise<{ + issuerKey: TokenIssuerKey; + issuer: string; +}> { + const issuer = + process.env.DPOP_ISSUER || 'https://conformance-dpop-issuer.example.com'; + const envJwk = process.env.DPOP_ISSUER_PRIVATE_JWK; + if (envJwk) { + const jwk = JSON.parse(envJwk); + return { + issuerKey: await importIssuerKey(jwk, jwk.alg || 'ES256'), + issuer + }; + } + return { issuerKey: await generateIssuerKey(), issuer }; +} + +export class DPoPServerValidationScenario implements ClientScenario { + name = 'auth/dpop-server-validation'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Test that an MCP server validates DPoP (RFC 9449) sender-constrained access tokens (SEP-1932). + +The framework acts as a DPoP client: it presents a valid DPoP-bound access token +and proof (which a conformant server MUST accept) and a series of deliberately +malformed requests (which a conformant server MUST reject with HTTP 401 and a +\`WWW-Authenticate: DPoP\` challenge), following RFC 9449 §4.3. + +Covers: proof validation per §4.3, the ±5-minute \`iat\` window, asymmetric-only +algorithms, the 401 challenge format, token audience validation under DPoP, and +(optionally) the server-provided nonce flow.`; + + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; + const checks: ConformanceCheck[] = []; + + const { issuerKey, issuer } = await resolveIssuer(); + const audience = serverUrl; + const kp = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + issuer, + audience, + jkt: kp.thumbprint + }); + + const send = async ( + authz: string, + dpop: string | string[] | undefined + ): Promise => { + const probe = probeBody(specVersion); + const base = buildStandardHeaders(probe.method, probe.params, { + specVersion + }); + const headers: Record = { + ...base, + Authorization: authz + }; + if (dpop !== undefined) headers['DPoP'] = dpop; + const res = await request(serverUrl, { + method: 'POST', + headers, + body: JSON.stringify(probe) + }); + // Drain the body so the socket can be reused / freed. + try { + await res.body.text(); + } catch { + /* ignore */ + } + // undici may surface a repeated header as string[]; coalesce so challenge + // matching sees every advertised scheme. + const rawWww = res.headers['www-authenticate']; + const wwwAuthenticate = Array.isArray(rawWww) + ? rawWww.join(', ') + : rawWww || ''; + const rawNonce = res.headers['dpop-nonce']; + const dpopNonce = Array.isArray(rawNonce) ? rawNonce[0] : rawNonce; + const rawDate = res.headers['date']; + const dateStr = Array.isArray(rawDate) ? rawDate[0] : rawDate; + const parsedDate = dateStr ? Date.parse(dateStr) : NaN; + const date = Number.isNaN(parsedDate) + ? undefined + : Math.floor(parsedDate / 1000); + return { statusCode: res.statusCode, wwwAuthenticate, dpopNonce, date }; + }; + + // The server-provided nonce captured from the baseline handshake (RFC 9449 + // §9), if the server requires one. Once set, the local `buildDpopProof` + // wrapper folds it into every subsequent proof — including the negatives — + // so a server that checks the nonce first still evaluates the injected + // defect rather than merely challenging for a nonce. + let heldNonce: string | undefined; + const buildDpopProof = ( + opts: Parameters[0] + ): Promise => + baseBuildDpopProof({ + ...opts, + ...(heldNonce ? { nonce: heldNonce } : {}) + }); + + const validProof = (): Promise => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token + }); + + // Send a valid DPoP-bound request, transparently completing the nonce + // handshake if the server demands one (RFC 9449 §9): a server that requires + // a nonce answers the first (nonce-less) proof with 401 use_dpop_nonce + + // DPoP-Nonce, so we capture the nonce and retry once before judging whether + // it accepts a valid request (and reuse the nonce for the negatives). + const acceptValid = async (): Promise => { + const first = await send(`DPoP ${token}`, await validProof()); + if ( + first.statusCode === 401 && + first.dpopNonce && + first.wwwAuthenticate.includes('use_dpop_nonce') + ) { + heldNonce = first.dpopNonce; + return send(`DPoP ${token}`, await validProof()); + } + return first; + }; + + // ---- Positive: a valid DPoP-bound request is accepted ---- + // Whether this succeeds gates every rejection check below (#248): if the + // server refuses a valid request, a 401 on a malformed one proves nothing. + // Also anchor iat probes to the server's own clock (its `Date` header) so + // the ±5-minute boundary is measured against the clock the server validates + // against, immune to framework↔server skew. + let positiveAccepted = false; + let serverClockOffset = 0; + try { + const res = await acceptValid(); + if (res.date !== undefined) { + serverClockOffset = res.date - Math.floor(Date.now() / 1000); + } + positiveAccepted = isAccepted(res.statusCode); + checks.push( + dpopCheck( + 'sep-1932-server-validate-proof', + 'AcceptsValidProof', + 'Server accepts a valid DPoP-bound access token and proof', + positiveAccepted ? 'SUCCESS' : 'FAILURE', + positiveAccepted ? undefined : `Expected 2xx, got ${res.statusCode}`, + { case: 'valid', statusCode: res.statusCode } + ) + ); + } catch (e) { + checks.push( + dpopCheck( + 'sep-1932-server-validate-proof', + 'AcceptsValidProof', + 'Server accepts a valid DPoP-bound access token and proof', + 'FAILURE', + String(e), + { case: 'valid' } + ) + ); + } + + // ---- Negative §4.3 variants: each malformed proof must be rejected ---- + // `buildDpop` is a thunk so a failure minting one proof isolates to that + // case (caught below) instead of aborting the whole battery. `predicate` + // and `description` override the default (401 + DPoP challenge) where a + // case is judged differently. + const negatives: Array<{ + case: string; + name: string; + authz: string; + buildDpop: () => Promise; + predicate?: (res: Response) => boolean; + description?: string; + }> = [ + { + case: 'tampered-signature', + name: 'RejectsTamperedSignature', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + tamperSignature: true + }) + }, + { + case: 'missing-jti', + name: 'RejectsMissingJti', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + omit: ['jti'] + }) + }, + { + case: 'wrong-typ', + name: 'RejectsWrongTyp', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + typ: 'jwt' + }) + }, + { + case: 'htu-mismatch', + name: 'RejectsHtuMismatch', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: 'https://wrong.example.com/mcp', + accessToken: token + }) + }, + { + case: 'htm-mismatch', + name: 'RejectsHtmMismatch', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'GET', + htu: serverUrl, + accessToken: token + }) + }, + { + case: 'private-key-in-jwk', + name: 'RejectsPrivateKeyInJwk', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + embedPrivateKey: true + }) + }, + { + case: 'wrong-ath', + name: 'RejectsWrongAth', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + athOverride: 'not-the-right-hash' + }) + }, + { + // A DPoP-bound token presented under the Bearer scheme MUST NOT be + // accepted, but the server need not answer with a DPoP challenge (it may + // treat it as a Bearer failure) — so accept any non-2xx as a rejection. + case: 'bearer-scheme', + name: 'RejectsBearerScheme', + authz: `Bearer ${token}`, + buildDpop: () => validProof(), + predicate: (res) => !isAccepted(res.statusCode), + description: + 'Server does not accept a DPoP-bound token presented under the Bearer scheme' + }, + { + case: 'duplicate-dpop-header', + name: 'RejectsDuplicateDpopHeader', + authz: `DPoP ${token}`, + buildDpop: async () => [await validProof(), await validProof()] + }, + { + case: 'missing-htm', + name: 'RejectsMissingHtm', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + omit: ['htm'] + }) + }, + { + case: 'missing-htu', + name: 'RejectsMissingHtu', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + omit: ['htu'] + }) + }, + { + case: 'missing-iat', + name: 'RejectsMissingIat', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + omit: ['iat'] + }) + }, + { + case: 'missing-jwk', + name: 'RejectsMissingJwk', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + omit: ['jwk'] + }) + }, + { + // Token is presented (Authorization: DPoP ...) but the proof carries no + // ath claim — RFC 9449 §4.3 step 12a requires it. + case: 'ath-absent', + name: 'RejectsAthAbsent', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ keyPair: kp, htm: 'POST', htu: serverUrl }) + }, + { + case: 'malformed-not-a-jwt', + name: 'RejectsMalformedProof', + authz: `DPoP ${token}`, + buildDpop: () => Promise.resolve('this-is-not-a-jwt') + } + ]; + + for (const n of negatives) { + const description = + n.description ?? `Server rejects a DPoP request with defect: ${n.case}`; + try { + const dpop = await n.buildDpop(); + const res = await send(n.authz, dpop); + checks.push( + rejectionCheck( + positiveAccepted, + 'sep-1932-server-validate-proof', + n.name, + description, + res, + { case: n.case }, + n.predicate + ) + ); + } catch (e) { + checks.push( + probeErrorCheck( + positiveAccepted, + 'sep-1932-server-validate-proof', + n.name, + description, + n.case, + e + ) + ); + } + } + + // ---- cnf.jkt mismatch (token bound to a foreign key) ---- + try { + const foreign = await generateDpopKeyPair(); + const mismatchToken = await mintDpopBoundToken({ + issuerKey, + issuer, + audience, + jkt: kp.thumbprint, + jktOverride: foreign.thumbprint + }); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: mismatchToken + }); + const res = await send(`DPoP ${mismatchToken}`, proof); + checks.push( + rejectionCheck( + positiveAccepted, + 'sep-1932-server-validate-proof', + 'RejectsCnfJktMismatch', + 'Server rejects a token whose cnf.jkt does not match the proof key', + res, + { case: 'cnf-jkt-mismatch' } + ) + ); + } catch (e) { + checks.push( + probeErrorCheck( + positiveAccepted, + 'sep-1932-server-validate-proof', + 'RejectsCnfJktMismatch', + 'Server rejects a token whose cnf.jkt does not match the proof key', + 'cnf-jkt-mismatch', + e + ) + ); + } + + // ---- iat acceptance window ---- + // Probes sit just outside the ±5-minute window (±300 s). The future probe + // uses +303 s (not +301) because `iat` is whole-seconds and the proof drifts + // ~1 s toward "now" in transit; +301 could read as +300 (in-window) at the + // server and false-accept. The stale side only ages further, so −301 is safe. + for (const { label, name, iatDelta } of [ + { label: 'stale', name: 'RejectsStaleIat', iatDelta: -301 }, + { label: 'future', name: 'RejectsFutureIat', iatDelta: 303 } + ]) { + const description = `Server rejects a proof whose iat is ${label} — just outside the ±5-minute window (RFC 9449 §4.3 / SEP-1932)`; + try { + const iat = + Math.floor(Date.now() / 1000) + serverClockOffset + iatDelta; + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + iat + }); + const res = await send(`DPoP ${token}`, proof); + checks.push( + rejectionCheck( + positiveAccepted, + 'sep-1932-server-iat-window', + name, + description, + res, + { case: `iat-${label}` } + ) + ); + } catch (e) { + checks.push( + probeErrorCheck( + positiveAccepted, + 'sep-1932-server-iat-window', + name, + description, + `iat-${label}`, + e + ) + ); + } + } + + // ---- asymmetric-only algorithm ---- + for (const { label, name, opt } of [ + { + label: 'none', + name: 'RejectsAlgNone', + opt: { unsigned: true } as const + }, + { + label: 'symmetric', + name: 'RejectsAlgSymmetric', + opt: { symmetric: true } as const + } + ]) { + try { + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + ...opt + }); + const res = await send(`DPoP ${token}`, proof); + checks.push( + rejectionCheck( + positiveAccepted, + 'sep-1932-asymmetric-alg-only', + name, + `Server rejects a proof signed with a non-asymmetric algorithm (${label})`, + res, + { case: `alg-${label}` } + ) + ); + } catch (e) { + checks.push( + probeErrorCheck( + positiveAccepted, + 'sep-1932-asymmetric-alg-only', + name, + `Server rejects a proof signed with a non-asymmetric algorithm (${label})`, + `alg-${label}`, + e + ) + ); + } + } + + // ---- token audience validation under DPoP ---- + try { + const wrongAudToken = await mintDpopBoundToken({ + issuerKey, + issuer, + audience: 'https://not-this-server.example.com/mcp', + jkt: kp.thumbprint + }); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: wrongAudToken + }); + const res = await send(`DPoP ${wrongAudToken}`, proof); + checks.push( + rejectionCheck( + positiveAccepted, + 'sep-1932-server-audience-validation', + 'RejectsWrongAudience', + 'Server rejects an access token whose audience is not this server, even with a valid proof', + res, + { case: 'wrong-audience' } + ) + ); + } catch (e) { + checks.push( + probeErrorCheck( + positiveAccepted, + 'sep-1932-server-audience-validation', + 'RejectsWrongAudience', + 'Server rejects an access token whose audience is not this server, even with a valid proof', + 'wrong-audience', + e + ) + ); + } + + // ---- 401 + WWW-Authenticate challenge format (on a known-bad request) ---- + const challengeDesc = + 'On validation failure the server responds 401 with a WWW-Authenticate: DPoP challenge'; + if (!positiveAccepted) { + // A server that 401s everything trivially "passes" this — can't attribute + // the challenge to a validation failure, so report it notTestable (#248). + checks.push( + untestableCheck( + 'sep-1932-server-reject-401', + 'RejectsWith401Challenge', + challengeDesc, + gateReason('challenge-format'), + SPEC_REFERENCES + ) + ); + } else { + try { + const tampered = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + tamperSignature: true + }); + const res = await send(`DPoP ${token}`, tampered); + if (isNonceChallenge(res)) { + checks.push( + untestableCheck( + 'sep-1932-server-reject-401', + 'RejectsWith401Challenge', + challengeDesc, + 'server answered with a DPoP nonce challenge (use_dpop_nonce), so this 401 cannot be attributed to the validation failure', + SPEC_REFERENCES + ) + ); + } else { + const ok = properlyRejected(res); + checks.push( + dpopCheck( + 'sep-1932-server-reject-401', + 'RejectsWith401Challenge', + challengeDesc, + ok ? 'SUCCESS' : 'FAILURE', + ok + ? undefined + : `Expected 401 + WWW-Authenticate: DPoP, got ${res.statusCode} / "${res.wwwAuthenticate}"`, + { + statusCode: res.statusCode, + wwwAuthenticate: res.wwwAuthenticate + } + ) + ); + } + } catch (e) { + checks.push( + dpopCheck( + 'sep-1932-server-reject-401', + 'RejectsWith401Challenge', + challengeDesc, + 'FAILURE', + String(e) + ) + ); + } + } + + // ---- server-provided nonce (SHOULD / WARNING) — only if the server uses it ---- + // This section manages the nonce explicitly, so it builds proofs with the + // raw `baseBuildDpopProof` (not the held-nonce-injecting wrapper): the + // detection probe MUST be nonce-less to observe whether the server challenges. + try { + const first = await send( + `DPoP ${token}`, + await baseBuildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token + }) + ); + const requiresNonce = + first.statusCode === 401 && + first.wwwAuthenticate.includes('use_dpop_nonce'); + if (!requiresNonce) { + checks.push( + dpopCheck( + 'sep-1932-server-nonce', + 'NonceFlow', + 'Server-provided nonce flow (optional; server did not request a nonce)', + 'SKIPPED', + undefined, + { reason: 'server does not require a DPoP nonce' } + ) + ); + } else if (!first.dpopNonce) { + checks.push( + dpopCheck( + 'sep-1932-server-nonce', + 'NonceFlow', + 'Server issues use_dpop_nonce + DPoP-Nonce, accepts the matching-nonce retry, and rejects a wrong nonce', + 'WARNING', + 'use_dpop_nonce returned without a DPoP-Nonce header' + ) + ); + } else { + const retry = await send( + `DPoP ${token}`, + await baseBuildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + nonce: first.dpopNonce + }) + ); + // A conformant nonce server MUST also reject a WRONG nonce + // (RFC 9449 §4.3 step 10), else the nonce adds no replay protection. + const wrong = await send( + `DPoP ${token}`, + await baseBuildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + nonce: 'definitely-not-the-server-nonce' + }) + ); + const ok = isAccepted(retry.statusCode) && properlyRejected(wrong); + // SHOULD-level: satisfied → SUCCESS; partial/buggy nonce impl → WARNING. + checks.push( + dpopCheck( + 'sep-1932-server-nonce', + 'NonceFlow', + 'Server issues use_dpop_nonce + DPoP-Nonce, accepts the matching-nonce retry, and rejects a wrong nonce', + ok ? 'SUCCESS' : 'WARNING', + ok + ? undefined + : `Nonce flow incomplete: matching-retry=${retry.statusCode}, wrong-nonce=${wrong.statusCode}`, + { + nonce: first.dpopNonce, + retryStatus: retry.statusCode, + wrongNonceStatus: wrong.statusCode + } + ) + ); + } + } catch (e) { + checks.push( + dpopCheck( + 'sep-1932-server-nonce', + 'NonceFlow', + 'Server issues use_dpop_nonce + DPoP-Nonce, accepts the matching-nonce retry, and rejects a wrong nonce', + 'WARNING', + String(e) + ) + ); + } + + return checks; + } +} diff --git a/src/scenarios/server/auth/spec-references.ts b/src/scenarios/server/auth/spec-references.ts new file mode 100644 index 00000000..4b940894 --- /dev/null +++ b/src/scenarios/server/auth/spec-references.ts @@ -0,0 +1,28 @@ +import { SpecReference } from '../../../types'; + +export const SpecReferences: { [key: string]: SpecReference } = { + SEP_1932_DPOP: { + id: 'SEP-1932-DPoP', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932' + }, + DPOP_EXTENSION: { + id: 'MCP-DPoP-Extension', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx' + }, + RFC_9449_CHECKING_PROOFS: { + id: 'RFC-9449-checking-dpop-proofs', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-4.3' + }, + RFC_9449_AUTH_SCHEME: { + id: 'RFC-9449-dpop-authentication-scheme', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-7.1' + }, + RFC_9449_NONCE: { + id: 'RFC-9449-resource-server-provided-nonce', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-9' + }, + RFC_9449_ALGORITHMS: { + id: 'RFC-9449-dpop-proof-jwt-syntax', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-11.6' + } +}; From 7459a389fa6406d60e542d73608e4e06df7d5114 Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:51:15 +0100 Subject: [PATCH 7/8] server/dpop: round-4 nonce/clock hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refresh the held DPoP nonce from every response's DPoP-Nonce header (RFC 9449 §8.2 newest-wins) instead of capturing it once, so a server that rotates or single-uses its nonce no longer turns the negative probes into false "not testable" failures (or vacuous passes). (#1) - Widen the stale iat probe to -303 s (matching the future side) so the ±1 s quantization of the whole-second Date header can't pull it onto the ±300 s boundary and be false-accepted. (#2) - Update the isNonceChallenge comment and the untestable message: probes now carry the held nonce, so a use_dpop_nonce challenge is about the nonce lifetime (rotated/stale/single-use), not a missing nonce. (#3) - Fixture: parse integer env vars NaN-safely (intEnv helper) so a malformed DPOP_CLOCK_OFFSET_SECONDS / DPOP_IAT_SKEW_SECONDS falls back to its default instead of silently disabling the iat window. (#4) Co-Authored-By: Claude Opus 4.8 --- .../typescript/sep-1932-compliant-server.ts | 11 ++++- src/scenarios/server/auth/dpop.ts | 45 ++++++++++++------- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/examples/servers/typescript/sep-1932-compliant-server.ts b/examples/servers/typescript/sep-1932-compliant-server.ts index 8cece692..8cfc5950 100644 --- a/examples/servers/typescript/sep-1932-compliant-server.ts +++ b/examples/servers/typescript/sep-1932-compliant-server.ts @@ -27,7 +27,14 @@ import { createHash } from 'node:crypto'; const ISSUER_JWK = JSON.parse(process.env.DPOP_ISSUER_JWK || '{}'); const ISSUER = process.env.DPOP_ISSUER || 'https://auth.example.com'; const AUDIENCE = process.env.DPOP_AUDIENCE || ''; -const IAT_SKEW = parseInt(process.env.DPOP_IAT_SKEW_SECONDS || '300', 10); +// Parse an integer env var, falling back to the default on absent OR malformed +// input — a bare parseInt would yield NaN, which silently disables the iat +// window (`Math.abs(...) > NaN` is false) or the clock skew (NaN is falsy). +const intEnv = (name: string, def: number): number => { + const n = parseInt(process.env[name] ?? '', 10); + return Number.isNaN(n) ? def : n; +}; +const IAT_SKEW = intEnv('DPOP_IAT_SKEW_SECONDS', 300); const REQUIRE_NONCE = process.env.DPOP_REQUIRE_NONCE === '1'; const NONCE = process.env.DPOP_NONCE || 'conformance-test-nonce'; // Buggy-nonce mode (negative-test fixture only): challenge when no nonce is @@ -43,7 +50,7 @@ const NONCE_FIRST = process.env.DPOP_NONCE_FIRST === '1'; // Clock-offset mode (negative-test fixture only): shift the server's notion of // "now" — and its `Date` header — by N seconds to simulate a skewed server // clock. Used to prove the scenario anchors its iat probes to the server's Date. -const CLOCK_OFFSET = parseInt(process.env.DPOP_CLOCK_OFFSET_SECONDS || '0', 10); +const CLOCK_OFFSET = intEnv('DPOP_CLOCK_OFFSET_SECONDS', 0); // Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 step 5). const ASYMMETRIC_ALGS = [ diff --git a/src/scenarios/server/auth/dpop.ts b/src/scenarios/server/auth/dpop.ts index 9cd31fb0..6268e778 100644 --- a/src/scenarios/server/auth/dpop.ts +++ b/src/scenarios/server/auth/dpop.ts @@ -100,10 +100,12 @@ function properlyRejected(res: Response): boolean { return res.statusCode === 401 && hasDpopChallenge(res.wwwAuthenticate); } -// A `use_dpop_nonce` challenge means the server is demanding a nonce before it -// will look at the proof. The negative probes are sent without a nonce, so such -// a 401 is a rejection for the MISSING NONCE, not for the injected defect — it -// cannot be attributed to the defect and must not count as a proper rejection. +// A `use_dpop_nonce` challenge means the server is demanding a (different) nonce +// before it will look at the proof. Negative probes carry the held nonce, so +// such a 401 means the server did not accept that nonce (it rotated or +// single-uses it, or wants a fresh one) — the rejection is about the nonce +// lifetime, NOT the injected defect, so it cannot be attributed to the defect +// and must not count as a proper rejection. function isNonceChallenge(res: Response): boolean { return ( res.statusCode === 401 && @@ -192,7 +194,7 @@ function rejectionCheck( id, name, description, - `server answered with a DPoP nonce challenge (use_dpop_nonce), so this rejection cannot be attributed to the ${String(details.case ?? 'injected')} defect rather than the missing nonce`, + `server answered with a DPoP nonce challenge (use_dpop_nonce) despite the probe carrying the held nonce, so this rejection is about the nonce (rotated/stale/single-use) and cannot be attributed to the ${String(details.case ?? 'injected')} defect`, SPEC_REFERENCES ); } @@ -258,6 +260,15 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and jkt: kp.thumbprint }); + // The server-provided nonce (RFC 9449 §8/§9), if the server requires one. + // `send` refreshes it from every response's DPoP-Nonce header (newest wins, + // RFC 9449 §8.2), and the local `buildDpopProof` wrapper folds the current + // value into every subsequent proof — including the negatives — so a server + // that checks the nonce first still evaluates the injected defect rather + // than merely re-challenging. Refreshing (vs capturing once) keeps this + // correct against servers that rotate or single-use their nonces. + let heldNonce: string | undefined; + const send = async ( authz: string, dpop: string | string[] | undefined @@ -296,15 +307,11 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and const date = Number.isNaN(parsedDate) ? undefined : Math.floor(parsedDate / 1000); + // Newest-wins (RFC 9449 §8.2): carry the latest nonce into the next probe. + if (dpopNonce) heldNonce = dpopNonce; return { statusCode: res.statusCode, wwwAuthenticate, dpopNonce, date }; }; - // The server-provided nonce captured from the baseline handshake (RFC 9449 - // §9), if the server requires one. Once set, the local `buildDpopProof` - // wrapper folds it into every subsequent proof — including the negatives — - // so a server that checks the nonce first still evaluates the injected - // defect rather than merely challenging for a nonce. - let heldNonce: string | undefined; const buildDpopProof = ( opts: Parameters[0] ): Promise => @@ -333,7 +340,8 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and first.dpopNonce && first.wwwAuthenticate.includes('use_dpop_nonce') ) { - heldNonce = first.dpopNonce; + // `send` has already refreshed heldNonce from the challenge response, + // so the retry's proof carries it. return send(`DPoP ${token}`, await validProof()); } return first; @@ -637,12 +645,15 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and } // ---- iat acceptance window ---- - // Probes sit just outside the ±5-minute window (±300 s). The future probe - // uses +303 s (not +301) because `iat` is whole-seconds and the proof drifts - // ~1 s toward "now" in transit; +301 could read as +300 (in-window) at the - // server and false-accept. The stale side only ages further, so −301 is safe. + // Probes sit just outside the ±5-minute window (±300 s), ±303 s on both + // sides. The 3 s margin absorbs two sources of ±1 s error that a bare ±301 + // would not: the whole-second `Date` header makes `serverClockOffset` + // quantized to ±1 s, and `iat` is whole-seconds and drifts ~1 s toward "now" + // in transit. At ±301 either could pull the probe onto the ±300 boundary and + // be false-accepted; ±303 stays safely outside for a conformant server while + // still being well inside a rejection for any sane implementation. for (const { label, name, iatDelta } of [ - { label: 'stale', name: 'RejectsStaleIat', iatDelta: -301 }, + { label: 'stale', name: 'RejectsStaleIat', iatDelta: -303 }, { label: 'future', name: 'RejectsFutureIat', iatDelta: 303 } ]) { const description = `Server rejects a proof whose iat is ${label} — just outside the ±5-minute window (RFC 9449 §4.3 / SEP-1932)`; From c51d0606d3da3d6a85b48aae1a26e9509204e6e9 Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:59:14 +0100 Subject: [PATCH 8/8] server/dpop: round-5 comment fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Soften the heldNonce comment: newest-wins refresh is correct for ROTATING servers; a strict single-use server that re-arms only via challenges can still push alternate negatives to untestable (correctly reported, not mis-scored) — the previous "rotate or single-use" over-claimed. (R5) - Fix the clock-skew test comment: the stale probe is now -303s (~273s old), not -301s/~271s. (R5) Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 --- src/scenarios/server/auth/dpop.test.ts | 2 +- src/scenarios/server/auth/dpop.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/scenarios/server/auth/dpop.test.ts b/src/scenarios/server/auth/dpop.test.ts index 21f80a9e..54dca1da 100644 --- a/src/scenarios/server/auth/dpop.test.ts +++ b/src/scenarios/server/auth/dpop.test.ts @@ -378,7 +378,7 @@ describe('DPoP server validation scenario', () => { it('anchors iat probes to the server clock (no false failure under skew)', async () => { // The server's clock is 30s behind the framework. Without anchoring, the - // stale probe (now−301s by the framework clock) looks only ~271s old to the + // stale probe (now−303s by the framework clock) looks only ~273s old to the // server → inside ±5 min → wrongly accepted → the iat check would FAIL a // conformant server. Anchoring to the server's Date header keeps it rejected. const checks = await new DPoPServerValidationScenario().run( diff --git a/src/scenarios/server/auth/dpop.ts b/src/scenarios/server/auth/dpop.ts index 6268e778..1cfe840d 100644 --- a/src/scenarios/server/auth/dpop.ts +++ b/src/scenarios/server/auth/dpop.ts @@ -266,7 +266,11 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and // value into every subsequent proof — including the negatives — so a server // that checks the nonce first still evaluates the injected defect rather // than merely re-challenging. Refreshing (vs capturing once) keeps this - // correct against servers that rotate or single-use their nonces. + // correct against servers that ROTATE their nonce (each response carries the + // next one). A strict single-use server that re-arms the nonce only via a + // use_dpop_nonce challenge (not on ordinary rejections, which §8.2 doesn't + // require) can still push alternate negatives to untestable — acceptable, as + // those are correctly reported not-testable rather than mis-scored. let heldNonce: string | undefined; const send = async (