From 692db64d7950f7578cb5ec8fc0cfd8cbc9015bc1 Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:06:07 +0100 Subject: [PATCH 1/8] Add DPoP server proof-validation scenario (SEP-1932, #369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the DPoP client PR (shared foundation). Adds the server-role conformance for SEP-1932 / RFC 9449: the framework acts as a DPoP client against the MCP server under test and emits the sep-1932-server-* checks across the RFC 9449 §4.3 validation surface — proof validation, the ±5-minute iat window, asymmetric-only algorithms, the 401 + WWW-Authenticate challenge, token audience validation under DPoP, and the optional server-provided nonce. - src/scenarios/server/auth/dpop.ts (+ test, spec-references): the scenario. - examples/servers/typescript/sep-1932-{compliant,broken}-server.ts: passing and failing fixtures proving every check passes and fails. - Registered in the pending + all-client scenario lists. Depends only on the shared DPoP foundation (dpopProof/dpopToken); independent of the authorization-server PR. No DPoP-capable MCP SDK exists yet, so correctness rests on RFC vectors + an independent verifier + the fixtures. Co-Authored-By: Claude Opus 4.8 --- examples/servers/typescript/package.json | 1 + .../typescript/sep-1932-broken-server.ts | 66 ++ .../typescript/sep-1932-compliant-server.ts | 317 ++++++ .../typescript/sep-1932-reject-all-server.ts | 38 + src/scenarios/index.ts | 12 +- src/scenarios/server/auth/dpop.test.ts | 393 ++++++++ src/scenarios/server/auth/dpop.ts | 928 ++++++++++++++++++ src/scenarios/server/auth/spec-references.ts | 28 + 8 files changed, 1782 insertions(+), 1 deletion(-) create mode 100644 examples/servers/typescript/sep-1932-broken-server.ts create mode 100644 examples/servers/typescript/sep-1932-compliant-server.ts create mode 100644 examples/servers/typescript/sep-1932-reject-all-server.ts create mode 100644 src/scenarios/server/auth/dpop.test.ts create mode 100644 src/scenarios/server/auth/dpop.ts create mode 100644 src/scenarios/server/auth/spec-references.ts diff --git a/examples/servers/typescript/package.json b/examples/servers/typescript/package.json index 4356d425..b8bdbbd1 100644 --- a/examples/servers/typescript/package.json +++ b/examples/servers/typescript/package.json @@ -19,6 +19,7 @@ "@types/cors": "^2.8.19", "cors": "^2.8.5", "express": "^5.2.1", + "jose": "^6.1.2", "zod": "^4.3.6" }, "devDependencies": { diff --git a/examples/servers/typescript/sep-1932-broken-server.ts b/examples/servers/typescript/sep-1932-broken-server.ts new file mode 100644 index 00000000..d0ffb3e9 --- /dev/null +++ b/examples/servers/typescript/sep-1932-broken-server.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +/** + * MCP server that does NOT enforce DPoP — NEGATIVE test fixture. + * + * It accepts requests without validating the DPoP proof or the access-token + * binding at all, so it should FAIL the sep-1932-server-* negative checks + * (proving those checks actually detect non-conformance). DO NOT use in + * production. It is NOT what an SDK author runs against. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express, { type Request, type Response } from 'express'; +import { z } from 'zod'; + +function createMcpServer(): McpServer { + const server = new McpServer({ + name: 'sep-1932-broken-server', + version: '1.0.0' + }); + server.registerTool( + 'echo', + { + description: 'Echo the input back', + inputSchema: { message: z.string() } + }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Echo: ${message}` }] + }) + ); + return server; +} + +const app = express(); +app.use(express.json()); + +// NO DPoP validation: every request is handled regardless of the (missing, +// malformed, replayed, or unbound) DPoP proof or access token. +app.post('/mcp', async (req: Request, res: Response) => { + try { + const server = createMcpServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}` + }, + id: null + }); + } + } +}); + +const PORT = parseInt(process.env.PORT || '3011', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log(`DPoP broken server running on http://localhost:${PORT}/mcp`); + console.log('WARNING: No DPoP validation enabled!'); +}); diff --git a/examples/servers/typescript/sep-1932-compliant-server.ts b/examples/servers/typescript/sep-1932-compliant-server.ts new file mode 100644 index 00000000..8cece692 --- /dev/null +++ b/examples/servers/typescript/sep-1932-compliant-server.ts @@ -0,0 +1,317 @@ +#!/usr/bin/env node + +/** + * MCP server that correctly enforces DPoP (RFC 9449) — POSITIVE test fixture. + * + * Used only to validate the dpop server conformance scenario: it should PASS + * every sep-1932-server-* check. It is NOT what an SDK author runs against. + * + * The DPoP validation here is written from scratch against RFC 9449 §4.3 (using + * jose only for primitive verify/thumbprint) so it is an INDEPENDENT code path + * from the conformance suite's proof-builder/minter — a shared bug surfaces as a + * test failure rather than mutual agreement on a wrong answer. + * + * Trust config is supplied via env (the scenario mints tokens with the matching + * issuer private key): + * PORT, DPOP_ISSUER_JWK (public JWK JSON), DPOP_ISSUER, DPOP_AUDIENCE, + * DPOP_IAT_SKEW_SECONDS (default 300), DPOP_REQUIRE_NONCE ('1'), DPOP_NONCE. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express, { type Request, type Response } from 'express'; +import { z } from 'zod'; +import * as jose from 'jose'; +import { createHash } from 'node:crypto'; + +const ISSUER_JWK = JSON.parse(process.env.DPOP_ISSUER_JWK || '{}'); +const ISSUER = process.env.DPOP_ISSUER || 'https://auth.example.com'; +const AUDIENCE = process.env.DPOP_AUDIENCE || ''; +const IAT_SKEW = parseInt(process.env.DPOP_IAT_SKEW_SECONDS || '300', 10); +const REQUIRE_NONCE = process.env.DPOP_REQUIRE_NONCE === '1'; +const NONCE = process.env.DPOP_NONCE || 'conformance-test-nonce'; +// Buggy-nonce mode (negative-test fixture only): challenge when no nonce is +// present, but accept ANY nonce value without validating it. Used to prove the +// scenario's nonce check can report WARNING. +const NONCE_ACCEPT_ANY = process.env.DPOP_NONCE_ACCEPT_ANY === '1'; +// Nonce-first mode (negative-test fixture only): gate on the nonce BEFORE any +// structural proof validation, so every nonce-less request — even a malformed +// proof — is answered with use_dpop_nonce. Models a server that checks the +// nonce first; used to prove the scenario reports those rejections as +// not-testable rather than a vacuous SUCCESS. +const NONCE_FIRST = process.env.DPOP_NONCE_FIRST === '1'; +// Clock-offset mode (negative-test fixture only): shift the server's notion of +// "now" — and its `Date` header — by N seconds to simulate a skewed server +// clock. Used to prove the scenario anchors its iat probes to the server's Date. +const CLOCK_OFFSET = parseInt(process.env.DPOP_CLOCK_OFFSET_SECONDS || '0', 10); + +// Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 step 5). +const ASYMMETRIC_ALGS = [ + 'ES256', + 'ES384', + 'ES512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA' +]; + +type Failure = { error: string; description: string; nonce?: boolean }; +type Result = { ok: true } | ({ ok: false } & Failure); + +const fail = (error: string, description: string, nonce = false): Result => ({ + ok: false, + error, + description, + nonce +}); + +let issuerKey: jose.CryptoKey | Uint8Array; + +function reconstructHtu(req: Request): string { + const proto = (req.headers['x-forwarded-proto'] as string) || 'http'; + const host = req.headers.host; + return `${proto}://${host}${req.originalUrl.split('?')[0]}`; +} + +function countHeader(req: Request, name: string): number { + let n = 0; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + if (req.rawHeaders[i].toLowerCase() === name) n++; + } + return n; +} + +async function validateDpop(req: Request): Promise { + // --- Access token presentation: must use the DPoP scheme (RFC 9449 §7.1) --- + const authz = req.headers.authorization; + if (!authz) return fail('invalid_token', 'missing Authorization header'); + if (!authz.startsWith('DPoP ')) { + return fail( + 'invalid_token', + 'access token must be presented with the DPoP scheme' + ); + } + const accessToken = authz.slice('DPoP '.length).trim(); + + // --- Exactly one DPoP header (§4.3 step 1) --- + const dpopCount = countHeader(req, 'dpop'); + if (dpopCount === 0) + return fail('invalid_dpop_proof', 'missing DPoP proof header'); + if (dpopCount > 1) + return fail('invalid_dpop_proof', 'more than one DPoP header field'); + const proof = req.headers.dpop as string; + + // --- Nonce-first negative mode: challenge before structural validation --- + if (REQUIRE_NONCE && NONCE_FIRST) { + let nonce: unknown; + try { + nonce = jose.decodeJwt(proof).nonce; + } catch { + nonce = undefined; + } + const hasNonce = typeof nonce === 'string' && nonce.length > 0; + if (!hasNonce) { + return fail( + 'use_dpop_nonce', + 'a server-provided nonce is required', + true + ); + } + if (!NONCE_ACCEPT_ANY && nonce !== NONCE) { + return fail('use_dpop_nonce', 'the supplied nonce does not match', true); + } + } + + // --- Proof is a well-formed JWT with required header params (§4.3 steps 2,4,5,7) --- + let header: jose.ProtectedHeaderParameters; + try { + header = jose.decodeProtectedHeader(proof); + } catch { + return fail('invalid_dpop_proof', 'DPoP proof is not a well-formed JWT'); + } + if (header.typ !== 'dpop+jwt') + return fail('invalid_dpop_proof', 'typ must be dpop+jwt'); + if (!header.alg || !ASYMMETRIC_ALGS.includes(header.alg)) { + return fail( + 'invalid_dpop_proof', + 'alg must be a supported asymmetric algorithm' + ); + } + const jwk = header.jwk as jose.JWK | undefined; + if (!jwk) return fail('invalid_dpop_proof', 'missing jwk header parameter'); + if ((jwk as Record).d !== undefined) { + return fail('invalid_dpop_proof', 'jwk must not contain a private key'); + } + + // --- Signature verifies with the embedded public key (§4.3 step 6) --- + let claims: jose.JWTPayload; + try { + const proofKey = await jose.importJWK(jwk, header.alg); + const verified = await jose.jwtVerify(proof, proofKey, { + algorithms: ASYMMETRIC_ALGS + }); + claims = verified.payload; + } catch { + return fail('invalid_dpop_proof', 'DPoP proof signature does not verify'); + } + + // --- Required claims + htm/htu match (§4.3 steps 3,8,9) --- + if (typeof claims.jti !== 'string') + return fail('invalid_dpop_proof', 'missing jti claim'); + if (claims.htm !== req.method) + return fail('invalid_dpop_proof', 'htm does not match request method'); + if (claims.htu !== reconstructHtu(req)) + return fail('invalid_dpop_proof', 'htu does not match request URI'); + + // --- iat acceptance window of ±IAT_SKEW (§4.3 step 11; SEP ±5 min) --- + if (typeof claims.iat !== 'number') + return fail('invalid_dpop_proof', 'missing iat claim'); + const now = Math.floor(Date.now() / 1000) + CLOCK_OFFSET; + if (Math.abs(now - claims.iat) > IAT_SKEW) { + return fail('invalid_dpop_proof', 'iat outside the acceptable window'); + } + + // --- Access token validity: signature, issuer, audience, expiry --- + let tokenClaims: jose.JWTPayload; + try { + const verified = await jose.jwtVerify(accessToken, issuerKey, { + issuer: ISSUER, + audience: AUDIENCE + }); + tokenClaims = verified.payload; + } catch { + return fail( + 'invalid_token', + 'access token is invalid (signature/issuer/audience/expiry)' + ); + } + + // --- ath binds the proof to this access token (§4.3 step 12a) --- + const expectedAth = createHash('sha256') + .update(accessToken, 'ascii') + .digest('base64url'); + if (claims.ath !== expectedAth) + return fail('invalid_dpop_proof', 'ath does not match the access token'); + + // --- Token is bound to the proof key (§4.3 step 12b) --- + const cnf = tokenClaims.cnf as { jkt?: string } | undefined; + const thumbprint = await jose.calculateJwkThumbprint(jwk, 'sha256'); + if (!cnf || cnf.jkt !== thumbprint) { + return fail( + 'invalid_token', + 'access token is not bound to the DPoP proof key (cnf.jkt mismatch)' + ); + } + + // --- Optional server-provided nonce (§4.3 step 10; §9) --- + if (REQUIRE_NONCE) { + const hasNonce = + typeof claims.nonce === 'string' && claims.nonce.length > 0; + if (!hasNonce) { + return fail( + 'use_dpop_nonce', + 'a server-provided nonce is required', + true + ); + } + if (!NONCE_ACCEPT_ANY && claims.nonce !== NONCE) { + return fail('use_dpop_nonce', 'the supplied nonce does not match', true); + } + } + + return { ok: true }; +} + +function send401(res: Response, f: Failure): void { + const algs = ASYMMETRIC_ALGS.join(' '); + res.setHeader( + 'WWW-Authenticate', + `DPoP error="${f.error}", error_description="${f.description}", algs="${algs}"` + ); + if (f.nonce) res.setHeader('DPoP-Nonce', NONCE); + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null + }); +} + +function createMcpServer(): McpServer { + const server = new McpServer({ + name: 'sep-1932-compliant-server', + version: '1.0.0' + }); + server.registerTool( + 'echo', + { + description: 'Echo the input back', + inputSchema: { message: z.string() } + }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Echo: ${message}` }] + }) + ); + return server; +} + +const app = express(); +app.use(express.json()); + +// Clock-offset mode: reflect the skewed clock in the Date header too, so a +// client that anchors to it measures against the same clock the server uses. +if (CLOCK_OFFSET) { + app.use((_req: Request, res: Response, next) => { + res.setHeader( + 'Date', + new Date(Date.now() + CLOCK_OFFSET * 1000).toUTCString() + ); + next(); + }); +} + +app.post('/mcp', async (req: Request, res: Response) => { + const result = await validateDpop(req); + if (!result.ok) { + send401(res, result); + return; + } + try { + const server = createMcpServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}` + }, + id: null + }); + } + } +}); + +const PORT = parseInt(process.env.PORT || '3010', 10); + +async function main(): Promise { + issuerKey = await jose.importJWK(ISSUER_JWK, ISSUER_JWK.alg || 'ES256'); + app.listen(PORT, '127.0.0.1', () => { + console.log( + `DPoP compliant server running on http://localhost:${PORT}/mcp` + ); + }); +} + +main().catch((err) => { + console.error('Failed to start DPoP compliant server:', err); + process.exit(1); +}); diff --git a/examples/servers/typescript/sep-1932-reject-all-server.ts b/examples/servers/typescript/sep-1932-reject-all-server.ts new file mode 100644 index 00000000..061f48e4 --- /dev/null +++ b/examples/servers/typescript/sep-1932-reject-all-server.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +/** + * MCP server that rejects EVERY request with 401 + `WWW-Authenticate: DPoP` — + * NEGATIVE test fixture for the gating in the DPoP server-validation scenario. + * + * A naive rejection check ("did the server 401 the malformed proof?") passes + * vacuously here, because this server also 401s a perfectly valid DPoP request. + * The scenario must therefore gate its rejection checks on the positive + * baseline: against this fixture `AcceptsValidProof` FAILs, and every rejection + * check must report notTestable rather than SUCCESS. DO NOT use in production. + */ + +import express, { type Request, type Response } from 'express'; + +const ASYMMETRIC_ALGS = ['ES256', 'ES384', 'ES512', 'RS256', 'PS256', 'EdDSA']; + +const app = express(); +app.use(express.json()); + +// Reject unconditionally — a valid proof is refused exactly like a malformed one. +app.post('/mcp', (_req: Request, res: Response) => { + res.setHeader( + 'WWW-Authenticate', + `DPoP error="invalid_token", error_description="rejects everything", algs="${ASYMMETRIC_ALGS.join(' ')}"` + ); + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null + }); +}); + +const PORT = parseInt(process.env.PORT || '3012', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log(`DPoP reject-all server running on http://localhost:${PORT}/mcp`); + console.log('WARNING: Rejects every request, including valid ones!'); +}); diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 03cf55f5..e1d8f57b 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -67,6 +67,7 @@ import { } from './server/prompts'; import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +import { DPoPServerValidationScenario } from './server/auth/dpop'; import { CachingScenario } from './server/caching'; // InputRequiredResult scenarios from (SEP-2322) @@ -151,7 +152,12 @@ const pendingClientScenariosList: ClientScenario[] = [ new TasksDispatchScenario(), new TasksStatusNotificationsScenario(), new TasksRequiredTaskErrorScenario(), - new TasksMrtrCompositionScenario() + new TasksMrtrCompositionScenario(), + + // DPoP server validation (SEP-1932): the everything-server is not DPoP-aware, + // so this runs against a dedicated fixture, e.g. + // `npm start -- server --scenario auth/dpop-server-validation --url `. + new DPoPServerValidationScenario() ]; // All client scenarios @@ -232,6 +238,10 @@ const allClientScenariosList: ClientScenario[] = [ new TasksRequiredTaskErrorScenario(), new TasksMrtrCompositionScenario(), + // DPoP server validation (SEP-1932). Pending against the everything-server + // (not DPoP-aware); targeted runs point at the sep-1932-compliant-server fixture. + new DPoPServerValidationScenario(), + // InputRequiredResult scenarios (SEP-2322) new InputRequiredResultBasicElicitationScenario(), new InputRequiredResultBasicSamplingScenario(), diff --git a/src/scenarios/server/auth/dpop.test.ts b/src/scenarios/server/auth/dpop.test.ts new file mode 100644 index 00000000..21f80a9e --- /dev/null +++ b/src/scenarios/server/auth/dpop.test.ts @@ -0,0 +1,393 @@ +import { spawn, ChildProcess } from 'child_process'; +import { createServer } from 'node:net'; +import path from 'path'; +import * as jose from 'jose'; +import { testContext } from '../../../connection/testing'; +import { DPoPServerValidationScenario } from './dpop'; +import type { ConformanceCheck } from '../../../types'; + +const WINDOWS = process.platform === 'win32'; + +/** Find an unused TCP port (small TOCTOU window, fine for tests). */ +function freePort(): Promise { + return new Promise((resolve, reject) => { + const s = createServer(); + s.once('error', reject); + s.listen(0, '127.0.0.1', () => { + const addr = s.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + s.close(() => resolve(port)); + }); + }); +} + +async function freePorts(n: number): Promise { + const ports = new Set(); + while (ports.size < n) ports.add(await freePort()); + return [...ports]; +} + +/** Kill the whole process group so the `tsx`/`node` child isn't orphaned. */ +function killTree(proc: ChildProcess, signal: NodeJS.Signals): void { + if (!WINDOWS && proc.pid !== undefined) { + process.kill(-proc.pid, signal); // negative pid → the detached group + } else { + proc.kill(signal); + } +} + +function startServer( + script: string, + port: number, + extraEnv: Record +): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('npx', ['tsx', script], { + env: { ...process.env, PORT: port.toString(), ...extraEnv }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: !WINDOWS, // own process group → killable as a unit + shell: WINDOWS + }); + let stderr = ''; + proc.stderr?.on('data', (d) => (stderr += d.toString())); + const timeout = setTimeout(() => { + killTree(proc, 'SIGKILL'); + reject( + new Error(`Server ${script} failed to start within 30s: ${stderr}`) + ); + }, 30000); + proc.stdout?.on('data', (data) => { + if (data.toString().includes('running on')) { + clearTimeout(timeout); + resolve(proc); + } + }); + proc.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +function stopServer(proc: ChildProcess | null): Promise { + return new Promise((resolve) => { + if (!proc || proc.killed || proc.pid === undefined) return resolve(); + const t = setTimeout(() => { + try { + killTree(proc, 'SIGKILL'); + } catch { + /* already gone */ + } + resolve(); + }, 5000); + proc.once('exit', () => { + clearTimeout(t); + resolve(); + }); + try { + killTree(proc, 'SIGTERM'); + } catch { + clearTimeout(t); + resolve(); + } + }); +} + +const COMPLIANT = path.join( + process.cwd(), + 'examples/servers/typescript/sep-1932-compliant-server.ts' +); +const BROKEN = path.join( + process.cwd(), + 'examples/servers/typescript/sep-1932-broken-server.ts' +); +const REJECT_ALL = path.join( + process.cwd(), + 'examples/servers/typescript/sep-1932-reject-all-server.ts' +); +const ISSUER = 'https://conformance-dpop-issuer.example.com'; +const url = (p: number) => `http://localhost:${p}/mcp`; + +function byId(checks: ConformanceCheck[], id: string): ConformanceCheck[] { + return checks.filter((c) => c.id === id); +} + +describe('DPoP server validation scenario', () => { + let compliant: ChildProcess | null = null; + let broken: ChildProcess | null = null; + let rejectAll: ChildProcess | null = null; + let nonceStrict: ChildProcess | null = null; + let nonceBuggy: ChildProcess | null = null; + let nonceFirst: ChildProcess | null = null; + let clockSkew: ChildProcess | null = null; + let ports: { + compliant: number; + broken: number; + rejectAll: number; + strict: number; + buggy: number; + nonceFirst: number; + clockSkew: number; + }; + let savedEnv: { jwk?: string; issuer?: string }; + + beforeAll(async () => { + // One issuer key, shared: the scenario mints with the private key (via env), + // the example servers trust the matching public key (via env). + const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { + extractable: true + }); + const publicJwk = { ...(await jose.exportJWK(publicKey)), alg: 'ES256' }; + const privateJwk = { ...(await jose.exportJWK(privateKey)), alg: 'ES256' }; + + savedEnv = { + jwk: process.env.DPOP_ISSUER_PRIVATE_JWK, + issuer: process.env.DPOP_ISSUER + }; + process.env.DPOP_ISSUER_PRIVATE_JWK = JSON.stringify(privateJwk); + process.env.DPOP_ISSUER = ISSUER; + + const [cp, bp, rp, sp, gp, nf, ck] = await freePorts(7); + ports = { + compliant: cp, + broken: bp, + rejectAll: rp, + strict: sp, + buggy: gp, + nonceFirst: nf, + clockSkew: ck + }; + + const issuerEnv = (port: number, extra: Record = {}) => ({ + DPOP_ISSUER_JWK: JSON.stringify(publicJwk), + DPOP_ISSUER: ISSUER, + DPOP_AUDIENCE: url(port), + ...extra + }); + + [ + compliant, + broken, + rejectAll, + nonceStrict, + nonceBuggy, + nonceFirst, + clockSkew + ] = await Promise.all([ + startServer(COMPLIANT, cp, issuerEnv(cp)), + startServer(BROKEN, bp, {}), + startServer(REJECT_ALL, rp, {}), + startServer(COMPLIANT, sp, issuerEnv(sp, { DPOP_REQUIRE_NONCE: '1' })), + startServer( + COMPLIANT, + gp, + issuerEnv(gp, { DPOP_REQUIRE_NONCE: '1', DPOP_NONCE_ACCEPT_ANY: '1' }) + ), + startServer( + COMPLIANT, + nf, + issuerEnv(nf, { DPOP_REQUIRE_NONCE: '1', DPOP_NONCE_FIRST: '1' }) + ), + startServer( + COMPLIANT, + ck, + issuerEnv(ck, { DPOP_CLOCK_OFFSET_SECONDS: '-30' }) + ) + ]); + }, 60000); + + afterAll(async () => { + await Promise.all([ + stopServer(compliant), + stopServer(broken), + stopServer(rejectAll), + stopServer(nonceStrict), + stopServer(nonceBuggy), + stopServer(nonceFirst), + stopServer(clockSkew) + ]); + process.env.DPOP_ISSUER_PRIVATE_JWK = savedEnv.jwk; + process.env.DPOP_ISSUER = savedEnv.issuer; + if (savedEnv.jwk === undefined) delete process.env.DPOP_ISSUER_PRIVATE_JWK; + if (savedEnv.issuer === undefined) delete process.env.DPOP_ISSUER; + }); + + it('passes every check against a compliant server (no FAILUREs)', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.compliant)) + ); + + const failures = checks.filter((c) => c.status === 'FAILURE'); + expect(failures.map((c) => `${c.id}/${c.name}: ${c.errorMessage}`)).toEqual( + [] + ); + + expect( + byId(checks, 'sep-1932-server-validate-proof').every( + (c) => c.status === 'SUCCESS' + ) + ).toBe(true); + expect( + byId(checks, 'sep-1932-server-iat-window').every( + (c) => c.status === 'SUCCESS' + ) + ).toBe(true); + expect( + byId(checks, 'sep-1932-asymmetric-alg-only').every( + (c) => c.status === 'SUCCESS' + ) + ).toBe(true); + expect( + byId(checks, 'sep-1932-server-audience-validation').every( + (c) => c.status === 'SUCCESS' + ) + ).toBe(true); + expect( + byId(checks, 'sep-1932-server-reject-401').every( + (c) => c.status === 'SUCCESS' + ) + ).toBe(true); + // Compliant server does not require a nonce → nonce flow is optional/SKIPPED. + expect(byId(checks, 'sep-1932-server-nonce')[0].status).toBe('SKIPPED'); + }, 30000); + + it('emits FAILURE against a server that does not validate DPoP', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.broken)) + ); + + const rejects = byId(checks, 'sep-1932-server-validate-proof').filter((c) => + c.name.startsWith('Rejects') + ); + expect(rejects.length).toBeGreaterThan(0); + expect(rejects.every((c) => c.status === 'FAILURE')).toBe(true); + + expect(byId(checks, 'sep-1932-server-reject-401')[0].status).toBe( + 'FAILURE' + ); + expect( + byId(checks, 'sep-1932-server-iat-window').every( + (c) => c.status === 'FAILURE' + ) + ).toBe(true); + expect( + byId(checks, 'sep-1932-asymmetric-alg-only').every( + (c) => c.status === 'FAILURE' + ) + ).toBe(true); + expect(byId(checks, 'sep-1932-server-audience-validation')[0].status).toBe( + 'FAILURE' + ); + }, 30000); + + it('reports rejection checks notTestable (not vacuous SUCCESS) against a reject-everything server', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.rejectAll)) + ); + + // The valid baseline is refused → the positive check fails. + const positive = byId(checks, 'sep-1932-server-validate-proof').find( + (c) => c.name === 'AcceptsValidProof' + ); + expect(positive?.status).toBe('FAILURE'); + + // Every rejection check must be gated to notTestable (#248), never SUCCESS: + // a 401 here cannot be attributed to validation when the server 401s + // everything — otherwise a reject-all server would pass the whole battery. + const gated = [ + ...byId(checks, 'sep-1932-server-validate-proof').filter((c) => + c.name.startsWith('Rejects') + ), + ...byId(checks, 'sep-1932-server-iat-window'), + ...byId(checks, 'sep-1932-asymmetric-alg-only'), + ...byId(checks, 'sep-1932-server-audience-validation'), + ...byId(checks, 'sep-1932-server-reject-401') + ]; + + expect(gated.length).toBeGreaterThan(0); + expect(gated.every((c) => c.details?.untestable === true)).toBe(true); + expect(gated.some((c) => c.status === 'SUCCESS')).toBe(false); + }, 30000); + + // The baseline completes the nonce handshake (acceptValid retries with the + // server-issued nonce), so it passes against a nonce-requiring server. A + // server that requires a nonce but checks it AFTER structural validation + // still rejects the malformed negatives on their real defect, so those checks + // stay meaningful; the nonce-FIRST case below covers the adversary that gates + // on the nonce before anything else. + it('reports the nonce check SUCCESS against a correct nonce server', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.strict)) + ); + expect(byId(checks, 'sep-1932-server-nonce')[0].status).toBe('SUCCESS'); + }, 30000); + + it('correctly tests a nonce-first server by retrying negatives with the nonce', async () => { + // Without the retry, a server that gates on the nonce before any structural + // check would answer every nonce-less negative with use_dpop_nonce → each + // check would go not-testable → a red run for a *conformant* server. The + // scenario instead folds the handshake nonce into the negatives, so the + // server rejects each on its real defect (SUCCESS). Only malformed-not-a-jwt, + // which can't carry a nonce, stays not-testable. + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.nonceFirst)) + ); + + // Baseline succeeds via the nonce handshake. + expect( + byId(checks, 'sep-1932-server-validate-proof').find( + (c) => c.name === 'AcceptsValidProof' + )?.status + ).toBe('SUCCESS'); + + const rejections = [ + ...byId(checks, 'sep-1932-server-validate-proof').filter((c) => + c.name.startsWith('Rejects') + ), + ...byId(checks, 'sep-1932-server-iat-window'), + ...byId(checks, 'sep-1932-asymmetric-alg-only'), + ...byId(checks, 'sep-1932-server-audience-validation'), + ...byId(checks, 'sep-1932-server-reject-401') + ]; + // No spurious FAILURE against a conformant server, and the negatives are + // genuinely exercised (SUCCESS) rather than all going not-testable. + // No *genuine* FAILURE against a conformant server (untestable checks carry + // FAILURE status but are flagged details.untestable — handled separately). + expect( + rejections + .filter((c) => c.status === 'FAILURE' && !c.details?.untestable) + .map((c) => `${c.name}: ${c.errorMessage}`) + ).toEqual([]); + // Every retryable negative is genuinely exercised (SUCCESS); exactly one — + // the malformed proof that can't carry a nonce — remains untestable. + expect(rejections.filter((c) => c.status === 'SUCCESS').length).toBe( + rejections.length - 1 + ); + const malformed = byId(checks, 'sep-1932-server-validate-proof').find( + (c) => c.name === 'RejectsMalformedProof' + ); + expect(malformed?.details?.untestable).toBe(true); + }, 30000); + + it('reports the nonce check WARNING against a server that accepts any nonce', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.buggy)) + ); + expect(byId(checks, 'sep-1932-server-nonce')[0].status).toBe('WARNING'); + }, 30000); + + it('anchors iat probes to the server clock (no false failure under skew)', async () => { + // The server's clock is 30s behind the framework. Without anchoring, the + // stale probe (now−301s by the framework clock) looks only ~271s old to the + // server → inside ±5 min → wrongly accepted → the iat check would FAIL a + // conformant server. Anchoring to the server's Date header keeps it rejected. + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.clockSkew)) + ); + expect( + byId(checks, 'sep-1932-server-iat-window').every( + (c) => c.status === 'SUCCESS' + ) + ).toBe(true); + }, 30000); +}); diff --git a/src/scenarios/server/auth/dpop.ts b/src/scenarios/server/auth/dpop.ts new file mode 100644 index 00000000..9cd31fb0 --- /dev/null +++ b/src/scenarios/server/auth/dpop.ts @@ -0,0 +1,928 @@ +/** + * DPoP server proof-validation scenario (SEP-1932 / RFC 9449). + * + * The framework acts as a DPoP client against the MCP server under test: it + * presents a valid DPoP-bound access token + proof (expect acceptance) and a + * battery of deliberately-malformed requests (expect 401), recording one check + * per case. Emits the sep-1932-server-* check IDs declared in + * src/seps/sep-1932.yaml. + * + * Token-issuer trust is supplied via env so the server under test can validate + * the access token (the compliant example server reads the matching public key): + * DPOP_ISSUER_PRIVATE_JWK (JSON), DPOP_ISSUER. Falls back to an ephemeral + * issuer if unset (only a server configured to trust it will then pass). + */ + +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../../types'; +import { + buildStandardHeaders, + withRequestMeta, + type RunContext +} from '../../../connection'; +import { request } from 'undici'; +import { untestableCheck } from '../../untestable'; +import { + generateDpopKeyPair, + buildDpopProof as baseBuildDpopProof +} from '../../client/auth/helpers/dpopProof'; +import { + generateIssuerKey, + importIssuerKey, + mintDpopBoundToken, + type TokenIssuerKey +} from '../../client/auth/helpers/dpopToken'; +import { SpecReferences } from './spec-references'; + +const SPEC_REFERENCES = [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_CHECKING_PROOFS, + SpecReferences.RFC_9449_AUTH_SCHEME, + SpecReferences.RFC_9449_NONCE, + SpecReferences.RFC_9449_ALGORITHMS +]; + +interface Probe { + jsonrpc: '2.0'; + id: number; + method: string; + params: Record; +} + +function probeBody(specVersion: string): Probe { + if (specVersion === DRAFT_PROTOCOL_VERSION) { + // Reuse the shared `_meta` envelope builder so the stateless probe carries + // exactly the required keys a strictly-conformant server expects. + return { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: withRequestMeta({}, specVersion) + }; + } + const clientInfo = { name: 'conformance-dpop-server-test', version: '1.0.0' }; + return { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: specVersion, capabilities: {}, clientInfo } + }; +} + +interface Response { + statusCode: number; + wwwAuthenticate: string; + dpopNonce: string | undefined; + /** The server's `Date` header as epoch seconds, if present (RFC 9110 §6.6.1). */ + date: number | undefined; +} + +function isAccepted(status: number): boolean { + return status >= 200 && status < 300; +} + +// A DPoP failure MUST be a 401 carrying a WWW-Authenticate: DPoP challenge +// (SEP-1932 / RFC 9449 §7.1) — not merely "some 4xx", so that an unrelated +// MCP-layer rejection cannot vacuously pass a negative check. +// +// The header may advertise several challenges (e.g. `Bearer ..., DPoP ...`), +// so match a `DPoP` auth-scheme token at the start or after a comma rather +// than requiring the header to *begin* with it (RFC 9110 §11.6.1). +function hasDpopChallenge(wwwAuthenticate: string): boolean { + return /(?:^|,)\s*dpop(?:\s|$|,)/i.test(wwwAuthenticate); +} + +function properlyRejected(res: Response): boolean { + return res.statusCode === 401 && hasDpopChallenge(res.wwwAuthenticate); +} + +// A `use_dpop_nonce` challenge means the server is demanding a nonce before it +// will look at the proof. The negative probes are sent without a nonce, so such +// a 401 is a rejection for the MISSING NONCE, not for the injected defect — it +// cannot be attributed to the defect and must not count as a proper rejection. +function isNonceChallenge(res: Response): boolean { + return ( + res.statusCode === 401 && + res.wwwAuthenticate.toLowerCase().includes('use_dpop_nonce') + ); +} + +function dpopCheck( + id: string, + name: string, + description: string, + status: ConformanceCheck['status'], + errorMessage?: string, + details?: Record +): ConformanceCheck { + return { + id, + name, + description, + timestamp: new Date().toISOString(), + specReferences: SPEC_REFERENCES, + status, + ...(errorMessage ? { errorMessage } : {}), + ...(details ? { details } : {}) + }; +} + +// Reason a rejection check cannot be attributed when the baseline is refused. +function gateReason(caseLabel: unknown): string { + return `server did not accept the valid baseline DPoP request, so a rejection of the ${String(caseLabel ?? 'malformed')} case cannot be distinguished from a server that rejects everything`; +} + +// A probe that throws (proof build / transport error) is a genuine FAILURE when +// the baseline was accepted, but — like the gated checks — not attributable when +// it wasn't, so the catch mirrors the gate rather than emitting a raw FAILURE. +function probeErrorCheck( + positiveAccepted: boolean, + id: string, + name: string, + description: string, + caseLabel: string, + error: unknown +): ConformanceCheck { + return positiveAccepted + ? dpopCheck(id, name, description, 'FAILURE', String(error), { + case: caseLabel + }) + : untestableCheck( + id, + name, + description, + gateReason(caseLabel), + SPEC_REFERENCES + ); +} + +// Build a check that passes when the server properly rejected a malformed +// request (401 + DPoP challenge) and fails otherwise. +// +// Gated on the positive baseline: a server that refuses even a valid DPoP +// request would 401 every negative probe too, making these checks pass +// vacuously — so when `positiveAccepted` is false we report them notTestable +// (#248) rather than SUCCESS. `predicate` lets a case relax what counts as a +// proper rejection (e.g. the Bearer-scheme case, where no DPoP challenge is +// required). +function rejectionCheck( + positiveAccepted: boolean, + id: string, + name: string, + description: string, + res: Response, + details: Record, + predicate: (res: Response) => boolean = properlyRejected +): ConformanceCheck { + if (!positiveAccepted) { + return untestableCheck( + id, + name, + description, + gateReason(details.case), + SPEC_REFERENCES + ); + } + if (isNonceChallenge(res)) { + return untestableCheck( + id, + name, + description, + `server answered with a DPoP nonce challenge (use_dpop_nonce), so this rejection cannot be attributed to the ${String(details.case ?? 'injected')} defect rather than the missing nonce`, + SPEC_REFERENCES + ); + } + const ok = predicate(res); + return dpopCheck( + id, + name, + description, + ok ? 'SUCCESS' : 'FAILURE', + ok + ? undefined + : `Expected the server to reject this request, got ${res.statusCode} / "${res.wwwAuthenticate}"`, + { + ...details, + statusCode: res.statusCode, + wwwAuthenticate: res.wwwAuthenticate + } + ); +} + +async function resolveIssuer(): Promise<{ + issuerKey: TokenIssuerKey; + issuer: string; +}> { + const issuer = + process.env.DPOP_ISSUER || 'https://conformance-dpop-issuer.example.com'; + const envJwk = process.env.DPOP_ISSUER_PRIVATE_JWK; + if (envJwk) { + const jwk = JSON.parse(envJwk); + return { + issuerKey: await importIssuerKey(jwk, jwk.alg || 'ES256'), + issuer + }; + } + return { issuerKey: await generateIssuerKey(), issuer }; +} + +export class DPoPServerValidationScenario implements ClientScenario { + name = 'auth/dpop-server-validation'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Test that an MCP server validates DPoP (RFC 9449) sender-constrained access tokens (SEP-1932). + +The framework acts as a DPoP client: it presents a valid DPoP-bound access token +and proof (which a conformant server MUST accept) and a series of deliberately +malformed requests (which a conformant server MUST reject with HTTP 401 and a +\`WWW-Authenticate: DPoP\` challenge), following RFC 9449 §4.3. + +Covers: proof validation per §4.3, the ±5-minute \`iat\` window, asymmetric-only +algorithms, the 401 challenge format, token audience validation under DPoP, and +(optionally) the server-provided nonce flow.`; + + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; + const checks: ConformanceCheck[] = []; + + const { issuerKey, issuer } = await resolveIssuer(); + const audience = serverUrl; + const kp = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + issuer, + audience, + jkt: kp.thumbprint + }); + + const send = async ( + authz: string, + dpop: string | string[] | undefined + ): Promise => { + const probe = probeBody(specVersion); + const base = buildStandardHeaders(probe.method, probe.params, { + specVersion + }); + const headers: Record = { + ...base, + Authorization: authz + }; + if (dpop !== undefined) headers['DPoP'] = dpop; + const res = await request(serverUrl, { + method: 'POST', + headers, + body: JSON.stringify(probe) + }); + // Drain the body so the socket can be reused / freed. + try { + await res.body.text(); + } catch { + /* ignore */ + } + // undici may surface a repeated header as string[]; coalesce so challenge + // matching sees every advertised scheme. + const rawWww = res.headers['www-authenticate']; + const wwwAuthenticate = Array.isArray(rawWww) + ? rawWww.join(', ') + : rawWww || ''; + const rawNonce = res.headers['dpop-nonce']; + const dpopNonce = Array.isArray(rawNonce) ? rawNonce[0] : rawNonce; + const rawDate = res.headers['date']; + const dateStr = Array.isArray(rawDate) ? rawDate[0] : rawDate; + const parsedDate = dateStr ? Date.parse(dateStr) : NaN; + const date = Number.isNaN(parsedDate) + ? undefined + : Math.floor(parsedDate / 1000); + return { statusCode: res.statusCode, wwwAuthenticate, dpopNonce, date }; + }; + + // The server-provided nonce captured from the baseline handshake (RFC 9449 + // §9), if the server requires one. Once set, the local `buildDpopProof` + // wrapper folds it into every subsequent proof — including the negatives — + // so a server that checks the nonce first still evaluates the injected + // defect rather than merely challenging for a nonce. + let heldNonce: string | undefined; + const buildDpopProof = ( + opts: Parameters[0] + ): Promise => + baseBuildDpopProof({ + ...opts, + ...(heldNonce ? { nonce: heldNonce } : {}) + }); + + const validProof = (): Promise => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token + }); + + // Send a valid DPoP-bound request, transparently completing the nonce + // handshake if the server demands one (RFC 9449 §9): a server that requires + // a nonce answers the first (nonce-less) proof with 401 use_dpop_nonce + + // DPoP-Nonce, so we capture the nonce and retry once before judging whether + // it accepts a valid request (and reuse the nonce for the negatives). + const acceptValid = async (): Promise => { + const first = await send(`DPoP ${token}`, await validProof()); + if ( + first.statusCode === 401 && + first.dpopNonce && + first.wwwAuthenticate.includes('use_dpop_nonce') + ) { + heldNonce = first.dpopNonce; + return send(`DPoP ${token}`, await validProof()); + } + return first; + }; + + // ---- Positive: a valid DPoP-bound request is accepted ---- + // Whether this succeeds gates every rejection check below (#248): if the + // server refuses a valid request, a 401 on a malformed one proves nothing. + // Also anchor iat probes to the server's own clock (its `Date` header) so + // the ±5-minute boundary is measured against the clock the server validates + // against, immune to framework↔server skew. + let positiveAccepted = false; + let serverClockOffset = 0; + try { + const res = await acceptValid(); + if (res.date !== undefined) { + serverClockOffset = res.date - Math.floor(Date.now() / 1000); + } + positiveAccepted = isAccepted(res.statusCode); + checks.push( + dpopCheck( + 'sep-1932-server-validate-proof', + 'AcceptsValidProof', + 'Server accepts a valid DPoP-bound access token and proof', + positiveAccepted ? 'SUCCESS' : 'FAILURE', + positiveAccepted ? undefined : `Expected 2xx, got ${res.statusCode}`, + { case: 'valid', statusCode: res.statusCode } + ) + ); + } catch (e) { + checks.push( + dpopCheck( + 'sep-1932-server-validate-proof', + 'AcceptsValidProof', + 'Server accepts a valid DPoP-bound access token and proof', + 'FAILURE', + String(e), + { case: 'valid' } + ) + ); + } + + // ---- Negative §4.3 variants: each malformed proof must be rejected ---- + // `buildDpop` is a thunk so a failure minting one proof isolates to that + // case (caught below) instead of aborting the whole battery. `predicate` + // and `description` override the default (401 + DPoP challenge) where a + // case is judged differently. + const negatives: Array<{ + case: string; + name: string; + authz: string; + buildDpop: () => Promise; + predicate?: (res: Response) => boolean; + description?: string; + }> = [ + { + case: 'tampered-signature', + name: 'RejectsTamperedSignature', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + tamperSignature: true + }) + }, + { + case: 'missing-jti', + name: 'RejectsMissingJti', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + omit: ['jti'] + }) + }, + { + case: 'wrong-typ', + name: 'RejectsWrongTyp', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + typ: 'jwt' + }) + }, + { + case: 'htu-mismatch', + name: 'RejectsHtuMismatch', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: 'https://wrong.example.com/mcp', + accessToken: token + }) + }, + { + case: 'htm-mismatch', + name: 'RejectsHtmMismatch', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'GET', + htu: serverUrl, + accessToken: token + }) + }, + { + case: 'private-key-in-jwk', + name: 'RejectsPrivateKeyInJwk', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + embedPrivateKey: true + }) + }, + { + case: 'wrong-ath', + name: 'RejectsWrongAth', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + athOverride: 'not-the-right-hash' + }) + }, + { + // A DPoP-bound token presented under the Bearer scheme MUST NOT be + // accepted, but the server need not answer with a DPoP challenge (it may + // treat it as a Bearer failure) — so accept any non-2xx as a rejection. + case: 'bearer-scheme', + name: 'RejectsBearerScheme', + authz: `Bearer ${token}`, + buildDpop: () => validProof(), + predicate: (res) => !isAccepted(res.statusCode), + description: + 'Server does not accept a DPoP-bound token presented under the Bearer scheme' + }, + { + case: 'duplicate-dpop-header', + name: 'RejectsDuplicateDpopHeader', + authz: `DPoP ${token}`, + buildDpop: async () => [await validProof(), await validProof()] + }, + { + case: 'missing-htm', + name: 'RejectsMissingHtm', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + omit: ['htm'] + }) + }, + { + case: 'missing-htu', + name: 'RejectsMissingHtu', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + omit: ['htu'] + }) + }, + { + case: 'missing-iat', + name: 'RejectsMissingIat', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + omit: ['iat'] + }) + }, + { + case: 'missing-jwk', + name: 'RejectsMissingJwk', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + omit: ['jwk'] + }) + }, + { + // Token is presented (Authorization: DPoP ...) but the proof carries no + // ath claim — RFC 9449 §4.3 step 12a requires it. + case: 'ath-absent', + name: 'RejectsAthAbsent', + authz: `DPoP ${token}`, + buildDpop: () => + buildDpopProof({ keyPair: kp, htm: 'POST', htu: serverUrl }) + }, + { + case: 'malformed-not-a-jwt', + name: 'RejectsMalformedProof', + authz: `DPoP ${token}`, + buildDpop: () => Promise.resolve('this-is-not-a-jwt') + } + ]; + + for (const n of negatives) { + const description = + n.description ?? `Server rejects a DPoP request with defect: ${n.case}`; + try { + const dpop = await n.buildDpop(); + const res = await send(n.authz, dpop); + checks.push( + rejectionCheck( + positiveAccepted, + 'sep-1932-server-validate-proof', + n.name, + description, + res, + { case: n.case }, + n.predicate + ) + ); + } catch (e) { + checks.push( + probeErrorCheck( + positiveAccepted, + 'sep-1932-server-validate-proof', + n.name, + description, + n.case, + e + ) + ); + } + } + + // ---- cnf.jkt mismatch (token bound to a foreign key) ---- + try { + const foreign = await generateDpopKeyPair(); + const mismatchToken = await mintDpopBoundToken({ + issuerKey, + issuer, + audience, + jkt: kp.thumbprint, + jktOverride: foreign.thumbprint + }); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: mismatchToken + }); + const res = await send(`DPoP ${mismatchToken}`, proof); + checks.push( + rejectionCheck( + positiveAccepted, + 'sep-1932-server-validate-proof', + 'RejectsCnfJktMismatch', + 'Server rejects a token whose cnf.jkt does not match the proof key', + res, + { case: 'cnf-jkt-mismatch' } + ) + ); + } catch (e) { + checks.push( + probeErrorCheck( + positiveAccepted, + 'sep-1932-server-validate-proof', + 'RejectsCnfJktMismatch', + 'Server rejects a token whose cnf.jkt does not match the proof key', + 'cnf-jkt-mismatch', + e + ) + ); + } + + // ---- iat acceptance window ---- + // Probes sit just outside the ±5-minute window (±300 s). The future probe + // uses +303 s (not +301) because `iat` is whole-seconds and the proof drifts + // ~1 s toward "now" in transit; +301 could read as +300 (in-window) at the + // server and false-accept. The stale side only ages further, so −301 is safe. + for (const { label, name, iatDelta } of [ + { label: 'stale', name: 'RejectsStaleIat', iatDelta: -301 }, + { label: 'future', name: 'RejectsFutureIat', iatDelta: 303 } + ]) { + const description = `Server rejects a proof whose iat is ${label} — just outside the ±5-minute window (RFC 9449 §4.3 / SEP-1932)`; + try { + const iat = + Math.floor(Date.now() / 1000) + serverClockOffset + iatDelta; + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + iat + }); + const res = await send(`DPoP ${token}`, proof); + checks.push( + rejectionCheck( + positiveAccepted, + 'sep-1932-server-iat-window', + name, + description, + res, + { case: `iat-${label}` } + ) + ); + } catch (e) { + checks.push( + probeErrorCheck( + positiveAccepted, + 'sep-1932-server-iat-window', + name, + description, + `iat-${label}`, + e + ) + ); + } + } + + // ---- asymmetric-only algorithm ---- + for (const { label, name, opt } of [ + { + label: 'none', + name: 'RejectsAlgNone', + opt: { unsigned: true } as const + }, + { + label: 'symmetric', + name: 'RejectsAlgSymmetric', + opt: { symmetric: true } as const + } + ]) { + try { + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + ...opt + }); + const res = await send(`DPoP ${token}`, proof); + checks.push( + rejectionCheck( + positiveAccepted, + 'sep-1932-asymmetric-alg-only', + name, + `Server rejects a proof signed with a non-asymmetric algorithm (${label})`, + res, + { case: `alg-${label}` } + ) + ); + } catch (e) { + checks.push( + probeErrorCheck( + positiveAccepted, + 'sep-1932-asymmetric-alg-only', + name, + `Server rejects a proof signed with a non-asymmetric algorithm (${label})`, + `alg-${label}`, + e + ) + ); + } + } + + // ---- token audience validation under DPoP ---- + try { + const wrongAudToken = await mintDpopBoundToken({ + issuerKey, + issuer, + audience: 'https://not-this-server.example.com/mcp', + jkt: kp.thumbprint + }); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: wrongAudToken + }); + const res = await send(`DPoP ${wrongAudToken}`, proof); + checks.push( + rejectionCheck( + positiveAccepted, + 'sep-1932-server-audience-validation', + 'RejectsWrongAudience', + 'Server rejects an access token whose audience is not this server, even with a valid proof', + res, + { case: 'wrong-audience' } + ) + ); + } catch (e) { + checks.push( + probeErrorCheck( + positiveAccepted, + 'sep-1932-server-audience-validation', + 'RejectsWrongAudience', + 'Server rejects an access token whose audience is not this server, even with a valid proof', + 'wrong-audience', + e + ) + ); + } + + // ---- 401 + WWW-Authenticate challenge format (on a known-bad request) ---- + const challengeDesc = + 'On validation failure the server responds 401 with a WWW-Authenticate: DPoP challenge'; + if (!positiveAccepted) { + // A server that 401s everything trivially "passes" this — can't attribute + // the challenge to a validation failure, so report it notTestable (#248). + checks.push( + untestableCheck( + 'sep-1932-server-reject-401', + 'RejectsWith401Challenge', + challengeDesc, + gateReason('challenge-format'), + SPEC_REFERENCES + ) + ); + } else { + try { + const tampered = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + tamperSignature: true + }); + const res = await send(`DPoP ${token}`, tampered); + if (isNonceChallenge(res)) { + checks.push( + untestableCheck( + 'sep-1932-server-reject-401', + 'RejectsWith401Challenge', + challengeDesc, + 'server answered with a DPoP nonce challenge (use_dpop_nonce), so this 401 cannot be attributed to the validation failure', + SPEC_REFERENCES + ) + ); + } else { + const ok = properlyRejected(res); + checks.push( + dpopCheck( + 'sep-1932-server-reject-401', + 'RejectsWith401Challenge', + challengeDesc, + ok ? 'SUCCESS' : 'FAILURE', + ok + ? undefined + : `Expected 401 + WWW-Authenticate: DPoP, got ${res.statusCode} / "${res.wwwAuthenticate}"`, + { + statusCode: res.statusCode, + wwwAuthenticate: res.wwwAuthenticate + } + ) + ); + } + } catch (e) { + checks.push( + dpopCheck( + 'sep-1932-server-reject-401', + 'RejectsWith401Challenge', + challengeDesc, + 'FAILURE', + String(e) + ) + ); + } + } + + // ---- server-provided nonce (SHOULD / WARNING) — only if the server uses it ---- + // This section manages the nonce explicitly, so it builds proofs with the + // raw `baseBuildDpopProof` (not the held-nonce-injecting wrapper): the + // detection probe MUST be nonce-less to observe whether the server challenges. + try { + const first = await send( + `DPoP ${token}`, + await baseBuildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token + }) + ); + const requiresNonce = + first.statusCode === 401 && + first.wwwAuthenticate.includes('use_dpop_nonce'); + if (!requiresNonce) { + checks.push( + dpopCheck( + 'sep-1932-server-nonce', + 'NonceFlow', + 'Server-provided nonce flow (optional; server did not request a nonce)', + 'SKIPPED', + undefined, + { reason: 'server does not require a DPoP nonce' } + ) + ); + } else if (!first.dpopNonce) { + checks.push( + dpopCheck( + 'sep-1932-server-nonce', + 'NonceFlow', + 'Server issues use_dpop_nonce + DPoP-Nonce, accepts the matching-nonce retry, and rejects a wrong nonce', + 'WARNING', + 'use_dpop_nonce returned without a DPoP-Nonce header' + ) + ); + } else { + const retry = await send( + `DPoP ${token}`, + await baseBuildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + nonce: first.dpopNonce + }) + ); + // A conformant nonce server MUST also reject a WRONG nonce + // (RFC 9449 §4.3 step 10), else the nonce adds no replay protection. + const wrong = await send( + `DPoP ${token}`, + await baseBuildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + nonce: 'definitely-not-the-server-nonce' + }) + ); + const ok = isAccepted(retry.statusCode) && properlyRejected(wrong); + // SHOULD-level: satisfied → SUCCESS; partial/buggy nonce impl → WARNING. + checks.push( + dpopCheck( + 'sep-1932-server-nonce', + 'NonceFlow', + 'Server issues use_dpop_nonce + DPoP-Nonce, accepts the matching-nonce retry, and rejects a wrong nonce', + ok ? 'SUCCESS' : 'WARNING', + ok + ? undefined + : `Nonce flow incomplete: matching-retry=${retry.statusCode}, wrong-nonce=${wrong.statusCode}`, + { + nonce: first.dpopNonce, + retryStatus: retry.statusCode, + wrongNonceStatus: wrong.statusCode + } + ) + ); + } + } catch (e) { + checks.push( + dpopCheck( + 'sep-1932-server-nonce', + 'NonceFlow', + 'Server issues use_dpop_nonce + DPoP-Nonce, accepts the matching-nonce retry, and rejects a wrong nonce', + 'WARNING', + String(e) + ) + ); + } + + return checks; + } +} diff --git a/src/scenarios/server/auth/spec-references.ts b/src/scenarios/server/auth/spec-references.ts new file mode 100644 index 00000000..4b940894 --- /dev/null +++ b/src/scenarios/server/auth/spec-references.ts @@ -0,0 +1,28 @@ +import { SpecReference } from '../../../types'; + +export const SpecReferences: { [key: string]: SpecReference } = { + SEP_1932_DPOP: { + id: 'SEP-1932-DPoP', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932' + }, + DPOP_EXTENSION: { + id: 'MCP-DPoP-Extension', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx' + }, + RFC_9449_CHECKING_PROOFS: { + id: 'RFC-9449-checking-dpop-proofs', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-4.3' + }, + RFC_9449_AUTH_SCHEME: { + id: 'RFC-9449-dpop-authentication-scheme', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-7.1' + }, + RFC_9449_NONCE: { + id: 'RFC-9449-resource-server-provided-nonce', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-9' + }, + RFC_9449_ALGORITHMS: { + id: 'RFC-9449-dpop-proof-jwt-syntax', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-11.6' + } +}; From 13676b7d4deba9fdb9eb74ddac30ee41c66d447a Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:51:15 +0100 Subject: [PATCH 2/8] server/dpop: round-4 nonce/clock hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refresh the held DPoP nonce from every response's DPoP-Nonce header (RFC 9449 §8.2 newest-wins) instead of capturing it once, so a server that rotates or single-uses its nonce no longer turns the negative probes into false "not testable" failures (or vacuous passes). (#1) - Widen the stale iat probe to -303 s (matching the future side) so the ±1 s quantization of the whole-second Date header can't pull it onto the ±300 s boundary and be false-accepted. (#2) - Update the isNonceChallenge comment and the untestable message: probes now carry the held nonce, so a use_dpop_nonce challenge is about the nonce lifetime (rotated/stale/single-use), not a missing nonce. (#3) - Fixture: parse integer env vars NaN-safely (intEnv helper) so a malformed DPOP_CLOCK_OFFSET_SECONDS / DPOP_IAT_SKEW_SECONDS falls back to its default instead of silently disabling the iat window. (#4) Co-Authored-By: Claude Opus 4.8 --- .../typescript/sep-1932-compliant-server.ts | 11 ++++- src/scenarios/server/auth/dpop.ts | 45 ++++++++++++------- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/examples/servers/typescript/sep-1932-compliant-server.ts b/examples/servers/typescript/sep-1932-compliant-server.ts index 8cece692..8cfc5950 100644 --- a/examples/servers/typescript/sep-1932-compliant-server.ts +++ b/examples/servers/typescript/sep-1932-compliant-server.ts @@ -27,7 +27,14 @@ import { createHash } from 'node:crypto'; const ISSUER_JWK = JSON.parse(process.env.DPOP_ISSUER_JWK || '{}'); const ISSUER = process.env.DPOP_ISSUER || 'https://auth.example.com'; const AUDIENCE = process.env.DPOP_AUDIENCE || ''; -const IAT_SKEW = parseInt(process.env.DPOP_IAT_SKEW_SECONDS || '300', 10); +// Parse an integer env var, falling back to the default on absent OR malformed +// input — a bare parseInt would yield NaN, which silently disables the iat +// window (`Math.abs(...) > NaN` is false) or the clock skew (NaN is falsy). +const intEnv = (name: string, def: number): number => { + const n = parseInt(process.env[name] ?? '', 10); + return Number.isNaN(n) ? def : n; +}; +const IAT_SKEW = intEnv('DPOP_IAT_SKEW_SECONDS', 300); const REQUIRE_NONCE = process.env.DPOP_REQUIRE_NONCE === '1'; const NONCE = process.env.DPOP_NONCE || 'conformance-test-nonce'; // Buggy-nonce mode (negative-test fixture only): challenge when no nonce is @@ -43,7 +50,7 @@ const NONCE_FIRST = process.env.DPOP_NONCE_FIRST === '1'; // Clock-offset mode (negative-test fixture only): shift the server's notion of // "now" — and its `Date` header — by N seconds to simulate a skewed server // clock. Used to prove the scenario anchors its iat probes to the server's Date. -const CLOCK_OFFSET = parseInt(process.env.DPOP_CLOCK_OFFSET_SECONDS || '0', 10); +const CLOCK_OFFSET = intEnv('DPOP_CLOCK_OFFSET_SECONDS', 0); // Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 step 5). const ASYMMETRIC_ALGS = [ diff --git a/src/scenarios/server/auth/dpop.ts b/src/scenarios/server/auth/dpop.ts index 9cd31fb0..6268e778 100644 --- a/src/scenarios/server/auth/dpop.ts +++ b/src/scenarios/server/auth/dpop.ts @@ -100,10 +100,12 @@ function properlyRejected(res: Response): boolean { return res.statusCode === 401 && hasDpopChallenge(res.wwwAuthenticate); } -// A `use_dpop_nonce` challenge means the server is demanding a nonce before it -// will look at the proof. The negative probes are sent without a nonce, so such -// a 401 is a rejection for the MISSING NONCE, not for the injected defect — it -// cannot be attributed to the defect and must not count as a proper rejection. +// A `use_dpop_nonce` challenge means the server is demanding a (different) nonce +// before it will look at the proof. Negative probes carry the held nonce, so +// such a 401 means the server did not accept that nonce (it rotated or +// single-uses it, or wants a fresh one) — the rejection is about the nonce +// lifetime, NOT the injected defect, so it cannot be attributed to the defect +// and must not count as a proper rejection. function isNonceChallenge(res: Response): boolean { return ( res.statusCode === 401 && @@ -192,7 +194,7 @@ function rejectionCheck( id, name, description, - `server answered with a DPoP nonce challenge (use_dpop_nonce), so this rejection cannot be attributed to the ${String(details.case ?? 'injected')} defect rather than the missing nonce`, + `server answered with a DPoP nonce challenge (use_dpop_nonce) despite the probe carrying the held nonce, so this rejection is about the nonce (rotated/stale/single-use) and cannot be attributed to the ${String(details.case ?? 'injected')} defect`, SPEC_REFERENCES ); } @@ -258,6 +260,15 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and jkt: kp.thumbprint }); + // The server-provided nonce (RFC 9449 §8/§9), if the server requires one. + // `send` refreshes it from every response's DPoP-Nonce header (newest wins, + // RFC 9449 §8.2), and the local `buildDpopProof` wrapper folds the current + // value into every subsequent proof — including the negatives — so a server + // that checks the nonce first still evaluates the injected defect rather + // than merely re-challenging. Refreshing (vs capturing once) keeps this + // correct against servers that rotate or single-use their nonces. + let heldNonce: string | undefined; + const send = async ( authz: string, dpop: string | string[] | undefined @@ -296,15 +307,11 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and const date = Number.isNaN(parsedDate) ? undefined : Math.floor(parsedDate / 1000); + // Newest-wins (RFC 9449 §8.2): carry the latest nonce into the next probe. + if (dpopNonce) heldNonce = dpopNonce; return { statusCode: res.statusCode, wwwAuthenticate, dpopNonce, date }; }; - // The server-provided nonce captured from the baseline handshake (RFC 9449 - // §9), if the server requires one. Once set, the local `buildDpopProof` - // wrapper folds it into every subsequent proof — including the negatives — - // so a server that checks the nonce first still evaluates the injected - // defect rather than merely challenging for a nonce. - let heldNonce: string | undefined; const buildDpopProof = ( opts: Parameters[0] ): Promise => @@ -333,7 +340,8 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and first.dpopNonce && first.wwwAuthenticate.includes('use_dpop_nonce') ) { - heldNonce = first.dpopNonce; + // `send` has already refreshed heldNonce from the challenge response, + // so the retry's proof carries it. return send(`DPoP ${token}`, await validProof()); } return first; @@ -637,12 +645,15 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and } // ---- iat acceptance window ---- - // Probes sit just outside the ±5-minute window (±300 s). The future probe - // uses +303 s (not +301) because `iat` is whole-seconds and the proof drifts - // ~1 s toward "now" in transit; +301 could read as +300 (in-window) at the - // server and false-accept. The stale side only ages further, so −301 is safe. + // Probes sit just outside the ±5-minute window (±300 s), ±303 s on both + // sides. The 3 s margin absorbs two sources of ±1 s error that a bare ±301 + // would not: the whole-second `Date` header makes `serverClockOffset` + // quantized to ±1 s, and `iat` is whole-seconds and drifts ~1 s toward "now" + // in transit. At ±301 either could pull the probe onto the ±300 boundary and + // be false-accepted; ±303 stays safely outside for a conformant server while + // still being well inside a rejection for any sane implementation. for (const { label, name, iatDelta } of [ - { label: 'stale', name: 'RejectsStaleIat', iatDelta: -301 }, + { label: 'stale', name: 'RejectsStaleIat', iatDelta: -303 }, { label: 'future', name: 'RejectsFutureIat', iatDelta: 303 } ]) { const description = `Server rejects a proof whose iat is ${label} — just outside the ±5-minute window (RFC 9449 §4.3 / SEP-1932)`; From dd9fd77ab8244bd08eb41df856e10760ca9543fc Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:59:14 +0100 Subject: [PATCH 3/8] server/dpop: round-5 comment fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Soften the heldNonce comment: newest-wins refresh is correct for ROTATING servers; a strict single-use server that re-arms only via challenges can still push alternate negatives to untestable (correctly reported, not mis-scored) — the previous "rotate or single-use" over-claimed. (R5) - Fix the clock-skew test comment: the stale probe is now -303s (~273s old), not -301s/~271s. (R5) Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 --- src/scenarios/server/auth/dpop.test.ts | 2 +- src/scenarios/server/auth/dpop.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/scenarios/server/auth/dpop.test.ts b/src/scenarios/server/auth/dpop.test.ts index 21f80a9e..54dca1da 100644 --- a/src/scenarios/server/auth/dpop.test.ts +++ b/src/scenarios/server/auth/dpop.test.ts @@ -378,7 +378,7 @@ describe('DPoP server validation scenario', () => { it('anchors iat probes to the server clock (no false failure under skew)', async () => { // The server's clock is 30s behind the framework. Without anchoring, the - // stale probe (now−301s by the framework clock) looks only ~271s old to the + // stale probe (now−303s by the framework clock) looks only ~273s old to the // server → inside ±5 min → wrongly accepted → the iat check would FAIL a // conformant server. Anchoring to the server's Date header keeps it rejected. const checks = await new DPoPServerValidationScenario().run( diff --git a/src/scenarios/server/auth/dpop.ts b/src/scenarios/server/auth/dpop.ts index 6268e778..1cfe840d 100644 --- a/src/scenarios/server/auth/dpop.ts +++ b/src/scenarios/server/auth/dpop.ts @@ -266,7 +266,11 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and // value into every subsequent proof — including the negatives — so a server // that checks the nonce first still evaluates the injected defect rather // than merely re-challenging. Refreshing (vs capturing once) keeps this - // correct against servers that rotate or single-use their nonces. + // correct against servers that ROTATE their nonce (each response carries the + // next one). A strict single-use server that re-arms the nonce only via a + // use_dpop_nonce challenge (not on ordinary rejections, which §8.2 doesn't + // require) can still push alternate negatives to untestable — acceptable, as + // those are correctly reported not-testable rather than mis-scored. let heldNonce: string | undefined; const send = async ( From dc35b8c070fa9b344ad21aa10578b06da775f75f Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Fri, 25 Sep 2026 09:02:12 -0700 Subject: [PATCH 4/8] server/dpop: require 401 + challenge for Bearer-scheme rejection A crash or 404 must not count as correctly refusing a DPoP-bound token presented as Bearer. Co-authored-by: Cursor --- .../typescript/sep-1932-compliant-server.ts | 18 +++++++- src/scenarios/server/auth/dpop.test.ts | 42 +++++++++++++++++-- src/scenarios/server/auth/dpop.ts | 27 +++++++++--- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/examples/servers/typescript/sep-1932-compliant-server.ts b/examples/servers/typescript/sep-1932-compliant-server.ts index 8cfc5950..db8fda54 100644 --- a/examples/servers/typescript/sep-1932-compliant-server.ts +++ b/examples/servers/typescript/sep-1932-compliant-server.ts @@ -14,7 +14,8 @@ * Trust config is supplied via env (the scenario mints tokens with the matching * issuer private key): * PORT, DPOP_ISSUER_JWK (public JWK JSON), DPOP_ISSUER, DPOP_AUDIENCE, - * DPOP_IAT_SKEW_SECONDS (default 300), DPOP_REQUIRE_NONCE ('1'), DPOP_NONCE. + * DPOP_IAT_SKEW_SECONDS (default 300), DPOP_REQUIRE_NONCE ('1'), DPOP_NONCE, + * DPOP_BEARER_REJECT_STATUS (negative-test: Bearer-scheme status, no challenge). */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -51,6 +52,12 @@ const NONCE_FIRST = process.env.DPOP_NONCE_FIRST === '1'; // "now" — and its `Date` header — by N seconds to simulate a skewed server // clock. Used to prove the scenario anchors its iat probes to the server's Date. const CLOCK_OFFSET = intEnv('DPOP_CLOCK_OFFSET_SECONDS', 0); +// Bearer-downgrade negative mode: answer a Bearer-scheme request with this +// status and no WWW-Authenticate challenge. Used to prove RejectsBearerScheme +// FAILs a server that crashes or 404s instead of authenticating. +const BEARER_REJECT_STATUS = process.env.DPOP_BEARER_REJECT_STATUS + ? intEnv('DPOP_BEARER_REJECT_STATUS', 0) + : 0; // Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 step 5). const ASYMMETRIC_ALGS = [ @@ -281,6 +288,15 @@ if (CLOCK_OFFSET) { } app.post('/mcp', async (req: Request, res: Response) => { + const authz = req.headers.authorization ?? ''; + if (BEARER_REJECT_STATUS && authz.startsWith('Bearer ')) { + res.status(BEARER_REJECT_STATUS).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal error' }, + id: null + }); + return; + } const result = await validateDpop(req); if (!result.ok) { send401(res, result); diff --git a/src/scenarios/server/auth/dpop.test.ts b/src/scenarios/server/auth/dpop.test.ts index 54dca1da..734fc988 100644 --- a/src/scenarios/server/auth/dpop.test.ts +++ b/src/scenarios/server/auth/dpop.test.ts @@ -120,6 +120,7 @@ describe('DPoP server validation scenario', () => { let nonceBuggy: ChildProcess | null = null; let nonceFirst: ChildProcess | null = null; let clockSkew: ChildProcess | null = null; + let bearerReject: ChildProcess | null = null; let ports: { compliant: number; broken: number; @@ -128,6 +129,7 @@ describe('DPoP server validation scenario', () => { buggy: number; nonceFirst: number; clockSkew: number; + bearerReject: number; }; let savedEnv: { jwk?: string; issuer?: string }; @@ -147,7 +149,7 @@ describe('DPoP server validation scenario', () => { process.env.DPOP_ISSUER_PRIVATE_JWK = JSON.stringify(privateJwk); process.env.DPOP_ISSUER = ISSUER; - const [cp, bp, rp, sp, gp, nf, ck] = await freePorts(7); + const [cp, bp, rp, sp, gp, nf, ck, br] = await freePorts(8); ports = { compliant: cp, broken: bp, @@ -155,7 +157,8 @@ describe('DPoP server validation scenario', () => { strict: sp, buggy: gp, nonceFirst: nf, - clockSkew: ck + clockSkew: ck, + bearerReject: br }; const issuerEnv = (port: number, extra: Record = {}) => ({ @@ -172,7 +175,8 @@ describe('DPoP server validation scenario', () => { nonceStrict, nonceBuggy, nonceFirst, - clockSkew + clockSkew, + bearerReject ] = await Promise.all([ startServer(COMPLIANT, cp, issuerEnv(cp)), startServer(BROKEN, bp, {}), @@ -192,6 +196,11 @@ describe('DPoP server validation scenario', () => { COMPLIANT, ck, issuerEnv(ck, { DPOP_CLOCK_OFFSET_SECONDS: '-30' }) + ), + startServer( + COMPLIANT, + br, + issuerEnv(br, { DPOP_BEARER_REJECT_STATUS: '500' }) ) ]); }, 60000); @@ -204,7 +213,8 @@ describe('DPoP server validation scenario', () => { stopServer(nonceStrict), stopServer(nonceBuggy), stopServer(nonceFirst), - stopServer(clockSkew) + stopServer(clockSkew), + stopServer(bearerReject) ]); process.env.DPOP_ISSUER_PRIVATE_JWK = savedEnv.jwk; process.env.DPOP_ISSUER = savedEnv.issuer; @@ -227,6 +237,11 @@ describe('DPoP server validation scenario', () => { (c) => c.status === 'SUCCESS' ) ).toBe(true); + expect( + byId(checks, 'sep-1932-server-validate-proof').find( + (c) => c.name === 'RejectsBearerScheme' + )?.status + ).toBe('SUCCESS'); expect( byId(checks, 'sep-1932-server-iat-window').every( (c) => c.status === 'SUCCESS' @@ -376,6 +391,25 @@ describe('DPoP server validation scenario', () => { expect(byId(checks, 'sep-1932-server-nonce')[0].status).toBe('WARNING'); }, 30000); + it('reports FAILURE when a Bearer-scheme request is answered with a non-auth status and no challenge', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.bearerReject)) + ); + + const bearer = byId(checks, 'sep-1932-server-validate-proof').find( + (c) => c.name === 'RejectsBearerScheme' + ); + expect(bearer?.status).toBe('FAILURE'); + + // This fixture only mishandles Bearer-scheme presentation — every other + // validate-proof probe still behaves as on the compliant server. + const others = byId(checks, 'sep-1932-server-validate-proof').filter( + (c) => c.name !== 'RejectsBearerScheme' + ); + expect(others.length).toBeGreaterThan(0); + expect(others.every((c) => c.status === 'SUCCESS')).toBe(true); + }, 30000); + it('anchors iat probes to the server clock (no false failure under skew)', async () => { // The server's clock is 30s behind the framework. Without anchoring, the // stale probe (now−303s by the framework clock) looks only ~273s old to the diff --git a/src/scenarios/server/auth/dpop.ts b/src/scenarios/server/auth/dpop.ts index 1cfe840d..bd4f3cb2 100644 --- a/src/scenarios/server/auth/dpop.ts +++ b/src/scenarios/server/auth/dpop.ts @@ -96,10 +96,24 @@ function hasDpopChallenge(wwwAuthenticate: string): boolean { return /(?:^|,)\s*dpop(?:\s|$|,)/i.test(wwwAuthenticate); } +// RFC 6750 §3 (Bearer) / RFC 9449 §7.1 (DPoP): a scheme token at the start of +// the header or after a comma. Used by the Bearer-downgrade probe, which +// accepts either challenge — the server may treat the request as a Bearer +// failure or as a DPoP presentation error. +function hasBearerOrDpopChallenge(wwwAuthenticate: string): boolean { + return /(?:^|,)\s*(?:bearer|dpop)(?:\s|$|,)/i.test(wwwAuthenticate); +} + function properlyRejected(res: Response): boolean { return res.statusCode === 401 && hasDpopChallenge(res.wwwAuthenticate); } +function bearerSchemeRejected(res: Response): boolean { + return ( + res.statusCode === 401 && hasBearerOrDpopChallenge(res.wwwAuthenticate) + ); +} + // A `use_dpop_nonce` challenge means the server is demanding a (different) nonce // before it will look at the proof. Negative probes carry the held nonce, so // such a 401 means the server did not accept that nonce (it rotated or @@ -168,9 +182,9 @@ function probeErrorCheck( // Gated on the positive baseline: a server that refuses even a valid DPoP // request would 401 every negative probe too, making these checks pass // vacuously — so when `positiveAccepted` is false we report them notTestable -// (#248) rather than SUCCESS. `predicate` lets a case relax what counts as a -// proper rejection (e.g. the Bearer-scheme case, where no DPoP challenge is -// required). +// (#248) rather than SUCCESS. `predicate` lets a case change what counts as a +// proper rejection (e.g. the Bearer-scheme case, which accepts a Bearer or +// DPoP challenge rather than requiring DPoP specifically). function rejectionCheck( positiveAccepted: boolean, id: string, @@ -492,13 +506,14 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and }, { // A DPoP-bound token presented under the Bearer scheme MUST NOT be - // accepted, but the server need not answer with a DPoP challenge (it may - // treat it as a Bearer failure) — so accept any non-2xx as a rejection. + // accepted. Count only a real authentication rejection: 401 plus a + // Bearer (RFC 6750 §3) or DPoP challenge. A 500/404 with no + // WWW-Authenticate is not a rejection of the scheme. case: 'bearer-scheme', name: 'RejectsBearerScheme', authz: `Bearer ${token}`, buildDpop: () => validProof(), - predicate: (res) => !isAccepted(res.statusCode), + predicate: bearerSchemeRejected, description: 'Server does not accept a DPoP-bound token presented under the Bearer scheme' }, From 138573213a6cc3a047181f4ef0c9e16fcce1a009 Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Sun, 4 Oct 2026 08:53:16 -0700 Subject: [PATCH 5/8] server/dpop: prove RejectsBearerScheme accepts a Bearer challenge The compliant fixture only challenged with DPoP, so a later edit could drop the Bearer arm of the check and the suite would still pass. Co-authored-by: Cursor --- .../typescript/sep-1932-compliant-server.ts | 16 +++++++- src/scenarios/server/auth/dpop.test.ts | 38 ++++++++++++++++--- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/examples/servers/typescript/sep-1932-compliant-server.ts b/examples/servers/typescript/sep-1932-compliant-server.ts index db8fda54..700bbf55 100644 --- a/examples/servers/typescript/sep-1932-compliant-server.ts +++ b/examples/servers/typescript/sep-1932-compliant-server.ts @@ -15,7 +15,8 @@ * issuer private key): * PORT, DPOP_ISSUER_JWK (public JWK JSON), DPOP_ISSUER, DPOP_AUDIENCE, * DPOP_IAT_SKEW_SECONDS (default 300), DPOP_REQUIRE_NONCE ('1'), DPOP_NONCE, - * DPOP_BEARER_REJECT_STATUS (negative-test: Bearer-scheme status, no challenge). + * DPOP_BEARER_REJECT_STATUS (negative-test: Bearer-scheme status, no challenge), + * DPOP_BEARER_CHALLENGE ('1': Bearer-scheme probe gets a Bearer challenge). */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -58,6 +59,10 @@ const CLOCK_OFFSET = intEnv('DPOP_CLOCK_OFFSET_SECONDS', 0); const BEARER_REJECT_STATUS = process.env.DPOP_BEARER_REJECT_STATUS ? intEnv('DPOP_BEARER_REJECT_STATUS', 0) : 0; +// Bearer-challenge mode: answer a Bearer-scheme request with 401 and a +// WWW-Authenticate: Bearer challenge (RFC 6750 §3). The default path challenges +// with DPoP; this proves RejectsBearerScheme also accepts the Bearer scheme. +const BEARER_CHALLENGE = process.env.DPOP_BEARER_CHALLENGE === '1'; // Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 step 5). const ASYMMETRIC_ALGS = [ @@ -297,6 +302,15 @@ app.post('/mcp', async (req: Request, res: Response) => { }); return; } + if (BEARER_CHALLENGE && authz.startsWith('Bearer ')) { + res.setHeader('WWW-Authenticate', 'Bearer error="invalid_token"'); + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null + }); + return; + } const result = await validateDpop(req); if (!result.ok) { send401(res, result); diff --git a/src/scenarios/server/auth/dpop.test.ts b/src/scenarios/server/auth/dpop.test.ts index 734fc988..60cd2f82 100644 --- a/src/scenarios/server/auth/dpop.test.ts +++ b/src/scenarios/server/auth/dpop.test.ts @@ -121,6 +121,7 @@ describe('DPoP server validation scenario', () => { let nonceFirst: ChildProcess | null = null; let clockSkew: ChildProcess | null = null; let bearerReject: ChildProcess | null = null; + let bearerChallenge: ChildProcess | null = null; let ports: { compliant: number; broken: number; @@ -130,6 +131,7 @@ describe('DPoP server validation scenario', () => { nonceFirst: number; clockSkew: number; bearerReject: number; + bearerChallenge: number; }; let savedEnv: { jwk?: string; issuer?: string }; @@ -149,7 +151,7 @@ describe('DPoP server validation scenario', () => { process.env.DPOP_ISSUER_PRIVATE_JWK = JSON.stringify(privateJwk); process.env.DPOP_ISSUER = ISSUER; - const [cp, bp, rp, sp, gp, nf, ck, br] = await freePorts(8); + const [cp, bp, rp, sp, gp, nf, ck, br, bc] = await freePorts(9); ports = { compliant: cp, broken: bp, @@ -158,7 +160,8 @@ describe('DPoP server validation scenario', () => { buggy: gp, nonceFirst: nf, clockSkew: ck, - bearerReject: br + bearerReject: br, + bearerChallenge: bc }; const issuerEnv = (port: number, extra: Record = {}) => ({ @@ -176,7 +179,8 @@ describe('DPoP server validation scenario', () => { nonceBuggy, nonceFirst, clockSkew, - bearerReject + bearerReject, + bearerChallenge ] = await Promise.all([ startServer(COMPLIANT, cp, issuerEnv(cp)), startServer(BROKEN, bp, {}), @@ -201,7 +205,8 @@ describe('DPoP server validation scenario', () => { COMPLIANT, br, issuerEnv(br, { DPOP_BEARER_REJECT_STATUS: '500' }) - ) + ), + startServer(COMPLIANT, bc, issuerEnv(bc, { DPOP_BEARER_CHALLENGE: '1' })) ]); }, 60000); @@ -214,7 +219,8 @@ describe('DPoP server validation scenario', () => { stopServer(nonceBuggy), stopServer(nonceFirst), stopServer(clockSkew), - stopServer(bearerReject) + stopServer(bearerReject), + stopServer(bearerChallenge) ]); process.env.DPOP_ISSUER_PRIVATE_JWK = savedEnv.jwk; process.env.DPOP_ISSUER = savedEnv.issuer; @@ -410,6 +416,28 @@ describe('DPoP server validation scenario', () => { expect(others.every((c) => c.status === 'SUCCESS')).toBe(true); }, 30000); + it('reports SUCCESS when a Bearer-scheme request is answered with a Bearer challenge', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.bearerChallenge)) + ); + + const bearer = byId(checks, 'sep-1932-server-validate-proof').find( + (c) => c.name === 'RejectsBearerScheme' + ); + expect(bearer?.status).toBe('SUCCESS'); + // The compliant server challenges with DPoP. This fixture must actually + // send a Bearer challenge, or SUCCESS would only re-prove the DPoP arm. + expect(bearer?.details?.wwwAuthenticate).toBe( + 'Bearer error="invalid_token"' + ); + + const others = byId(checks, 'sep-1932-server-validate-proof').filter( + (c) => c.name !== 'RejectsBearerScheme' + ); + expect(others.length).toBeGreaterThan(0); + expect(others.every((c) => c.status === 'SUCCESS')).toBe(true); + }, 30000); + it('anchors iat probes to the server clock (no false failure under skew)', async () => { // The server's clock is 30s behind the framework. Without anchoring, the // stale probe (now−303s by the framework clock) looks only ~273s old to the From 248e9577d1abea49d5e4e952e815be7e067a9eac Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Sun, 4 Oct 2026 09:08:07 -0700 Subject: [PATCH 6/8] server/dpop: emit RejectsBearerScheme as its own check id SEP-1932 now requires rejecting a DPoP-bound token presented as Bearer, and the traceability row sep-1932-server-reject-bearer-downgrade names that requirement. The result path and the probe-error path both use that id. Co-authored-by: Cursor --- src/scenarios/server/auth/dpop.test.ts | 48 ++++++++++++++++---------- src/scenarios/server/auth/dpop.ts | 25 +++++++------- 2 files changed, 41 insertions(+), 32 deletions(-) diff --git a/src/scenarios/server/auth/dpop.test.ts b/src/scenarios/server/auth/dpop.test.ts index 60cd2f82..594d383a 100644 --- a/src/scenarios/server/auth/dpop.test.ts +++ b/src/scenarios/server/auth/dpop.test.ts @@ -244,10 +244,16 @@ describe('DPoP server validation scenario', () => { ) ).toBe(true); expect( - byId(checks, 'sep-1932-server-validate-proof').find( + byId(checks, 'sep-1932-server-validate-proof').some( (c) => c.name === 'RejectsBearerScheme' - )?.status - ).toBe('SUCCESS'); + ) + ).toBe(false); + expect( + byId(checks, 'sep-1932-server-reject-bearer-downgrade').map((c) => [ + c.name, + c.status + ]) + ).toEqual([['RejectsBearerScheme', 'SUCCESS']]); expect( byId(checks, 'sep-1932-server-iat-window').every( (c) => c.status === 'SUCCESS' @@ -282,6 +288,12 @@ describe('DPoP server validation scenario', () => { ); expect(rejects.length).toBeGreaterThan(0); expect(rejects.every((c) => c.status === 'FAILURE')).toBe(true); + expect( + byId(checks, 'sep-1932-server-reject-bearer-downgrade').map((c) => [ + c.name, + c.status + ]) + ).toEqual([['RejectsBearerScheme', 'FAILURE']]); expect(byId(checks, 'sep-1932-server-reject-401')[0].status).toBe( 'FAILURE' @@ -319,6 +331,7 @@ describe('DPoP server validation scenario', () => { ...byId(checks, 'sep-1932-server-validate-proof').filter((c) => c.name.startsWith('Rejects') ), + ...byId(checks, 'sep-1932-server-reject-bearer-downgrade'), ...byId(checks, 'sep-1932-server-iat-window'), ...byId(checks, 'sep-1932-asymmetric-alg-only'), ...byId(checks, 'sep-1932-server-audience-validation'), @@ -365,6 +378,7 @@ describe('DPoP server validation scenario', () => { ...byId(checks, 'sep-1932-server-validate-proof').filter((c) => c.name.startsWith('Rejects') ), + ...byId(checks, 'sep-1932-server-reject-bearer-downgrade'), ...byId(checks, 'sep-1932-server-iat-window'), ...byId(checks, 'sep-1932-asymmetric-alg-only'), ...byId(checks, 'sep-1932-server-audience-validation'), @@ -402,16 +416,14 @@ describe('DPoP server validation scenario', () => { testContext(url(ports.bearerReject)) ); - const bearer = byId(checks, 'sep-1932-server-validate-proof').find( - (c) => c.name === 'RejectsBearerScheme' - ); - expect(bearer?.status).toBe('FAILURE'); + const bearer = byId(checks, 'sep-1932-server-reject-bearer-downgrade'); + expect(bearer.map((c) => [c.name, c.status])).toEqual([ + ['RejectsBearerScheme', 'FAILURE'] + ]); - // This fixture only mishandles Bearer-scheme presentation — every other + // This fixture only mishandles Bearer-scheme presentation — every // validate-proof probe still behaves as on the compliant server. - const others = byId(checks, 'sep-1932-server-validate-proof').filter( - (c) => c.name !== 'RejectsBearerScheme' - ); + const others = byId(checks, 'sep-1932-server-validate-proof'); expect(others.length).toBeGreaterThan(0); expect(others.every((c) => c.status === 'SUCCESS')).toBe(true); }, 30000); @@ -421,19 +433,17 @@ describe('DPoP server validation scenario', () => { testContext(url(ports.bearerChallenge)) ); - const bearer = byId(checks, 'sep-1932-server-validate-proof').find( - (c) => c.name === 'RejectsBearerScheme' - ); - expect(bearer?.status).toBe('SUCCESS'); + const bearer = byId(checks, 'sep-1932-server-reject-bearer-downgrade'); + expect(bearer.map((c) => [c.name, c.status])).toEqual([ + ['RejectsBearerScheme', 'SUCCESS'] + ]); // The compliant server challenges with DPoP. This fixture must actually // send a Bearer challenge, or SUCCESS would only re-prove the DPoP arm. - expect(bearer?.details?.wwwAuthenticate).toBe( + expect(bearer[0]?.details?.wwwAuthenticate).toBe( 'Bearer error="invalid_token"' ); - const others = byId(checks, 'sep-1932-server-validate-proof').filter( - (c) => c.name !== 'RejectsBearerScheme' - ); + const others = byId(checks, 'sep-1932-server-validate-proof'); expect(others.length).toBeGreaterThan(0); expect(others.every((c) => c.status === 'SUCCESS')).toBe(true); }, 30000); diff --git a/src/scenarios/server/auth/dpop.ts b/src/scenarios/server/auth/dpop.ts index bd4f3cb2..d2afd4f9 100644 --- a/src/scenarios/server/auth/dpop.ts +++ b/src/scenarios/server/auth/dpop.ts @@ -414,6 +414,10 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and buildDpop: () => Promise; predicate?: (res: Response) => boolean; description?: string; + // Defaults to sep-1932-server-validate-proof. A case whose requirement + // is a separate yaml row sets its own id; the catch path uses the same + // id so a probe that throws is still attributed to that requirement. + id?: string; }> = [ { case: 'tampered-signature', @@ -505,12 +509,13 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and }) }, { - // A DPoP-bound token presented under the Bearer scheme MUST NOT be - // accepted. Count only a real authentication rejection: 401 plus a - // Bearer (RFC 6750 §3) or DPoP challenge. A 500/404 with no - // WWW-Authenticate is not a rejection of the scheme. + // Not an RFC 9449 §4.3 step. SEP-1932 requires rejecting a DPoP-bound + // token presented as Bearer with 401 plus a Bearer (RFC 6750 §3) or + // DPoP challenge. A 500/404 with no WWW-Authenticate is not that + // rejection. Own check id: sep-1932-server-reject-bearer-downgrade. case: 'bearer-scheme', name: 'RejectsBearerScheme', + id: 'sep-1932-server-reject-bearer-downgrade', authz: `Bearer ${token}`, buildDpop: () => validProof(), predicate: bearerSchemeRejected, @@ -595,13 +600,14 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and for (const n of negatives) { const description = n.description ?? `Server rejects a DPoP request with defect: ${n.case}`; + const id = n.id ?? 'sep-1932-server-validate-proof'; try { const dpop = await n.buildDpop(); const res = await send(n.authz, dpop); checks.push( rejectionCheck( positiveAccepted, - 'sep-1932-server-validate-proof', + id, n.name, description, res, @@ -611,14 +617,7 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and ); } catch (e) { checks.push( - probeErrorCheck( - positiveAccepted, - 'sep-1932-server-validate-proof', - n.name, - description, - n.case, - e - ) + probeErrorCheck(positiveAccepted, id, n.name, description, n.case, e) ); } } From 8a2b2af601a9c5522117bf5fc44faac403a15ebf Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Fri, 25 Sep 2026 10:35:16 -0700 Subject: [PATCH 7/8] server/dpop: grade the DPoP error code A challenge with no error code was passing. The code is a SHOULD, so a wrong or missing value is a warning and the existing MUST checks stay as they are. Co-authored-by: Cursor --- .../typescript/sep-1932-compliant-server.ts | 9 +- src/scenarios/server/auth/dpop.test.ts | 83 +++- src/scenarios/server/auth/dpop.ts | 412 +++++++++++++----- src/seps/sep-1932.yaml | 2 + 4 files changed, 401 insertions(+), 105 deletions(-) diff --git a/examples/servers/typescript/sep-1932-compliant-server.ts b/examples/servers/typescript/sep-1932-compliant-server.ts index 700bbf55..d523c09c 100644 --- a/examples/servers/typescript/sep-1932-compliant-server.ts +++ b/examples/servers/typescript/sep-1932-compliant-server.ts @@ -16,7 +16,8 @@ * PORT, DPOP_ISSUER_JWK (public JWK JSON), DPOP_ISSUER, DPOP_AUDIENCE, * DPOP_IAT_SKEW_SECONDS (default 300), DPOP_REQUIRE_NONCE ('1'), DPOP_NONCE, * DPOP_BEARER_REJECT_STATUS (negative-test: Bearer-scheme status, no challenge), - * DPOP_BEARER_CHALLENGE ('1': Bearer-scheme probe gets a Bearer challenge). + * DPOP_BEARER_CHALLENGE ('1': Bearer-scheme probe gets a Bearer challenge), + * DPOP_ERROR_CODE_OVERRIDE (negative-test: that code on every 401). */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -63,6 +64,9 @@ const BEARER_REJECT_STATUS = process.env.DPOP_BEARER_REJECT_STATUS // WWW-Authenticate: Bearer challenge (RFC 6750 §3). The default path challenges // with DPoP; this proves RejectsBearerScheme also accepts the Bearer scheme. const BEARER_CHALLENGE = process.env.DPOP_BEARER_CHALLENGE === '1'; +// Wrong-error-code mode (negative-test fixture only): every 401 uses this +// `error` value instead of the real one. Proves ReportsDpopErrorCode can WARNING. +const ERROR_CODE_OVERRIDE = process.env.DPOP_ERROR_CODE_OVERRIDE || ''; // Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 step 5). const ASYMMETRIC_ALGS = [ @@ -247,9 +251,10 @@ async function validateDpop(req: Request): Promise { function send401(res: Response, f: Failure): void { const algs = ASYMMETRIC_ALGS.join(' '); + const error = ERROR_CODE_OVERRIDE || f.error; res.setHeader( 'WWW-Authenticate', - `DPoP error="${f.error}", error_description="${f.description}", algs="${algs}"` + `DPoP error="${error}", error_description="${f.description}", algs="${algs}"` ); if (f.nonce) res.setHeader('DPoP-Nonce', NONCE); res.status(401).json({ diff --git a/src/scenarios/server/auth/dpop.test.ts b/src/scenarios/server/auth/dpop.test.ts index 594d383a..0ab55e61 100644 --- a/src/scenarios/server/auth/dpop.test.ts +++ b/src/scenarios/server/auth/dpop.test.ts @@ -3,7 +3,7 @@ import { createServer } from 'node:net'; import path from 'path'; import * as jose from 'jose'; import { testContext } from '../../../connection/testing'; -import { DPoPServerValidationScenario } from './dpop'; +import { DPoPServerValidationScenario, dpopChallengeError } from './dpop'; import type { ConformanceCheck } from '../../../types'; const WINDOWS = process.platform === 'win32'; @@ -112,6 +112,38 @@ function byId(checks: ConformanceCheck[], id: string): ConformanceCheck[] { return checks.filter((c) => c.id === id); } +describe('dpopChallengeError', () => { + it('reads a quoted or unquoted error from the DPoP challenge', () => { + expect( + dpopChallengeError( + 'DPoP error="invalid_dpop_proof", error_description="bad proof"' + ) + ).toBe('invalid_dpop_proof'); + expect(dpopChallengeError('DPoP error=invalid_token')).toBe( + 'invalid_token' + ); + expect(dpopChallengeError('dpop error="invalid_dpop_proof"')).toBe( + 'invalid_dpop_proof' + ); + }); + + it('ignores the error param of a sibling challenge', () => { + expect( + dpopChallengeError( + 'Bearer error="invalid_token", error_description="nope", DPoP error="invalid_dpop_proof", algs="ES256"' + ) + ).toBe('invalid_dpop_proof'); + expect( + dpopChallengeError( + 'DPoP error="invalid_dpop_proof", error_description="a, b", Bearer error="invalid_token"' + ) + ).toBe('invalid_dpop_proof'); + expect( + dpopChallengeError('Bearer error="invalid_token", DPoP algs="ES256"') + ).toBeUndefined(); + }); +}); + describe('DPoP server validation scenario', () => { let compliant: ChildProcess | null = null; let broken: ChildProcess | null = null; @@ -122,6 +154,7 @@ describe('DPoP server validation scenario', () => { let clockSkew: ChildProcess | null = null; let bearerReject: ChildProcess | null = null; let bearerChallenge: ChildProcess | null = null; + let errorOverride: ChildProcess | null = null; let ports: { compliant: number; broken: number; @@ -132,6 +165,7 @@ describe('DPoP server validation scenario', () => { clockSkew: number; bearerReject: number; bearerChallenge: number; + errorOverride: number; }; let savedEnv: { jwk?: string; issuer?: string }; @@ -151,7 +185,7 @@ describe('DPoP server validation scenario', () => { process.env.DPOP_ISSUER_PRIVATE_JWK = JSON.stringify(privateJwk); process.env.DPOP_ISSUER = ISSUER; - const [cp, bp, rp, sp, gp, nf, ck, br, bc] = await freePorts(9); + const [cp, bp, rp, sp, gp, nf, ck, br, bc, eo] = await freePorts(10); ports = { compliant: cp, broken: bp, @@ -161,7 +195,8 @@ describe('DPoP server validation scenario', () => { nonceFirst: nf, clockSkew: ck, bearerReject: br, - bearerChallenge: bc + bearerChallenge: bc, + errorOverride: eo }; const issuerEnv = (port: number, extra: Record = {}) => ({ @@ -180,7 +215,8 @@ describe('DPoP server validation scenario', () => { nonceFirst, clockSkew, bearerReject, - bearerChallenge + bearerChallenge, + errorOverride ] = await Promise.all([ startServer(COMPLIANT, cp, issuerEnv(cp)), startServer(BROKEN, bp, {}), @@ -206,7 +242,12 @@ describe('DPoP server validation scenario', () => { br, issuerEnv(br, { DPOP_BEARER_REJECT_STATUS: '500' }) ), - startServer(COMPLIANT, bc, issuerEnv(bc, { DPOP_BEARER_CHALLENGE: '1' })) + startServer(COMPLIANT, bc, issuerEnv(bc, { DPOP_BEARER_CHALLENGE: '1' })), + startServer( + COMPLIANT, + eo, + issuerEnv(eo, { DPOP_ERROR_CODE_OVERRIDE: 'invalid_request' }) + ) ]); }, 60000); @@ -220,7 +261,8 @@ describe('DPoP server validation scenario', () => { stopServer(nonceFirst), stopServer(clockSkew), stopServer(bearerReject), - stopServer(bearerChallenge) + stopServer(bearerChallenge), + stopServer(errorOverride) ]); process.env.DPOP_ISSUER_PRIVATE_JWK = savedEnv.jwk; process.env.DPOP_ISSUER = savedEnv.issuer; @@ -254,6 +296,9 @@ describe('DPoP server validation scenario', () => { c.status ]) ).toEqual([['RejectsBearerScheme', 'SUCCESS']]); + expect(byId(checks, 'sep-1932-server-error-code')[0].status).toBe( + 'SUCCESS' + ); expect( byId(checks, 'sep-1932-server-iat-window').every( (c) => c.status === 'SUCCESS' @@ -448,6 +493,32 @@ describe('DPoP server validation scenario', () => { expect(others.every((c) => c.status === 'SUCCESS')).toBe(true); }, 30000); + it('reports WARNING when every rejection uses the wrong DPoP error code', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.errorOverride)) + ); + + const errorCode = byId(checks, 'sep-1932-server-error-code')[0]; + expect(errorCode.status).toBe('WARNING'); + expect(errorCode.details?.untestable).toBeUndefined(); + const mismatches = errorCode.details?.mismatches as Array<{ + case: string; + expected: string; + actual: string | null; + }>; + expect(mismatches.length).toBeGreaterThan(0); + expect(mismatches.every((m) => m.actual === 'invalid_request')).toBe(true); + + // The override only changes the error param. MUST checks still see 401 + + // a DPoP challenge. + const failures = checks.filter( + (c) => c.status === 'FAILURE' && !c.details?.untestable + ); + expect(failures.map((c) => `${c.id}/${c.name}: ${c.errorMessage}`)).toEqual( + [] + ); + }, 30000); + it('anchors iat probes to the server clock (no false failure under skew)', async () => { // The server's clock is 30s behind the framework. Without anchoring, the // stale probe (now−303s by the framework clock) looks only ~273s old to the diff --git a/src/scenarios/server/auth/dpop.ts b/src/scenarios/server/auth/dpop.ts index d2afd4f9..60da45b6 100644 --- a/src/scenarios/server/auth/dpop.ts +++ b/src/scenarios/server/auth/dpop.ts @@ -79,6 +79,20 @@ interface Response { dpopNonce: string | undefined; /** The server's `Date` header as epoch seconds, if present (RFC 9110 §6.6.1). */ date: number | undefined; + /** `error` auth-param of the DPoP challenge, if that challenge carries one. */ + dpopError: string | undefined; +} + +// Proof defects (§4.3) vs token defects (RFC 6750 `invalid_token`). `either` +// covers cases implementations reasonably report as either (key binding, and a +// DPoP-bound token presented as Bearer). +type ExpectedDpopError = 'invalid_dpop_proof' | 'invalid_token' | 'either'; + +interface ErrorObservation { + case: string; + expected: ExpectedDpopError; + actual: string | undefined; + attributable: boolean; } function isAccepted(status: number): boolean { @@ -104,6 +118,105 @@ function hasBearerOrDpopChallenge(wwwAuthenticate: string): boolean { return /(?:^|,)\s*(?:bearer|dpop)(?:\s|$|,)/i.test(wwwAuthenticate); } +// RFC 9110 token. Auth-params are `token "=" ( token / quoted-string )`; a new +// challenge is a token that is NOT followed by "=" (RFC 9110 §11.6.1). +const AUTH_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+/; + +function splitChallenges(header: string): string[] { + const parts: string[] = []; + let start = 0; + let i = 0; + let quoted = false; + while (i < header.length) { + const ch = header[i]; + if (quoted) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === '"') quoted = false; + i++; + continue; + } + if (ch === '"') { + quoted = true; + i++; + continue; + } + if (ch === ',') { + const rest = header.slice(i + 1).trimStart(); + const token = AUTH_TOKEN.exec(rest); + const after = token ? rest.slice(token[0].length).trimStart() : ''; + if (token && !after.startsWith('=')) { + parts.push(header.slice(start, i)); + start = i + 1; + } + } + i++; + } + parts.push(header.slice(start)); + return parts.map((part) => part.trim()).filter((part) => part.length > 0); +} + +function readAuthParam(params: string, name: string): string | undefined { + let i = 0; + while (i < params.length) { + while (i < params.length && /[\s,]/.test(params[i])) i++; + if (i >= params.length) break; + const token = AUTH_TOKEN.exec(params.slice(i)); + if (!token) break; + i += token[0].length; + while (i < params.length && params[i] === ' ') i++; + if (params[i] !== '=') break; + i++; + while (i < params.length && params[i] === ' ') i++; + let value = ''; + if (params[i] === '"') { + i++; + while (i < params.length && params[i] !== '"') { + if (params[i] === '\\' && i + 1 < params.length) { + value += params[i + 1]; + i += 2; + } else { + value += params[i]; + i++; + } + } + if (params[i] === '"') i++; + } else { + const raw = AUTH_TOKEN.exec(params.slice(i)); + if (!raw) break; + value = raw[0]; + i += value.length; + } + if (token[0].toLowerCase() === name.toLowerCase()) return value; + } + return undefined; +} + +/** `error` auth-param of the DPoP challenge. Quoted and unquoted values. */ +export function dpopChallengeError( + wwwAuthenticate: string +): string | undefined { + for (const challenge of splitChallenges(wwwAuthenticate)) { + const scheme = AUTH_TOKEN.exec(challenge); + if (!scheme || scheme[0].toLowerCase() !== 'dpop') continue; + return readAuthParam(challenge.slice(scheme[0].length), 'error'); + } + return undefined; +} + +function errorCodeMatches( + expected: ExpectedDpopError, + actual: string | undefined +): boolean { + if (!actual) return false; + if (expected === 'either') { + return actual === 'invalid_dpop_proof' || actual === 'invalid_token'; + } + return actual === expected; +} + function properlyRejected(res: Response): boolean { return res.statusCode === 401 && hasDpopChallenge(res.wwwAuthenticate); } @@ -256,9 +369,10 @@ and proof (which a conformant server MUST accept) and a series of deliberately malformed requests (which a conformant server MUST reject with HTTP 401 and a \`WWW-Authenticate: DPoP\` challenge), following RFC 9449 §4.3. -Covers: proof validation per §4.3, the ±5-minute \`iat\` window, asymmetric-only -algorithms, the 401 challenge format, token audience validation under DPoP, and -(optionally) the server-provided nonce flow.`; +Covers: proof validation per §4.3, the ±5-minute \`iat\` window, +asymmetric-only algorithms, the 401 challenge format, the §7.1 \`error\` code +(SHOULD), token audience validation under DPoP, and (optionally) the +server-provided nonce flow.`; async run(ctx: RunContext): Promise { const { serverUrl, specVersion } = ctx; @@ -286,25 +400,28 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and // require) can still push alternate negatives to untestable — acceptable, as // those are correctly reported not-testable rather than mis-scored. let heldNonce: string | undefined; + let positiveAccepted = false; - const send = async ( - authz: string, - dpop: string | string[] | undefined - ): Promise => { - const probe = probeBody(specVersion); - const base = buildStandardHeaders(probe.method, probe.params, { - specVersion - }); - const headers: Record = { - ...base, - Authorization: authz - }; - if (dpop !== undefined) headers['DPoP'] = dpop; - const res = await request(serverUrl, { - method: 'POST', - headers, - body: JSON.stringify(probe) + const errorObservations: ErrorObservation[] = []; + // A nonce challenge or a probe that threw is not a validation result, so it + // is not scored. Absent and unexpected codes are scored later, once. + const observeError = ( + caseName: string, + expected: ExpectedDpopError, + res: Response | undefined + ): void => { + errorObservations.push({ + case: caseName, + expected, + actual: res?.dpopError, + attributable: + positiveAccepted && res !== undefined && !isNonceChallenge(res) }); + }; + + const readHttp = async ( + res: Awaited> + ): Promise => { // Drain the body so the socket can be reused / freed. try { await res.body.text(); @@ -327,7 +444,34 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and : Math.floor(parsedDate / 1000); // Newest-wins (RFC 9449 §8.2): carry the latest nonce into the next probe. if (dpopNonce) heldNonce = dpopNonce; - return { statusCode: res.statusCode, wwwAuthenticate, dpopNonce, date }; + return { + statusCode: res.statusCode, + wwwAuthenticate, + dpopNonce, + date, + dpopError: dpopChallengeError(wwwAuthenticate) + }; + }; + + const send = async ( + authz: string, + dpop: string | string[] | undefined + ): Promise => { + const probe = probeBody(specVersion); + const base = buildStandardHeaders(probe.method, probe.params, { + specVersion + }); + const headers: Record = { + ...base, + Authorization: authz + }; + if (dpop !== undefined) headers['DPoP'] = dpop; + const res = await request(serverUrl, { + method: 'POST', + headers, + body: JSON.stringify(probe) + }); + return readHttp(res); }; const buildDpopProof = ( @@ -371,7 +515,6 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and // Also anchor iat probes to the server's own clock (its `Date` header) so // the ±5-minute boundary is measured against the clock the server validates // against, immune to framework↔server skew. - let positiveAccepted = false; let serverClockOffset = 0; try { const res = await acceptValid(); @@ -418,6 +561,8 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and // is a separate yaml row sets its own id; the catch path uses the same // id so a probe that throws is still attributed to that requirement. id?: string; + /** Defaults to invalid_dpop_proof (a §4.3 proof defect). */ + expectedError?: ExpectedDpopError; }> = [ { case: 'tampered-signature', @@ -519,6 +664,7 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and authz: `Bearer ${token}`, buildDpop: () => validProof(), predicate: bearerSchemeRejected, + expectedError: 'either', description: 'Server does not accept a DPoP-bound token presented under the Bearer scheme' }, @@ -601,9 +747,10 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and const description = n.description ?? `Server rejects a DPoP request with defect: ${n.case}`; const id = n.id ?? 'sep-1932-server-validate-proof'; + let res: Response | undefined; try { const dpop = await n.buildDpop(); - const res = await send(n.authz, dpop); + res = await send(n.authz, dpop); checks.push( rejectionCheck( positiveAccepted, @@ -620,46 +767,53 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and probeErrorCheck(positiveAccepted, id, n.name, description, n.case, e) ); } + observeError(n.case, n.expectedError ?? 'invalid_dpop_proof', res); } // ---- cnf.jkt mismatch (token bound to a foreign key) ---- - try { - const foreign = await generateDpopKeyPair(); - const mismatchToken = await mintDpopBoundToken({ - issuerKey, - issuer, - audience, - jkt: kp.thumbprint, - jktOverride: foreign.thumbprint - }); - const proof = await buildDpopProof({ - keyPair: kp, - htm: 'POST', - htu: serverUrl, - accessToken: mismatchToken - }); - const res = await send(`DPoP ${mismatchToken}`, proof); - checks.push( - rejectionCheck( - positiveAccepted, - 'sep-1932-server-validate-proof', - 'RejectsCnfJktMismatch', - 'Server rejects a token whose cnf.jkt does not match the proof key', - res, - { case: 'cnf-jkt-mismatch' } - ) - ); - } catch (e) { - checks.push( - probeErrorCheck( - positiveAccepted, - 'sep-1932-server-validate-proof', - 'RejectsCnfJktMismatch', - 'Server rejects a token whose cnf.jkt does not match the proof key', - 'cnf-jkt-mismatch', - e - ) - ); + // Key binding is a §4.3 step, but implementations reasonably report it as a + // token problem, so either error code is accepted. + { + let res: Response | undefined; + try { + const foreign = await generateDpopKeyPair(); + const mismatchToken = await mintDpopBoundToken({ + issuerKey, + issuer, + audience, + jkt: kp.thumbprint, + jktOverride: foreign.thumbprint + }); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: mismatchToken + }); + res = await send(`DPoP ${mismatchToken}`, proof); + checks.push( + rejectionCheck( + positiveAccepted, + 'sep-1932-server-validate-proof', + 'RejectsCnfJktMismatch', + 'Server rejects a token whose cnf.jkt does not match the proof key', + res, + { case: 'cnf-jkt-mismatch' } + ) + ); + } catch (e) { + checks.push( + probeErrorCheck( + positiveAccepted, + 'sep-1932-server-validate-proof', + 'RejectsCnfJktMismatch', + 'Server rejects a token whose cnf.jkt does not match the proof key', + 'cnf-jkt-mismatch', + e + ) + ); + } + observeError('cnf-jkt-mismatch', 'either', res); } // ---- iat acceptance window ---- @@ -675,6 +829,7 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and { label: 'future', name: 'RejectsFutureIat', iatDelta: 303 } ]) { const description = `Server rejects a proof whose iat is ${label} — just outside the ±5-minute window (RFC 9449 §4.3 / SEP-1932)`; + let res: Response | undefined; try { const iat = Math.floor(Date.now() / 1000) + serverClockOffset + iatDelta; @@ -685,7 +840,7 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and accessToken: token, iat }); - const res = await send(`DPoP ${token}`, proof); + res = await send(`DPoP ${token}`, proof); checks.push( rejectionCheck( positiveAccepted, @@ -708,6 +863,7 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and ) ); } + observeError(`iat-${label}`, 'invalid_dpop_proof', res); } // ---- asymmetric-only algorithm ---- @@ -723,6 +879,7 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and opt: { symmetric: true } as const } ]) { + let res: Response | undefined; try { const proof = await buildDpopProof({ keyPair: kp, @@ -731,7 +888,7 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and accessToken: token, ...opt }); - const res = await send(`DPoP ${token}`, proof); + res = await send(`DPoP ${token}`, proof); checks.push( rejectionCheck( positiveAccepted, @@ -754,44 +911,49 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and ) ); } + observeError(`alg-${label}`, 'invalid_dpop_proof', res); } // ---- token audience validation under DPoP ---- - try { - const wrongAudToken = await mintDpopBoundToken({ - issuerKey, - issuer, - audience: 'https://not-this-server.example.com/mcp', - jkt: kp.thumbprint - }); - const proof = await buildDpopProof({ - keyPair: kp, - htm: 'POST', - htu: serverUrl, - accessToken: wrongAudToken - }); - const res = await send(`DPoP ${wrongAudToken}`, proof); - checks.push( - rejectionCheck( - positiveAccepted, - 'sep-1932-server-audience-validation', - 'RejectsWrongAudience', - 'Server rejects an access token whose audience is not this server, even with a valid proof', - res, - { case: 'wrong-audience' } - ) - ); - } catch (e) { - checks.push( - probeErrorCheck( - positiveAccepted, - 'sep-1932-server-audience-validation', - 'RejectsWrongAudience', - 'Server rejects an access token whose audience is not this server, even with a valid proof', - 'wrong-audience', - e - ) - ); + { + let res: Response | undefined; + try { + const wrongAudToken = await mintDpopBoundToken({ + issuerKey, + issuer, + audience: 'https://not-this-server.example.com/mcp', + jkt: kp.thumbprint + }); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: wrongAudToken + }); + res = await send(`DPoP ${wrongAudToken}`, proof); + checks.push( + rejectionCheck( + positiveAccepted, + 'sep-1932-server-audience-validation', + 'RejectsWrongAudience', + 'Server rejects an access token whose audience is not this server, even with a valid proof', + res, + { case: 'wrong-audience' } + ) + ); + } catch (e) { + checks.push( + probeErrorCheck( + positiveAccepted, + 'sep-1932-server-audience-validation', + 'RejectsWrongAudience', + 'Server rejects an access token whose audience is not this server, even with a valid proof', + 'wrong-audience', + e + ) + ); + } + observeError('wrong-audience', 'invalid_token', res); } // ---- 401 + WWW-Authenticate challenge format (on a known-bad request) ---- @@ -952,6 +1114,62 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and ); } + // ---- §7.1 error code (SHOULD): one aggregate, MUST checks unchanged ---- + const errorCodeDesc = + 'Declined DPoP requests include an error parameter on the DPoP challenge naming the reason (RFC 9449 §7.1)'; + if (!positiveAccepted) { + checks.push( + untestableCheck( + 'sep-1932-server-error-code', + 'ReportsDpopErrorCode', + errorCodeDesc, + gateReason('error-code'), + SPEC_REFERENCES, + 'WARNING' + ) + ); + } else { + const attributable = errorObservations.filter((o) => o.attributable); + if (attributable.length === 0) { + checks.push( + untestableCheck( + 'sep-1932-server-error-code', + 'ReportsDpopErrorCode', + errorCodeDesc, + 'no declined DPoP request could be attributed to its defect, so the error code cannot be checked', + SPEC_REFERENCES, + 'WARNING' + ) + ); + } else { + const mismatches = attributable + .filter((o) => !errorCodeMatches(o.expected, o.actual)) + .map((o) => ({ + case: o.case, + expected: o.expected, + actual: o.actual ?? null + })); + const ok = mismatches.length === 0; + checks.push( + dpopCheck( + 'sep-1932-server-error-code', + 'ReportsDpopErrorCode', + errorCodeDesc, + ok ? 'SUCCESS' : 'WARNING', + ok + ? undefined + : `DPoP error code missing or unexpected: ${mismatches + .map( + (m) => + `${m.case} expected ${m.expected} got ${m.actual ?? 'absent'}` + ) + .join('; ')}`, + { mismatches } + ) + ); + } + } + return checks; } } diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index 46271843..63d64839 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -17,6 +17,8 @@ requirements: 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-server-error-code + text: 'An `error` parameter ([RFC6750], Section 3) SHOULD be included to indicate the reason why the request was declined, if the request included an access token but failed authentication. The error parameter values described in [RFC6750], Section 3.1 are suitable, as are any appropriate values defined by extension. The value `use_dpop_nonce` can be used as described in Section 9 to signal that a nonce is needed in the DPoP proof of a subsequent request(s). Additionally, `invalid_dpop_proof` is used to indicate that the DPoP proof itself was deemed invalid based on the criteria of Section 4.3.' - 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 From 770a6f43fe562c1b14a04b7fb6e24a0b91e08f8a Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Sun, 4 Oct 2026 09:23:17 -0700 Subject: [PATCH 8/8] server/dpop: score error codes only on real 401s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A §7.2 Bearer rejection and a 2xx acceptance were both warned as a missing DPoP error code. Read the code from the challenge that applies, and only when the server actually declined the request. Co-authored-by: Cursor --- src/scenarios/server/auth/dpop.test.ts | 76 +++++++++++++++++++++++++- src/scenarios/server/auth/dpop.ts | 74 +++++++++++++++++++------ 2 files changed, 131 insertions(+), 19 deletions(-) diff --git a/src/scenarios/server/auth/dpop.test.ts b/src/scenarios/server/auth/dpop.test.ts index 0ab55e61..ff12ec58 100644 --- a/src/scenarios/server/auth/dpop.test.ts +++ b/src/scenarios/server/auth/dpop.test.ts @@ -3,7 +3,11 @@ import { createServer } from 'node:net'; import path from 'path'; import * as jose from 'jose'; import { testContext } from '../../../connection/testing'; -import { DPoPServerValidationScenario, dpopChallengeError } from './dpop'; +import { + DPoPServerValidationScenario, + dpopChallengeError, + scoredDpopError +} from './dpop'; import type { ConformanceCheck } from '../../../types'; const WINDOWS = process.platform === 'win32'; @@ -142,6 +146,56 @@ describe('dpopChallengeError', () => { dpopChallengeError('Bearer error="invalid_token", DPoP algs="ES256"') ).toBeUndefined(); }); + + it('reads the first DPoP challenge that carries an error', () => { + expect( + dpopChallengeError( + 'DPoP algs="ES256", DPoP error="invalid_dpop_proof", algs="ES256"' + ) + ).toBe('invalid_dpop_proof'); + }); + + it('accepts tabs as bad whitespace around "="', () => { + expect(dpopChallengeError('DPoP error\t=\t"invalid_token"')).toBe( + 'invalid_token' + ); + }); + + it('returns an empty error param as the empty string', () => { + expect(dpopChallengeError('DPoP error=""')).toBe(''); + }); +}); + +describe('scoredDpopError', () => { + const figure18 = 'Bearer error="invalid_token", DPoP algs="ES256"'; + + it('accepts the Bearer challenge code for a Bearer-scheme attempt', () => { + // RFC 9449 §7.2 puts the error on the scheme the client attempted. + expect(scoredDpopError(figure18, 'Bearer')).toBe('invalid_token'); + expect(scoredDpopError('Bearer error="invalid_token"', 'Bearer')).toBe( + 'invalid_token' + ); + }); + + it('still prefers a code carried on the DPoP challenge', () => { + expect( + scoredDpopError( + 'Bearer error="invalid_token", DPoP error="invalid_dpop_proof"', + 'Bearer' + ) + ).toBe('invalid_dpop_proof'); + }); + + it('does not read a Bearer challenge for a DPoP attempt', () => { + expect(scoredDpopError(figure18, 'DPoP')).toBeUndefined(); + }); + + it('treats an empty error param as absent', () => { + expect(scoredDpopError('DPoP error=""', 'DPoP')).toBeUndefined(); + expect( + scoredDpopError('DPoP error="", Bearer error="invalid_token"', 'Bearer') + ).toBe('invalid_token'); + }); }); describe('DPoP server validation scenario', () => { @@ -356,6 +410,13 @@ describe('DPoP server validation scenario', () => { expect(byId(checks, 'sep-1932-server-audience-validation')[0].status).toBe( 'FAILURE' ); + + // Accepting a bad proof is already a MUST failure. With no 401, the + // error-code check is untestable: there is no declined request to label. + const errorCode = byId(checks, 'sep-1932-server-error-code')[0]; + expect(errorCode.status).toBe('WARNING'); + expect(errorCode.details?.untestable).toBe(true); + expect(errorCode.errorMessage).not.toContain('got absent'); }, 30000); it('reports rejection checks notTestable (not vacuous SUCCESS) against a reject-everything server', async () => { @@ -471,6 +532,12 @@ describe('DPoP server validation scenario', () => { const others = byId(checks, 'sep-1932-server-validate-proof'); expect(others.length).toBeGreaterThan(0); expect(others.every((c) => c.status === 'SUCCESS')).toBe(true); + + // The 500 is not a declined authentication, so it is not an error-code + // observation. The other probes still are, and they use the right code. + expect(byId(checks, 'sep-1932-server-error-code')[0].status).toBe( + 'SUCCESS' + ); }, 30000); it('reports SUCCESS when a Bearer-scheme request is answered with a Bearer challenge', async () => { @@ -491,6 +558,13 @@ describe('DPoP server validation scenario', () => { const others = byId(checks, 'sep-1932-server-validate-proof'); expect(others.length).toBeGreaterThan(0); expect(others.every((c) => c.status === 'SUCCESS')).toBe(true); + + // RFC 9449 §7.2 puts invalid_token on the Bearer challenge. That satisfies + // the error-code SHOULD for this probe; the other probes still challenge + // with DPoP. + expect(byId(checks, 'sep-1932-server-error-code')[0].status).toBe( + 'SUCCESS' + ); }, 30000); it('reports WARNING when every rejection uses the wrong DPoP error code', async () => { diff --git a/src/scenarios/server/auth/dpop.ts b/src/scenarios/server/auth/dpop.ts index 60da45b6..174a51eb 100644 --- a/src/scenarios/server/auth/dpop.ts +++ b/src/scenarios/server/auth/dpop.ts @@ -79,8 +79,6 @@ interface Response { dpopNonce: string | undefined; /** The server's `Date` header as epoch seconds, if present (RFC 9110 §6.6.1). */ date: number | undefined; - /** `error` auth-param of the DPoP challenge, if that challenge carries one. */ - dpopError: string | undefined; } // Proof defects (§4.3) vs token defects (RFC 6750 `invalid_token`). `either` @@ -166,10 +164,12 @@ function readAuthParam(params: string, name: string): string | undefined { const token = AUTH_TOKEN.exec(params.slice(i)); if (!token) break; i += token[0].length; - while (i < params.length && params[i] === ' ') i++; + // RFC 9110 BWS around "=" is *( SP / HTAB ). Senders should not emit it; + // receivers must accept it. + while (i < params.length && /[ \t]/.test(params[i])) i++; if (params[i] !== '=') break; i++; - while (i < params.length && params[i] === ' ') i++; + while (i < params.length && /[ \t]/.test(params[i])) i++; let value = ''; if (params[i] === '"') { i++; @@ -194,18 +194,46 @@ function readAuthParam(params: string, name: string): string | undefined { return undefined; } -/** `error` auth-param of the DPoP challenge. Quoted and unquoted values. */ -export function dpopChallengeError( - wwwAuthenticate: string +/** First `error` auth-param present on a challenge of `schemeName`. + * A response may carry several challenges with the same scheme (RFC 9110 + * §11.1), including across repeated WWW-Authenticate headers joined by + * readHttp. A challenge with no error param is skipped. */ +function challengeError( + wwwAuthenticate: string, + schemeName: string ): string | undefined { for (const challenge of splitChallenges(wwwAuthenticate)) { const scheme = AUTH_TOKEN.exec(challenge); - if (!scheme || scheme[0].toLowerCase() !== 'dpop') continue; - return readAuthParam(challenge.slice(scheme[0].length), 'error'); + if (!scheme || scheme[0].toLowerCase() !== schemeName) continue; + const err = readAuthParam(challenge.slice(scheme[0].length), 'error'); + if (err !== undefined) return err; } return undefined; } +/** `error` auth-param of the DPoP challenge. Quoted and unquoted values. */ +export function dpopChallengeError( + wwwAuthenticate: string +): string | undefined { + return challengeError(wwwAuthenticate, 'dpop'); +} + +/** Error code scored for one probe. Proof probes are read from the DPoP + * challenge. A Bearer-scheme attempt used the Bearer scheme, so a code on + * the Bearer challenge counts when the DPoP challenge carries none + * (RFC 9449 §7.2). An empty `error=""` is absent. */ +export function scoredDpopError( + wwwAuthenticate: string, + attemptedScheme: 'DPoP' | 'Bearer' +): string | undefined { + const dpop = dpopChallengeError(wwwAuthenticate); + const code = + attemptedScheme === 'Bearer' + ? dpop || challengeError(wwwAuthenticate, 'bearer') + : dpop; + return code || undefined; +} + function errorCodeMatches( expected: ExpectedDpopError, actual: string | undefined @@ -403,8 +431,11 @@ server-provided nonce flow.`; let positiveAccepted = false; const errorObservations: ErrorObservation[] = []; - // A nonce challenge or a probe that threw is not a validation result, so it - // is not scored. Absent and unexpected codes are scored later, once. + // The §7.1 SHOULD applies only to a request the server actually declined. + // A 2xx has no challenge to label — the MUST rejection checks already + // cover accepting a bad proof. A nonce challenge or a probe that threw is + // not a validation result either. Absent and unexpected codes are scored + // later, once. const observeError = ( caseName: string, expected: ExpectedDpopError, @@ -413,9 +444,17 @@ server-provided nonce flow.`; errorObservations.push({ case: caseName, expected, - actual: res?.dpopError, + actual: res + ? scoredDpopError( + res.wwwAuthenticate, + caseName === 'bearer-scheme' ? 'Bearer' : 'DPoP' + ) + : undefined, attributable: - positiveAccepted && res !== undefined && !isNonceChallenge(res) + positiveAccepted && + res !== undefined && + res.statusCode === 401 && + !isNonceChallenge(res) }); }; @@ -448,8 +487,7 @@ server-provided nonce flow.`; statusCode: res.statusCode, wwwAuthenticate, dpopNonce, - date, - dpopError: dpopChallengeError(wwwAuthenticate) + date }; }; @@ -1116,7 +1154,7 @@ server-provided nonce flow.`; // ---- §7.1 error code (SHOULD): one aggregate, MUST checks unchanged ---- const errorCodeDesc = - 'Declined DPoP requests include an error parameter on the DPoP challenge naming the reason (RFC 9449 §7.1)'; + 'A declined request that included an access token names the reason with an error parameter (RFC 9449 §7.1). A Bearer-scheme attempt may carry that code on the Bearer challenge (RFC 9449 §7.2)'; if (!positiveAccepted) { checks.push( untestableCheck( @@ -1147,7 +1185,7 @@ server-provided nonce flow.`; .map((o) => ({ case: o.case, expected: o.expected, - actual: o.actual ?? null + actual: o.actual || null })); const ok = mismatches.length === 0; checks.push( @@ -1161,7 +1199,7 @@ server-provided nonce flow.`; : `DPoP error code missing or unexpected: ${mismatches .map( (m) => - `${m.case} expected ${m.expected} got ${m.actual ?? 'absent'}` + `${m.case} expected ${m.expected} got ${m.actual || 'absent'}` ) .join('; ')}`, { mismatches }