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/9] 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/9] 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/9] 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/9] =?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/9] =?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 d245132b5dedf2d0060ddaacae306479996113bd Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:07:12 +0100 Subject: [PATCH 6/9] Add DPoP authorization-server scenario (SEP-1932, #370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the DPoP client PR (shared foundation: createAuthServer DPoP core + dpopProof/dpopToken helpers). Adds an authorization-server scenario testing DPoP (SEP-1932 / RFC 9449): metadata (dpop_signing_alg_values_supported present, asymmetric-only), token binding (cnf.jkt + token_type=DPoP), and no-proof enforcement when dpop_bound_access_tokens is advertised. Probes a live AS via authorization_code + PKCE (auto-follows a direct redirect, falls back to an interactive callback) and returns four sep-1932-as-* checks (compliant run + four one-defect-isolation misbehaving configs). - authorization-server/dpop.ts (+ acceptance test, spec-references). - dpopToken: adds readTokenBinding() (reads token_type + cnf.jkt back out of a token response) — introduced here because this scenario is its only consumer. Depends only on the shared DPoP foundation; independent of the server PR. Co-Authored-By: Claude Opus 4.8 --- .../auth/spec-references.ts | 21 + .../authorization-server/dpop.test.ts | 144 ++++ src/scenarios/authorization-server/dpop.ts | 712 ++++++++++++++++++ .../client/auth/helpers/dpopToken.test.ts | 86 ++- .../client/auth/helpers/dpopToken.ts | 51 ++ src/scenarios/index.ts | 4 +- src/seps/sep-1932.yaml | 4 +- 7 files changed, 1018 insertions(+), 4 deletions(-) create mode 100644 src/scenarios/authorization-server/dpop.test.ts create mode 100644 src/scenarios/authorization-server/dpop.ts diff --git a/src/scenarios/authorization-server/auth/spec-references.ts b/src/scenarios/authorization-server/auth/spec-references.ts index e89ec868..38a1beec 100644 --- a/src/scenarios/authorization-server/auth/spec-references.ts +++ b/src/scenarios/authorization-server/auth/spec-references.ts @@ -8,5 +8,26 @@ export const SpecReferences: { [key: string]: SpecReference } = { OAUTH_2_1_AUTHORIZATION_CODE_GRANT: { id: 'OAUTH-2.1-authorization-code-grant', url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.1' + }, + // DPoP (SEP-1932 / RFC 9449) — authorization-server 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_AS_METADATA: { + id: 'RFC-9449-authorization-server-metadata', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-5.1' + }, + RFC_9449_PUBLIC_KEY_CONFIRMATION: { + id: 'RFC-9449-public-key-confirmation', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-6' + }, + RFC_9449_ALGORITHMS: { + id: 'RFC-9449-dpop-proof-jwt-syntax', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-11.6' } }; diff --git a/src/scenarios/authorization-server/dpop.test.ts b/src/scenarios/authorization-server/dpop.test.ts new file mode 100644 index 00000000..10706b2d --- /dev/null +++ b/src/scenarios/authorization-server/dpop.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; +import { + createAuthServer, + type AuthServerOptions +} from '../client/auth/helpers/createAuthServer'; +import { ServerLifecycle } from '../client/auth/helpers/serverLifecycle'; +import { testScenarioContext } from '../../mock-server/testing'; +import type { CheckStatus, ConformanceCheck } from '../../types'; +import { DPoPAuthorizationServerScenario } from './dpop'; + +const ALL_IDS = [ + 'sep-1932-as-metadata-alg-values', + 'sep-1932-as-no-none-alg', + 'sep-1932-as-token-binding' +] as const; + +const statusOf = ( + checks: ConformanceCheck[], + id: string +): CheckStatus | undefined => checks.find((c) => c.id === id)?.status; + +/** + * Start an in-process test AS (real Express app, no mocks) with the given DPoP + * options, run the scenario against its live URL, and return the emitted checks. + * The AS 302s straight to the redirect_uri, so the scenario auto-follows headless. + */ +async function runAgainst( + dpopOptions: Partial, + // `false` means "send no client_id" — a plain `undefined` would re-trigger the + // default via JS default-parameter semantics. + clientId: string | false = 'test-client-id' +): Promise { + const lifecycle = new ServerLifecycle(); + const app = createAuthServer(testScenarioContext(), [], lifecycle.getUrl, { + loggingEnabled: false, + grantTypesSupported: ['authorization_code', 'refresh_token'], + ...dpopOptions + }); + await lifecycle.start(app); + try { + return await new DPoPAuthorizationServerScenario().run( + { url: lifecycle.getUrl(), port: 45678, clientId: clientId || undefined }, + {} + ); + } finally { + await lifecycle.stop(); + } +} + +// A DPoP-capable AS: advertises an asymmetric alg and issues bound tokens. +// (`dpop_bound_access_tokens` is per-client registration metadata, RFC 9449 +// §5.2 — not an AS option — so it is deliberately not set here.) +const COMPLIANT: Partial = { + dpopSigningAlgValuesSupported: ['ES256'] +}; + +describe('DPoPAuthorizationServerScenario — compliant AS', () => { + it('emits all three sep-1932-as-* checks as SUCCESS', async () => { + const checks = await runAgainst(COMPLIANT); + for (const id of ALL_IDS) { + expect(statusOf(checks, id)).toBe('SUCCESS'); + } + expect(checks.filter((c) => c.status === 'FAILURE')).toHaveLength(0); + }); + + it('binds the issued token to the presented proof key (cnf.jkt matches)', async () => { + const checks = await runAgainst(COMPLIANT); + const binding = checks.find((c) => c.id === 'sep-1932-as-token-binding'); + expect(binding?.status).toBe('SUCCESS'); + const details = binding?.details as { + tokenType: string; + cnfJkt: string; + expectedJkt: string; + }; + expect(details.tokenType).toBe('DPoP'); + expect(details.cnfJkt).toBe(details.expectedJkt); + }); +}); + +// Isolation matrix: each defect fails EXACTLY its target check, the rest stay +// SUCCESS. (`omit-alg-values` is not here — dropping the field means "not a DPoP +// AS", which SKIPs the whole scenario; see the support-gate tests below.) +describe('DPoPAuthorizationServerScenario — one-defect isolation', () => { + const CASES = [ + { + misbehavior: 'empty-alg-values', + target: 'sep-1932-as-metadata-alg-values' + }, + { misbehavior: 'include-none', target: 'sep-1932-as-no-none-alg' }, + { misbehavior: 'unbound-token', target: 'sep-1932-as-token-binding' } + ] as const; + + for (const { misbehavior, target } of CASES) { + it(`misbehaving AS (${misbehavior}) fails only ${target}`, async () => { + const checks = await runAgainst({ + ...COMPLIANT, + dpopMisbehavior: misbehavior + }); + expect(statusOf(checks, target)).toBe('FAILURE'); + for (const id of ALL_IDS.filter((c) => c !== target)) { + expect(statusOf(checks, id)).toBe('SUCCESS'); + } + }); + } + + it('fails the no-none-alg check when a symmetric algorithm is advertised', async () => { + const checks = await runAgainst({ + dpopSigningAlgValuesSupported: ['ES256', 'HS256'] + }); + expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-1932-as-no-none-alg')).toBe('FAILURE'); + }); +}); + +describe('DPoPAuthorizationServerScenario — skip conditions', () => { + it('skips the token-binding check when no client_id is supplied', async () => { + const checks = await runAgainst(COMPLIANT, false); + expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-1932-as-no-none-alg')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('SKIPPED'); + }); + + it('skips token binding when no advertised proof alg is supported (no ES256 fallback)', async () => { + // ES256K is asymmetric (passes no-none-alg) but not one the harness can + // produce; the scenario must SKIP rather than send an unadvertised ES256 + // proof the AS would reject and mis-score as a binding failure. + const checks = await runAgainst({ + dpopSigningAlgValuesSupported: ['ES256K'] + }); + expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-1932-as-no-none-alg')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('SKIPPED'); + }); + + it('skips the whole scenario when the AS does not advertise DPoP support', async () => { + // No dpop_signing_alg_values_supported → not a DPoP AS (RFC 9449 §5.1), so + // the DPoP requirements do not apply: every check SKIPs rather than fails. + const checks = await runAgainst({ dpopMisbehavior: 'omit-alg-values' }); + for (const id of ALL_IDS) { + expect(statusOf(checks, id)).toBe('SKIPPED'); + } + expect(checks.filter((c) => c.status === 'FAILURE')).toHaveLength(0); + }); +}); diff --git a/src/scenarios/authorization-server/dpop.ts b/src/scenarios/authorization-server/dpop.ts new file mode 100644 index 00000000..171a3c92 --- /dev/null +++ b/src/scenarios/authorization-server/dpop.ts @@ -0,0 +1,712 @@ +/** + * DPoP authorization-server scenario (SEP-1932 / RFC 9449). + * + * The framework acts as a DPoP-capable OAuth client against the authorization + * server under test at `options.url`. It probes: + * + * - metadata: `dpop_signing_alg_values_supported` is advertised (RFC 9449 §5.1) + * and does not include the `none` or symmetric algorithms; + * - token binding: a code exchanged WITH a DPoP proof yields a token bound to + * the proof key (`cnf.jkt`) with `token_type: DPoP` (RFC 9449 §5–§6). + * + * An AS that does not advertise `dpop_signing_alg_values_supported` is not a + * DPoP authorization server (RFC 9449 §5.1 is how support is signalled), so the + * whole scenario SKIPs rather than failing it — the DPoP checks only apply once + * the AS opts in. (`dpop_bound_access_tokens` is per-client registration + * metadata, RFC 9449 §5.2, not an AS capability, so no enforcement check is + * made here.) + * + * Tokens are obtained via the authorization_code + PKCE grant (the MCP grant). + * The authorization step auto-follows a direct redirect to the registered + * redirect_uri (the headless path used by auto-approving/test ASs) and falls + * back to an interactive browser + callback-server wait for login-gated ASs. + * + * Emits the sep-1932-as-* check IDs declared in src/seps/sep-1932.yaml. + */ + +import { + CheckStatus, + ClientScenarioForAuthorizationServer, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION, + SpecReference +} from '../../types'; +import { AuthorizationServerOptions } from '../../schemas'; +import { request } from 'undici'; +import { createHash, randomBytes } from 'crypto'; +import { startCallbackServer } from './auth/helpers/createCallbackServer'; +import { + generateDpopKeyPair, + buildDpopProof +} from '../client/auth/helpers/dpopProof'; +import { readTokenBinding } from '../client/auth/helpers/dpopToken'; +import { SpecReferences } from './auth/spec-references'; + +const REDIRECT_URI_ORIGIN = 'http://127.0.0.1'; +const REDIRECT_URI_PATH = '/callback'; + +/** Static id → (name, description, spec references) for each emitted check. */ +const CHECK_DEFS: Record< + string, + { name: string; description: string; specReferences: SpecReference[] } +> = { + 'sep-1932-as-metadata-alg-values': { + name: 'DpopMetadataAlgValues', + description: + 'Authorization server metadata advertises dpop_signing_alg_values_supported', + specReferences: [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_AS_METADATA + ] + }, + 'sep-1932-as-no-none-alg': { + name: 'DpopNoNoneAlg', + description: + 'dpop_signing_alg_values_supported lists only asymmetric algorithms (no none or symmetric algorithms)', + specReferences: [ + SpecReferences.RFC_9449_AS_METADATA, + SpecReferences.RFC_9449_ALGORITHMS + ] + }, + 'sep-1932-as-token-binding': { + name: 'DpopTokenBinding', + description: + 'Issued access token is bound to the DPoP key (cnf.jkt) with token_type DPoP', + specReferences: [ + SpecReferences.RFC_9449_PUBLIC_KEY_CONFIRMATION, + SpecReferences.DPOP_EXTENSION + ] + } +}; + +/** Proof-JWS algorithms the harness can generate a key + proof for. */ +const SUPPORTED_PROOF_ALGS = [ + 'ES256', + 'ES384', + 'ES512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA' +]; + +/** Strip query + fragment from a URL for use as an `htu` (RFC 9449 §4.2). */ +function stripUrlQuery(url: string): string { + try { + const u = new URL(url); + return `${u.origin}${u.pathname}`; + } catch { + return url; + } +} + +interface CodeResult { + code: string; + codeVerifier: string; +} + +interface TokenExchangeResult { + statusCode: number; + body: Record | undefined; + dpopNonce?: string; +} + +export class DPoPAuthorizationServerScenario implements ClientScenarioForAuthorizationServer { + name = 'dpop'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Test DPoP support in the authorization server (SEP-1932 / RFC 9449). + +**Authorization Server Implementation Requirements:** + +**Endpoints**: \`authorization server metadata\`, \`authorization endpoint\`, \`token endpoint\` + +**Requirements** (checked only when the AS advertises DPoP support): +- Metadata MUST advertise \`dpop_signing_alg_values_supported\` (RFC 9449 §5.1) +- \`dpop_signing_alg_values_supported\` MUST list only asymmetric algorithms (no \`none\` or symmetric algorithms) +- A token issued for a request carrying a DPoP proof MUST be bound to the proof key: \`cnf.jkt\` equals the JWK thumbprint and \`token_type\` is \`DPoP\` (RFC 9449 §5–§6) + +An AS that does not advertise \`dpop_signing_alg_values_supported\` is treated as +not supporting DPoP and the scenario SKIPs. Tokens are obtained via the +authorization_code + PKCE grant. The authorization step auto-follows a direct +redirect to the registered redirect_uri, or falls back to an interactive +browser login + callback for login-gated servers.`; + + async run( + options: AuthorizationServerOptions, + _details: Record + ): Promise { + const checks: ConformanceCheck[] = []; + + let metadata: Record; + try { + metadata = await this.fetchMetadata(options.url); + } catch (error) { + checks.push( + this.check('sep-1932-as-metadata-alg-values', 'FAILURE', { + errorMessage: `Could not fetch authorization server metadata: ${this.message(error)}` + }) + ); + for (const id of [ + 'sep-1932-as-no-none-alg', + 'sep-1932-as-token-binding' + ]) { + checks.push( + this.check(id, 'SKIPPED', { + errorMessage: 'Authorization server metadata unavailable' + }) + ); + } + return checks; + } + + // Support gate (RFC 9449 §5.1): an AS signals DPoP support by advertising + // `dpop_signing_alg_values_supported`. If the field is absent the AS is not + // a DPoP server and the DPoP requirements do not apply, so the scenario + // SKIPs rather than failing. (An empty/invalid value IS a claim of support, + // so it falls through and fails the metadata check below.) + if (metadata.dpop_signing_alg_values_supported === undefined) { + const reason = + 'Authorization server does not advertise dpop_signing_alg_values_supported (not a DPoP authorization server)'; + for (const id of [ + 'sep-1932-as-metadata-alg-values', + 'sep-1932-as-no-none-alg', + 'sep-1932-as-token-binding' + ]) { + checks.push(this.check(id, 'SKIPPED', { errorMessage: reason })); + } + return checks; + } + + this.checkMetadataAlgValues(metadata, checks); + await this.checkTokenEndpointBehaviour(metadata, options, checks); + + return checks; + } + + // ----- metadata checks ----- + + private checkMetadataAlgValues( + metadata: Record, + checks: ConformanceCheck[] + ): void { + const algValues = metadata.dpop_signing_alg_values_supported; + const isNonEmptyArray = Array.isArray(algValues) && algValues.length > 0; + + checks.push( + this.check( + 'sep-1932-as-metadata-alg-values', + isNonEmptyArray ? 'SUCCESS' : 'FAILURE', + { + errorMessage: isNonEmptyArray + ? undefined + : 'Metadata is missing a non-empty dpop_signing_alg_values_supported array', + details: { dpop_signing_alg_values_supported: algValues ?? null } + } + ) + ); + + // RFC 9449 §11.6 / the extension: only asymmetric algorithms are + // permitted — the `none` algorithm and symmetric (HMAC, `HS*`) algorithms + // MUST NOT appear in the advertised list. + const list: unknown[] = Array.isArray(algValues) ? algValues : []; + const forbidden = list.filter( + (a) => + typeof a === 'string' && + (a.toLowerCase() === 'none' || a.toUpperCase().startsWith('HS')) + ); + checks.push( + this.check( + 'sep-1932-as-no-none-alg', + forbidden.length > 0 ? 'FAILURE' : 'SUCCESS', + { + errorMessage: + forbidden.length > 0 + ? `dpop_signing_alg_values_supported MUST list only asymmetric algorithms; found non-asymmetric: ${forbidden.join(', ')}` + : undefined, + details: { + dpop_signing_alg_values_supported: algValues ?? null, + ...(forbidden.length > 0 ? { forbidden } : {}) + } + } + ) + ); + } + + // ----- token-endpoint check (DPoP token binding) ----- + + private async checkTokenEndpointBehaviour( + metadata: Record, + options: AuthorizationServerOptions, + checks: ConformanceCheck[] + ): Promise { + if (!options.clientId) { + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: 'Requires a client_id (pass --client-id)' + }) + ); + return; + } + if ( + typeof metadata.authorization_endpoint !== 'string' || + typeof metadata.token_endpoint !== 'string' + ) { + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: + 'Metadata is missing authorization_endpoint or token_endpoint' + }) + ); + return; + } + + // Negotiate the proof algorithm BEFORE the (possibly interactive) authorize + // step: if we can't produce one the AS advertises, SKIP now rather than + // forcing a pointless interactive login only to fail afterwards. + const alg = this.negotiateProofAlg(metadata); + if (alg === null) { + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: + 'Authorization server advertises no DPoP proof algorithm the harness can produce, so token binding cannot be exercised', + details: { + dpop_signing_alg_values_supported: + metadata.dpop_signing_alg_values_supported ?? null + } + }) + ); + return; + } + + // Acquire the authorization code first, in its OWN try/catch, so a failure + // here is reported as "could not obtain a code" and never conflated with a + // token-exchange or binding problem below (which is what a single wrapping + // catch used to do). + let code: string; + let codeVerifier: string; + try { + ({ code, codeVerifier } = await this.obtainAuthorizationCode( + metadata, + options + )); + } catch (error) { + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: `Could not obtain an authorization code: ${this.message(error)}` + }) + ); + return; + } + + // Exchange the code WITH a DPoP proof and inspect the binding. + try { + const keyPair = await generateDpopKeyPair(alg); + const result = await this.exchangeWithProof( + metadata, + options, + code, + codeVerifier, + keyPair, + alg + ); + + if (result.statusCode !== 200) { + // Only a DPoP-specific rejection is a binding failure. Any other token + // error (e.g. the AS wanted client auth we didn't send) is inconclusive + // for the binding requirement, so skip rather than mis-attribute a + // FAILURE against a real third-party AS. + const dpopRejection = result.body?.error === 'invalid_dpop_proof'; + checks.push( + this.check( + 'sep-1932-as-token-binding', + dpopRejection ? 'FAILURE' : 'SKIPPED', + { + errorMessage: dpopRejection + ? `Authorization server rejected a valid DPoP proof (HTTP ${result.statusCode}, error=invalid_dpop_proof)` + : `Could not complete the token exchange for a non-DPoP reason (HTTP ${result.statusCode}, error=${result.body?.error ?? 'none'}); binding is inconclusive`, + details: { + statusCode: result.statusCode, + error: result.body?.error ?? null, + alg + } + } + ) + ); + return; + } + + const binding = readTokenBinding(result.body ?? {}); + // A 200 response with no access_token at all is a plainly broken AS, not + // an "inconclusive/opaque" case — fail it rather than fall into the SKIP + // branch below. + const hasAccessToken = + typeof result.body?.access_token === 'string' && + result.body.access_token.length > 0; + if (!hasAccessToken) { + checks.push( + this.check('sep-1932-as-token-binding', 'FAILURE', { + errorMessage: 'Token response was 200 but carried no access_token', + details: { tokenType: binding.tokenType ?? null } + }) + ); + return; + } + // Only inconclusive when the AS CLAIMS a DPoP binding (token_type=DPoP) + // but the token is opaque: cnf.jkt can't be read off the wire (it may + // still hold, verifiable only via introspection) → documented harness gap + // → SKIP. A non-DPoP token_type is a plain binding failure below, opaque + // or not, so it does not reach here. + if (binding.isDpopTokenType && !binding.accessTokenIsJwt) { + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: + 'Issued access token is opaque (not a JWT); its cnf.jkt binding cannot be verified off the wire', + details: { tokenType: binding.tokenType ?? null } + }) + ); + return; + } + const bound = + binding.isDpopTokenType && binding.jkt === keyPair.thumbprint; + checks.push( + this.check('sep-1932-as-token-binding', bound ? 'SUCCESS' : 'FAILURE', { + errorMessage: bound + ? undefined + : 'Issued token is not bound to the DPoP key (expected token_type=DPoP and cnf.jkt to match the proof key)', + details: { + tokenType: binding.tokenType ?? null, + cnfJkt: binding.jkt ?? null, + expectedJkt: keyPair.thumbprint + } + }) + ); + } catch (error) { + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: `Could not complete the DPoP token exchange: ${this.message(error)}` + }) + ); + } + } + + /** + * Pick a proof-signing algorithm the harness can produce that the AS also + * advertises (RFC 9449 §5.1). Returns null when the AS advertises a non-empty + * list with no algorithm we support (including a non-empty but malformed list) + * — the caller then SKIPs rather than sending an unadvertised alg (e.g. + * defaulting to ES256) that the AS would legitimately reject and we'd mis-score + * as a binding failure. Only an absent or empty list (itself flagged by the + * metadata check) falls back to ES256 as a best effort to still exercise the + * binding. + */ + private negotiateProofAlg(metadata: Record): string | null { + const advertised = metadata.dpop_signing_alg_values_supported; + if (Array.isArray(advertised) && advertised.length > 0) { + const match = advertised.find( + (a) => typeof a === 'string' && SUPPORTED_PROOF_ALGS.includes(a) + ); + return typeof match === 'string' ? match : null; + } + return 'ES256'; + } + + /** + * Choose a token-endpoint client-authentication method from the AS's + * advertised methods (RFC 8414 §2: an omitted list defaults to + * client_secret_basic). Mirrors the authorization-code-grant scenario's + * selection; unsupported methods (…_jwt / tls_client_auth) yield null. + */ + private selectTokenAuthMethod( + metadata: Record, + options: AuthorizationServerOptions + ): 'none' | 'client_secret_post' | 'client_secret_basic' | null { + const authMethods: string[] = + metadata.token_endpoint_auth_methods_supported ?? ['client_secret_basic']; + if (!options.clientSecret || authMethods.includes('none')) return 'none'; + if (authMethods.includes('client_secret_post')) return 'client_secret_post'; + if (authMethods.includes('client_secret_basic')) { + return 'client_secret_basic'; + } + return null; + } + + /** + * Exchange the code with a DPoP proof, completing the nonce handshake if the + * AS demands one (RFC 9449 §8): a `400 use_dpop_nonce` + `DPoP-Nonce` response + * is retried once with the supplied nonce before the result is judged. + */ + private async exchangeWithProof( + metadata: Record, + options: AuthorizationServerOptions, + code: string, + codeVerifier: string, + keyPair: Awaited>, + alg: string + ): Promise { + // RFC 9449 §4.2: htu carries no query/fragment, but RFC 6749 permits them in + // the token endpoint URL — strip them so we don't build a proof our own (and + // a conformant AS's) validator would reject. + const htu = stripUrlQuery(metadata.token_endpoint); + const first = await this.exchangeCode( + metadata, + options, + code, + codeVerifier, + await buildDpopProof({ keyPair, htm: 'POST', htu, alg }) + ); + if ( + first.statusCode === 400 && + first.body?.error === 'use_dpop_nonce' && + first.dpopNonce + ) { + return this.exchangeCode( + metadata, + options, + code, + codeVerifier, + await buildDpopProof({ + keyPair, + htm: 'POST', + htu, + alg, + nonce: first.dpopNonce + }) + ); + } + return first; + } + + // ----- authorization_code + PKCE helpers ----- + + private async obtainAuthorizationCode( + metadata: Record, + options: AuthorizationServerOptions + ): Promise { + const state = randomBytes(32).toString('base64url'); + const codeVerifier = randomBytes(32).toString('base64url'); + const codeChallenge = createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + const redirectUri = `${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH}`; + + const params = new URLSearchParams({ + response_type: 'code', + client_id: options.clientId!, + state, + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }); + const authorizeUrl = `${metadata.authorization_endpoint}?${params.toString()}`; + + const responseUrl = await this.resolveAuthorizationResponse( + authorizeUrl, + redirectUri, + options + ); + const code = this.validateAuthorizationResponse( + responseUrl, + metadata, + redirectUri, + state + ); + return { code, codeVerifier }; + } + + /** + * Auto-follow a direct redirect to the registered redirect_uri (headless + * path); otherwise print the URL and wait for an interactive browser callback. + */ + private async resolveAuthorizationResponse( + authorizeUrl: string, + redirectUri: string, + options: AuthorizationServerOptions + ): Promise { + // undici's request() does not follow redirects, so a 3xx is returned as-is + // with its Location header — exactly what we want to inspect. + const res = await request(authorizeUrl, { method: 'GET' }); + const rawLocation = res.headers['location']; + const location = Array.isArray(rawLocation) ? rawLocation[0] : rawLocation; + await res.body.text().catch(() => undefined); // drain the socket + + if ( + res.statusCode >= 300 && + res.statusCode < 400 && + typeof location === 'string' + ) { + // Location may be relative (RFC 9110 §10.2.2 permits a relative-ref); + // resolve it against the request URL before matching the redirect_uri. + const resolved = new URL(location, authorizeUrl).toString(); + if (resolved.startsWith(redirectUri)) { + return resolved; + } + } + + // Interactive fallback for login-gated authorization servers. + const callback = startCallbackServer(options.port); + try { + console.log( + `Ensure ${redirectUri} is registered as a redirect URI for client '${options.clientId}'.` + ); + console.log( + 'Access the following URL in your browser and complete authentication:' + ); + console.log(authorizeUrl); + console.log('Waiting up to 5 minutes for the authorization callback...'); + return await callback.waitForCallback(300_000); + } finally { + callback.close(); + } + } + + private validateAuthorizationResponse( + responseUrl: string, + metadata: Record, + redirectUri: string, + state: string + ): string { + const url = new URL(responseUrl); + + if (url.searchParams.has('error')) { + const error = url.searchParams.get('error'); + const desc = url.searchParams.get('error_description'); + throw new Error(`Authorization error: ${error} ${desc ?? ''}`.trim()); + } + + const expected = new URL(redirectUri); + if (url.origin !== expected.origin || url.pathname !== expected.pathname) { + throw new Error( + `Unexpected redirect target: ${url.origin}${url.pathname}` + ); + } + + const stateParams = url.searchParams.getAll('state'); + if (stateParams.length !== 1 || stateParams[0] !== state) { + throw new Error( + `Invalid state parameter: ${stateParams.join(',') || 'missing'}` + ); + } + + const code = url.searchParams.getAll('code'); + if (code.length !== 1 || code[0] === '') { + throw new Error(`Invalid code parameter: ${code.join(',') || 'missing'}`); + } + + const iss = url.searchParams.getAll('iss'); + if (iss.length > 0 && (iss.length !== 1 || iss[0] !== metadata.issuer)) { + throw new Error(`Invalid iss parameter: ${iss.join(',')}`); + } + + return code[0]; + } + + private async exchangeCode( + metadata: Record, + options: AuthorizationServerOptions, + code: string, + codeVerifier: string, + proof?: string + ): Promise { + const redirectUri = `${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH}`; + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + client_id: options.clientId! + }); + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded' + }; + if (proof) { + headers['dpop'] = proof; + } + // Client authentication per the AS's advertised methods (RFC 8414 default is + // client_secret_basic when the field is omitted). No secret → public client. + const authMethod = this.selectTokenAuthMethod(metadata, options); + if (authMethod === 'client_secret_basic' && options.clientSecret) { + const credentials = `${encodeURIComponent(options.clientId!)}:${encodeURIComponent(options.clientSecret)}`; + headers['authorization'] = + `Basic ${Buffer.from(credentials).toString('base64')}`; + } else if (authMethod === 'client_secret_post' && options.clientSecret) { + params.set('client_secret', options.clientSecret); + } + + const res = await request(metadata.token_endpoint, { + method: 'POST', + headers, + body: params.toString() + }); + + const rawNonce = res.headers['dpop-nonce']; + const dpopNonce = Array.isArray(rawNonce) ? rawNonce[0] : rawNonce; + + let body: Record | undefined; + try { + body = (await res.body.json()) as Record; + } catch { + await res.body.text().catch(() => undefined); + body = undefined; + } + return { statusCode: res.statusCode, body, dpopNonce }; + } + + // ----- metadata discovery ----- + + private async fetchMetadata(serverUrl: string): Promise> { + for (const url of this.createWellKnownUrls(serverUrl)) { + try { + const res = await request(url, { method: 'GET' }); + if (res.statusCode === 200) { + return (await res.body.json()) as Record; + } + await res.body.text().catch(() => undefined); + } catch { + // Try the next candidate URL. + } + } + throw new Error('No authorization server metadata endpoint returned 200'); + } + + private createWellKnownUrls(serverUrl: string): string[] { + const base = new URL(serverUrl); + const origin = base.origin; + const path = base.pathname.replace(/\/$/, ''); + const urls = new Set(); + urls.add(`${origin}/.well-known/oauth-authorization-server${path}`); + urls.add(`${origin}/.well-known/openid-configuration${path}`); + urls.add(`${origin}${path}/.well-known/openid-configuration`); + return Array.from(urls); + } + + // ----- check construction ----- + + private check( + 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 } : {}) + }; + } + + private message(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/src/scenarios/client/auth/helpers/dpopToken.test.ts b/src/scenarios/client/auth/helpers/dpopToken.test.ts index 71038093..7758c469 100644 --- a/src/scenarios/client/auth/helpers/dpopToken.test.ts +++ b/src/scenarios/client/auth/helpers/dpopToken.test.ts @@ -6,7 +6,11 @@ import { buildDpopProof, accessTokenHash } from './dpopProof'; -import { generateIssuerKey, mintDpopBoundToken } from './dpopToken'; +import { + generateIssuerKey, + mintDpopBoundToken, + readTokenBinding +} from './dpopToken'; /** Independent ES256 verification via Node WebCrypto (a different path from jose). */ async function verifyEs256Independently( @@ -156,3 +160,83 @@ describe('DPoP token minter — invalid variants', () => { expect((claims.exp as number) < (claims.iat as number)).toBe(true); }); }); + +describe('readTokenBinding — reads the sender-constraint back out', () => { + const base = { issuer: ISSUER, audience: AUDIENCE }; + + it('reads token_type=DPoP and cnf.jkt from a bound token-endpoint response', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const access_token = await mintDpopBoundToken({ + issuerKey, + ...base, + jkt: kp.thumbprint + }); + + const binding = readTokenBinding({ access_token, token_type: 'DPoP' }); + expect(binding.isDpopTokenType).toBe(true); + expect(binding.tokenType).toBe('DPoP'); + expect(binding.jkt).toBe(kp.thumbprint); + expect(binding.accessTokenIsJwt).toBe(true); + }); + + it('treats token_type as case-insensitive (RFC 6749 §7.1)', () => { + for (const token_type of ['dpop', 'DPoP', 'DPOP']) { + expect(readTokenBinding({ token_type }).isDpopTokenType).toBe(true); + } + expect(readTokenBinding({ token_type: 'Bearer' }).isDpopTokenType).toBe( + false + ); + }); + + it('reports no jkt for an unbound Bearer response (cnf omitted)', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const access_token = await mintDpopBoundToken({ + issuerKey, + ...base, + jkt: kp.thumbprint, + omitCnf: true + }); + + const binding = readTokenBinding({ access_token, token_type: 'Bearer' }); + expect(binding.isDpopTokenType).toBe(false); + expect(binding.jkt).toBeUndefined(); + }); + + it('never throws on an opaque (non-JWT) access token', () => { + const binding = readTokenBinding({ + access_token: 'test-token-1700000000000', + token_type: 'Bearer' + }); + expect(binding.jkt).toBeUndefined(); + expect(binding.isDpopTokenType).toBe(false); + // Opaque token → not a JWT, so a missing jkt is inconclusive, not a failure. + expect(binding.accessTokenIsJwt).toBe(false); + }); + + it('handles a response missing both fields', () => { + const binding = readTokenBinding({}); + expect(binding.tokenType).toBeUndefined(); + expect(binding.isDpopTokenType).toBe(false); + expect(binding.jkt).toBeUndefined(); + }); + + it('surfaces the foreign thumbprint when the AS binds to the wrong key', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const foreign = await generateDpopKeyPair(); + const access_token = await mintDpopBoundToken({ + issuerKey, + ...base, + jkt: kp.thumbprint, + jktOverride: foreign.thumbprint + }); + + // The scenario compares this against its own proof-key thumbprint (kp); + // a mismatch is exactly the failure it must catch. + const binding = readTokenBinding({ access_token, token_type: 'DPoP' }); + expect(binding.jkt).toBe(foreign.thumbprint); + expect(binding.jkt).not.toBe(kp.thumbprint); + }); +}); diff --git a/src/scenarios/client/auth/helpers/dpopToken.ts b/src/scenarios/client/auth/helpers/dpopToken.ts index afe5632f..90e20bfb 100644 --- a/src/scenarios/client/auth/helpers/dpopToken.ts +++ b/src/scenarios/client/auth/helpers/dpopToken.ts @@ -108,3 +108,54 @@ export async function mintDpopBoundToken( .setExpirationTime(exp) .sign(options.issuerKey.privateKey); } + +/** The two DPoP sender-constraint signals carried by a token-endpoint response. */ +export interface TokenBinding { + /** Raw `token_type` from the token-endpoint response, or undefined if absent. */ + tokenType?: string; + /** True when `token_type` is `DPoP` (RFC 6749 §7.1 makes token_type case-insensitive). */ + isDpopTokenType: boolean; + /** `cnf.jkt` bound into the access token (RFC 9449 §6), or undefined if the + * token is opaque / not a JWT / carries no confirmation. */ + jkt?: string; + /** True when the access token parsed as a JWT. When false the token is opaque, + * so `cnf.jkt` cannot be inspected off the wire (the binding may still hold, + * verifiable only via introspection) — a caller must not read a missing `jkt` + * as a binding failure in that case. */ + accessTokenIsJwt: boolean; +} + +/** + * Read the DPoP binding back out of an OAuth token-endpoint response, from the + * perspective of an inspector (the #370 AS scenario). Combines the two signals + * RFC 9449 §5 requires an AS to emit when it issues a bound token: + * 1. `token_type: "DPoP"` in the JSON response, and + * 2. `cnf.jkt` inside the access token. + * Never throws — an opaque (non-JWT) access token yields `jkt: undefined` so a + * caller can distinguish "bound" from "unbound/bearer" without special-casing. + */ +export function readTokenBinding(response: { + access_token?: unknown; + token_type?: unknown; +}): TokenBinding { + const tokenType = + typeof response.token_type === 'string' ? response.token_type : undefined; + const isDpopTokenType = tokenType?.toLowerCase() === 'dpop'; + + let jkt: string | undefined; + let accessTokenIsJwt = false; + if (typeof response.access_token === 'string') { + try { + const claims = jose.decodeJwt(response.access_token); + accessTokenIsJwt = true; + const cnf = claims.cnf as { jkt?: unknown } | undefined; + if (cnf && typeof cnf.jkt === 'string') { + jkt = cnf.jkt; + } + } catch { + // Opaque / non-JWT access token → no readable binding. + } + } + + return { tokenType, isDpopTokenType, jkt, accessTokenIsJwt }; +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index db1584ea..fd424d6d 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -111,6 +111,7 @@ import { import { listMetadataScenarios } from './client/auth/discovery-metadata'; import { AuthorizationServerMetadataEndpointScenario } from './authorization-server/authorization-server-metadata'; import { AuthorizationCodeGrantScenario } from './authorization-server/authorization-code-grant'; +import { DPoPAuthorizationServerScenario } from './authorization-server/dpop'; import { HttpStandardHeadersScenario } from './client/http-standard-headers'; import { @@ -276,7 +277,8 @@ const allClientScenariosListForAuthorizationServer: ClientScenarioForAuthorizati [ // Authorization server scenarios new AuthorizationServerMetadataEndpointScenario(), - new AuthorizationCodeGrantScenario() + new AuthorizationCodeGrantScenario(), + new DPoPAuthorizationServerScenario() ]; // Client scenarios map for authorization server - built from list diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index c3c650f3..8a287eb5 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -21,8 +21,6 @@ requirements: 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 @@ -32,6 +30,8 @@ requirements: - 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: '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' + excluded: 'Enforcement is gated on the `dpop_bound_access_tokens` client-registration metadata (RFC 9449 §5.2), a per-client policy — not exercisable without dynamic client registration, which is out of scope for these DPoP scenarios.' - 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' From b1eae5e49b76a0357e40b0dc371821e903eb35fb Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:52:58 +0100 Subject: [PATCH 7/9] authorization-server/dpop: treat non-array alg-values as SKIP negotiateProofAlg fell back to ES256 for a present-but-non-array dpop_signing_alg_values_supported (e.g. the string "RS256"), contradicting its docstring and risking a token-binding mis-score for that malformed shape. Treat a present-but-non-array value as null (SKIP), like a non-empty list with no supported alg; only an absent/empty list still falls back to ES256. Defensive against malformed metadata; not independently exercised by a fixture (would need a malformed-metadata AS option), consistent with the htu-strip defensive fixes. Co-Authored-By: Claude Opus 4.8 --- src/scenarios/authorization-server/dpop.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/scenarios/authorization-server/dpop.ts b/src/scenarios/authorization-server/dpop.ts index 171a3c92..c59b0b1e 100644 --- a/src/scenarios/authorization-server/dpop.ts +++ b/src/scenarios/authorization-server/dpop.ts @@ -396,8 +396,8 @@ browser login + callback for login-gated servers.`; /** * Pick a proof-signing algorithm the harness can produce that the AS also * advertises (RFC 9449 §5.1). Returns null when the AS advertises a non-empty - * list with no algorithm we support (including a non-empty but malformed list) - * — the caller then SKIPs rather than sending an unadvertised alg (e.g. + * list with no algorithm we support, OR a present-but-non-array (malformed) + * value — the caller then SKIPs rather than sending an unadvertised alg (e.g. * defaulting to ES256) that the AS would legitimately reject and we'd mis-score * as a binding failure. Only an absent or empty list (itself flagged by the * metadata check) falls back to ES256 as a best effort to still exercise the @@ -405,6 +405,15 @@ browser login + callback for login-gated servers.`; */ private negotiateProofAlg(metadata: Record): string | null { const advertised = metadata.dpop_signing_alg_values_supported; + // A present-but-non-array value (e.g. the string "RS256") is malformed + // metadata, not "unspecified" — SKIP rather than fall back to ES256. + if ( + advertised !== undefined && + advertised !== null && + !Array.isArray(advertised) + ) { + return null; + } if (Array.isArray(advertised) && advertised.length > 0) { const match = advertised.find( (a) => typeof a === 'string' && SUPPORTED_PROOF_ALGS.includes(a) From c14a558b96e566cbb7f22b266a1618b72a442b81 Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:01:40 +0100 Subject: [PATCH 8/9] authorization-server/dpop: fix null alg-values hole; extract + test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-4 non-array guard carved out `null` (advertised !== null), so metadata with "dpop_signing_alg_values_supported": null passed the support gate (which only tests === undefined), skipped the guard, and fell through to the ES256 fallback — the exact binding mis-score the fix targeted. Extract the negotiation to an exported pure function negotiateProofAlg(advertised) and treat ANY present-but-non-array shape (string, null, number, object) as malformed → null (SKIP). Only an empty array still falls back to ES256. Correct the docstring (an absent field never reaches here — the support gate SKIPs upstream). Add unit tests for every shape (array / empty / no-overlap / string / null / number / object), pinning the fix against a silent refactor regression. Co-Authored-By: Claude Opus 4.8 --- .../authorization-server/dpop.test.ts | 26 +++++++- src/scenarios/authorization-server/dpop.ts | 60 ++++++++++--------- 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/src/scenarios/authorization-server/dpop.test.ts b/src/scenarios/authorization-server/dpop.test.ts index 10706b2d..7d9c8064 100644 --- a/src/scenarios/authorization-server/dpop.test.ts +++ b/src/scenarios/authorization-server/dpop.test.ts @@ -6,7 +6,7 @@ import { import { ServerLifecycle } from '../client/auth/helpers/serverLifecycle'; import { testScenarioContext } from '../../mock-server/testing'; import type { CheckStatus, ConformanceCheck } from '../../types'; -import { DPoPAuthorizationServerScenario } from './dpop'; +import { DPoPAuthorizationServerScenario, negotiateProofAlg } from './dpop'; const ALL_IDS = [ 'sep-1932-as-metadata-alg-values', @@ -142,3 +142,27 @@ describe('DPoPAuthorizationServerScenario — skip conditions', () => { expect(checks.filter((c) => c.status === 'FAILURE')).toHaveLength(0); }); }); + +describe('negotiateProofAlg (dpop_signing_alg_values_supported shapes)', () => { + it('picks the first supported alg from a non-empty array', () => { + expect(negotiateProofAlg(['ES256'])).toBe('ES256'); + expect(negotiateProofAlg(['RS256', 'ES256'])).toBe('RS256'); + }); + + it('returns null for a non-empty array with no supported alg (→ SKIP)', () => { + expect(negotiateProofAlg(['ES256K'])).toBeNull(); + }); + + it('falls back to ES256 only for an empty array', () => { + expect(negotiateProofAlg([])).toBe('ES256'); + }); + + it('returns null for a present-but-non-array (malformed) value (→ SKIP)', () => { + // Regression guard: a string or JSON null must NOT fall through to the + // ES256 fallback, which would mis-score token binding. + expect(negotiateProofAlg('RS256')).toBeNull(); + expect(negotiateProofAlg(null)).toBeNull(); + expect(negotiateProofAlg(42)).toBeNull(); + expect(negotiateProofAlg({ 0: 'ES256' })).toBeNull(); + }); +}); diff --git a/src/scenarios/authorization-server/dpop.ts b/src/scenarios/authorization-server/dpop.ts index c59b0b1e..e8aa702e 100644 --- a/src/scenarios/authorization-server/dpop.ts +++ b/src/scenarios/authorization-server/dpop.ts @@ -94,6 +94,37 @@ const SUPPORTED_PROOF_ALGS = [ 'EdDSA' ]; +/** + * Pick a proof-signing algorithm the harness can produce that the AS also + * advertises (RFC 9449 §5.1), given `dpop_signing_alg_values_supported`. + * + * Returns null (→ the caller SKIPs the binding check) when the value is a + * non-empty array with no algorithm we support, OR any present-but-non-array + * shape — a string, `null`, number, or object are all malformed metadata, not + * "unspecified", so we must not fall back to ES256 (which the AS would reject, + * mis-scoring binding). Only an EMPTY array falls back to ES256 as a best-effort + * to still exercise the binding (an empty list is itself flagged by the metadata + * check). An absent field never reaches here — the scenario's support gate SKIPs + * the whole scenario upstream — but is treated as the empty case for safety. + */ +export function negotiateProofAlg( + advertised: unknown, + supported: readonly string[] = SUPPORTED_PROOF_ALGS +): string | null { + // Any present-but-non-array value (string / null / number / object) is + // malformed — SKIP rather than fall back to ES256. + if (advertised !== undefined && !Array.isArray(advertised)) { + return null; + } + if (Array.isArray(advertised) && advertised.length > 0) { + const match = advertised.find( + (a) => typeof a === 'string' && supported.includes(a) + ); + return typeof match === 'string' ? match : null; + } + return 'ES256'; +} + /** Strip query + fragment from a URL for use as an `htu` (RFC 9449 §4.2). */ function stripUrlQuery(url: string): string { try { @@ -393,34 +424,9 @@ browser login + callback for login-gated servers.`; } } - /** - * Pick a proof-signing algorithm the harness can produce that the AS also - * advertises (RFC 9449 §5.1). Returns null when the AS advertises a non-empty - * list with no algorithm we support, OR a present-but-non-array (malformed) - * value — the caller then SKIPs rather than sending an unadvertised alg (e.g. - * defaulting to ES256) that the AS would legitimately reject and we'd mis-score - * as a binding failure. Only an absent or empty list (itself flagged by the - * metadata check) falls back to ES256 as a best effort to still exercise the - * binding. - */ + /** See the module-level {@link negotiateProofAlg}. */ private negotiateProofAlg(metadata: Record): string | null { - const advertised = metadata.dpop_signing_alg_values_supported; - // A present-but-non-array value (e.g. the string "RS256") is malformed - // metadata, not "unspecified" — SKIP rather than fall back to ES256. - if ( - advertised !== undefined && - advertised !== null && - !Array.isArray(advertised) - ) { - return null; - } - if (Array.isArray(advertised) && advertised.length > 0) { - const match = advertised.find( - (a) => typeof a === 'string' && SUPPORTED_PROOF_ALGS.includes(a) - ); - return typeof match === 'string' ? match : null; - } - return 'ES256'; + return negotiateProofAlg(metadata.dpop_signing_alg_values_supported); } /** From e49389d4645d7f72b86b1f47d43bd18f41e10ee8 Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:32:03 +0100 Subject: [PATCH 9/9] authorization-server/dpop: round-6 test polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broaden the negotiateProofAlg fallback test to also assert undefined → ES256 and correct its title ("empty array or absent field") — the contract covers both, though absent is gated upstream in the scenario. Co-Authored-By: Claude Opus 4.8 --- src/scenarios/authorization-server/dpop.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/scenarios/authorization-server/dpop.test.ts b/src/scenarios/authorization-server/dpop.test.ts index 7d9c8064..6ba2091e 100644 --- a/src/scenarios/authorization-server/dpop.test.ts +++ b/src/scenarios/authorization-server/dpop.test.ts @@ -153,8 +153,11 @@ describe('negotiateProofAlg (dpop_signing_alg_values_supported shapes)', () => { expect(negotiateProofAlg(['ES256K'])).toBeNull(); }); - it('falls back to ES256 only for an empty array', () => { + it('falls back to ES256 only for an empty array or an absent field', () => { expect(negotiateProofAlg([])).toBe('ES256'); + // Absent never reaches here in the scenario (the support gate SKIPs upstream), + // but the contract still treats undefined as the empty/best-effort case. + expect(negotiateProofAlg(undefined)).toBe('ES256'); }); it('returns null for a present-but-non-array (malformed) value (→ SKIP)', () => {