diff --git a/examples/clients/typescript/helpers/dpopClientFlow.ts b/examples/clients/typescript/helpers/dpopClientFlow.ts index f4cb81ff..ef512b3c 100644 --- a/examples/clients/typescript/helpers/dpopClientFlow.ts +++ b/examples/clients/typescript/helpers/dpopClientFlow.ts @@ -55,6 +55,9 @@ export async function runDpopClient( ); const prm = await (await fetch(prmUrl.toString())).json(); const authServerUrl: string = prm.authorization_servers[0]; + // RFC 8707 resource indicator — sent in both the authorization request and + // the token request, per the MCP authorization spec. + const resource: string = prm.resource; // 2. Authorization server metadata. const asMeta = await ( @@ -96,7 +99,8 @@ export async function runDpopClient( state, redirect_uri: REDIRECT_URI, code_challenge: codeChallenge, - code_challenge_method: 'S256' + code_challenge_method: 'S256', + resource }).toString()}`; const authorizeResponse = await request(authorizeUrl, { method: 'GET' }); await authorizeResponse.body.text().catch(() => undefined); @@ -105,8 +109,20 @@ export async function runDpopClient( if (!locationStr) { throw new Error('Authorization endpoint did not redirect with a code'); } - const code = new URL(locationStr).searchParams.get('code'); + const callbackParams = new URL(locationStr).searchParams; + const code = callbackParams.get('code'); if (!code) throw new Error('No authorization code in redirect'); + // CSRF binding: the callback must echo our state (OAuth 2.1 §7.1). + if (callbackParams.get('state') !== state) { + throw new Error('Authorization response state does not match the request'); + } + // RFC 9207: when the AS returns iss, it must equal the metadata issuer. + const callbackIss = callbackParams.get('iss'); + if (callbackIss !== null && callbackIss !== asMeta.issuer) { + throw new Error( + `Authorization response iss "${callbackIss}" does not match the metadata issuer "${asMeta.issuer}"` + ); + } // 5. Token request with a DPoP proof → DPoP-bound access token. The broken // `sendTokenRequestProof:false` variant omits the proof, so the AS issues an @@ -116,7 +132,8 @@ export async function runDpopClient( code, redirect_uri: REDIRECT_URI, code_verifier: codeVerifier, - client_id: clientId + client_id: clientId, + resource }).toString(); const requestToken = async (nonce?: string): Promise => { const headers: Record = { @@ -156,7 +173,17 @@ export async function runDpopClient( } } if (!tokenResponse.ok) { - throw new Error(`Token request failed: HTTP ${tokenResponse.status}`); + // Surface the OAuth error body — error_description carries the actionable + // detail (e.g. which DPoP proof claim the AS rejected). + const body = await tokenResponse + .json() + .catch(() => ({}) as { error?: string; error_description?: string }); + const detail = [body.error, body.error_description] + .filter(Boolean) + .join(': '); + throw new Error( + `Token request failed: HTTP ${tokenResponse.status}${detail ? ` (${detail})` : ''}` + ); } const tokenBody = await tokenResponse.json(); const accessToken: string = tokenBody.access_token; diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index e993369d..6fc98865 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -17,6 +17,8 @@ import { newDpopClientObservations, type DpopClientObservations } from './helpers/dpopResourceAuth'; +import { DPOP_ASYMMETRIC_ALGS } from './helpers/dpopAlgs'; +import { generateIssuerKey } from './helpers/dpopToken'; import { SpecReferences } from './spec-references'; const PRM_PATH = '/.well-known/oauth-protected-resource/mcp'; @@ -174,10 +176,16 @@ export class DPoPClientScenario implements Scenario { this.obs = newDpopClientObservations(); this.tokenReqObs = newTokenReqObs(); + // The scenario owns the issuer key: the AS mints tokens with it and the + // resource judge verifies presented tokens against it. + const issuerKey = await generateIssuerKey(); const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { - dpopSigningAlgValuesSupported: ['ES256'], + // Advertise exactly what the validators enforce, so a client honoring + // RFC 9449 §5.1 alg negotiation is graded the same as one that doesn't. + dpopSigningAlgValuesSupported: DPOP_ASYMMETRIC_ALGS, dpopTokenRequestObs: this.tokenReqObs, - dpopRequireNonce: this.requireNonce + dpopRequireNonce: this.requireNonce, + dpopIssuerKey: issuerKey }); await this.authServer.start(authApp); @@ -191,8 +199,22 @@ export class DPoPClientScenario implements Scenario { this.obs, () => `${this.server.getUrl()}/mcp`, () => `${this.server.getUrl()}${PRM_PATH}`, - this.requireNonce - ) + this.requireNonce, + { + issuerKey, + getIssuer: () => this.authServer.getUrl(), + getBoundJkt: () => this.tokenReqObs.jkt + } + ), + // RFC 9728 §2: tell discovery-driven clients this resource expects + // DPoP-bound tokens (the wire-level enablement signal for DPoP). + prmDpop: { + signingAlgValuesSupported: DPOP_ASYMMETRIC_ALGS, + boundAccessTokensRequired: true + }, + // SEP-1932's per-request-proof requirement is not POST-scoped, so + // observe proofs on GET /mcp too (answered 405 — no SSE stream here). + observeGetMcp: true } ); await this.server.start(app); @@ -326,7 +348,11 @@ export class DPoPClientScenario implements Scenario { errorMessage = 'Client never presented an access token to the MCP server'; } else if (!this.obs.allProofsWellFormed) { status = 'FAILURE'; - errorMessage = `DPoP proof was missing or malformed: ${this.obs.proofError}`; + // Attribute the failure to the right layer: a defect in the proof itself + // vs. an access token the proof cannot be validated against. + errorMessage = this.obs.proofError + ? `DPoP proof was missing or malformed: ${this.obs.proofError}` + : `DPoP proof could not be validated against the presented access token: ${this.obs.tokenError}`; } else if (this.obs.replayDetected) { status = 'FAILURE'; errorMessage = diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 162f93aa..678c8d62 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -7,6 +7,7 @@ import { createRequestLogger } from '../../../request-logger'; import { SpecReferences } from '../spec-references'; import { MockTokenVerifier } from './mockTokenVerifier'; import * as jose from 'jose'; +import { DPOP_ASYMMETRIC_ALGS } from './dpopAlgs'; import { generateIssuerKey, mintDpopBoundToken, @@ -29,31 +30,16 @@ function computeS256Challenge(codeVerifier: string): string { // Fixed nonce the test AS hands out when `dpopRequireNonce` is set (RFC 9449 §8). const AS_DPOP_NONCE = 'conformance-as-dpop-nonce'; -// Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3, §11.6). -const DPOP_ASYMMETRIC_ALGS = [ - 'ES256', - 'ES384', - 'ES512', - 'RS256', - 'RS384', - 'RS512', - 'PS256', - 'PS384', - 'PS512', - 'EdDSA' -]; - /** - * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based - * normalization): lowercase scheme/host, drop the default port, ignore a - * trailing slash. Query/fragment are rejected by the caller (RFC 9449 §4.2), - * not silently stripped here. + * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 + * syntax-based normalization): lowercase scheme/host, drop the default port, + * empty path → `/`. No leniency beyond that — `/mcp/` and `/mcp` are distinct + * URIs. Query/fragment are rejected by the caller (RFC 9449 §4.2), not + * silently stripped here. */ function normalizeHtu(u: string): string { try { - const url = new URL(u); - // Tolerate a single trailing slash only; `/mcp//` is a distinct path. - return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}`; + return new URL(u).href; } catch { return u; } @@ -63,12 +49,22 @@ function normalizeHtu(u: string): string { * Validate a DPoP proof presented at the token endpoint (the token-request * subset of RFC 9449 §4.3). Hand-rolled with jose primitives — deliberately an * INDEPENDENT code path from the suite's proof builder, so a shared bug - * surfaces rather than hides. Returns the JWK thumbprint to bind on success. + * surfaces rather than hides. (Kept as a deliberate two-copy contract with + * validateResourceProof in dpopResourceAuth.ts — apply fixes to both.) + * Returns the JWK thumbprint to bind on success. + * + * `advertisedAlgs` is the AS's `dpop_signing_alg_values_supported`; proofs + * signed with an alg outside it are rejected (RFC 9449 §5.1), with an + * asymmetric-only floor (§11.6) regardless of what is advertised. */ export async function validateDpopProofAtTokenEndpoint( proof: string, - tokenEndpointUrl: string + tokenEndpointUrl: string, + advertisedAlgs: string[] = DPOP_ASYMMETRIC_ALGS ): Promise<{ ok: true; jkt: string } | { ok: false; error: string }> { + const acceptedAlgs = advertisedAlgs.filter((a) => + DPOP_ASYMMETRIC_ALGS.includes(a) + ); let header: jose.ProtectedHeaderParameters; try { header = jose.decodeProtectedHeader(proof); @@ -81,6 +77,12 @@ export async function validateDpopProofAtTokenEndpoint( if (!header.alg || !DPOP_ASYMMETRIC_ALGS.includes(header.alg)) { return { ok: false, error: 'alg must be a supported asymmetric algorithm' }; } + if (!acceptedAlgs.includes(header.alg)) { + return { + ok: false, + error: `alg ${header.alg} is not among the advertised dpop_signing_alg_values_supported (${advertisedAlgs.join(', ')})` + }; + } const jwk = header.jwk as jose.JWK | undefined; if (!jwk) { return { ok: false, error: 'missing jwk header parameter' }; @@ -91,9 +93,8 @@ export async function validateDpopProofAtTokenEndpoint( let claims: jose.JWTPayload; try { const key = await jose.importJWK(jwk, header.alg); - claims = ( - await jose.jwtVerify(proof, key, { algorithms: DPOP_ASYMMETRIC_ALGS }) - ).payload; + claims = (await jose.jwtVerify(proof, key, { algorithms: acceptedAlgs })) + .payload; } catch { return { ok: false, error: 'DPoP proof signature does not verify' }; } @@ -101,10 +102,13 @@ export async function validateDpopProofAtTokenEndpoint( return { ok: false, error: 'missing jti claim' }; } if (claims.htm !== 'POST') { - return { ok: false, error: 'htm does not match POST' }; + return { + ok: false, + error: `htm "${claims.htm}" does not match the token request method "POST"` + }; } if (typeof claims.htu !== 'string') { - return { ok: false, error: 'htu does not match the token endpoint' }; + return { ok: false, error: 'missing or non-string htu claim' }; } if (claims.htu.includes('?') || claims.htu.includes('#')) { return { @@ -113,13 +117,20 @@ export async function validateDpopProofAtTokenEndpoint( }; } if (normalizeHtu(claims.htu) !== normalizeHtu(tokenEndpointUrl)) { - return { ok: false, error: 'htu does not match the token endpoint' }; + return { + ok: false, + error: `htu "${claims.htu}" does not match the token endpoint "${tokenEndpointUrl}" (compared after RFC 9449 §4.3 normalization)` + }; } - if ( - typeof claims.iat !== 'number' || - Math.abs(Math.floor(Date.now() / 1000) - claims.iat) > 300 - ) { - return { ok: false, error: 'iat outside the acceptable window' }; + if (typeof claims.iat !== 'number') { + return { ok: false, error: 'missing or non-numeric iat claim' }; + } + const iatSkew = Math.floor(Date.now() / 1000) - claims.iat; + if (Math.abs(iatSkew) > 300) { + return { + ok: false, + error: `iat ${claims.iat} is ${Math.abs(iatSkew)}s ${iatSkew > 0 ? 'behind' : 'ahead of'} server time; allowed window ±300s` + }; } const jkt = await jose.calculateJwkThumbprint(jwk, 'sha256'); return { ok: true, jkt }; @@ -152,6 +163,11 @@ export interface DpopTokenRequestObservation { asNonceChallengeIssued: boolean; /** The client retried the token request carrying the correct nonce. */ asNonceHonored: boolean; + /** + * `cnf.jkt` bound into the most recently issued token, so the resource judge + * can assert the token presented at the MCP server is the one issued here. + */ + jkt?: string; } export interface AuthServerOptions { @@ -207,6 +223,12 @@ export interface AuthServerOptions { | 'unbound-token'; /** Sink for the DPoP token-request observation; see the interface docstring. */ dpopTokenRequestObs?: DpopTokenRequestObservation; + /** + * Issuer key for minting DPoP-bound tokens. Supply it when the scenario also + * needs to VERIFY the issued tokens (e.g. the resource judge); lazily + * generated when omitted. + */ + dpopIssuerKey?: TokenIssuerKey; /** * When true, the token endpoint requires a DPoP nonce (RFC 9449 §8): a * proof-bearing request without the correct `nonce` claim is answered with @@ -275,12 +297,19 @@ export function createAuthServer( let lastAuthorizationScopes: string[] = []; // Track PKCE code_challenge for verification in token request let storedCodeChallenge: string | undefined; - // Lazily-created issuer key for minting DPoP-bound JWT access tokens. - let dpopIssuerKey: TokenIssuerKey | undefined; + // Issuer key for minting DPoP-bound JWT access tokens (caller-supplied or + // lazily created). + let dpopIssuerKey: TokenIssuerKey | undefined = options.dpopIssuerKey; // DPoP behaviour is active only when the caller opts in (any DPoP option). const dpopEnabled = dpopSigningAlgValuesSupported !== undefined || dpopMisbehavior !== undefined; + // Sub-options without DPoP enabled would be a silent no-op — fail fast. + if (!dpopEnabled && (dpopRequireNonce || dpopTokenRequestObs)) { + throw new Error( + 'dpopRequireNonce/dpopTokenRequestObs require DPoP to be enabled (set dpopSigningAlgValuesSupported or dpopMisbehavior)' + ); + } // Records whether the client presented a valid DPoP proof at its // authorization_code token request (RFC 9449 §5) into the caller's observation @@ -602,7 +631,8 @@ export function createAuthServer( if (proof) { const result = await validateDpopProofAtTokenEndpoint( proof, - tokenEndpointUrl + tokenEndpointUrl, + dpopSigningAlgValuesSupported ); if (!result.ok) { recordTokenRequestProof(grantType, false, result.error); @@ -659,6 +689,11 @@ export function createAuthServer( if (!dpopIssuerKey) { dpopIssuerKey = await generateIssuerKey(); } + // Record the bound thumbprint so the resource judge can assert the + // token presented at the MCP server is the one issued here. + if (grantType === 'authorization_code' && dpopTokenRequestObs) { + dpopTokenRequestObs.jkt = result.jkt; + } const boundToken = await mintDpopBoundToken({ issuerKey: dpopIssuerKey, issuer: resolveIssuer(), diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index cd32d21b..632a7c34 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -30,6 +30,19 @@ export interface ServerOptions { tokenVerifier?: MockTokenVerifier; /** Override the resource field in PRM response (for testing resource mismatch) */ prmResourceOverride?: string; + /** + * DPoP signals for the PRM document (RFC 9728 §2), set by the DPoP scenarios + * so a discovery-driven client can learn the resource expects DPoP. + */ + prmDpop?: { + signingAlgValuesSupported: string[]; + boundAccessTokensRequired: boolean; + }; + /** + * Run `authMiddleware` on GET /mcp too, then answer 405 (this server offers + * no SSE stream). Lets the DPoP judge observe non-POST proofs. + */ + observeGetMcp?: boolean; } export function createServer( @@ -46,7 +59,8 @@ export function createServer( includePrmInWwwAuth = true, includeScopeInWwwAuth = false, tokenVerifier, - prmResourceOverride + prmResourceOverride, + prmDpop } = options; // Factory: create a fresh Server per request to avoid "Already connected" errors // after the v1.26.0 security fix (GHSA-345p-7cg4-v4c7) @@ -139,6 +153,13 @@ export function createServer( prmResponse.scopes_supported = scopesSupported; } + if (prmDpop) { + prmResponse.dpop_signing_alg_values_supported = + prmDpop.signingAlgValuesSupported; + prmResponse.dpop_bound_access_tokens_required = + prmDpop.boundAccessTokensRequired; + } + res.json(prmResponse); }); } @@ -197,6 +218,25 @@ export function createServer( }); }); + // Streamable HTTP: this server offers no SSE stream, so GET /mcp is 405 — + // but the judge middleware runs first so non-POST proofs are observed too. + if (options.observeGetMcp && options.authMiddleware) { + const judge = options.authMiddleware; + app.get('/mcp', (req: Request, res: Response, next: NextFunction) => { + judge(req, res, (err?: unknown) => { + if (err) return next(err); + res + .status(405) + .set('Allow', 'POST') + .json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method Not Allowed' }, + id: null + }); + }); + }); + } + // Stateless lifecycle for the /mcp route: shared SEP-2575 validation + // server/discover from mock-server/stateless, then the same tools handlers // as createMcpServer. Bearer-auth middleware and PRM route above are diff --git a/src/scenarios/client/auth/helpers/dpopAlgs.ts b/src/scenarios/client/auth/helpers/dpopAlgs.ts new file mode 100644 index 00000000..c8c6bb02 --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopAlgs.ts @@ -0,0 +1,17 @@ +/** + * Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3, §11.6). + * Single source of truth shared by the test AS metadata and both proof + * validators, so what is advertised and what is enforced cannot drift. + */ +export const DPOP_ASYMMETRIC_ALGS = [ + 'ES256', + 'ES384', + 'ES512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA' +]; diff --git a/src/scenarios/client/auth/helpers/dpopProof.test.ts b/src/scenarios/client/auth/helpers/dpopProof.test.ts index e0d1efcd..0301f5d0 100644 --- a/src/scenarios/client/auth/helpers/dpopProof.test.ts +++ b/src/scenarios/client/auth/helpers/dpopProof.test.ts @@ -162,7 +162,8 @@ describe('DPoP proof helper — invalid variants isolate exactly one defect (Lay }); it('embedPrivateKey leaks the private scalar into the jwk header', async () => { - const kp = await generateDpopKeyPair(); + // embedPrivateKey must export the private JWK, so opt into extractability. + const kp = await generateDpopKeyPair('ES256', { extractable: true }); const jwt = await buildDpopProof({ keyPair: kp, ...base, diff --git a/src/scenarios/client/auth/helpers/dpopProof.ts b/src/scenarios/client/auth/helpers/dpopProof.ts index 5ebc8687..4ce0b5f9 100644 --- a/src/scenarios/client/auth/helpers/dpopProof.ts +++ b/src/scenarios/client/auth/helpers/dpopProof.ts @@ -11,8 +11,8 @@ import { createHash, randomBytes } from 'node:crypto'; * deliberately-malformed variants (one defect at a time) for the negative checks. * * Correctness of this module is anchored to published RFC test vectors in - * `proof.test.ts` (RFC 9449 §4 examples), not to the conformance servers that - * consume it — see the validation strategy in the project notes. + * `dpopProof.test.ts` (RFC 9449 §4 examples), not to the conformance servers + * that consume it — so the builder and the validators cannot share a bug. */ const DEFAULT_ALG = 'ES256'; @@ -29,12 +29,18 @@ export interface DpopKeyPair { thumbprint: string; } -/** Generate an asymmetric key pair for DPoP proofs (default ES256 / P-256). */ +/** + * Generate an asymmetric key pair for DPoP proofs (default ES256 / P-256). + * The private key is non-extractable by default (RFC 9449 §11 guidance — + * production clients should do the same); pass `extractable: true` only for + * the negative variants that must export it (e.g. `embedPrivateKey`). + */ export async function generateDpopKeyPair( - alg: string = DEFAULT_ALG + alg: string = DEFAULT_ALG, + { extractable = false }: { extractable?: boolean } = {} ): Promise { const { publicKey, privateKey } = await jose.generateKeyPair(alg, { - extractable: true + extractable }); const publicJwk = await jose.exportJWK(publicKey); const thumbprint = await jose.calculateJwkThumbprint(publicJwk, 'sha256'); diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts index 8d20cf7a..199fbab2 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts @@ -1,21 +1,40 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { generateDpopKeyPair, buildDpopProof, type DpopKeyPair } from './dpopProof'; -import { generateIssuerKey, mintDpopBoundToken } from './dpopToken'; +import { + generateIssuerKey, + mintDpopBoundToken, + type TokenIssuerKey +} from './dpopToken'; import { validateResourceProof } from './dpopResourceAuth'; const ISSUER = 'https://auth.example.com'; const RESOURCE = 'https://mcp.example.com/mcp'; +// One issuer key for the whole file: the validator verifies presented tokens +// against the issuer key, so minting and validation must share it. +let issuerKey: TokenIssuerKey; +beforeAll(async () => { + issuerKey = await generateIssuerKey(); +}); + +/** The token-binding argument matching {@link boundToken}'s issuer. */ +function binding(boundJkt?: string): { + issuerKey: TokenIssuerKey; + issuer: string; + boundJkt?: string; +} { + return { issuerKey, issuer: ISSUER, ...(boundJkt ? { boundJkt } : {}) }; +} + /** Mint a DPoP-bound token for `kp`'s key, optionally bound to a foreign key. */ async function boundToken( kp: DpopKeyPair, jktOverride?: string ): Promise { - const issuerKey = await generateIssuerKey(); return mintDpopBoundToken({ issuerKey, issuer: ISSUER, @@ -25,7 +44,7 @@ async function boundToken( }); } -describe('validateResourceProof — accepts a well-formed resource proof', () => { +describe('validateResourceProof — baseline acceptance and htu normalization', () => { it('accepts a valid proof bound to the presented token', async () => { const kp = await generateDpopKeyPair(); const token = await boundToken(kp); @@ -35,22 +54,54 @@ describe('validateResourceProof — accepts a well-formed resource proof', () => htu: RESOURCE, accessToken: token }); - const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + const result = await validateResourceProof( + proof, + token, + 'POST', + RESOURCE, + binding(kp.thumbprint) + ); expect(result.ok).toBe(true); }); - it('accepts an htu that differs only by normalization (trailing slash)', async () => { + it('accepts an htu that differs only by RFC 3986 normalization (case, default port)', async () => { const kp = await generateDpopKeyPair(); const token = await boundToken(kp); const proof = await buildDpopProof({ keyPair: kp, htm: 'POST', - htu: `${RESOURCE}/`, + htu: 'HTTPS://MCP.EXAMPLE.COM:443/mcp', accessToken: token }); - const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + const result = await validateResourceProof( + proof, + token, + 'POST', + RESOURCE, + binding() + ); expect(result.ok).toBe(true); }); + + it('rejects an htu with a spurious trailing slash (a distinct URI per RFC 3986)', async () => { + const kp = await generateDpopKeyPair(); + const token = await boundToken(kp); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: `${RESOURCE}/`, + accessToken: token + }); + const result = await validateResourceProof( + proof, + token, + 'POST', + RESOURCE, + binding() + ); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch(/htu/); + }); }); describe('validateResourceProof — rejects each single defect', () => { @@ -62,7 +113,13 @@ describe('validateResourceProof — rejects each single defect', () => { const kp = (build.keyPair as DpopKeyPair) ?? (await generateDpopKeyPair()); const token = await boundToken(kp, tokenJktOverride); const proof = await buildDpopProof({ ...build, keyPair: kp }); - const result = await validateResourceProof(proof, token, method, RESOURCE); + const result = await validateResourceProof( + proof, + token, + method, + RESOURCE, + binding() + ); expect(result.ok).toBe(false); return result.ok ? '' : result.error; } @@ -74,7 +131,8 @@ describe('validateResourceProof — rejects each single defect', () => { undefined, token, 'POST', - RESOURCE + RESOURCE, + binding() ); expect(result.ok).toBe(false); }); @@ -104,7 +162,8 @@ describe('validateResourceProof — rejects each single defect', () => { }); it('rejects a private key embedded in the jwk header', async () => { - const kp = await generateDpopKeyPair(); + // embedPrivateKey must export the private JWK, so opt into extractability. + const kp = await generateDpopKeyPair('ES256', { extractable: true }); expect( await expectRejected({ keyPair: kp, @@ -192,11 +251,69 @@ describe('validateResourceProof — rejects each single defect', () => { htu: RESOURCE, accessToken: token }); - const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + const result = await validateResourceProof( + proof, + token, + 'POST', + RESOURCE, + binding() + ); expect(result.ok).toBe(false); expect(result.ok ? '' : result.error).toMatch(/cnf\.jkt/); }); + it('rejects a token signed by a key other than the test AS issuer key', async () => { + const kp = await generateDpopKeyPair(); + // Self-minted lookalike: correct claims, wrong issuer key. + const rogueIssuer = await generateIssuerKey(); + const token = await mintDpopBoundToken({ + issuerKey: rogueIssuer, + issuer: ISSUER, + audience: RESOURCE, + jkt: kp.thumbprint + }); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + accessToken: token + }); + const result = await validateResourceProof( + proof, + token, + 'POST', + RESOURCE, + binding() + ); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch(/not a valid JWT issued/); + }); + + it('rejects a validly-issued token bound to a different key than the token endpoint bound', async () => { + // The AS bound the token issued at the token endpoint to `original`; the + // client presents a different (validly-issued) token bound to `other`. + const original = await generateDpopKeyPair(); + const other = await generateDpopKeyPair(); + const token = await boundToken(other); + const proof = await buildDpopProof({ + keyPair: other, + htm: 'POST', + htu: RESOURCE, + accessToken: token + }); + const result = await validateResourceProof( + proof, + token, + 'POST', + RESOURCE, + binding(original.thumbprint) + ); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch( + /not the one issued at the token endpoint/ + ); + }); + it('rejects an htu containing a query string (RFC 9449 §4.2)', async () => { const kp = await generateDpopKeyPair(); expect( @@ -229,7 +346,8 @@ describe('validateResourceProof — rejects each single defect', () => { `${proof}, ${proof}`, token, 'POST', - RESOURCE + RESOURCE, + binding() ); expect(result.ok).toBe(false); expect(result.ok ? '' : result.error).toMatch( diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts index 33f44be4..5e93e4fc 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts @@ -1,24 +1,12 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express'; import * as jose from 'jose'; import { createHash } from 'node:crypto'; +import { DPOP_ASYMMETRIC_ALGS } from './dpopAlgs'; +import type { TokenIssuerKey } from './dpopToken'; /** Fixed nonce the judge hands out when `requireNonce` is set (RFC 9449 §9). */ const RS_DPOP_NONCE = 'conformance-rs-dpop-nonce'; -/** Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 / §11.6). */ -const DPOP_ASYMMETRIC_ALGS = [ - 'ES256', - 'ES384', - 'ES512', - 'RS256', - 'RS384', - 'RS512', - 'PS256', - 'PS384', - 'PS512', - 'EdDSA' -]; - /** * Accumulated observations of how the client presented its access token and * per-request DPoP proof. The scenario turns these into the two @@ -32,7 +20,10 @@ export interface DpopClientObservations { jtisSeen: string[]; replayDetected: boolean; allProofsWellFormed: boolean; + /** First defect in a proof itself (malformed/mismatched claims, bad signature). */ proofError?: string; + /** First defect in the presented access token (e.g. not a DPoP-bound JWT). */ + tokenError?: string; /** The judge issued a `use_dpop_nonce` challenge (RFC 9449 §9). */ rsNonceChallengeIssued: boolean; /** The client retried the request carrying the correct nonce. */ @@ -52,6 +43,18 @@ export function newDpopClientObservations(): DpopClientObservations { }; } +/** + * How the resource judge verifies the presented access token: the test AS + * issuer key (signature/issuer/exp) plus the `cnf.jkt` the AS bound at the + * token endpoint. Getters are lazy — issuer URL and bound jkt only exist once + * the servers are up and a token has been issued. + */ +export interface DpopResourceTokenBinding { + issuerKey: TokenIssuerKey; + getIssuer: () => string; + getBoundJkt: () => string | undefined; +} + /** * Test MCP server DPoP judge (SEP-1932 / RFC 9449), passed to `createServer` * via its `options.authMiddleware` hook. An unauthenticated request gets a @@ -59,6 +62,11 @@ export function newDpopClientObservations(): DpopClientObservations { * per-request proof) and allowed through so the MCP session can complete and * multiple requests can be examined for proof freshness. * + * Observe-don't-enforce: an INVALID proof is recorded and the request is still + * served 200 (so more evidence accrues) — a real DPoP resource server MUST + * reject it with 401 per RFC 9449 §7.2. The check report, not the wire + * behaviour, is the conformance signal here. + * * Proof validation is hand-rolled with jose — deliberately an INDEPENDENT code * path from the suite's proof builder, so a shared bug surfaces rather than hides. */ @@ -66,7 +74,8 @@ export function createDpopResourceAuth( obs: DpopClientObservations, getResourceUrl: () => string, getPrmUrl: () => string, - requireNonce = false + requireNonce: boolean, + tokenBinding: DpopResourceTokenBinding ): RequestHandler { return async ( req: Request, @@ -99,11 +108,17 @@ export function createDpopResourceAuth( proofValue, token, req.method, - getResourceUrl() + getResourceUrl(), + { + issuerKey: tokenBinding.issuerKey, + issuer: tokenBinding.getIssuer(), + boundJkt: tokenBinding.getBoundJkt() + } ); if (!result.ok) { obs.allProofsWellFormed = false; - obs.proofError ??= result.error; + if (result.tokenProblem) obs.tokenError ??= result.error; + else obs.proofError ??= result.error; next(); return; } @@ -165,16 +180,15 @@ function splitAuthorization(authorization: string): { } /** - * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based - * normalization): lowercase scheme/host, drop the default port, ignore a - * trailing slash. Query/fragment are rejected by the caller (RFC 9449 §4.2), - * not silently stripped here. + * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 + * syntax-based normalization): lowercase scheme/host, drop the default port, + * empty path → `/`. No leniency beyond that — `/mcp/` and `/mcp` are distinct + * URIs. Query/fragment are rejected by the caller (RFC 9449 §4.2), not + * silently stripped here. */ function normalizeHtu(u: string): string { try { - const url = new URL(u); - // Tolerate a single trailing slash only; `/mcp//` is a distinct path. - return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}`; + return new URL(u).href; } catch { return u; } @@ -185,13 +199,24 @@ function normalizeHtu(u: string): string { * (RFC 9449 §4.3, resource-request subset): well-formed `dpop+jwt`, asymmetric * alg, embedded public jwk, htm/htu match, `ath` binds the token, signature * verifies, and the proof key's thumbprint matches the token's `cnf.jkt`. + * The token itself is verified against the test AS issuer key (signature, + * issuer, exp) and, when `boundJkt` is known, must be the very token issued at + * the token endpoint — not a self-minted lookalike. + * (Kept as a deliberate two-copy contract with validateDpopProofAtTokenEndpoint + * in createAuthServer.ts — apply fixes to both.) */ export async function validateResourceProof( proof: string | undefined, token: string, method: string, - resourceUrl: string -): Promise<{ ok: true; jti: string } | { ok: false; error: string }> { + resourceUrl: string, + tokenBinding: { issuerKey: TokenIssuerKey; issuer: string; boundJkt?: string } +): Promise< + | { ok: true; jti: string } + // `tokenProblem` marks defects in the presented access token, as opposed to + // the proof itself, so the scenario can attribute the failure accurately. + | { ok: false; error: string; tokenProblem?: boolean } +> { if (!proof) return { ok: false, error: 'missing DPoP proof header' }; // RFC 9449 §4.2: at most one DPoP header. A single proof JWT has no comma, so // a comma means duplicate headers were sent (Node joins them with ", "). @@ -227,10 +252,14 @@ export async function validateResourceProof( } if (typeof claims.jti !== 'string') return { ok: false, error: 'missing jti claim' }; - if (claims.htm !== method) - return { ok: false, error: 'htm does not match the request method' }; + if (claims.htm !== method) { + return { + ok: false, + error: `htm "${claims.htm}" does not match the request method "${method}"` + }; + } if (typeof claims.htu !== 'string') { - return { ok: false, error: 'htu does not match the request URI' }; + return { ok: false, error: 'missing or non-string htu claim' }; } if (claims.htu.includes('?') || claims.htu.includes('#')) { return { @@ -239,13 +268,20 @@ export async function validateResourceProof( }; } if (normalizeHtu(claims.htu) !== normalizeHtu(resourceUrl)) { - return { ok: false, error: 'htu does not match the request URI' }; + return { + ok: false, + error: `htu "${claims.htu}" does not match the request URI "${resourceUrl}" (compared after RFC 9449 §4.3 normalization)` + }; } - if ( - typeof claims.iat !== 'number' || - Math.abs(Math.floor(Date.now() / 1000) - claims.iat) > 300 - ) { - return { ok: false, error: 'iat outside the acceptable window' }; + if (typeof claims.iat !== 'number') { + return { ok: false, error: 'missing or non-numeric iat claim' }; + } + const iatSkew = Math.floor(Date.now() / 1000) - claims.iat; + if (Math.abs(iatSkew) > 300) { + return { + ok: false, + error: `iat ${claims.iat} is ${Math.abs(iatSkew)}s ${iatSkew > 0 ? 'behind' : 'ahead of'} server time; allowed window ±300s` + }; } // Recompute ath and the thumbprint inline (jose + node crypto) rather than // via the proof builder's helpers, so this validator stays a fully @@ -260,19 +296,47 @@ export async function validateResourceProof( }; } - // Possession: the proof key must be the key the token is bound to. + // The token itself: signed by the test AS issuer key, right issuer, unexpired. + let tokenClaims: jose.JWTPayload; try { - const tokenClaims = jose.decodeJwt(token); - const cnf = tokenClaims.cnf as { jkt?: unknown } | undefined; - const thumbprint = await jose.calculateJwkThumbprint(jwk, 'sha256'); - if (!cnf || cnf.jkt !== thumbprint) { - return { - ok: false, - error: 'proof key thumbprint does not match the token cnf.jkt' - }; - } + tokenClaims = ( + await jose.jwtVerify(token, tokenBinding.issuerKey.publicKey, { + issuer: tokenBinding.issuer, + algorithms: [tokenBinding.issuerKey.alg] + }) + ).payload; } catch { - return { ok: false, error: 'access token is not a decodable JWT' }; + // The token, not the proof, is at fault (e.g. an opaque Bearer token from + // a proof-less token request, or a self-minted JWT). RFC 9449 §6.2 permits + // non-JWT bound tokens via introspection; this harness only mints JWTs. + return { + ok: false, + error: + 'presented access token is not a valid JWT issued by the test authorization server (signature/issuer/exp)', + tokenProblem: true + }; + } + + // Possession: the proof key must be the key the token is bound to. + const cnf = tokenClaims.cnf as { jkt?: unknown } | undefined; + const thumbprint = await jose.calculateJwkThumbprint(jwk, 'sha256'); + if (!cnf || cnf.jkt !== thumbprint) { + return { + ok: false, + error: 'proof key thumbprint does not match the token cnf.jkt' + }; + } + // And the token must be the one the AS issued at the token endpoint. + if ( + tokenBinding.boundJkt !== undefined && + cnf.jkt !== tokenBinding.boundJkt + ) { + return { + ok: false, + error: + 'presented token is not the one issued at the token endpoint (cnf.jkt differs from the key bound there)', + tokenProblem: true + }; } return { ok: true, jti: claims.jti }; diff --git a/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts b/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts index e4a23102..0a5e3c44 100644 --- a/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts +++ b/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts @@ -14,7 +14,7 @@ const TOKEN_ENDPOINT = 'https://auth.example.com/token'; * cover the token-request subset of RFC 9449 §4.3 (no `ath`/`cnf` — there is no * access token yet at the token request). */ -describe('validateDpopProofAtTokenEndpoint — accepts a valid token-request proof', () => { +describe('validateDpopProofAtTokenEndpoint — baseline acceptance, htu normalization, alg negotiation', () => { it('accepts a well-formed proof and returns the JWK thumbprint', async () => { const kp = await generateDpopKeyPair(); const proof = await buildDpopProof({ @@ -30,12 +30,12 @@ describe('validateDpopProofAtTokenEndpoint — accepts a valid token-request pro expect(result.ok ? result.jkt : '').toBe(kp.thumbprint); }); - it('accepts an htu differing only by a single trailing slash', async () => { + it('accepts an htu differing only by RFC 3986 normalization (case, default port)', async () => { const kp = await generateDpopKeyPair(); const proof = await buildDpopProof({ keyPair: kp, htm: 'POST', - htu: `${TOKEN_ENDPOINT}/` + htu: 'HTTPS://AUTH.EXAMPLE.COM:443/token' }); const result = await validateDpopProofAtTokenEndpoint( proof, @@ -43,6 +43,40 @@ describe('validateDpopProofAtTokenEndpoint — accepts a valid token-request pro ); expect(result.ok).toBe(true); }); + + it('rejects a proof signed with an alg outside the advertised list (RFC 9449 §5.1)', async () => { + const kp = await generateDpopKeyPair(); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT + }); + // Proof is ES256; the AS advertises RS256 only. + const result = await validateDpopProofAtTokenEndpoint( + proof, + TOKEN_ENDPOINT, + ['RS256'] + ); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch( + /dpop_signing_alg_values_supported/ + ); + }); + + it('rejects an htu with a spurious trailing slash (a distinct URI per RFC 3986)', async () => { + const kp = await generateDpopKeyPair(); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: `${TOKEN_ENDPOINT}/` + }); + const result = await validateDpopProofAtTokenEndpoint( + proof, + TOKEN_ENDPOINT + ); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch(/htu/); + }); }); describe('validateDpopProofAtTokenEndpoint — rejects each single defect', () => { @@ -104,7 +138,8 @@ describe('validateDpopProofAtTokenEndpoint — rejects each single defect', () = }); it('rejects a private key embedded in the jwk header', async () => { - const kp = await generateDpopKeyPair(); + // embedPrivateKey must export the private JWK, so opt into extractability. + const kp = await generateDpopKeyPair('ES256', { extractable: true }); expect( await expectRejected({ keyPair: kp, diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 57ff27ac..8ab389b1 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -356,15 +356,25 @@ describe('WIF JWT-bearer negative tests', () => { describe('DPoP client negative tests (SEP-1932)', () => { test('auth/dpop: client presents the token with the Bearer scheme', async () => { const runner = new InlineClientRunner(dpopBearerClient); + // expectedSuccessSlugs pins single-defect isolation: expectedFailureSlugs + // alone only asserts a subset of the actual failures. await runClientAgainstScenario(runner, 'auth/dpop', { - expectedFailureSlugs: ['sep-1932-client-dpop-auth-scheme'] + expectedFailureSlugs: ['sep-1932-client-dpop-auth-scheme'], + expectedSuccessSlugs: [ + 'sep-1932-client-token-request-proof', + 'sep-1932-client-fresh-proof' + ] }); }); test('auth/dpop: client reuses a DPoP proof across requests', async () => { const runner = new InlineClientRunner(dpopReplayClient); await runClientAgainstScenario(runner, 'auth/dpop', { - expectedFailureSlugs: ['sep-1932-client-fresh-proof'] + expectedFailureSlugs: ['sep-1932-client-fresh-proof'], + expectedSuccessSlugs: [ + 'sep-1932-client-dpop-auth-scheme', + 'sep-1932-client-token-request-proof' + ] }); }); @@ -377,7 +387,8 @@ describe('DPoP client negative tests (SEP-1932)', () => { expectedFailureSlugs: [ 'sep-1932-client-token-request-proof', 'sep-1932-client-fresh-proof' - ] + ], + expectedSuccessSlugs: ['sep-1932-client-dpop-auth-scheme'] }); }); diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 972417bd..351e1e29 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -121,7 +121,9 @@ export const SpecReferences: { [key: string]: SpecReference } = { }, DPOP_EXTENSION: { id: 'MCP-DPoP-Extension', - url: 'https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx' + // Commit-pinned so reports don't dead-link when the working branch moves; + // TODO: switch to the main-branch path once the doc merges into ext-auth. + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/f2f94539fe4dc28aa53b7caed88f929aba02aec6/specification/draft/dpop-extension.mdx' }, RFC_9449_PROOF_SYNTAX: { id: 'RFC-9449-dpop-proof-jwt-syntax', diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index c3c650f3..4858cc94 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -1,5 +1,7 @@ sep: 1932 -spec_url: https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx +# Commit-pinned so the link survives the working branch moving; switch to the +# main-branch path once the extension doc merges into ext-auth. +spec_url: https://github.com/modelcontextprotocol/ext-auth/blob/f2f94539fe4dc28aa53b7caed88f929aba02aec6/specification/draft/dpop-extension.mdx requirements: - check: sep-1932-client-dpop-auth-scheme text: 'When making requests to protected MCP server resources, clients MUST include the access token using the `Authorization` header with the `DPoP` authentication scheme as specified in RFC 9449 Section 7.1' @@ -13,24 +15,34 @@ requirements: text: 'When the MCP server responds with `use_dpop_nonce`, the client MUST retry the request with a DPoP proof that includes the supplied `nonce` (RFC 9449 Section 9)' - check: sep-1932-server-validate-proof text: 'MCP servers MUST validate DPoP proofs according to RFC 9449 Section 4.3' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' - check: sep-1932-server-iat-window - text: "Implementations conforming to this specification MUST verify that the `iat` claim is within ±5 minutes of the server's current time / MCP servers that are not capable of keeping state or performing global `jti` tracking MUST provide DPoP proof replay protection by enforcing short `iat` acceptance windows of ±5 minutes and standard RFC 9449 claim validation" + text: "Implementations conforming to this specification MUST verify that the `iat` claim is within ±5 minutes of the server's current time" + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' - 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' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' - 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' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/370' - check: sep-1932-as-no-none-alg text: 'Only asymmetric signature algorithms are permitted; the `none` algorithm MUST NOT be included' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/370' - check: sep-1932-as-dpop-bound-enforcement text: 'When `dpop_bound_access_tokens` is set to `true`, the authorization server MUST reject token requests from the client that do not include a valid DPoP proof' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/370' - check: sep-1932-as-token-binding - text: "When issuing a DPoP-bound access token, the authorization server MUST bind it to the client's DPoP public key by including a `cnf` claim carrying the JWK SHA-256 thumbprint (`jkt`) of that key (RFC 9449 Section 6) and MUST set the token response `token_type` to `DPoP` (RFC 9449 Section 5)" + text: "When issuing a DPoP-bound access token, the authorization server MUST associate the token with the public key from the client's DPoP proof such that the resource server can verify the binding — for JWT access tokens via a `cnf` claim carrying the JWK SHA-256 thumbprint (`jkt`) of that key; token introspection is also permitted (RFC 9449 Section 6) — and MUST set the token response `token_type` to `DPoP` (RFC 9449 Section 5)" + issue: 'https://github.com/modelcontextprotocol/conformance/issues/370' - check: sep-1932-asymmetric-alg-only text: 'Only asymmetric signature algorithms MUST be used for DPoP proofs. Symmetric algorithms and the `none` algorithm MUST NOT be permitted as specified in RFC 9449 Section 11.6' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' - check: sep-1932-server-nonce text: 'For high-security environments, MCP servers SHOULD implement server-provided nonces as described in RFC 9449 Section 9 to further limit proof lifetime, even if it is stateless' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' - check: sep-1932-server-audience-validation text: 'MCP servers MUST continue to validate that access tokens were specifically issued for them, even when DPoP is used' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' - text: 'Implementations MUST conform to all requirements specified in this extension' excluded: 'Umbrella requirement satisfied by the specific checks above; not separately observable on the wire.' @@ -46,3 +58,6 @@ requirements: excluded: 'Client implementation detail, not observable on the wire.' - text: 'MCP servers requiring maximum replay protection SHOULD implement `jti` tracking despite the operational complexity' excluded: 'Explicitly optional (servers are NOT required to track jti) and stateful/internal; only indirectly observable via replay.' + - text: 'MCP servers that is not capable of keeping state or perform global `jti` tracking MUST provide DPoP proof replay protection by enforcing short `iat` acceptance windows of ±5 minutes and standard RFC 9449 claim validation' + excluded: 'Conditional on whether the server keeps state, which is not wire-observable; the unconditional ±5-minute `iat` window it prescribes is covered by sep-1932-server-iat-window.' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369'