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..5e8e0e90 --- /dev/null +++ b/examples/servers/typescript/sep-1932-compliant-server.ts @@ -0,0 +1,522 @@ +#!/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, + * DPOP_BEARER_REJECT_STATUS (negative-test: Bearer-scheme status, no challenge), + * DPOP_UNBOUND_REJECT_STATUS (negative-test: unbound Bearer status), + * DPOP_UNBOUND_NONCE_CHALLENGE (unbound Bearer gets a use_dpop_nonce 401), + * DPOP_ERROR_CODE_OVERRIDE (negative-test: that code on every 401), + * DPOP_PRM_OMIT_ALGS (negative-test: PRM omits dpop_signing_alg_values_supported), + * DPOP_PRM_OMIT_REQUIRED (negative-test: PRM omits dpop_bound_access_tokens_required), + * DPOP_PRM_EMPTY_ALGS, DPOP_PRM_ALGS_NOT_ARRAY, DPOP_PRM_ALG_NON_STRING, + * DPOP_PRM_REQUIRED_NOT_BOOLEAN, DPOP_PRM_INCLUDE_NONE, DPOP_PRM_INCLUDE_HS256, + * DPOP_PRM_DISABLED (no PRM route), DPOP_PRM_WRONG_RESOURCE, + * DPOP_PRM_OMIT_RESOURCE, DPOP_PRM_HTML, + * DPOP_PRM_AT_ROOT (PRM only at the root well-known URL, resource = origin), + * DPOP_PRM_REDIRECT (metadata URL 308s to the document), + * DPOP_ACCEPT_UNBOUND_BEARER (negative-test: accept an unbound Bearer token), + * DPOP_OMIT_CHALLENGE_ALGS (negative-test: DPoP challenge without `algs`), + * DPOP_BEARER_ONLY_CHALLENGE (unauthenticated 401 is Bearer, not DPoP), + * DPOP_OMIT_RESOURCE_METADATA (challenge omits resource_metadata). + */ + +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); +// 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; +// 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 || ''; +// PRM omits dpop_signing_alg_values_supported. Proves PrmDpop can FAILURE. +const PRM_OMIT_ALGS = process.env.DPOP_PRM_OMIT_ALGS === '1'; +// PRM omits dpop_bound_access_tokens_required. Absence is valid (RFC 9728). +const PRM_OMIT_REQUIRED = process.env.DPOP_PRM_OMIT_REQUIRED === '1'; +const PRM_EMPTY_ALGS = process.env.DPOP_PRM_EMPTY_ALGS === '1'; +const PRM_ALGS_NOT_ARRAY = process.env.DPOP_PRM_ALGS_NOT_ARRAY === '1'; +const PRM_ALG_NON_STRING = process.env.DPOP_PRM_ALG_NON_STRING === '1'; +const PRM_REQUIRED_NOT_BOOLEAN = + process.env.DPOP_PRM_REQUIRED_NOT_BOOLEAN === '1'; +// PRM lists `none` among its DPoP algs. Proves PrmDpop can FAILURE. +const PRM_INCLUDE_NONE = process.env.DPOP_PRM_INCLUDE_NONE === '1'; +const PRM_INCLUDE_HS256 = process.env.DPOP_PRM_INCLUDE_HS256 === '1'; +// No protected-resource-metadata route. Proves the unreachable paths. +const PRM_DISABLED = process.env.DPOP_PRM_DISABLED === '1'; +const PRM_WRONG_RESOURCE = process.env.DPOP_PRM_WRONG_RESOURCE === '1'; +const PRM_OMIT_RESOURCE = process.env.DPOP_PRM_OMIT_RESOURCE === '1'; +// 200 whose body is JSON but whose Content-Type is not application/json. +const PRM_HTML = process.env.DPOP_PRM_HTML === '1'; +// resource_metadata URL answers 308, then the real document. +const PRM_REDIRECT = process.env.DPOP_PRM_REDIRECT === '1'; +// PRM is served only at the root well-known URL, naming the origin as its +// resource (RFC 9728 §3.3 for that URL). +const PRM_AT_ROOT = process.env.DPOP_PRM_AT_ROOT === '1'; +// Accept a valid unbound Bearer token (no cnf). A DPoP-bound token presented +// as Bearer is still rejected. Proves PrmConsistency can FAILURE. +const ACCEPT_UNBOUND_BEARER = process.env.DPOP_ACCEPT_UNBOUND_BEARER === '1'; +// Unbound Bearer token is answered with this status (4xx counts as rejection; +// 5xx must not). +const UNBOUND_REJECT_STATUS = process.env.DPOP_UNBOUND_REJECT_STATUS + ? intEnv('DPOP_UNBOUND_REJECT_STATUS', 0) + : 0; +// Unbound Bearer token is answered with a use_dpop_nonce 401 (no DPoP-Nonce +// header, so later probes are unaffected). +const UNBOUND_NONCE_CHALLENGE = + process.env.DPOP_UNBOUND_NONCE_CHALLENGE === '1'; +// DPoP challenge omits `algs`. Proves AdvertisesDpop can WARNING. +const OMIT_CHALLENGE_ALGS = process.env.DPOP_OMIT_CHALLENGE_ALGS === '1'; +// Unauthenticated 401 carries only a Bearer challenge. +const BEARER_ONLY_CHALLENGE = process.env.DPOP_BEARER_ONLY_CHALLENGE === '1'; +// Challenge omits resource_metadata so discovery uses the well-known URL. +const OMIT_RESOURCE_METADATA = process.env.DPOP_OMIT_RESOURCE_METADATA === '1'; + +// 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 metadataUrl(): string { + const origin = new URL(AUDIENCE).origin; + if (PRM_REDIRECT) return `${origin}/prm-redirect`; + if (PRM_AT_ROOT) return `${origin}/.well-known/oauth-protected-resource`; + return `${origin}/.well-known/oauth-protected-resource/mcp`; +} + +function prmResource(): string { + if (PRM_WRONG_RESOURCE) return 'https://attacker.example/mcp'; + if (PRM_AT_ROOT) return new URL(AUDIENCE).origin; + return AUDIENCE; +} + +function prmDocument(): Record { + const doc: Record = PRM_OMIT_RESOURCE + ? {} + : { resource: prmResource() }; + if (!PRM_OMIT_ALGS) { + const listed = [ + ...ASYMMETRIC_ALGS, + ...(PRM_INCLUDE_NONE ? ['none'] : []), + ...(PRM_INCLUDE_HS256 ? ['HS256'] : []) + ]; + doc.dpop_signing_alg_values_supported = PRM_EMPTY_ALGS + ? [] + : PRM_ALGS_NOT_ARRAY + ? 'ES256' + : PRM_ALG_NON_STRING + ? [1] + : listed; + } + if (!PRM_OMIT_REQUIRED) { + doc.dpop_bound_access_tokens_required = PRM_REQUIRED_NOT_BOOLEAN + ? 'true' + : true; + } + return doc; +} + +function tokenIsBound(authz: string): boolean { + try { + const claims = jose.decodeJwt(authz.slice('Bearer '.length).trim()); + const cnf = claims.cnf as { jkt?: string } | undefined; + return typeof cnf?.jkt === 'string'; + } catch { + return false; + } +} + +function send401(req: Request, res: Response, f: Failure): void { + const error = ERROR_CODE_OVERRIDE || f.error; + const metadata = OMIT_RESOURCE_METADATA + ? [] + : [`resource_metadata="${metadataUrl()}"`]; + // Only the unauthenticated probe. Authenticated 401s keep the DPoP challenge + // so the rejection battery still has a scheme to grade. + if (BEARER_ONLY_CHALLENGE && !req.headers.authorization) { + const bearerParams = ['realm="mcp"', ...metadata]; + res.setHeader('WWW-Authenticate', `Bearer ${bearerParams.join(', ')}`); + } else { + const params = [`error="${error}"`, `error_description="${f.description}"`]; + if (!OMIT_CHALLENGE_ALGS) { + params.push(`algs="${ASYMMETRIC_ALGS.join(' ')}"`); + } + params.push(...metadata); + res.setHeader('WWW-Authenticate', `DPoP ${params.join(', ')}`); + } + if (f.nonce) res.setHeader('DPoP-Nonce', NONCE); + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null + }); +} + +async function handleMcp(req: Request, res: Response): Promise { + 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 + }); + } + } +} + +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(); + }); +} + +if (PRM_REDIRECT) { + app.get('/prm-redirect', (_req, res) => { + res.redirect(308, '/.well-known/oauth-protected-resource/mcp'); + }); +} + +if (!PRM_DISABLED) { + const prmPath = PRM_AT_ROOT + ? '/.well-known/oauth-protected-resource' + : '/.well-known/oauth-protected-resource/mcp'; + app.get(prmPath, (_req, res) => { + if (PRM_HTML) { + res.status(200).type('text/html').send(JSON.stringify(prmDocument())); + return; + } + res.json(prmDocument()); + }); +} + +app.post('/mcp', async (req: Request, res: Response) => { + const authz = req.headers.authorization ?? ''; + if (authz.startsWith('Bearer ')) { + const bound = tokenIsBound(authz); + if (bound && BEARER_REJECT_STATUS) { + // Only the downgrade probe (a DPoP-bound token presented as Bearer). An + // unbound Bearer token is the consistency probe and must still be judged + // by validateDpop, unless an UNBOUND_* mode below answers it first. + res.status(BEARER_REJECT_STATUS).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal error' }, + id: null + }); + return; + } + if (!bound && UNBOUND_NONCE_CHALLENGE) { + res.setHeader('WWW-Authenticate', `DPoP error="use_dpop_nonce"`); + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null + }); + return; + } + if (!bound && UNBOUND_REJECT_STATUS) { + res.status(UNBOUND_REJECT_STATUS).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null + }); + return; + } + } + if (ACCEPT_UNBOUND_BEARER && authz.startsWith('Bearer ')) { + const raw = authz.slice('Bearer '.length).trim(); + try { + const verified = await jose.jwtVerify(raw, issuerKey, { + issuer: ISSUER, + audience: AUDIENCE + }); + const cnf = verified.payload.cnf as { jkt?: string } | undefined; + if (!cnf?.jkt) { + await handleMcp(req, res); + return; + } + } catch { + // Not a valid unbound token. Fall through and reject it as Bearer. + } + } + const result = await validateDpop(req); + if (!result.ok) { + send401(req, res, result); + return; + } + await handleMcp(req, res); +}); + +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..3ac9df9c --- /dev/null +++ b/src/scenarios/server/auth/dpop.test.ts @@ -0,0 +1,1036 @@ +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, + dpopChallengeError, + protectedResourceMetadataFallbacks +} 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('protectedResourceMetadataFallbacks', () => { + it('tries the path-based well-known URL before the root one', () => { + expect( + protectedResourceMetadataFallbacks('http://localhost:9/mcp') + ).toEqual({ + pathBased: 'http://localhost:9/.well-known/oauth-protected-resource/mcp', + root: 'http://localhost:9/.well-known/oauth-protected-resource' + }); + expect( + protectedResourceMetadataFallbacks('http://localhost:9/public/mcp') + ).toEqual({ + pathBased: + 'http://localhost:9/.well-known/oauth-protected-resource/public/mcp', + root: 'http://localhost:9/.well-known/oauth-protected-resource' + }); + expect(protectedResourceMetadataFallbacks('http://localhost:9')).toEqual({ + pathBased: 'http://localhost:9/.well-known/oauth-protected-resource', + root: 'http://localhost:9/.well-known/oauth-protected-resource' + }); + }); +}); + +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(); + }); +}); + +function genuineFailures(checks: ConformanceCheck[]): string[] { + return checks + .filter((c) => c.status === 'FAILURE' && !c.details?.untestable) + .map((c) => `${c.id}/${c.name}: ${c.errorMessage}`); +} + +describe('DPoP server validation scenario', () => { + let procs: ChildProcess[] = []; + let ports: Record = {}; + 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 issuerEnv = (port: number, extra: Record = {}) => ({ + DPOP_ISSUER_JWK: JSON.stringify(publicJwk), + DPOP_ISSUER: ISSUER, + DPOP_AUDIENCE: url(port), + ...extra + }); + + const fixtures: Array<{ + name: string; + script: string; + env: Record; + }> = [ + { name: 'compliant', script: COMPLIANT, env: {} }, + { name: 'broken', script: BROKEN, env: {} }, + { name: 'rejectAll', script: REJECT_ALL, env: {} }, + { name: 'strict', script: COMPLIANT, env: { DPOP_REQUIRE_NONCE: '1' } }, + { + name: 'buggy', + script: COMPLIANT, + env: { DPOP_REQUIRE_NONCE: '1', DPOP_NONCE_ACCEPT_ANY: '1' } + }, + { + name: 'nonceFirst', + script: COMPLIANT, + env: { DPOP_REQUIRE_NONCE: '1', DPOP_NONCE_FIRST: '1' } + }, + { + name: 'clockSkew', + script: COMPLIANT, + env: { DPOP_CLOCK_OFFSET_SECONDS: '-30' } + }, + { + name: 'bearerReject', + script: COMPLIANT, + env: { DPOP_BEARER_REJECT_STATUS: '500' } + }, + { + name: 'errorOverride', + script: COMPLIANT, + env: { DPOP_ERROR_CODE_OVERRIDE: 'invalid_request' } + }, + { + name: 'omitAlgsField', + script: COMPLIANT, + env: { DPOP_PRM_OMIT_ALGS: '1' } + }, + { + name: 'omitRequired', + script: COMPLIANT, + env: { DPOP_PRM_OMIT_REQUIRED: '1' } + }, + { + name: 'optionalDpop', + script: COMPLIANT, + env: { + DPOP_PRM_OMIT_REQUIRED: '1', + DPOP_ACCEPT_UNBOUND_BEARER: '1' + } + }, + { + name: 'acceptUnbound', + script: COMPLIANT, + env: { DPOP_ACCEPT_UNBOUND_BEARER: '1' } + }, + { + name: 'prmNone', + script: COMPLIANT, + env: { DPOP_PRM_INCLUDE_NONE: '1' } + }, + { + name: 'omitAlgs', + script: COMPLIANT, + env: { DPOP_OMIT_CHALLENGE_ALGS: '1' } + }, + { + name: 'prmDisabled', + script: COMPLIANT, + env: { DPOP_PRM_DISABLED: '1' } + }, + { + name: 'wrongResource', + script: COMPLIANT, + env: { DPOP_PRM_WRONG_RESOURCE: '1' } + }, + { + name: 'bearerOnly', + script: COMPLIANT, + env: { DPOP_BEARER_ONLY_CHALLENGE: '1' } + }, + { + name: 'bearerOnlyOptional', + script: COMPLIANT, + env: { + DPOP_BEARER_ONLY_CHALLENGE: '1', + DPOP_PRM_OMIT_REQUIRED: '1', + DPOP_ACCEPT_UNBOUND_BEARER: '1' + } + }, + { + name: 'emptyAlgs', + script: COMPLIANT, + env: { DPOP_PRM_EMPTY_ALGS: '1' } + }, + { + name: 'algsNotArray', + script: COMPLIANT, + env: { DPOP_PRM_ALGS_NOT_ARRAY: '1' } + }, + { + name: 'algNonString', + script: COMPLIANT, + env: { DPOP_PRM_ALG_NON_STRING: '1' } + }, + { + name: 'requiredNotBoolean', + script: COMPLIANT, + env: { + DPOP_PRM_REQUIRED_NOT_BOOLEAN: '1', + DPOP_ACCEPT_UNBOUND_BEARER: '1' + } + }, + { + name: 'prmHs256', + script: COMPLIANT, + env: { DPOP_PRM_INCLUDE_HS256: '1' } + }, + { + name: 'unbound403', + script: COMPLIANT, + env: { DPOP_UNBOUND_REJECT_STATUS: '403' } + }, + { + name: 'unbound400', + script: COMPLIANT, + env: { DPOP_UNBOUND_REJECT_STATUS: '400' } + }, + { + name: 'prmRoot', + script: COMPLIANT, + env: { DPOP_PRM_AT_ROOT: '1', DPOP_OMIT_RESOURCE_METADATA: '1' } + }, + { + name: 'prmRootWrongResource', + script: COMPLIANT, + env: { + DPOP_PRM_AT_ROOT: '1', + DPOP_OMIT_RESOURCE_METADATA: '1', + DPOP_PRM_WRONG_RESOURCE: '1' + } + }, + { + name: 'unbound500', + script: COMPLIANT, + env: { DPOP_UNBOUND_REJECT_STATUS: '500' } + }, + { + name: 'unboundNonce', + script: COMPLIANT, + env: { + DPOP_UNBOUND_NONCE_CHALLENGE: '1', + DPOP_PRM_OMIT_REQUIRED: '1' + } + }, + { + name: 'omitResource', + script: COMPLIANT, + env: { DPOP_PRM_OMIT_RESOURCE: '1' } + }, + { + name: 'wellKnown', + script: COMPLIANT, + env: { DPOP_OMIT_RESOURCE_METADATA: '1' } + }, + { + name: 'prmRedirect', + script: COMPLIANT, + env: { DPOP_PRM_REDIRECT: '1' } + }, + { name: 'prmHtml', script: COMPLIANT, env: { DPOP_PRM_HTML: '1' } } + ]; + + const allocated = await freePorts(fixtures.length); + ports = Object.fromEntries( + fixtures.map((fixture, i) => [fixture.name, allocated[i]]) + ); + procs = await Promise.all( + fixtures.map((fixture, i) => { + const port = allocated[i]; + const env = + fixture.script === COMPLIANT + ? issuerEnv(port, fixture.env) + : fixture.env; + return startServer(fixture.script, port, env); + }) + ); + }, 180000); + + afterAll(async () => { + await Promise.all(procs.map((proc) => stopServer(proc))); + 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-validate-proof').find( + (c) => c.name === 'RejectsBearerScheme' + )?.status + ).toBe('SUCCESS'); + expect(byId(checks, 'sep-1932-server-error-code')[0].status).toBe( + 'SUCCESS' + ); + expect(byId(checks, 'sep-1932-server-advertises-dpop')[0].status).toBe( + 'SUCCESS' + ); + expect(byId(checks, 'sep-1932-server-prm-dpop')[0].status).toBe('SUCCESS'); + expect(byId(checks, 'sep-1932-server-prm-consistency')[0].status).toBe( + 'SUCCESS' + ); + 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' + ); + + // Not a 401, so the challenge sentences are not exercised. The unconditional + // obligation is a SHOULD, so the untestable result is a WARNING. + const advertises = byId(checks, 'sep-1932-server-advertises-dpop')[0]; + expect(advertises.status).toBe('WARNING'); + expect(advertises.details?.untestable).toBe(true); + // Nothing serves protected resource metadata, and consistency must not + // invent a document in order to accuse the server. + expect( + byId(checks, 'sep-1932-server-prm-dpop')[0].details?.untestable + ).toBe(true); + const consistency = byId(checks, 'sep-1932-server-prm-consistency')[0]; + expect(consistency.details?.untestable).toBe(true); + expect(consistency.errorMessage).toContain( + 'cannot evaluate advertisement consistency' + ); + }, 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); + + // Discovery is not gated on the positive probe: this server still 401s + // with a DPoP challenge and algs. Consistency is gated, because a 401 + // here cannot be attributed to the unbound token. + expect(byId(checks, 'sep-1932-server-advertises-dpop')[0].status).toBe( + 'SUCCESS' + ); + expect( + byId(checks, 'sep-1932-server-prm-consistency')[0].details?.untestable + ).toBe(true); + }, 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('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('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('reports FAILURE when protected resource metadata omits the algorithms field', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.omitAlgsField)) + ); + + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.status).toBe('FAILURE'); + expect(prm.errorMessage).toBe( + 'Protected resource metadata is missing dpop_signing_alg_values_supported' + ); + expect(byId(checks, 'sep-1932-server-prm-consistency')[0].status).toBe( + 'SUCCESS' + ); + expect(genuineFailures(checks)).toEqual([ + 'sep-1932-server-prm-dpop/PrmDpop: Protected resource metadata is missing dpop_signing_alg_values_supported' + ]); + }, 30000); + + it('treats an absent dpop_bound_access_tokens_required as not required', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.omitRequired)) + ); + + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.status).toBe('SUCCESS'); + expect(prm.details?.dpop_bound_access_tokens_required).toBeNull(); + const consistency = byId(checks, 'sep-1932-server-prm-consistency')[0]; + expect(consistency.status).toBe('FAILURE'); + expect(consistency.errorMessage).toBe( + 'server requires DPoP but does not advertise it' + ); + expect(consistency.details?.dpop_bound_access_tokens_required).toBeNull(); + expect(consistency.details?.wwwAuthenticate).toEqual(expect.any(String)); + expect(genuineFailures(checks)).toEqual([ + 'sep-1932-server-prm-consistency/PrmConsistency: server requires DPoP but does not advertise it' + ]); + }, 30000); + + it('passes when DPoP is supported but not required and bearer tokens are accepted', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.optionalDpop)) + ); + + expect(byId(checks, 'sep-1932-server-prm-dpop')[0].status).toBe('SUCCESS'); + expect(byId(checks, 'sep-1932-server-prm-consistency')[0].status).toBe( + 'SUCCESS' + ); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('does not grade DPoP fields or consistency when metadata is unreachable', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.prmDisabled)) + ); + + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.details?.untestable).toBe(true); + expect(prm.errorMessage).toContain('not 200'); + const consistency = byId(checks, 'sep-1932-server-prm-consistency')[0]; + expect(consistency.details?.untestable).toBe(true); + expect(consistency.errorMessage).toContain( + 'cannot evaluate advertisement consistency' + ); + expect(consistency.errorMessage).not.toContain('does not advertise'); + expect(byId(checks, 'sep-1932-server-advertises-dpop')[0].status).toBe( + 'SUCCESS' + ); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('does not grade protected resource metadata whose resource identifier does not match', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.wrongResource)) + ); + + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.details?.untestable).toBe(true); + expect(prm.errorMessage).toContain('unusable (RFC 9728 §3.3)'); + expect(prm.errorMessage).toContain('https://attacker.example/mcp'); + expect(prm.errorMessage).toContain(url(ports.wrongResource)); + const consistency = byId(checks, 'sep-1932-server-prm-consistency')[0]; + expect(consistency.details?.untestable).toBe(true); + expect(consistency.errorMessage).toContain( + 'cannot evaluate advertisement consistency' + ); + expect(consistency.errorMessage).toContain('unusable (RFC 9728 §3.3)'); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('fails a required-DPoP server whose 401 has no DPoP challenge', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.bearerOnly)) + ); + + const advertises = byId(checks, 'sep-1932-server-advertises-dpop')[0]; + expect(advertises.status).toBe('FAILURE'); + expect(advertises.errorMessage).toBe( + 'Protected resource metadata sets dpop_bound_access_tokens_required to true, but the HTTP 401 did not include a DPoP challenge' + ); + expect(byId(checks, 'sep-1932-server-prm-dpop')[0].status).toBe('SUCCESS'); + expect(byId(checks, 'sep-1932-server-prm-consistency')[0].status).toBe( + 'SUCCESS' + ); + expect(genuineFailures(checks)).toEqual([ + 'sep-1932-server-advertises-dpop/AdvertisesDpop: Protected resource metadata sets dpop_bound_access_tokens_required to true, but the HTTP 401 did not include a DPoP challenge' + ]); + }, 30000); + + it('warns when an optional-DPoP server answers 401 with only a Bearer challenge', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.bearerOnlyOptional)) + ); + + const advertises = byId(checks, 'sep-1932-server-advertises-dpop')[0]; + expect(advertises.status).toBe('WARNING'); + expect(advertises.errorMessage).toBe( + 'HTTP 401 did not include a DPoP challenge' + ); + expect(byId(checks, 'sep-1932-server-prm-dpop')[0].status).toBe('SUCCESS'); + expect(byId(checks, 'sep-1932-server-prm-consistency')[0].status).toBe( + 'SUCCESS' + ); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('reports an empty algorithms array separately from a missing field', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.emptyAlgs)) + ); + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.status).toBe('FAILURE'); + expect(prm.errorMessage).toBe('dpop_signing_alg_values_supported is empty'); + expect(genuineFailures(checks)).toEqual([ + 'sep-1932-server-prm-dpop/PrmDpop: dpop_signing_alg_values_supported is empty' + ]); + }, 30000); + + it('reports a non-array algorithms value as malformed', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.algsNotArray)) + ); + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.errorMessage).toBe( + 'dpop_signing_alg_values_supported is malformed: expected a JSON array' + ); + expect(genuineFailures(checks)).toEqual([ + 'sep-1932-server-prm-dpop/PrmDpop: dpop_signing_alg_values_supported is malformed: expected a JSON array' + ]); + }, 30000); + + it('reports a non-string algorithms entry as malformed', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.algNonString)) + ); + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.errorMessage).toBe( + 'dpop_signing_alg_values_supported is malformed: every entry must be a non-empty string' + ); + expect(genuineFailures(checks)).toEqual([ + 'sep-1932-server-prm-dpop/PrmDpop: dpop_signing_alg_values_supported is malformed: every entry must be a non-empty string' + ]); + }, 30000); + + it('reports a non-boolean dpop_bound_access_tokens_required as malformed', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.requiredNotBoolean)) + ); + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.errorMessage).toBe( + 'dpop_bound_access_tokens_required is malformed: expected a boolean' + ); + expect(byId(checks, 'sep-1932-server-prm-consistency')[0].status).toBe( + 'SUCCESS' + ); + expect(genuineFailures(checks)).toEqual([ + 'sep-1932-server-prm-dpop/PrmDpop: dpop_bound_access_tokens_required is malformed: expected a boolean' + ]); + }, 30000); + + it('reports FAILURE when protected resource metadata lists HS256', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.prmHs256)) + ); + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.status).toBe('FAILURE'); + expect(prm.details?.forbidden).toEqual(['HS256']); + expect(genuineFailures(checks).map((line) => line.split(':')[0])).toEqual([ + 'sep-1932-server-prm-dpop/PrmDpop' + ]); + }, 30000); + + it('counts HTTP 403 as rejection when metadata says DPoP is required', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.unbound403)) + ); + const consistency = byId(checks, 'sep-1932-server-prm-consistency')[0]; + expect(consistency.status).toBe('SUCCESS'); + expect(consistency.details?.statusCode).toBe(403); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('counts HTTP 400 as rejection when metadata says DPoP is required', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.unbound400)) + ); + const consistency = byId(checks, 'sep-1932-server-prm-consistency')[0]; + expect(consistency.status).toBe('SUCCESS'); + expect(consistency.details?.statusCode).toBe(400); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('accepts the origin as resource for metadata at the root well-known URL', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.prmRoot)) + ); + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.status).toBe('SUCCESS'); + expect(prm.details?.source).toBe('well-known-root'); + expect(byId(checks, 'sep-1932-server-prm-consistency')[0].status).toBe( + 'SUCCESS' + ); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('still rejects a different origin as resource at the root well-known URL', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.prmRootWrongResource)) + ); + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.details?.untestable).toBe(true); + expect(prm.errorMessage).toContain( + `at http://localhost:${ports.prmRootWrongResource}/.well-known/oauth-protected-resource is unusable` + ); + expect(prm.errorMessage).toContain('https://attacker.example/mcp'); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('does not count HTTP 500 as rejection of an unbound Bearer token', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.unbound500)) + ); + const consistency = byId(checks, 'sep-1932-server-prm-consistency')[0]; + expect(consistency.status).toBe('FAILURE'); + expect(consistency.errorMessage).toBe( + 'The server did not reject an unbound Bearer token (HTTP 500)' + ); + expect(genuineFailures(checks)).toEqual([ + 'sep-1932-server-prm-consistency/PrmConsistency: The server did not reject an unbound Bearer token (HTTP 500)' + ]); + }, 30000); + + it('loads protected resource metadata from the well-known URL when the challenge omits resource_metadata', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.wellKnown)) + ); + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.status).toBe('SUCCESS'); + expect(prm.details?.source).toBe('well-known-path'); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('follows a redirect to protected resource metadata', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.prmRedirect)) + ); + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.status).toBe('SUCCESS'); + expect(prm.details?.source).toBe('resource_metadata'); + expect(prm.details?.url).toBe( + `http://localhost:${ports.prmRedirect}/.well-known/oauth-protected-resource/mcp` + ); + expect(prm.details?.redirectedFrom).toBe( + `http://localhost:${ports.prmRedirect}/prm-redirect` + ); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('does not infer unadvertised enforcement from a nonce challenge to an unbound Bearer token', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.unboundNonce)) + ); + const consistency = byId(checks, 'sep-1932-server-prm-consistency')[0]; + expect(consistency.details?.untestable).toBe(true); + expect(consistency.errorMessage).toContain('use_dpop_nonce'); + expect(consistency.errorMessage).not.toContain('does not advertise'); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('names a missing resource field instead of printing undefined', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.omitResource)) + ); + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.details?.untestable).toBe(true); + expect(prm.errorMessage).toContain( + `the resource field is missing (expected ${url(ports.omitResource)})` + ); + expect(prm.errorMessage).not.toContain('undefined'); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('does not grade a metadata response whose Content-Type is not application/json', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.prmHtml)) + ); + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.details?.untestable).toBe(true); + expect(prm.errorMessage).toContain('Content-Type'); + expect(prm.errorMessage).toContain('text/html'); + const consistency = byId(checks, 'sep-1932-server-prm-consistency')[0]; + expect(consistency.details?.untestable).toBe(true); + expect(genuineFailures(checks)).toEqual([]); + }, 30000); + + it('reports FAILURE when metadata requires DPoP but an unbound Bearer token is accepted', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.acceptUnbound)) + ); + + const consistency = byId(checks, 'sep-1932-server-prm-consistency')[0]; + expect(consistency.status).toBe('FAILURE'); + expect(consistency.details?.untestable).toBeUndefined(); + + const positive = byId(checks, 'sep-1932-server-validate-proof').find( + (c) => c.name === 'AcceptsValidProof' + ); + expect(positive?.status).toBe('SUCCESS'); + const bearer = byId(checks, 'sep-1932-server-validate-proof').find( + (c) => c.name === 'RejectsBearerScheme' + ); + expect(bearer?.status).toBe('SUCCESS'); + }, 30000); + + it('reports FAILURE when protected resource metadata lists the none algorithm', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.prmNone)) + ); + + const prm = byId(checks, 'sep-1932-server-prm-dpop')[0]; + expect(prm.status).toBe('FAILURE'); + expect(prm.details?.forbidden).toEqual(['none']); + + expect(byId(checks, 'sep-1932-server-prm-consistency')[0].status).toBe( + 'SUCCESS' + ); + const positive = byId(checks, 'sep-1932-server-validate-proof').find( + (c) => c.name === 'AcceptsValidProof' + ); + expect(positive?.status).toBe('SUCCESS'); + }, 30000); + + it('reports WARNING when the DPoP challenge omits algs', async () => { + const checks = await new DPoPServerValidationScenario().run( + testContext(url(ports.omitAlgs)) + ); + + const advertises = byId(checks, 'sep-1932-server-advertises-dpop')[0]; + expect(advertises.status).toBe('WARNING'); + expect(advertises.errorMessage).toBe( + 'DPoP challenge is missing the algs parameter' + ); + expect(byId(checks, 'sep-1932-server-prm-dpop')[0].status).toBe('SUCCESS'); + expect(byId(checks, 'sep-1932-server-prm-consistency')[0].status).toBe( + 'SUCCESS' + ); + }, 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..560a0f6f --- /dev/null +++ b/src/scenarios/server/auth/dpop.ts @@ -0,0 +1,1844 @@ +/** + * 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, + type SpecReference +} 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 +]; + +const DISCOVERY_REFERENCES: SpecReference[] = [ + ...SPEC_REFERENCES, + SpecReferences.RFC_9728_METADATA, + SpecReferences.RFC_9728_RESPONSE, + SpecReferences.RFC_9728_VALIDATION, + SpecReferences.RFC_9728_WWW_AUTHENTICATE +]; + +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; + /** `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 { + 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); +} + +// 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); +} + +// 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); +} + +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 +// 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, + specReferences: SpecReference[] = SPEC_REFERENCES +): ConformanceCheck { + return { + id, + name, + description, + timestamp: new Date().toISOString(), + specReferences, + 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 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, + 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 }; +} + +const ADVERTISES_ID = 'sep-1932-server-advertises-dpop'; +const ADVERTISES_NAME = 'AdvertisesDpop'; +const ADVERTISES_DESC = + 'An unauthenticated HTTP 401 includes a DPoP challenge (MUST when dpop_bound_access_tokens_required is true, SHOULD otherwise), and that challenge SHOULD include algs'; + +const PRM_ID = 'sep-1932-server-prm-dpop'; +const PRM_NAME = 'PrmDpop'; +const PRM_DESC = + 'Protected resource metadata includes a non-empty, asymmetric-only dpop_signing_alg_values_supported array; dpop_bound_access_tokens_required is optional and defaults to false'; + +const CONSISTENCY_ID = 'sep-1932-server-prm-consistency'; +const CONSISTENCY_NAME = 'PrmConsistency'; +const CONSISTENCY_DESC = + 'The server rejects an unbound Bearer token when protected resource metadata says DPoP is required, and advertises that requirement when it enforces DPoP'; + +type PrmSource = 'resource_metadata' | 'well-known-path' | 'well-known-root'; + +interface PrmDocument { + doc: Record; + source: PrmSource; + /** The URL that served the document, after any redirects. */ + url: string; + /** The URL first requested, when a redirect led elsewhere. */ + redirectedFrom?: string; +} + +/** Path-based well-known URL, then the root one (RFC 9728 §3.1, MCP discovery order). */ +export function protectedResourceMetadataFallbacks(resourceUrl: string): { + pathBased: string; + root: string; +} { + const u = new URL(resourceUrl); + // A resource at the origin has no path to insert. `pathname` is `/` there; + // appending it would leave a trailing slash the well-known URL does not use. + const path = u.pathname === '/' ? '' : u.pathname; + return { + pathBased: `${u.origin}/.well-known/oauth-protected-resource${path}${u.search}`, + root: `${u.origin}/.well-known/oauth-protected-resource` + }; +} + +function dpopAuthParam(header: string, name: string): string | undefined { + for (const challenge of splitChallenges(header)) { + const scheme = AUTH_TOKEN.exec(challenge); + if (!scheme || scheme[0].toLowerCase() !== 'dpop') continue; + const value = readAuthParam(challenge.slice(scheme[0].length), name); + if (value !== undefined) return value; + } + return undefined; +} + +/** `resource_metadata` from the DPoP challenge, else from any sibling challenge. */ +function resourceMetadataParam(header: string): string | undefined { + const fromDpop = dpopAuthParam(header, 'resource_metadata'); + if (fromDpop !== undefined) return fromDpop; + for (const challenge of splitChallenges(header)) { + const scheme = AUTH_TOKEN.exec(challenge); + if (!scheme) continue; + const value = readAuthParam( + challenge.slice(scheme[0].length), + 'resource_metadata' + ); + if (value !== undefined) return value; + } + return undefined; +} + +function forbiddenDpopAlgs(algs: unknown): string[] { + const list = Array.isArray(algs) ? algs : []; + return list.filter( + (alg): alg is string => + typeof alg === 'string' && + (alg.toLowerCase() === 'none' || alg.toUpperCase().startsWith('HS')) + ); +} + +function singleHeader( + value: string | string[] | undefined +): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +/** RFC 9728 §3.2: application/json, parameters such as charset allowed. */ +function isJsonContentType(value: string | string[] | undefined): boolean { + const raw = singleHeader(value); + if (!raw) return false; + return raw.split(';')[0].trim().toLowerCase() === 'application/json'; +} + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const MAX_METADATA_REDIRECTS = 2; + +interface FetchMiss { + reason: string; + /** + * A 200 that is not a metadata document. Stop; another well-known URL would + * hide the response the client actually received. + */ + terminal: boolean; +} + +async function fetchJsonDocument( + url: string +): Promise<{ doc: Record; url: string } | FetchMiss> { + let current = url; + for (let hop = 0; ; hop++) { + let res: Awaited>; + try { + res = await request(current, { + method: 'GET', + headers: { accept: 'application/json' } + }); + } catch (e) { + return { + reason: `request to ${current} failed: ${String(e)}`, + terminal: false + }; + } + let text = ''; + try { + text = await res.body.text(); + } catch (e) { + return { + reason: `reading ${current} failed: ${String(e)}`, + terminal: false + }; + } + const location = singleHeader(res.headers.location); + if ( + REDIRECT_STATUSES.has(res.statusCode) && + location && + hop < MAX_METADATA_REDIRECTS + ) { + try { + current = new URL(location, current).href; + } catch { + return { + reason: `redirect Location is not a URL (${location})`, + terminal: false + }; + } + continue; + } + if (res.statusCode !== 200) { + const redirecting = + REDIRECT_STATUSES.has(res.statusCode) && hop >= MAX_METADATA_REDIRECTS; + return { + reason: redirecting + ? `responded HTTP ${res.statusCode} and was still redirecting after ${MAX_METADATA_REDIRECTS} redirects` + : `responded HTTP ${res.statusCode}, not 200`, + terminal: false + }; + } + if (!isJsonContentType(res.headers['content-type'])) { + const got = singleHeader(res.headers['content-type']) ?? 'none'; + return { + reason: `Content-Type is ${got}, not application/json`, + terminal: true + }; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { reason: 'body was not a JSON object', terminal: true }; + } + return { doc: parsed as Record, url: current }; + } +} + +/** + * RFC 9728 §3.3: `resource` MUST be identical to the identifier the + * well-known suffix was inserted into, or the document is unusable. For the + * root URL that identifier is the origin. The MCP endpoint is also accepted + * there, matching what MCP clients (e.g. the TypeScript SDK) accept. + */ +function acceptedResources(resourceUrl: string, source: PrmSource): string[] { + if (source !== 'well-known-root') return [resourceUrl]; + const { origin } = new URL(resourceUrl); + return [resourceUrl, origin, `${origin}/`]; +} + +function resourceMismatch( + doc: Record, + resourceUrl: string, + source: PrmSource +): string | undefined { + const allowed = acceptedResources(resourceUrl, source); + if (typeof doc.resource === 'string' && allowed.includes(doc.resource)) { + return undefined; + } + const expected = allowed.join(' or '); + if (doc.resource === undefined) { + return `the resource field is missing (expected ${expected})`; + } + return `resource ${JSON.stringify(doc.resource)} is not identical to the resource identifier ${expected}`; +} + +/** A document that fails the §3.3 check is unusable, the same as no document. */ +type PrmDiscovery = PrmDocument | { reason: string }; + +/** + * RFC 9728 §5.1, then the MCP fallback order from the client discovery + * scenarios: the `resource_metadata` URL when the challenge carries one, + * otherwise the path-based well-known URL, otherwise the root one. A + * `resource_metadata` URL that does not yield a document is not replaced by + * a different well-known document. + */ +async function discoverProtectedResourceMetadata( + resourceUrl: string, + resourceMetadata: string | undefined +): Promise { + const accepted = ( + fetched: { doc: Record; url: string }, + source: PrmSource, + requestedUrl: string + ): PrmDiscovery => { + const { doc, url } = fetched; + const redirect = + url === requestedUrl ? {} : { redirectedFrom: requestedUrl }; + const mismatch = resourceMismatch(doc, resourceUrl, source); + if (mismatch) { + return { + reason: `protected resource metadata at ${url} is unusable (RFC 9728 §3.3): ${mismatch}` + }; + } + return { doc, source, url, ...redirect }; + }; + + if (resourceMetadata !== undefined && resourceMetadata.length > 0) { + let absolute: string; + try { + absolute = new URL(resourceMetadata, resourceUrl).href; + } catch { + return { + reason: `resource_metadata parameter is not a URL (${resourceMetadata})` + }; + } + const fetched = await fetchJsonDocument(absolute); + if (!('doc' in fetched)) { + return { + reason: `protected resource metadata advertised at ${absolute} ${fetched.reason}` + }; + } + return accepted(fetched, 'resource_metadata', absolute); + } + + const { pathBased, root } = protectedResourceMetadataFallbacks(resourceUrl); + const candidates: Array<{ source: PrmSource; url: string }> = + pathBased === root + ? [{ source: 'well-known-root', url: root }] + : [ + { source: 'well-known-path', url: pathBased }, + { source: 'well-known-root', url: root } + ]; + const misses: string[] = []; + for (const candidate of candidates) { + const fetched = await fetchJsonDocument(candidate.url); + if ('doc' in fetched) { + return accepted(fetched, candidate.source, candidate.url); + } + if (fetched.terminal) { + return { + reason: `protected resource metadata at ${candidate.url} ${fetched.reason}` + }; + } + misses.push(`${candidate.url} (${fetched.reason})`); + } + return { + reason: `protected resource metadata was not found at ${misses.join(' or ')}` + }; +} + +/** + * `true` only when a usable document set the field to boolean true. Anything + * else (absent, false, malformed, or no document) is not the MUST tier. + */ +type RequiredTier = boolean | 'unknown'; + +function gradeAdvertises( + res: Response, + required: RequiredTier, + published: unknown +): ConformanceCheck { + const algs = dpopAuthParam(res.wwwAuthenticate, 'algs'); + const details = { + statusCode: res.statusCode, + algs: algs ?? null, + resourceMetadata: resourceMetadataParam(res.wwwAuthenticate) ?? null, + dpop_bound_access_tokens_required: published ?? null, + metadataRead: required !== 'unknown' + }; + // Both advertisement sentences are about an HTTP 401 (the SHOULD) or a + // WWW-Authenticate response (the MUST). A different status does not exercise + // them. + if (res.statusCode !== 401) { + return untestableCheck( + ADVERTISES_ID, + ADVERTISES_NAME, + ADVERTISES_DESC, + `unauthenticated probe was HTTP ${res.statusCode}, not 401, so the DPoP challenge requirement was not exercised`, + DISCOVERY_REFERENCES, + 'WARNING' + ); + } + const challenged = hasDpopChallenge(res.wwwAuthenticate); + if (!challenged) { + if (required === true) { + return dpopCheck( + ADVERTISES_ID, + ADVERTISES_NAME, + ADVERTISES_DESC, + 'FAILURE', + 'Protected resource metadata sets dpop_bound_access_tokens_required to true, but the HTTP 401 did not include a DPoP challenge', + details, + DISCOVERY_REFERENCES + ); + } + return dpopCheck( + ADVERTISES_ID, + ADVERTISES_NAME, + ADVERTISES_DESC, + 'WARNING', + 'HTTP 401 did not include a DPoP challenge', + details, + DISCOVERY_REFERENCES + ); + } + if (!algs || algs.trim().length === 0) { + return dpopCheck( + ADVERTISES_ID, + ADVERTISES_NAME, + ADVERTISES_DESC, + 'WARNING', + 'DPoP challenge is missing the algs parameter', + details, + DISCOVERY_REFERENCES + ); + } + return dpopCheck( + ADVERTISES_ID, + ADVERTISES_NAME, + ADVERTISES_DESC, + 'SUCCESS', + undefined, + details, + DISCOVERY_REFERENCES + ); +} + +function algsProblem(algs: unknown): string | undefined { + if (algs === undefined) { + return 'Protected resource metadata is missing dpop_signing_alg_values_supported'; + } + if (!Array.isArray(algs)) { + return 'dpop_signing_alg_values_supported is malformed: expected a JSON array'; + } + if (algs.length === 0) { + return 'dpop_signing_alg_values_supported is empty'; + } + if (algs.some((alg) => typeof alg !== 'string' || alg.trim().length === 0)) { + return 'dpop_signing_alg_values_supported is malformed: every entry must be a non-empty string'; + } + return undefined; +} + +function gradePrm(found: PrmDiscovery): ConformanceCheck { + if (!('doc' in found)) { + return untestableCheck( + PRM_ID, + PRM_NAME, + PRM_DESC, + found.reason, + DISCOVERY_REFERENCES + ); + } + const algs = found.doc.dpop_signing_alg_values_supported; + const required = found.doc.dpop_bound_access_tokens_required; + const forbidden = forbiddenDpopAlgs(algs); + const details: Record = { + source: found.source, + url: found.url, + ...(found.redirectedFrom ? { redirectedFrom: found.redirectedFrom } : {}), + dpop_signing_alg_values_supported: algs ?? null, + dpop_bound_access_tokens_required: required ?? null, + ...(forbidden.length > 0 ? { forbidden } : {}) + }; + if (forbidden.length > 0) { + return dpopCheck( + PRM_ID, + PRM_NAME, + PRM_DESC, + 'FAILURE', + `dpop_signing_alg_values_supported MUST list only asymmetric algorithms; found non-asymmetric: ${forbidden.join(', ')}`, + details, + DISCOVERY_REFERENCES + ); + } + const problems = [ + algsProblem(algs), + required !== undefined && typeof required !== 'boolean' + ? 'dpop_bound_access_tokens_required is malformed: expected a boolean' + : undefined + ].filter((problem): problem is string => problem !== undefined); + if (problems.length > 0) { + return dpopCheck( + PRM_ID, + PRM_NAME, + PRM_DESC, + 'FAILURE', + problems.join('; '), + details, + DISCOVERY_REFERENCES + ); + } + return dpopCheck( + PRM_ID, + PRM_NAME, + PRM_DESC, + 'SUCCESS', + undefined, + details, + DISCOVERY_REFERENCES + ); +} + +function consistencyDetails( + published: unknown, + res: Response +): Record { + return { + dpop_bound_access_tokens_required: published ?? null, + statusCode: res.statusCode, + wwwAuthenticate: res.wwwAuthenticate || null, + error: res.dpopError ?? null + }; +} + +function gradeConsistency( + saysRequired: boolean, + published: unknown, + res: Response +): ConformanceCheck { + const accepted = isAccepted(res.statusCode); + // The SEP's MUST says "reject", not "401". A 4xx is a refusal of the request + // (RFC 6750 §3.1 uses 400, 401, and 403). A 5xx is a server failure. + const rejected = res.statusCode >= 400 && res.statusCode < 500; + const details = consistencyDetails(published, res); + if (saysRequired && accepted) { + return dpopCheck( + CONSISTENCY_ID, + CONSISTENCY_NAME, + CONSISTENCY_DESC, + 'FAILURE', + 'Protected resource metadata sets dpop_bound_access_tokens_required to true, but the server accepted an unbound Bearer token', + details, + DISCOVERY_REFERENCES + ); + } + if (saysRequired && rejected) { + return dpopCheck( + CONSISTENCY_ID, + CONSISTENCY_NAME, + CONSISTENCY_DESC, + 'SUCCESS', + undefined, + details, + DISCOVERY_REFERENCES + ); + } + if (saysRequired) { + return dpopCheck( + CONSISTENCY_ID, + CONSISTENCY_NAME, + CONSISTENCY_DESC, + 'FAILURE', + `The server did not reject an unbound Bearer token (HTTP ${res.statusCode})`, + details, + DISCOVERY_REFERENCES + ); + } + // A use_dpop_nonce 401 is about the nonce, not the Bearer token, so it is not + // evidence of unadvertised enforcement. + if (isNonceChallenge(res)) { + return untestableCheck( + CONSISTENCY_ID, + CONSISTENCY_NAME, + CONSISTENCY_DESC, + 'server answered the unbound Bearer token with a DPoP nonce challenge (use_dpop_nonce), which does not show whether it accepts Bearer tokens', + DISCOVERY_REFERENCES + ); + } + // Inferring unadvertised enforcement needs a 401. Another 4xx can be an + // unrelated denial, so it stays on the "neither" arm below. + if (res.statusCode === 401) { + return dpopCheck( + CONSISTENCY_ID, + CONSISTENCY_NAME, + CONSISTENCY_DESC, + 'FAILURE', + 'server requires DPoP but does not advertise it', + details, + DISCOVERY_REFERENCES + ); + } + if (accepted) { + return dpopCheck( + CONSISTENCY_ID, + CONSISTENCY_NAME, + CONSISTENCY_DESC, + 'SUCCESS', + undefined, + details, + DISCOVERY_REFERENCES + ); + } + return dpopCheck( + CONSISTENCY_ID, + CONSISTENCY_NAME, + CONSISTENCY_DESC, + 'WARNING', + `Unbound Bearer token was neither accepted nor rejected with HTTP 401 (got ${res.statusCode})`, + details, + DISCOVERY_REFERENCES + ); +} + +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, the §7.1 \`error\` code +(SHOULD), how an unauthenticated client learns DPoP support (a DPoP challenge +on HTTP 401, and \`dpop_signing_alg_values_supported\` in Protected Resource +Metadata) and that a server which requires DPoP-bound tokens says so and +enforces it, 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; + 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. + 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(); + } 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, + 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 sendUnauthenticated = async (): Promise => { + const probe = probeBody(specVersion); + const res = await request(serverUrl, { + method: 'POST', + headers: buildStandardHeaders(probe.method, probe.params, { + specVersion + }), + body: JSON.stringify(probe) + }); + return readHttp(res); + }; + + 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; + }; + + // ---- Discovery: an unauthenticated client must be able to learn DPoP ---- + // Runs before the positive probe and is not gated on it: a server that + // rejects a valid proof can still advertise DPoP on the 401. The challenge + // grade waits until metadata is read, because a missing DPoP challenge is + // a MUST only when dpop_bound_access_tokens_required is true. + let requiredTier: RequiredTier = 'unknown'; + let publishedRequired: unknown; + let metadataGap = 'protected resource metadata was not read'; + try { + const unauth = await sendUnauthenticated(); + try { + const found = await discoverProtectedResourceMetadata( + serverUrl, + resourceMetadataParam(unauth.wwwAuthenticate) + ); + if ('doc' in found) { + publishedRequired = found.doc.dpop_bound_access_tokens_required; + requiredTier = publishedRequired === true; + metadataGap = ''; + } else { + metadataGap = found.reason; + } + checks.push(gradeAdvertises(unauth, requiredTier, publishedRequired)); + checks.push(gradePrm(found)); + } catch (e) { + metadataGap = `protected resource metadata request failed: ${String(e)}`; + checks.push(gradeAdvertises(unauth, 'unknown', undefined)); + checks.push( + untestableCheck( + PRM_ID, + PRM_NAME, + PRM_DESC, + metadataGap, + DISCOVERY_REFERENCES + ) + ); + } + } catch (e) { + const reason = `unauthenticated probe failed: ${String(e)}`; + metadataGap = reason; + checks.push( + untestableCheck( + ADVERTISES_ID, + ADVERTISES_NAME, + ADVERTISES_DESC, + reason, + DISCOVERY_REFERENCES, + 'WARNING' + ) + ); + checks.push( + untestableCheck( + PRM_ID, + PRM_NAME, + PRM_DESC, + reason, + DISCOVERY_REFERENCES + ) + ); + } + + // ---- 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 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' } + ) + ); + } + + // ---- Consistency: advertised DPoP requirement matches enforcement ---- + // Gated on the positive baseline, same as the rejection battery: a server + // that 401s everything would otherwise look like it correctly refuses an + // unbound Bearer token. + if (!positiveAccepted) { + checks.push( + untestableCheck( + CONSISTENCY_ID, + CONSISTENCY_NAME, + CONSISTENCY_DESC, + gateReason('unbound-bearer'), + DISCOVERY_REFERENCES + ) + ); + } else if (metadataGap) { + checks.push( + untestableCheck( + CONSISTENCY_ID, + CONSISTENCY_NAME, + CONSISTENCY_DESC, + `cannot evaluate advertisement consistency: ${metadataGap}`, + DISCOVERY_REFERENCES + ) + ); + } else { + try { + const unbound = await mintDpopBoundToken({ + issuerKey, + issuer, + audience, + jkt: kp.thumbprint, + omitCnf: true + }); + const unboundRes = await send(`Bearer ${unbound}`, undefined); + checks.push( + gradeConsistency(requiredTier === true, publishedRequired, unboundRes) + ); + } catch (e) { + checks.push( + dpopCheck( + CONSISTENCY_ID, + CONSISTENCY_NAME, + CONSISTENCY_DESC, + 'FAILURE', + String(e), + { case: 'unbound-bearer' }, + DISCOVERY_REFERENCES + ) + ); + } + } + + // ---- 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; + /** Defaults to invalid_dpop_proof (a §4.3 proof defect). */ + expectedError?: ExpectedDpopError; + }> = [ + { + 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. 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: bearerSchemeRejected, + expectedError: 'either', + 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}`; + let res: Response | undefined; + try { + const dpop = await n.buildDpop(); + 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 + ) + ); + } + observeError(n.case, n.expectedError ?? 'invalid_dpop_proof', res); + } + + // ---- cnf.jkt mismatch (token bound to a foreign key) ---- + // 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 ---- + // 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)`; + let res: Response | undefined; + try { + const iat = + Math.floor(Date.now() / 1000) + serverClockOffset + iatDelta; + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + iat + }); + 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 + ) + ); + } + observeError(`iat-${label}`, 'invalid_dpop_proof', res); + } + + // ---- 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 + } + ]) { + let res: Response | undefined; + try { + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: serverUrl, + accessToken: token, + ...opt + }); + 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 + ) + ); + } + observeError(`alg-${label}`, 'invalid_dpop_proof', res); + } + + // ---- token audience validation under DPoP ---- + { + 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) ---- + 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) + ) + ); + } + + // ---- §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/scenarios/server/auth/spec-references.ts b/src/scenarios/server/auth/spec-references.ts new file mode 100644 index 00000000..05a42291 --- /dev/null +++ b/src/scenarios/server/auth/spec-references.ts @@ -0,0 +1,44 @@ +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' + }, + RFC_9728_METADATA: { + id: 'RFC-9728-protected-resource-metadata', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-2' + }, + RFC_9728_RESPONSE: { + id: 'RFC-9728-protected-resource-metadata-response', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3.2' + }, + RFC_9728_VALIDATION: { + id: 'RFC-9728-protected-resource-metadata-validation', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3.3' + }, + RFC_9728_WWW_AUTHENTICATE: { + id: 'RFC-9728-www-authenticate', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-5.1' + } +}; diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index c3c650f3..2204df6c 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -17,6 +17,19 @@ 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.' + # Quoted from the SEP's "MCP Server DPoP Advertisement" section. + # Challenge advertisement is two-tier: SHOULD for a server supporting the + # extension, MUST when dpop_bound_access_tokens_required is true. Missing + # `algs` stays WARNING (SHOULD) in both tiers. + - check: sep-1932-server-advertises-dpop + text: 'When responding to an unauthenticated or failed request with HTTP 401, an MCP server supporting this extension SHOULD include a `DPoP` challenge in the `WWW-Authenticate` header, and that challenge SHOULD include the `algs` parameter signaling the JWS algorithms acceptable for DPoP proof JWTs, as described in RFC 9449 Section 7.1. / An MCP server that sets `dpop_bound_access_tokens_required` to `true` in its Protected Resource Metadata MUST include a `DPoP` challenge in every `WWW-Authenticate` response it sends.' + - check: sep-1932-server-prm-dpop + text: 'MCP servers supporting this extension MUST include the `dpop_signing_alg_values_supported` field in their Protected Resource Metadata (RFC 9728 Section 2). This field MUST contain a non-empty JSON array of registered JWS algorithm values (from the IANA JSON Web Signature and Encryption Algorithms registry) that the server accepts for DPoP proof JWTs. Only asymmetric digital signature algorithms are permitted. The `none` algorithm and symmetric (MAC) algorithms MUST NOT be included.' + # Absence of the boolean is valid (RFC 9728 default). + - check: sep-1932-server-prm-consistency + text: 'An MCP server that requires DPoP-bound access tokens (that is, one that does not accept bearer tokens) MUST set the `dpop_bound_access_tokens_required` field to `true` in its Protected Resource Metadata. Conversely, an MCP server that sets `dpop_bound_access_tokens_required` to `true` MUST reject requests that do not present a DPoP-bound access token with a valid DPoP proof. When the field is absent, the default behavior defined in RFC 9728 applies and DPoP-bound access tokens are not required.' - 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