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..8cfc5950 --- /dev/null +++ b/examples/servers/typescript/sep-1932-compliant-server.ts @@ -0,0 +1,324 @@ +#!/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 || ''; +// 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 +// 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 = intEnv('DPOP_CLOCK_OFFSET_SECONDS', 0); + +// Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 step 5). +const ASYMMETRIC_ALGS = [ + 'ES256', + 'ES384', + 'ES512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA' +]; + +type Failure = { error: string; description: string; nonce?: boolean }; +type Result = { ok: true } | ({ ok: false } & Failure); + +const fail = (error: string, description: string, nonce = false): Result => ({ + ok: false, + error, + description, + nonce +}); + +let issuerKey: jose.CryptoKey | Uint8Array; + +function reconstructHtu(req: Request): string { + const proto = (req.headers['x-forwarded-proto'] as string) || 'http'; + const host = req.headers.host; + return `${proto}://${host}${req.originalUrl.split('?')[0]}`; +} + +function countHeader(req: Request, name: string): number { + let n = 0; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + if (req.rawHeaders[i].toLowerCase() === name) n++; + } + return n; +} + +async function validateDpop(req: Request): Promise { + // --- Access token presentation: must use the DPoP scheme (RFC 9449 §7.1) --- + const authz = req.headers.authorization; + if (!authz) return fail('invalid_token', 'missing Authorization header'); + if (!authz.startsWith('DPoP ')) { + return fail( + 'invalid_token', + 'access token must be presented with the DPoP scheme' + ); + } + const accessToken = authz.slice('DPoP '.length).trim(); + + // --- Exactly one DPoP header (§4.3 step 1) --- + const dpopCount = countHeader(req, 'dpop'); + if (dpopCount === 0) + return fail('invalid_dpop_proof', 'missing DPoP proof header'); + if (dpopCount > 1) + return fail('invalid_dpop_proof', 'more than one DPoP header field'); + const proof = req.headers.dpop as string; + + // --- Nonce-first negative mode: challenge before structural validation --- + if (REQUIRE_NONCE && NONCE_FIRST) { + let nonce: unknown; + try { + nonce = jose.decodeJwt(proof).nonce; + } catch { + nonce = undefined; + } + const hasNonce = typeof nonce === 'string' && nonce.length > 0; + if (!hasNonce) { + return fail( + 'use_dpop_nonce', + 'a server-provided nonce is required', + true + ); + } + if (!NONCE_ACCEPT_ANY && nonce !== NONCE) { + return fail('use_dpop_nonce', 'the supplied nonce does not match', true); + } + } + + // --- Proof is a well-formed JWT with required header params (§4.3 steps 2,4,5,7) --- + let header: jose.ProtectedHeaderParameters; + try { + header = jose.decodeProtectedHeader(proof); + } catch { + return fail('invalid_dpop_proof', 'DPoP proof is not a well-formed JWT'); + } + if (header.typ !== 'dpop+jwt') + return fail('invalid_dpop_proof', 'typ must be dpop+jwt'); + if (!header.alg || !ASYMMETRIC_ALGS.includes(header.alg)) { + return fail( + 'invalid_dpop_proof', + 'alg must be a supported asymmetric algorithm' + ); + } + const jwk = header.jwk as jose.JWK | undefined; + if (!jwk) return fail('invalid_dpop_proof', 'missing jwk header parameter'); + if ((jwk as Record).d !== undefined) { + return fail('invalid_dpop_proof', 'jwk must not contain a private key'); + } + + // --- Signature verifies with the embedded public key (§4.3 step 6) --- + let claims: jose.JWTPayload; + try { + const proofKey = await jose.importJWK(jwk, header.alg); + const verified = await jose.jwtVerify(proof, proofKey, { + algorithms: ASYMMETRIC_ALGS + }); + claims = verified.payload; + } catch { + return fail('invalid_dpop_proof', 'DPoP proof signature does not verify'); + } + + // --- Required claims + htm/htu match (§4.3 steps 3,8,9) --- + if (typeof claims.jti !== 'string') + return fail('invalid_dpop_proof', 'missing jti claim'); + if (claims.htm !== req.method) + return fail('invalid_dpop_proof', 'htm does not match request method'); + if (claims.htu !== reconstructHtu(req)) + return fail('invalid_dpop_proof', 'htu does not match request URI'); + + // --- iat acceptance window of ±IAT_SKEW (§4.3 step 11; SEP ±5 min) --- + if (typeof claims.iat !== 'number') + return fail('invalid_dpop_proof', 'missing iat claim'); + const now = Math.floor(Date.now() / 1000) + CLOCK_OFFSET; + if (Math.abs(now - claims.iat) > IAT_SKEW) { + return fail('invalid_dpop_proof', 'iat outside the acceptable window'); + } + + // --- Access token validity: signature, issuer, audience, expiry --- + let tokenClaims: jose.JWTPayload; + try { + const verified = await jose.jwtVerify(accessToken, issuerKey, { + issuer: ISSUER, + audience: AUDIENCE + }); + tokenClaims = verified.payload; + } catch { + return fail( + 'invalid_token', + 'access token is invalid (signature/issuer/audience/expiry)' + ); + } + + // --- ath binds the proof to this access token (§4.3 step 12a) --- + const expectedAth = createHash('sha256') + .update(accessToken, 'ascii') + .digest('base64url'); + if (claims.ath !== expectedAth) + return fail('invalid_dpop_proof', 'ath does not match the access token'); + + // --- Token is bound to the proof key (§4.3 step 12b) --- + const cnf = tokenClaims.cnf as { jkt?: string } | undefined; + const thumbprint = await jose.calculateJwkThumbprint(jwk, 'sha256'); + if (!cnf || cnf.jkt !== thumbprint) { + return fail( + 'invalid_token', + 'access token is not bound to the DPoP proof key (cnf.jkt mismatch)' + ); + } + + // --- Optional server-provided nonce (§4.3 step 10; §9) --- + if (REQUIRE_NONCE) { + const hasNonce = + typeof claims.nonce === 'string' && claims.nonce.length > 0; + if (!hasNonce) { + return fail( + 'use_dpop_nonce', + 'a server-provided nonce is required', + true + ); + } + if (!NONCE_ACCEPT_ANY && claims.nonce !== NONCE) { + return fail('use_dpop_nonce', 'the supplied nonce does not match', true); + } + } + + return { ok: true }; +} + +function send401(res: Response, f: Failure): void { + const algs = ASYMMETRIC_ALGS.join(' '); + res.setHeader( + 'WWW-Authenticate', + `DPoP error="${f.error}", error_description="${f.description}", algs="${algs}"` + ); + if (f.nonce) res.setHeader('DPoP-Nonce', NONCE); + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null + }); +} + +function createMcpServer(): McpServer { + const server = new McpServer({ + name: 'sep-1932-compliant-server', + version: '1.0.0' + }); + server.registerTool( + 'echo', + { + description: 'Echo the input back', + inputSchema: { message: z.string() } + }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Echo: ${message}` }] + }) + ); + return server; +} + +const app = express(); +app.use(express.json()); + +// Clock-offset mode: reflect the skewed clock in the Date header too, so a +// client that anchors to it measures against the same clock the server uses. +if (CLOCK_OFFSET) { + app.use((_req: Request, res: Response, next) => { + res.setHeader( + 'Date', + new Date(Date.now() + CLOCK_OFFSET * 1000).toUTCString() + ); + next(); + }); +} + +app.post('/mcp', async (req: Request, res: Response) => { + const result = await validateDpop(req); + if (!result.ok) { + send401(res, result); + return; + } + try { + const server = createMcpServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}` + }, + id: null + }); + } + } +}); + +const PORT = parseInt(process.env.PORT || '3010', 10); + +async function main(): Promise { + issuerKey = await jose.importJWK(ISSUER_JWK, ISSUER_JWK.alg || 'ES256'); + app.listen(PORT, '127.0.0.1', () => { + console.log( + `DPoP compliant server running on http://localhost:${PORT}/mcp` + ); + }); +} + +main().catch((err) => { + console.error('Failed to start DPoP compliant server:', err); + process.exit(1); +}); diff --git a/examples/servers/typescript/sep-1932-reject-all-server.ts b/examples/servers/typescript/sep-1932-reject-all-server.ts new file mode 100644 index 00000000..061f48e4 --- /dev/null +++ b/examples/servers/typescript/sep-1932-reject-all-server.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +/** + * MCP server that rejects EVERY request with 401 + `WWW-Authenticate: DPoP` — + * NEGATIVE test fixture for the gating in the DPoP server-validation scenario. + * + * A naive rejection check ("did the server 401 the malformed proof?") passes + * vacuously here, because this server also 401s a perfectly valid DPoP request. + * The scenario must therefore gate its rejection checks on the positive + * baseline: against this fixture `AcceptsValidProof` FAILs, and every rejection + * check must report notTestable rather than SUCCESS. DO NOT use in production. + */ + +import express, { type Request, type Response } from 'express'; + +const ASYMMETRIC_ALGS = ['ES256', 'ES384', 'ES512', 'RS256', 'PS256', 'EdDSA']; + +const app = express(); +app.use(express.json()); + +// Reject unconditionally — a valid proof is refused exactly like a malformed one. +app.post('/mcp', (_req: Request, res: Response) => { + res.setHeader( + 'WWW-Authenticate', + `DPoP error="invalid_token", error_description="rejects everything", algs="${ASYMMETRIC_ALGS.join(' ')}"` + ); + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null + }); +}); + +const PORT = parseInt(process.env.PORT || '3012', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log(`DPoP reject-all server running on http://localhost:${PORT}/mcp`); + console.log('WARNING: Rejects every request, including valid ones!'); +}); diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index db1584ea..b905bff0 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -66,6 +66,7 @@ import { } from './server/prompts'; import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +import { DPoPServerValidationScenario } from './server/auth/dpop'; import { CachingScenario } from './server/caching'; // InputRequiredResult scenarios from (SEP-2322) @@ -149,7 +150,12 @@ const pendingClientScenariosList: ClientScenario[] = [ new TasksDispatchScenario(), new TasksStatusNotificationsScenario(), new TasksRequiredTaskErrorScenario(), - new TasksMrtrCompositionScenario() + new TasksMrtrCompositionScenario(), + + // DPoP server validation (SEP-1932): the everything-server is not DPoP-aware, + // so this runs against a dedicated fixture, e.g. + // `npm start -- server --scenario auth/dpop-server-validation --url `. + new DPoPServerValidationScenario() ]; // All client scenarios @@ -229,6 +235,10 @@ const allClientScenariosList: ClientScenario[] = [ new TasksRequiredTaskErrorScenario(), new TasksMrtrCompositionScenario(), + // DPoP server validation (SEP-1932). Pending against the everything-server + // (not DPoP-aware); targeted runs point at the sep-1932-compliant-server fixture. + new DPoPServerValidationScenario(), + // InputRequiredResult scenarios (SEP-2322) new InputRequiredResultBasicElicitationScenario(), new InputRequiredResultBasicSamplingScenario(), diff --git a/src/scenarios/server/auth/dpop.test.ts b/src/scenarios/server/auth/dpop.test.ts new file mode 100644 index 00000000..54dca1da --- /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−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( + 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..1cfe840d --- /dev/null +++ b/src/scenarios/server/auth/dpop.ts @@ -0,0 +1,943 @@ +/** + * 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 (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 && + 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) 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 + ); + } + 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 + }); + + // 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 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 ( + 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); + // 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 }; + }; + + 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') + ) { + // `send` has already refreshed heldNonce from the challenge response, + // so the retry's proof carries it. + 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), ±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: -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)`; + 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' + } +};