Client Auth: DPoP Sender-Constrained Tokens (SEP-1932) (#394)

* Add DPoP client conformance scenario + shared DPoP helpers (SEP-1932, #368)

Client-anchored packaging: carries the shared DPoP foundation (dpopProof /
dpopToken helpers, the createAuthServer DPoP core, and sep-1932.yaml) together
with the client scenario, so it can merge first; the server (#369) and
authorization-server (#370) scenarios are follow-ups that depend on it.

DPoP is treated as a draft/not-yet-official feature (mirroring the WIF SEP-1933
scenario): the scenario's source is `introducedIn: DRAFT_PROTOCOL_VERSION` and
it is registered in draftScenariosList (informational, not scored). It is NOT
registered in EXTENSION_IDS, since the ext-auth DPoP extension is still on a
branch rather than merged.

Client checks (the two extension MUSTs, RFC 9449 §7.1 / §4.2-4.3):
- sep-1932-client-dpop-auth-scheme — token presented with the DPoP scheme.
- sep-1932-client-fresh-proof — a fresh, well-formed DPoP proof per request.

The test MCP server judges the client via an opt-in DPoP middleware
(dpopResourceAuth) passed through createServer's authMiddleware hook; a compliant
example client (covered by the Client Draft Scenarios loop) plus deliberately-
broken bearer/replay variants drive the pass/fail acceptance tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* client/dpop: split into nonce-less baseline + nonce-required scenario

Server-provided nonces are optional in RFC 9449 (AS §8 "MAY", RS §9 "can
also choose"), and the nonce-less flow is the common case. The single
auth/dpop scenario hard-wired nonce enforcement on both the test AS and
MCP server, so a plain nonce-less proof was never accepted end-to-end and
a client that only does plain proofs was never exercised on its happy path.

Parameterize DPoPClientScenario with `requireNonce` and register it twice:
- auth/dpop        (requireNonce=false) — nonce-less baseline; emits the
  three baseline checks (token-request-proof, dpop-auth-scheme, fresh-proof).
- auth/dpop-nonce  (requireNonce=true)  — prior behavior; adds as-nonce and
  rs-nonce (five checks).

Check IDs are reused (no new IDs, no sep-1932.yaml change); the two nonce
checks are emitted only by auth/dpop-nonce, so traceability stays intact.

Add auth-test-dpop-no-nonce.ts (nonce-incapable but otherwise compliant)
asserting SUCCESS against auth/dpop — proof that a client with no nonce
handling still completes DPoP when the server does not require a nonce.
Baseline negatives stay on auth/dpop (nonce-independent); the two nonce
negatives are re-pointed to auth/dpop-nonce.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* client/dpop: round-4 nonce-surface correctness fixes

Address review findings on the auth/dpop-nonce posture:

- Record the token-request proof observation BEFORE the §8 nonce challenge,
  so a client that is challenged but does not retry is no longer mis-reported
  by token-request-proof as "never completed a token request" (its proof was
  just validated). (#2)
- Record the resource-request jti/replay on ANY valid proof (before the §9
  nonce gate), so fresh-proof is never asserted on zero evidence and a client
  that never honours the RS nonce fails ONLY rs-nonce. (#5)
- Require challengeIssued && honored for as-nonce/rs-nonce SUCCESS, closing a
  vacuous-pass path (a client that pre-sends the nonce is never challenged). (#3a)
- Gate the AS nonce observation on the authorization_code exchange, matching
  recordTokenRequestProof (a refresh exchange must not satisfy §8). (#3c)
- Collapse duplicate shared token-flow check IDs (token-request, pkce-*) that
  the §8 round-trip re-POST would otherwise emit twice. (#4)
- Example client: retry the token request only on a use_dpop_nonce error code,
  not any 400 carrying a DPoP-Nonce header (consistent with the RS path). (#6)
- Fix the DpopTokenRequestObservation docstring ("last write wins" →
  sticky-failure). (#7)

Adds an expectedSuccessSlugs option to the client test helper and tightens the
two nonce negative tests to pin the now-clean behavior (token-request-proof
SUCCESS for no-as-nonce; only rs-nonce fails for no-rs-nonce).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* client/dpop: round-5 fixes — dedupe masking, wording, replay decision

- dedupeSharedChecks was masking failures: it kept the last occurrence of a
  duplicate check ID regardless of status and ran in BOTH postures, so a
  FAILURE on a challenged POST (or an earlier attempt in a restarted flow) was
  silently dropped. Replace with an exported, unit-tested collapseDuplicateChecks
  that prefers the MOST-SEVERE occurrence, and only apply it in the nonce
  posture (the baseline keeps genuinely distinct repeated attempts). (R5 #1)
- Reword the token-request-proof description: it can be SUCCESS for a client
  that presented a valid proof but never obtained a token (challenged, no
  retry), so it no longer claims "obtaining a DPoP-bound access token". (R5 #2)
- Reword the as-nonce/rs-nonce failure messages to cover a client that DID
  retry but whose retry proof was rejected (not only "did not retry"). (R5 #3)
- Document the deliberate replay decision: recording jti on challenged requests
  means re-sending the identical proof across the challenge/retry boundary trips
  replay detection — a genuine RFC 9449 §4.2 jti-uniqueness violation. (R5 #5)

Adds src/scenarios/client/auth/dpop.test.ts unit-testing collapseDuplicateChecks
(prefers failures, keeps INFO, preserves order) — the dedupe fix is now pinned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* client/dpop: round-6 polish — pin collapse wiring; docstring

- Add an integration assertion that the §8/§9 double-POST is collapsed to one
  token-request/pkce-* entry each in auth/dpop-nonce, so the requireNonce gating
  of collapseDuplicateChecks can't be silently deleted or inverted (previously
  mutation-survivable). runClientAgainstScenario now returns the emitted checks
  so callers can make occurrence-level assertions (backward-compatible).
- Docstring: note the severity ladder ranks SKIPPED below SUCCESS, and that
  equal-severity ties keep the last occurrence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
PieterKas 2026-07-10 17:13:02 +01:00 • committed by GitHub
commit 6e726734f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2713 additions and 4 deletions

View file

@ -0,0 +1,21 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Broken DPoP client: builds correct per-request proofs but presents the
* DPoP-bound token with the `Bearer` scheme instead of `DPoP`. Isolates a
* failure of sep-1932-client-dpop-auth-scheme (RFC 9449 §7.1).
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'Bearer',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: true,
handleRsNonce: true
});
}
runAsCli(runClient, import.meta.url, 'auth-test-dpop-bearer <server-url>');

View file

@ -0,0 +1,22 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Broken DPoP client: ignores the authorization server's `use_dpop_nonce`
* challenge at the token endpoint (RFC 9449 §8) — it does not retry the token
* request with the supplied nonce. Isolates a failure of
* sep-1932-client-as-nonce.
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: false,
handleRsNonce: true
});
}
runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-as-nonce <server-url>');

View file

@ -0,0 +1,25 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Nonce-incapable but otherwise compliant DPoP client (SEP-1932 / RFC 9449):
* presents the DPoP-bound token with the `DPoP` Authorization scheme and a fresh
* proof on every request, but implements NO `use_dpop_nonce` handling. Against
* the nonce-less `auth/dpop` scenario (where neither server issues a challenge)
* it completes the flow successfully — proving that a client which does not
* support nonces still passes when the server does not require one (the common
* case, since server nonces are OPTIONAL in RFC 9449 §8/§9).
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: false,
handleRsNonce: false
});
}
runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-nonce <server-url>');

View file

@ -0,0 +1,22 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Broken DPoP client: obtains the token correctly (handles the AS nonce) but
* ignores the MCP server's `use_dpop_nonce` challenge at the resource
* (RFC 9449 §9) — it does not retry the request with the supplied nonce.
* Isolates a failure of sep-1932-client-rs-nonce.
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: true,
handleRsNonce: false
});
}
runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-rs-nonce <server-url>');

View file

@ -0,0 +1,27 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Broken DPoP client: never sends a DPoP proof at the token endpoint, so it
* never asks for a sender-constrained token — the AS falls back to an unbound
* Bearer token. Isolates a failure of sep-1932-client-token-request-proof
* (RFC 9449 §5). Because the resulting token is unbound, the resource-side
* binding check (sep-1932-client-fresh-proof) also fails as a consequence.
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: false,
handleAsNonce: true,
handleRsNonce: true
});
}
runAsCli(
runClient,
import.meta.url,
'auth-test-dpop-no-token-proof <server-url>'
);

View file

@ -0,0 +1,21 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Broken DPoP client: uses the DPoP scheme correctly but reuses a single proof
* (same `jti`) across requests instead of a fresh one each time. Isolates a
* failure of sep-1932-client-fresh-proof.
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: false,
sendTokenRequestProof: true,
handleAsNonce: true,
handleRsNonce: true
});
}
runAsCli(runClient, import.meta.url, 'auth-test-dpop-replay <server-url>');

View file

@ -0,0 +1,20 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Well-behaved DPoP client (SEP-1932 / RFC 9449): presents the DPoP-bound token
* with the `DPoP` Authorization scheme and a fresh proof on every MCP request.
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: true,
handleRsNonce: true
});
}
runAsCli(runClient, import.meta.url, 'auth-test-dpop <server-url>');

View file

@ -42,6 +42,7 @@ import {
} from './helpers/withOAuthRetry.js';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
import { runClient as issValidationClient } from './auth-test-iss-validation.js';
import { runClient as dpopClient } from './auth-test-dpop.js';
import { logger } from './helpers/logger.js';
/**
@ -854,6 +855,13 @@ registerScenario(
runEnterpriseManagedAuthorization
);
// ============================================================================
// DPoP client conformance (SEP-1932)
// ============================================================================
registerScenario('auth/dpop', dpopClient);
registerScenario('auth/dpop-nonce', dpopClient);
// ============================================================================
// MRTR client conformance (SEP-2322)
// ============================================================================

View file

@ -0,0 +1,234 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { request } from 'undici';
import { createHash, randomBytes } from 'crypto';
import {
generateDpopKeyPair,
buildDpopProof
} from '../../../../src/scenarios/client/auth/helpers/dpopProof';
import { logger } from './logger';
/**
* Shared DPoP client flow (SEP-1932 / RFC 9449). Acquires a DPoP-bound access
* token via the authorization_code + PKCE grant (with a DPoP proof at the token
* endpoint), then opens an MCP session presenting the token to the resource.
*
* The OAuth flow is hand-rolled rather than reusing ConformanceOAuthProvider /
* withOAuthRetry because the SDK's OAuth provider offers no hook to attach a
* DPoP proof to the token-endpoint request, which is required to obtain a bound
* token. The MCP session itself does use the SDK Client (via a fetch wrapper).
*
* Parameterized so the compliant client and the deliberately-broken variants
* differ by exactly one behaviour:
* - `scheme: 'Bearer'` → fails sep-1932-client-dpop-auth-scheme
* - `freshProofPerRequest:false` → reuses one proof, fails sep-1932-client-fresh-proof
* - `sendTokenRequestProof:false`→ omits the token-endpoint proof, so the AS
* issues an unbound Bearer token; fails sep-1932-client-token-request-proof
* (and, downstream, the resource binding check, since the token isn't bound)
* - `handleAsNonce:false` → ignores the token endpoint's `use_dpop_nonce`
* challenge (RFC 9449 §8); fails sep-1932-client-as-nonce
* - `handleRsNonce:false` → ignores the MCP server's `use_dpop_nonce`
* challenge (RFC 9449 §9); fails sep-1932-client-rs-nonce
*/
export interface DpopClientOptions {
scheme: 'DPoP' | 'Bearer';
freshProofPerRequest: boolean;
sendTokenRequestProof: boolean;
/** Retry the token request with the AS-supplied nonce on a use_dpop_nonce challenge (RFC 9449 §8). */
handleAsNonce: boolean;
/** Retry an MCP request with the server-supplied nonce on a use_dpop_nonce challenge (RFC 9449 §9). */
handleRsNonce: boolean;
}
const REDIRECT_URI = 'http://127.0.0.1:9876/callback';
export async function runDpopClient(
serverUrl: string,
options: DpopClientOptions
): Promise<void> {
const keyPair = await generateDpopKeyPair();
// 1. Discover the authorization server via Protected Resource Metadata.
const prmUrl = new URL(
'/.well-known/oauth-protected-resource/mcp',
serverUrl
);
const prm = await (await fetch(prmUrl.toString())).json();
const authServerUrl: string = prm.authorization_servers[0];
// 2. Authorization server metadata.
const asMeta = await (
await fetch(
new URL(
'/.well-known/oauth-authorization-server',
authServerUrl
).toString()
)
).json();
const authorizationEndpoint: string = asMeta.authorization_endpoint;
const tokenEndpoint: string = asMeta.token_endpoint;
const registrationEndpoint: string = asMeta.registration_endpoint;
// 3. Dynamic client registration.
const reg = await (
await fetch(registrationEndpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
client_name: 'conformance-dpop-client',
redirect_uris: [REDIRECT_URI],
application_type: 'native'
})
})
).json();
const clientId: string = reg.client_id;
// 4. Authorization request (PKCE). The test AS redirects straight to the
// redirect_uri, so read the code from the Location header (no callback needed).
const state = randomBytes(16).toString('base64url');
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url');
const authorizeUrl = `${authorizationEndpoint}?${new URLSearchParams({
response_type: 'code',
client_id: clientId,
state,
redirect_uri: REDIRECT_URI,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
}).toString()}`;
const authorizeResponse = await request(authorizeUrl, { method: 'GET' });
await authorizeResponse.body.text().catch(() => undefined);
const location = authorizeResponse.headers['location'];
const locationStr = Array.isArray(location) ? location[0] : location;
if (!locationStr) {
throw new Error('Authorization endpoint did not redirect with a code');
}
const code = new URL(locationStr).searchParams.get('code');
if (!code) throw new Error('No authorization code in redirect');
// 5. Token request with a DPoP proof → DPoP-bound access token. The broken
// `sendTokenRequestProof:false` variant omits the proof, so the AS issues an
// unbound Bearer token instead.
const tokenReqBody = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
code_verifier: codeVerifier,
client_id: clientId
}).toString();
const requestToken = async (nonce?: string): Promise<Response> => {
const headers: Record<string, string> = {
'content-type': 'application/x-www-form-urlencoded'
};
if (options.sendTokenRequestProof) {
headers.dpop = await buildDpopProof({
keyPair,
htm: 'POST',
// RFC 9449 §4.2: htu carries no query/fragment (the token endpoint URL
// may legally have a query, so strip it here).
htu: stripQuery(tokenEndpoint),
...(nonce ? { nonce } : {})
});
}
return fetch(tokenEndpoint, {
method: 'POST',
headers,
body: tokenReqBody
});
};
let tokenResponse = await requestToken();
// RFC 9449 §8: the AS may answer with `use_dpop_nonce` (HTTP 400 + DPoP-Nonce);
// a conformant client retries the token request with the supplied nonce. Match
// on the `use_dpop_nonce` error code (not merely any 400 carrying a nonce), so
// an unrelated error (e.g. invalid_grant) that an AS proactively decorates with
// a DPoP-Nonce header does not burn the retry and mask the real failure —
// consistent with the resource-side check below.
const asNonce = tokenResponse.headers.get('DPoP-Nonce');
if (tokenResponse.status === 400 && asNonce && options.handleAsNonce) {
const challenge = await tokenResponse
.clone()
.json()
.catch(() => ({}) as { error?: string });
if (challenge?.error === 'use_dpop_nonce') {
tokenResponse = await requestToken(asNonce);
}
}
if (!tokenResponse.ok) {
throw new Error(`Token request failed: HTTP ${tokenResponse.status}`);
}
const tokenBody = await tokenResponse.json();
const accessToken: string = tokenBody.access_token;
logger.debug(`Obtained ${tokenBody.token_type} access token`);
// 6. MCP session — present the token to the resource with a per-request proof.
// On a `use_dpop_nonce` challenge (RFC 9449 §9) a conformant client retries
// with the server-supplied nonce embedded in the proof, and carries it on
// subsequent requests.
const mcpUrl = `${serverUrl}`;
let reusableProof: string | undefined;
let rsNonce: string | undefined;
const dpopFetch = async (
input: string | URL,
init?: RequestInit
): Promise<Response> => {
const method = (init?.method ?? 'POST').toUpperCase();
const htu = stripQuery(
typeof input === 'string' ? input : input.toString()
);
const attempt = async (): Promise<Response> => {
const headers = new Headers(init?.headers);
headers.set('Authorization', `${options.scheme} ${accessToken}`);
let proof: string;
if (options.freshProofPerRequest || !reusableProof) {
proof = await buildDpopProof({
keyPair,
htm: method,
htu,
accessToken,
...(rsNonce ? { nonce: rsNonce } : {})
});
if (!options.freshProofPerRequest) reusableProof = proof;
} else {
proof = reusableProof;
}
headers.set('DPoP', proof);
return fetch(input, { ...init, headers });
};
let res = await attempt();
const nonce = res.headers.get('DPoP-Nonce');
if (
res.status === 401 &&
nonce &&
options.handleRsNonce &&
(res.headers.get('WWW-Authenticate') ?? '').includes('use_dpop_nonce')
) {
rsNonce = nonce;
reusableProof = undefined; // rebuild the proof carrying the nonce
res = await attempt();
}
return res;
};
const client = new Client(
{ name: 'conformance-dpop-client', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
fetch: dpopFetch as typeof fetch
});
await client.connect(transport);
logger.debug('Connected to MCP server');
await client.listTools();
logger.debug('Listed tools');
await client.callTool({ name: 'test-tool', arguments: {} });
logger.debug('Called tool');
await transport.close();
}
function stripQuery(url: string): string {
const u = new URL(url);
return `${u.origin}${u.pathname}`;
}

View file

@ -0,0 +1,85 @@
import { collapseDuplicateChecks } from './dpop';
import type { ConformanceCheck, CheckStatus } from '../../../types';
/** Minimal check factory for the dedupe unit tests. */
function chk(id: string, status: CheckStatus, tag?: string): ConformanceCheck {
return {
id,
name: id,
description: tag ?? id,
status,
timestamp: '2026-07-06T00:00:00.000Z',
specReferences: []
};
}
describe('collapseDuplicateChecks (DPoP nonce-posture shared-check dedupe)', () => {
it('collapses duplicate SUCCESS ids to a single entry', () => {
const out = collapseDuplicateChecks([
chk('token-request', 'SUCCESS'),
chk('pkce-code-verifier-sent', 'SUCCESS'),
chk('token-request', 'SUCCESS')
]);
expect(out.map((c) => c.id)).toEqual([
'pkce-code-verifier-sent',
'token-request'
]);
});
it('never masks a FAILURE — keeps the failing occurrence over a later SUCCESS', () => {
// The regression this guards: a client that fails a check on the challenged
// POST but passes on the nonce retry must still be reported FAILURE.
const out = collapseDuplicateChecks([
chk('pkce-verifier-matches-challenge', 'FAILURE', 'first attempt'),
chk('pkce-verifier-matches-challenge', 'SUCCESS', 'retry')
]);
expect(out).toHaveLength(1);
expect(out[0].status).toBe('FAILURE');
expect(out[0].description).toBe('first attempt');
});
it('keeps the failing occurrence regardless of order (SUCCESS then FAILURE)', () => {
const out = collapseDuplicateChecks([
chk('token-request', 'SUCCESS'),
chk('token-request', 'FAILURE', 'later failure')
]);
expect(out).toHaveLength(1);
expect(out[0].status).toBe('FAILURE');
expect(out[0].description).toBe('later failure');
});
it('prefers WARNING over SUCCESS but FAILURE over WARNING', () => {
expect(
collapseDuplicateChecks([chk('x', 'SUCCESS'), chk('x', 'WARNING')])[0]
.status
).toBe('WARNING');
expect(
collapseDuplicateChecks([chk('x', 'WARNING'), chk('x', 'FAILURE')])[0]
.status
).toBe('FAILURE');
});
it('keeps every INFO log entry, even duplicates', () => {
const out = collapseDuplicateChecks([
chk('incoming-request', 'INFO'),
chk('incoming-request', 'INFO'),
chk('incoming-request', 'INFO')
]);
expect(out).toHaveLength(3);
});
it('preserves order and leaves a duplicate-free list untouched', () => {
const input = [
chk('a', 'SUCCESS'),
chk('b', 'FAILURE'),
chk('incoming', 'INFO'),
chk('c', 'SUCCESS')
];
expect(collapseDuplicateChecks(input).map((c) => c.id)).toEqual([
'a',
'b',
'incoming',
'c'
]);
});
});

View file

@ -0,0 +1,362 @@
import type { ScenarioContext } from '../../../mock-server';
import type {
Scenario,
ConformanceCheck,
CheckStatus,
SpecReference
} from '../../../types';
import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types';
import {
createAuthServer,
type DpopTokenRequestObservation
} from './helpers/createAuthServer';
import { createServer } from './helpers/createServer';
import { ServerLifecycle } from './helpers/serverLifecycle';
import {
createDpopResourceAuth,
newDpopClientObservations,
type DpopClientObservations
} from './helpers/dpopResourceAuth';
import { SpecReferences } from './spec-references';
const PRM_PATH = '/.well-known/oauth-protected-resource/mcp';
/** Static id → (name, description, spec references) for each emitted check. */
const CHECK_DEFS: Record<
string,
{ name: string; description: string; specReferences: SpecReference[] }
> = {
'sep-1932-client-dpop-auth-scheme': {
name: 'DpopAuthScheme',
description:
'Client presents the access token with the DPoP Authorization scheme (not Bearer)',
specReferences: [
SpecReferences.SEP_1932_DPOP,
SpecReferences.DPOP_EXTENSION,
SpecReferences.RFC_9449_AUTH_SCHEME
]
},
'sep-1932-client-fresh-proof': {
name: 'DpopFreshProof',
description:
'Client includes a fresh, well-formed DPoP proof in the DPoP header on each MCP POST request',
specReferences: [
SpecReferences.RFC_9449_CHECKING_PROOFS,
SpecReferences.RFC_9449_PROOF_SYNTAX,
SpecReferences.DPOP_EXTENSION
]
},
'sep-1932-client-token-request-proof': {
name: 'DpopTokenRequestProof',
description:
'Client includes a valid DPoP proof in the DPoP header of its token request — the prerequisite for obtaining a DPoP-bound access token (RFC 9449 §5)',
specReferences: [
SpecReferences.SEP_1932_DPOP,
SpecReferences.DPOP_EXTENSION,
SpecReferences.RFC_9449_TOKEN_REQUEST
]
},
'sep-1932-client-as-nonce': {
name: 'DpopAsNonce',
description:
'On a use_dpop_nonce challenge from the token endpoint, the client retries the token request with the server-supplied nonce (RFC 9449 §8)',
specReferences: [
SpecReferences.SEP_1932_DPOP,
SpecReferences.DPOP_EXTENSION,
SpecReferences.RFC_9449_AS_NONCE
]
},
'sep-1932-client-rs-nonce': {
name: 'DpopRsNonce',
description:
'On a use_dpop_nonce challenge from the MCP server, the client retries the request with the server-supplied nonce (RFC 9449 §9)',
specReferences: [
SpecReferences.SEP_1932_DPOP,
SpecReferences.DPOP_EXTENSION,
SpecReferences.RFC_9449_RS_NONCE
]
}
};
/**
* Scenario: DPoP sender-constrained tokens — MCP client (SEP-1932 / RFC 9449).
*
* The test authorization server (DPoP-capable `createAuthServer`) issues a
* DPoP-bound token; the test MCP server judges how the client presents it.
*
* Registered in two postures, because server-provided nonces are OPTIONAL in
* RFC 9449 (AS §8 "MAY", RS §9 "can also choose") and the two are mutually
* exclusive for a given run:
*
* - `auth/dpop` (`requireNonce = false`) — the common, nonce-less baseline.
* Neither the AS nor the MCP server issues a nonce challenge; the client
* completes the flow with plain proofs. Emits three checks:
* · token acquisition — a valid DPoP proof at the token request, obtaining
* a sender-constrained token (RFC 9449 §5);
* · the token is presented with the `DPoP` Authorization scheme (§7.1);
* · a fresh, well-formed DPoP proof accompanies each request (unique `jti`).
*
* - `auth/dpop-nonce` (`requireNonce = true`) — the AS and MCP server both
* require a server-provided nonce (§8/§9), exercising the client's nonce
* handling. Emits the three baseline checks plus two more:
* · the client retries the token request with the AS-supplied nonce (§8);
* · the client retries the MCP request with the server-supplied nonce (§9).
*/
function newTokenReqObs(): DpopTokenRequestObservation {
return {
recorded: false,
validProof: false,
asNonceChallengeIssued: false,
asNonceHonored: false
};
}
/**
* Collapse duplicate non-INFO check IDs to a single entry, preferring the
* MOST-SEVERE occurrence (FAILURE > WARNING > SUCCESS > any other status, e.g.
* SKIPPED) so a real failure is never masked. Equal-severity ties keep the LAST
* occurrence (for the nonce round-trip that is the retry's diagnostic, which is
* status-equivalent to the first). Per-request INFO log entries are always kept.
*
* The RFC 9449 §8/§9 nonce round-trip re-POSTs /token (challenge → retry), so
* the shared token-flow conformance checks (`token-request`, `pkce-*`) are
* appended twice; this reports each once without hiding a failure recorded on
* either attempt. Exported so the behaviour is unit-tested directly.
*/
export function collapseDuplicateChecks(
checks: ConformanceCheck[]
): ConformanceCheck[] {
const severity = (s: CheckStatus): number =>
s === 'FAILURE' ? 3 : s === 'WARNING' ? 2 : s === 'SUCCESS' ? 1 : 0;
// Winning index per non-INFO id: highest severity, ties → last occurrence.
const winner = new Map<string, number>();
checks.forEach((c, i) => {
if (c.status === 'INFO') return;
const cur = winner.get(c.id);
if (
cur === undefined ||
severity(c.status) >= severity(checks[cur].status)
) {
winner.set(c.id, i);
}
});
return checks.filter((c, i) => c.status === 'INFO' || winner.get(c.id) === i);
}
export class DPoPClientScenario implements Scenario {
readonly name: string;
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
readonly description: string;
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
private obs: DpopClientObservations = newDpopClientObservations();
private tokenReqObs: DpopTokenRequestObservation = newTokenReqObs();
/**
* @param requireNonce when true (`auth/dpop-nonce`) the test AS and MCP server
* both demand a server-provided nonce (RFC 9449 §8/§9); when false
* (`auth/dpop`) neither challenges and the client completes with plain
* proofs — the common, nonce-less baseline.
*/
constructor(private readonly requireNonce: boolean) {
this.name = requireNonce ? 'auth/dpop-nonce' : 'auth/dpop';
this.description = requireNonce
? 'Tests that an MCP client, when the authorization server and MCP server require a DPoP nonce, retries the token request and the MCP request with the server-supplied nonce (RFC 9449 §8/§9) — on top of requesting a DPoP-bound token and presenting it with the DPoP Authorization scheme and a fresh proof per request (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).'
: 'Tests that an MCP client requests a DPoP-bound access token (a valid DPoP proof at the token request) and presents it using the DPoP Authorization scheme (not Bearer) with a fresh, well-formed DPoP proof on each POST /mcp request, when the server does not require a nonce (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).';
}
async start(ctx: ScenarioContext): Promise<ScenarioUrls> {
this.checks = [];
this.obs = newDpopClientObservations();
this.tokenReqObs = newTokenReqObs();
const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, {
dpopSigningAlgValuesSupported: ['ES256'],
dpopTokenRequestObs: this.tokenReqObs,
dpopRequireNonce: this.requireNonce
});
await this.authServer.start(authApp);
const app = createServer(
ctx,
this.checks,
this.server.getUrl,
this.authServer.getUrl,
{
authMiddleware: createDpopResourceAuth(
this.obs,
() => `${this.server.getUrl()}/mcp`,
() => `${this.server.getUrl()}${PRM_PATH}`,
this.requireNonce
)
}
);
await this.server.start(app);
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop(): Promise<void> {
await this.authServer.stop();
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
// Only the nonce posture re-POSTs /token (challenge → retry), which
// duplicates the shared token-flow checks (token-request, pkce-*); collapse
// those. The baseline (`auth/dpop`) is left untouched so genuinely distinct
// repeated attempts (e.g. a restarted authorization flow) keep both entries.
const shared = this.requireNonce
? collapseDuplicateChecks(this.checks)
: this.checks;
const checks: ConformanceCheck[] = [
...shared,
this.tokenRequestProofCheck(),
this.authSchemeCheck(),
this.freshProofCheck()
];
// The nonce checks only apply to the nonce-requiring posture: in the
// baseline (`auth/dpop`) neither server issues a `use_dpop_nonce`
// challenge, so there is no nonce behaviour to assert.
if (this.requireNonce) {
checks.push(this.asNonceCheck(), this.rsNonceCheck());
}
return checks;
}
private asNonceCheck(): ConformanceCheck {
const challenged = this.tokenReqObs.asNonceChallengeIssued;
const honored = this.tokenReqObs.asNonceHonored;
// SUCCESS requires that a challenge was actually issued AND then honored —
// grading `honored` alone would let a client that pre-sends the nonce
// (never challenged) pass vacuously.
const pass = challenged && honored;
return this.build(
'sep-1932-client-as-nonce',
pass ? 'SUCCESS' : 'FAILURE',
{
errorMessage: pass
? undefined
: challenged
? 'Client did not complete the token request with the server-supplied nonce after a use_dpop_nonce challenge (it either did not retry or the retried proof was rejected)'
: 'Client never presented a valid proof that was answered with a use_dpop_nonce challenge',
details: {
challengeIssued: challenged,
nonceHonored: honored
}
}
);
}
private rsNonceCheck(): ConformanceCheck {
const challenged = this.obs.rsNonceChallengeIssued;
const honored = this.obs.rsNonceHonored;
// SUCCESS requires that a challenge was actually issued AND then honored —
// see asNonceCheck; grading `honored` alone permits a vacuous pass.
const pass = challenged && honored;
return this.build(
'sep-1932-client-rs-nonce',
pass ? 'SUCCESS' : 'FAILURE',
{
errorMessage: pass
? undefined
: challenged
? 'Client did not complete the MCP request with the server-supplied nonce after a use_dpop_nonce challenge (it either did not retry or the retried proof was rejected)'
: 'Client never made an MCP request with a valid proof that was answered with a use_dpop_nonce challenge',
details: {
challengeIssued: challenged,
nonceHonored: honored
}
}
);
}
private tokenRequestProofCheck(): ConformanceCheck {
let status: CheckStatus;
let errorMessage: string | undefined;
if (!this.tokenReqObs.recorded) {
status = 'FAILURE';
errorMessage =
'Client never completed an authorization_code token request, so it obtained no DPoP-bound token';
} else if (!this.tokenReqObs.validProof) {
status = 'FAILURE';
errorMessage = `Client did not present a valid DPoP proof at the token endpoint: ${this.tokenReqObs.error}`;
} else {
status = 'SUCCESS';
}
return this.build('sep-1932-client-token-request-proof', status, {
errorMessage,
details: {
tokenRequestObserved: this.tokenReqObs.recorded,
validProofPresented: this.tokenReqObs.validProof
}
});
}
private authSchemeCheck(): ConformanceCheck {
let status: CheckStatus;
let errorMessage: string | undefined;
if (this.obs.authenticatedRequests === 0) {
status = 'FAILURE';
errorMessage = 'Client never presented an access token to the MCP server';
} else if (this.obs.nonDpopSchemeSeen) {
status = 'FAILURE';
errorMessage = `Client presented the token with a non-DPoP Authorization scheme (${[...new Set(this.obs.observedSchemes)].join(', ')})`;
} else {
status = 'SUCCESS';
}
return this.build('sep-1932-client-dpop-auth-scheme', status, {
errorMessage,
details: {
authenticatedRequests: this.obs.authenticatedRequests,
observedSchemes: [...new Set(this.obs.observedSchemes)]
}
});
}
private freshProofCheck(): ConformanceCheck {
let status: CheckStatus;
let errorMessage: string | undefined;
if (this.obs.authenticatedRequests === 0) {
status = 'FAILURE';
errorMessage = 'Client never presented an access token to the MCP server';
} else if (!this.obs.allProofsWellFormed) {
status = 'FAILURE';
errorMessage = `DPoP proof was missing or malformed: ${this.obs.proofError}`;
} else if (this.obs.replayDetected) {
status = 'FAILURE';
errorMessage =
'Client reused a DPoP proof (duplicate jti) across requests instead of sending a fresh one';
} else {
status = 'SUCCESS';
}
return this.build('sep-1932-client-fresh-proof', status, {
errorMessage,
details: {
authenticatedRequests: this.obs.authenticatedRequests,
distinctJtis: this.obs.jtisSeen.length,
replayDetected: this.obs.replayDetected
}
});
}
private build(
id: string,
status: CheckStatus,
opts: { errorMessage?: string; details?: Record<string, unknown> } = {}
): ConformanceCheck {
const def = CHECK_DEFS[id];
return {
id,
name: def.name,
description: def.description,
status,
timestamp: new Date().toISOString(),
specReferences: def.specReferences,
...(opts.errorMessage ? { errorMessage: opts.errorMessage } : {}),
...(opts.details ? { details: opts.details } : {})
};
}
}

View file

@ -6,6 +6,12 @@ import { isStatefulVersion } from '../../../../connection/select';
import { createRequestLogger } from '../../../request-logger';
import { SpecReferences } from '../spec-references';
import { MockTokenVerifier } from './mockTokenVerifier';
import * as jose from 'jose';
import {
generateIssuerKey,
mintDpopBoundToken,
type TokenIssuerKey
} from './dpopToken';
/**
* Compute S256 code challenge from a code verifier.
@ -20,6 +26,105 @@ function computeS256Challenge(codeVerifier: string): string {
.replace(/=+$/, '');
}
// Fixed nonce the test AS hands out when `dpopRequireNonce` is set (RFC 9449 §8).
const AS_DPOP_NONCE = 'conformance-as-dpop-nonce';
// Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3, §11.6).
const DPOP_ASYMMETRIC_ALGS = [
'ES256',
'ES384',
'ES512',
'RS256',
'RS384',
'RS512',
'PS256',
'PS384',
'PS512',
'EdDSA'
];
/**
* Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based
* normalization): lowercase scheme/host, drop the default port, ignore a
* trailing slash. Query/fragment are rejected by the caller (RFC 9449 §4.2),
* not silently stripped here.
*/
function normalizeHtu(u: string): string {
try {
const url = new URL(u);
// Tolerate a single trailing slash only; `/mcp//` is a distinct path.
return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}`;
} catch {
return u;
}
}
/**
* Validate a DPoP proof presented at the token endpoint (the token-request
* subset of RFC 9449 §4.3). Hand-rolled with jose primitives — deliberately an
* INDEPENDENT code path from the suite's proof builder, so a shared bug
* surfaces rather than hides. Returns the JWK thumbprint to bind on success.
*/
export async function validateDpopProofAtTokenEndpoint(
proof: string,
tokenEndpointUrl: string
): Promise<{ ok: true; jkt: string } | { ok: false; error: string }> {
let header: jose.ProtectedHeaderParameters;
try {
header = jose.decodeProtectedHeader(proof);
} catch {
return { ok: false, error: 'DPoP proof is not a well-formed JWT' };
}
if (header.typ !== 'dpop+jwt') {
return { ok: false, error: 'typ must be dpop+jwt' };
}
if (!header.alg || !DPOP_ASYMMETRIC_ALGS.includes(header.alg)) {
return { ok: false, error: 'alg must be a supported asymmetric algorithm' };
}
const jwk = header.jwk as jose.JWK | undefined;
if (!jwk) {
return { ok: false, error: 'missing jwk header parameter' };
}
if ((jwk as Record<string, unknown>).d !== undefined) {
return { ok: false, error: 'jwk must not contain a private key' };
}
let claims: jose.JWTPayload;
try {
const key = await jose.importJWK(jwk, header.alg);
claims = (
await jose.jwtVerify(proof, key, { algorithms: DPOP_ASYMMETRIC_ALGS })
).payload;
} catch {
return { ok: false, error: 'DPoP proof signature does not verify' };
}
if (typeof claims.jti !== 'string') {
return { ok: false, error: 'missing jti claim' };
}
if (claims.htm !== 'POST') {
return { ok: false, error: 'htm does not match POST' };
}
if (typeof claims.htu !== 'string') {
return { ok: false, error: 'htu does not match the token endpoint' };
}
if (claims.htu.includes('?') || claims.htu.includes('#')) {
return {
ok: false,
error: 'htu MUST NOT contain a query or fragment (RFC 9449 §4.2)'
};
}
if (normalizeHtu(claims.htu) !== normalizeHtu(tokenEndpointUrl)) {
return { ok: false, error: 'htu does not match the token endpoint' };
}
if (
typeof claims.iat !== 'number' ||
Math.abs(Math.floor(Date.now() / 1000) - claims.iat) > 300
) {
return { ok: false, error: 'iat outside the acceptable window' };
}
const jkt = await jose.calculateJwkThumbprint(jwk, 'sha256');
return { ok: true, jkt };
}
export interface TokenRequestResult {
token: string;
scopes: string[];
@ -31,6 +136,24 @@ export interface TokenRequestError {
statusCode?: number;
}
/**
* Sink for the DPoP token-request observation (RFC 9449 §5). The AS writes to
* it on the authorization_code exchange (sticky-failure: once any exchange
* lacks a valid proof it stays FAILURE; refresh grants are ignored) so the
* client scenario can emit sep-1932-client-token-request-proof unconditionally
* — FAILURE by default when the client never reaches the token endpoint,
* matching its sibling checks.
*/
export interface DpopTokenRequestObservation {
recorded: boolean;
validProof: boolean;
error?: string;
/** The token endpoint issued a `use_dpop_nonce` challenge (RFC 9449 §8). */
asNonceChallengeIssued: boolean;
/** The client retried the token request carrying the correct nonce. */
asNonceHonored: boolean;
}
export interface AuthServerOptions {
metadataPath?: string;
isOpenIdConfiguration?: boolean;
@ -63,6 +186,34 @@ export interface AuthServerOptions {
* after `start()`.
*/
metadataIssuer?: string | (() => string);
/**
* DPoP (SEP-1932 / RFC 9449) — opt-in; absent ⇒ no DPoP behaviour at all.
* When set, the AS advertises `dpop_signing_alg_values_supported` in its
* metadata and validates+binds a DPoP proof presented at the token endpoint.
*/
dpopSigningAlgValuesSupported?: string[];
/**
* Negative-test mode: make the AS misbehave in one specific way so a check
* can be proven to FAIL.
* - 'omit-alg-values' — drop `dpop_signing_alg_values_supported` entirely
* - 'empty-alg-values' — advertise the field as an empty array
* - 'include-none' — list `none` among the supported proof algs
* - 'unbound-token' — issue a Bearer token ignoring a valid proof
*/
dpopMisbehavior?:
| 'omit-alg-values'
| 'empty-alg-values'
| 'include-none'
| 'unbound-token';
/** Sink for the DPoP token-request observation; see the interface docstring. */
dpopTokenRequestObs?: DpopTokenRequestObservation;
/**
* When true, the token endpoint requires a DPoP nonce (RFC 9449 §8): a
* proof-bearing request without the correct `nonce` claim is answered with
* `400 use_dpop_nonce` + a `DPoP-Nonce` header, and the retry carrying the
* nonce is accepted. Used to exercise the client's nonce handling.
*/
dpopRequireNonce?: boolean;
tokenVerifier?: MockTokenVerifier;
onTokenRequest?: (requestData: {
scope?: string;
@ -110,6 +261,10 @@ export function createAuthServer(
issParameterSupported = true,
issInRedirect = 'correct',
metadataIssuer,
dpopSigningAlgValuesSupported,
dpopMisbehavior,
dpopTokenRequestObs,
dpopRequireNonce = false,
tokenVerifier,
onTokenRequest,
onAuthorizationRequest,
@ -120,6 +275,34 @@ export function createAuthServer(
let lastAuthorizationScopes: string[] = [];
// Track PKCE code_challenge for verification in token request
let storedCodeChallenge: string | undefined;
// Lazily-created issuer key for minting DPoP-bound JWT access tokens.
let dpopIssuerKey: TokenIssuerKey | undefined;
// DPoP behaviour is active only when the caller opts in (any DPoP option).
const dpopEnabled =
dpopSigningAlgValuesSupported !== undefined ||
dpopMisbehavior !== undefined;
// Records whether the client presented a valid DPoP proof at its
// authorization_code token request (RFC 9449 §5) into the caller's observation
// sink. Sticky-failure: FAILURE if ANY exchange lacked a valid proof, SUCCESS
// only if every one had one — so a later proof-less exchange (e.g. a refresh
// done as authorization_code) can't overwrite an earlier failure, nor vice
// versa. Refresh grants are ignored entirely. The client scenario reads this
// to emit sep-1932-client-token-request-proof unconditionally (FAILURE by
// default when the client never reaches the token endpoint).
const recordTokenRequestProof = (
grantType: string,
ok: boolean,
detail?: string
): void => {
if (grantType !== 'authorization_code' || !dpopTokenRequestObs) return;
const valid = dpopTokenRequestObs.recorded
? dpopTokenRequestObs.validProof && ok
: ok;
dpopTokenRequestObs.recorded = true;
dpopTokenRequestObs.validProof = valid;
if (!valid && detail) dpopTokenRequestObs.error ??= detail;
};
const authRoutes = {
authorization_endpoint: `${routePrefix}/authorize`,
@ -185,7 +368,21 @@ export function createAuthServer(
...(tokenEndpointAuthSigningAlgValuesSupported && {
token_endpoint_auth_signing_alg_values_supported:
tokenEndpointAuthSigningAlgValuesSupported
})
}),
// DPoP AS-metadata support signal (SEP-1932 / RFC 9449 §5.1). Opt-in;
// misbehaviour modes alter it: 'omit-alg-values' drops the field,
// 'empty-alg-values' advertises an empty array, 'include-none' adds the
// forbidden `none` alg. (dpop_bound_access_tokens is client-registration
// metadata per RFC 9449 §5.2, so it is deliberately NOT advertised here.)
...(dpopMisbehavior !== 'omit-alg-values' &&
dpopSigningAlgValuesSupported !== undefined && {
dpop_signing_alg_values_supported:
dpopMisbehavior === 'empty-alg-values'
? []
: dpopMisbehavior === 'include-none'
? [...dpopSigningAlgValuesSupported, 'none']
: dpopSigningAlgValuesSupported
})
};
// Add scopes_supported if provided
@ -370,6 +567,122 @@ export function createAuthServer(
});
}
// ---- DPoP token binding (SEP-1932 / RFC 9449) — opt-in ----
if (dpopEnabled) {
const proofHeader = req.headers['dpop'];
const proofValue = Array.isArray(proofHeader)
? proofHeader.join(', ')
: proofHeader;
// RFC 9449 §4.2: at most one DPoP header field. Node collapses duplicate
// request headers into one comma-joined value; a valid proof JWT contains
// no comma, so a comma means more than one proof was sent.
if (typeof proofValue === 'string' && proofValue.includes(',')) {
recordTokenRequestProof(
grantType,
false,
'multiple DPoP proof headers'
);
res.status(400).json({
error: 'invalid_dpop_proof',
error_description: 'Multiple DPoP proof headers'
});
return;
}
const proof = proofValue;
const tokenEndpointUrl = `${getAuthBaseUrl()}${authRoutes.token_endpoint}`;
const grantedScopes = requestedScope
? requestedScope.split(' ')
: lastAuthorizationScopes;
// No proof ⇒ DPoP is not being exercised here; fall through to a Bearer
// token. (Requiring a proof is a per-client `dpop_bound_access_tokens`
// registration policy — RFC 9449 §5.2 — not a global AS behaviour.) The
// client-side check still records that the client failed to ask for a
// bound token.
if (proof) {
const result = await validateDpopProofAtTokenEndpoint(
proof,
tokenEndpointUrl
);
if (!result.ok) {
recordTokenRequestProof(grantType, false, result.error);
res.status(400).json({
error: 'invalid_dpop_proof',
error_description: result.error
});
return;
}
// The proof itself is valid — record that now, BEFORE any nonce
// challenge, so a client that is challenged but does not retry is not
// mis-reported by token-request-proof as "never completed a token
// request" (its proof was just verified). Sticky + gated on
// authorization_code inside recordTokenRequestProof.
recordTokenRequestProof(grantType, true);
// RFC 9449 §8: require a server-provided nonce. A proof without the
// correct nonce is challenged (400 use_dpop_nonce + DPoP-Nonce); the
// client is expected to retry with it. The nonce observation is gated
// on the authorization_code exchange, matching recordTokenRequestProof
// (honoring a challenge on a refresh exchange must not satisfy §8).
if (dpopRequireNonce) {
let proofNonce: unknown;
try {
proofNonce = jose.decodeJwt(proof).nonce;
} catch {
proofNonce = undefined;
}
if (proofNonce !== AS_DPOP_NONCE) {
if (grantType === 'authorization_code' && dpopTokenRequestObs)
dpopTokenRequestObs.asNonceChallengeIssued = true;
res.status(400).set('DPoP-Nonce', AS_DPOP_NONCE).json({
error: 'use_dpop_nonce',
error_description: 'Authorization server requires a DPoP nonce'
});
return;
}
if (grantType === 'authorization_code' && dpopTokenRequestObs)
dpopTokenRequestObs.asNonceHonored = true;
}
if (dpopMisbehavior === 'unbound-token') {
// Misbehaviour: ignore the binding and issue a plain Bearer token.
const bearer = `test-token-${Date.now()}`;
if (tokenVerifier) tokenVerifier.registerToken(bearer, grantedScopes);
res.json({
access_token: bearer,
token_type: 'Bearer',
expires_in: 3600
});
return;
}
if (!dpopIssuerKey) {
dpopIssuerKey = await generateIssuerKey();
}
const boundToken = await mintDpopBoundToken({
issuerKey: dpopIssuerKey,
issuer: resolveIssuer(),
audience:
(req.body.resource as string) || 'urn:conformance-test-resource',
jkt: result.jkt,
...(requestedScope && { scope: requestedScope })
});
res.json({
access_token: boundToken,
token_type: 'DPoP',
expires_in: 3600,
...(requestedScope && { scope: requestedScope })
});
return;
}
// dpopEnabled but the token request carried no DPoP proof.
recordTokenRequestProof(
grantType,
false,
'no DPoP proof in the token request'
);
}
let token = `test-token-${Date.now()}`;
let scopes: string[] = lastAuthorizationScopes;

View file

@ -0,0 +1,180 @@
import { webcrypto } from 'node:crypto';
import * as jose from 'jose';
import type { JWK } from 'jose';
import {
generateDpopKeyPair,
accessTokenHash,
jwkThumbprint,
buildDpopProof
} from './dpopProof';
/**
* Independent ES256 signature verification using Node's native WebCrypto —
* a different code path from jose's signer, so a signing bug can't be masked
* by symmetric use of one library (validation Layer 2).
*/
async function verifyEs256Independently(
jwt: string,
publicJwk: JWK
): Promise<boolean> {
const [h, p, s] = jwt.split('.');
const key = await webcrypto.subtle.importKey(
'jwk',
publicJwk as JsonWebKey,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['verify']
);
const signature = new Uint8Array(Buffer.from(s, 'base64url'));
const data = new TextEncoder().encode(`${h}.${p}`);
return webcrypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
signature,
data
);
}
describe('DPoP proof helper — RFC 9449 published vectors (Layer 1)', () => {
// RFC 9449 §4 / §6.1 example: this EC public key's JWK thumbprint is the
// bound `cnf.jkt` value shown in the spec.
const RFC9449_EC_JWK: JWK = {
kty: 'EC',
x: 'l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs',
y: '9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA',
crv: 'P-256'
};
const RFC9449_EXPECTED_JKT = '0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I';
// RFC 9449 §4.1 example: ath = base64url(SHA-256(ASCII access token)).
const RFC9449_ACCESS_TOKEN = 'Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU';
const RFC9449_EXPECTED_ATH = 'fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo';
it('reproduces the RFC 9449 JWK thumbprint vector', async () => {
expect(await jwkThumbprint(RFC9449_EC_JWK)).toBe(RFC9449_EXPECTED_JKT);
});
it('reproduces the RFC 9449 access-token-hash (ath) vector', () => {
expect(accessTokenHash(RFC9449_ACCESS_TOKEN)).toBe(RFC9449_EXPECTED_ATH);
});
});
describe('DPoP proof helper — key pair', () => {
it('generates an extractable P-256 public JWK with a consistent thumbprint', async () => {
const kp = await generateDpopKeyPair();
expect(kp.publicJwk.kty).toBe('EC');
expect(kp.publicJwk.crv).toBe('P-256');
expect(kp.publicJwk.d).toBeUndefined(); // public JWK must not carry the private scalar
expect(kp.thumbprint).toBe(
await jose.calculateJwkThumbprint(kp.publicJwk, 'sha256')
);
});
});
describe('DPoP proof helper — valid proof', () => {
it('builds a well-formed dpop+jwt with the required header and claims', async () => {
const kp = await generateDpopKeyPair();
const accessToken = 'example-access-token-value';
const jwt = await buildDpopProof({
keyPair: kp,
htm: 'POST',
htu: 'https://mcp.example.com/mcp',
accessToken
});
const header = jose.decodeProtectedHeader(jwt);
expect(header.typ).toBe('dpop+jwt');
expect(header.alg).toBe('ES256');
expect((header.jwk as JWK).kty).toBe('EC');
expect((header.jwk as JWK).d).toBeUndefined();
const claims = jose.decodeJwt(jwt);
expect(typeof claims.jti).toBe('string');
expect(claims.htm).toBe('POST');
expect(claims.htu).toBe('https://mcp.example.com/mcp');
expect(typeof claims.iat).toBe('number');
expect(claims.ath).toBe(accessTokenHash(accessToken));
});
it('signature verifies under an independent WebCrypto verifier (Layer 2)', async () => {
const kp = await generateDpopKeyPair();
const jwt = await buildDpopProof({
keyPair: kp,
htm: 'GET',
htu: 'https://mcp.example.com/mcp'
});
expect(await verifyEs256Independently(jwt, kp.publicJwk)).toBe(true);
});
});
describe('DPoP proof helper — invalid variants isolate exactly one defect (Layer 3)', () => {
const base = { htm: 'POST', htu: 'https://mcp.example.com/mcp' };
it('a tampered signature fails verification while header and claims are unchanged', async () => {
const kp = await generateDpopKeyPair();
const good = await buildDpopProof({ keyPair: kp, ...base });
// Derive the bad proof from the good one so ONLY the signature differs
// (each build has a fresh jti and ECDSA signatures are randomized, so two
// independent builds would differ in their claims too).
const [h, p, s] = good.split('.');
// Flip the FIRST signature char (fully significant); the last char carries
// base64url padding bits that can decode identically and stay valid.
const bad = [h, p, (s[0] === 'A' ? 'B' : 'A') + s.slice(1)].join('.');
expect(bad.split('.').slice(0, 2)).toEqual([h, p]);
expect(await verifyEs256Independently(good, kp.publicJwk)).toBe(true);
expect(await verifyEs256Independently(bad, kp.publicJwk)).toBe(false);
});
it('the builder tamperSignature option yields a proof that fails verification', async () => {
const kp = await generateDpopKeyPair();
const jwt = await buildDpopProof({
keyPair: kp,
...base,
tamperSignature: true
});
expect(await verifyEs256Independently(jwt, kp.publicJwk)).toBe(false);
});
it('omitting jti drops only the jti claim', async () => {
const kp = await generateDpopKeyPair();
const jwt = await buildDpopProof({ keyPair: kp, ...base, omit: ['jti'] });
const claims = jose.decodeJwt(jwt);
expect(claims.jti).toBeUndefined();
expect(claims.htm).toBe('POST');
expect(claims.htu).toBe('https://mcp.example.com/mcp');
expect(typeof claims.iat).toBe('number');
});
it('unsigned variant uses alg=none with an empty signature segment', async () => {
const kp = await generateDpopKeyPair();
const jwt = await buildDpopProof({ keyPair: kp, ...base, unsigned: true });
const parts = jwt.split('.');
expect(parts).toHaveLength(3);
expect(parts[2]).toBe('');
expect(jose.decodeProtectedHeader(jwt).alg).toBe('none');
});
it('symmetric variant signs with HS256 (must be rejected by servers)', async () => {
const kp = await generateDpopKeyPair();
const jwt = await buildDpopProof({ keyPair: kp, ...base, symmetric: true });
expect(jose.decodeProtectedHeader(jwt).alg).toBe('HS256');
});
it('embedPrivateKey leaks the private scalar into the jwk header', async () => {
const kp = await generateDpopKeyPair();
const jwt = await buildDpopProof({
keyPair: kp,
...base,
embedPrivateKey: true
});
expect((jose.decodeProtectedHeader(jwt).jwk as JWK).d).toBeDefined();
});
it('iat override produces a stale proof', async () => {
const kp = await generateDpopKeyPair();
const staleIat = Math.floor(Date.now() / 1000) - 3600;
const jwt = await buildDpopProof({ keyPair: kp, ...base, iat: staleIat });
expect(jose.decodeJwt(jwt).iat).toBe(staleIat);
});
});

View file

@ -0,0 +1,178 @@
import * as jose from 'jose';
import type { JWK, CryptoKey } from 'jose';
import { createHash, randomBytes } from 'node:crypto';
/**
* DPoP proof construction helpers (RFC 9449).
*
* Shared across the DPoP client, server, and authorization-server conformance
* scenarios. The framework uses these to act as a DPoP client: it generates a
* key pair, builds a well-formed `dpop+jwt` proof for the happy path, and builds
* deliberately-malformed variants (one defect at a time) for the negative checks.
*
* Correctness of this module is anchored to published RFC test vectors in
* `proof.test.ts` (RFC 9449 §4 examples), not to the conformance servers that
* consume it — see the validation strategy in the project notes.
*/
const DEFAULT_ALG = 'ES256';
const DPOP_TYP = 'dpop+jwt';
export interface DpopKeyPair {
/** Private key used to sign proofs. */
privateKey: CryptoKey;
/** Public key matching {@link publicJwk}. */
publicKey: CryptoKey;
/** Public JWK embedded in the proof's `jwk` header parameter. */
publicJwk: JWK;
/** RFC 7638 JWK SHA-256 thumbprint (the value bound as `cnf.jkt`). */
thumbprint: string;
}
/** Generate an asymmetric key pair for DPoP proofs (default ES256 / P-256). */
export async function generateDpopKeyPair(
alg: string = DEFAULT_ALG
): Promise<DpopKeyPair> {
const { publicKey, privateKey } = await jose.generateKeyPair(alg, {
extractable: true
});
const publicJwk = await jose.exportJWK(publicKey);
const thumbprint = await jose.calculateJwkThumbprint(publicJwk, 'sha256');
return { privateKey, publicKey, publicJwk, thumbprint };
}
/**
* Compute the `ath` claim for a DPoP proof presented with an access token:
* the base64url-encoded SHA-256 of the ASCII access-token value (RFC 9449 §4.1).
*/
export function accessTokenHash(accessToken: string): string {
return createHash('sha256').update(accessToken, 'ascii').digest('base64url');
}
/** RFC 7638 JWK SHA-256 thumbprint (base64url). */
export async function jwkThumbprint(jwk: JWK): Promise<string> {
return jose.calculateJwkThumbprint(jwk, 'sha256');
}
export interface DpopProofOptions {
/** Key pair whose private key signs the proof and whose public JWK is embedded. */
keyPair: DpopKeyPair;
/** HTTP method of the target request (`htm` claim). */
htm: string;
/** HTTP target URI of the request, no query/fragment (`htu` claim). */
htu: string;
/** When set, adds an `ath` claim bound to this access token. */
accessToken?: string;
/** When set, adds a `nonce` claim (server-supplied nonce). */
nonce?: string;
/** `iat` in epoch seconds. Defaults to now. Override to craft stale/future proofs. */
iat?: number;
/** `jti` value. Defaults to a fresh random id. */
jti?: string;
// --- override knobs for crafting deliberately-invalid variants ---
/** Override the `typ` header (default `dpop+jwt`). */
typ?: string;
/** Override the signing algorithm (default ES256). */
alg?: string;
/** Embed the PRIVATE JWK in the header instead of the public one (invalid). */
embedPrivateKey?: boolean;
/** Replace the embedded `jwk` header entirely. */
jwkOverride?: JWK;
/** Omit specific header params / claims to craft "missing field" variants. */
omit?: Array<'jti' | 'htm' | 'htu' | 'iat' | 'typ' | 'jwk'>;
/** Force a specific (wrong) `ath` value. */
athOverride?: string;
/** Sign with a symmetric key (HS256) — must be rejected (asymmetric-only). */
symmetric?: boolean;
/** Produce an unsigned `alg: none` proof — must be rejected. */
unsigned?: boolean;
/** Corrupt the signature after signing so verification fails. */
tamperSignature?: boolean;
}
function base64urlJson(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString('base64url');
}
/**
* Build a DPoP proof JWT. With no override knobs set, produces a well-formed,
* RFC 9449-conformant proof. The override knobs each introduce exactly one
* defect so a negative check can attribute a rejection to that single cause.
*/
export async function buildDpopProof(
options: DpopProofOptions
): Promise<string> {
const omit = new Set(options.omit ?? []);
const iat = options.iat ?? Math.floor(Date.now() / 1000);
const jti = options.jti ?? randomBytes(16).toString('base64url');
// ----- claims (payload) -----
const payload: Record<string, unknown> = {};
if (!omit.has('jti')) payload.jti = jti;
if (!omit.has('htm')) payload.htm = options.htm;
if (!omit.has('htu')) payload.htu = options.htu;
if (!omit.has('iat')) payload.iat = iat;
if (options.athOverride !== undefined) {
payload.ath = options.athOverride;
} else if (options.accessToken !== undefined) {
payload.ath = accessTokenHash(options.accessToken);
}
if (options.nonce !== undefined) payload.nonce = options.nonce;
// ----- protected header -----
const unsigned = options.unsigned === true || options.alg === 'none';
const alg = unsigned
? 'none'
: options.symmetric
? 'HS256'
: (options.alg ?? DEFAULT_ALG);
const header: Record<string, unknown> = { alg };
if (!omit.has('typ')) header.typ = options.typ ?? DPOP_TYP;
if (!omit.has('jwk')) {
if (options.jwkOverride !== undefined) {
header.jwk = options.jwkOverride;
} else if (options.embedPrivateKey === true) {
header.jwk = await jose.exportJWK(options.keyPair.privateKey);
} else {
header.jwk = options.keyPair.publicJwk;
}
}
// ----- assembly / signing -----
if (unsigned) {
// `alg: none` — no signature segment.
return `${base64urlJson(header)}.${base64urlJson(payload)}.`;
}
if (options.symmetric) {
const secret = new Uint8Array(randomBytes(32));
return new jose.SignJWT(payload)
.setProtectedHeader(header as jose.JWTHeaderParameters)
.sign(secret);
}
const jwt = await new jose.SignJWT(payload)
.setProtectedHeader(header as jose.JWTHeaderParameters)
.sign(options.keyPair.privateKey);
if (options.tamperSignature) {
const parts = jwt.split('.');
parts[2] = corruptSegment(parts[2]);
return parts.join('.');
}
return jwt;
}
/**
* Corrupt a base64url signature so it no longer verifies. We flip the FIRST
* character (all 6 of its bits are significant): the LAST character of a
* 64-byte ECDSA signature carries 4 zero padding bits, so flipping it (e.g.
* `A`→`B`) can decode to the same bytes and leave the signature valid.
*/
function corruptSegment(segment: string): string {
const first = segment[0];
const replacement = first === 'A' ? 'B' : 'A';
return replacement + segment.slice(1);
}

View file

@ -0,0 +1,239 @@
import { describe, it, expect } from 'vitest';
import {
generateDpopKeyPair,
buildDpopProof,
type DpopKeyPair
} from './dpopProof';
import { generateIssuerKey, mintDpopBoundToken } from './dpopToken';
import { validateResourceProof } from './dpopResourceAuth';
const ISSUER = 'https://auth.example.com';
const RESOURCE = 'https://mcp.example.com/mcp';
/** Mint a DPoP-bound token for `kp`'s key, optionally bound to a foreign key. */
async function boundToken(
kp: DpopKeyPair,
jktOverride?: string
): Promise<string> {
const issuerKey = await generateIssuerKey();
return mintDpopBoundToken({
issuerKey,
issuer: ISSUER,
audience: RESOURCE,
jkt: kp.thumbprint,
...(jktOverride ? { jktOverride } : {})
});
}
describe('validateResourceProof — accepts a well-formed resource proof', () => {
it('accepts a valid proof bound to the presented token', async () => {
const kp = await generateDpopKeyPair();
const token = await boundToken(kp);
const proof = await buildDpopProof({
keyPair: kp,
htm: 'POST',
htu: RESOURCE,
accessToken: token
});
const result = await validateResourceProof(proof, token, 'POST', RESOURCE);
expect(result.ok).toBe(true);
});
it('accepts an htu that differs only by normalization (trailing slash)', async () => {
const kp = await generateDpopKeyPair();
const token = await boundToken(kp);
const proof = await buildDpopProof({
keyPair: kp,
htm: 'POST',
htu: `${RESOURCE}/`,
accessToken: token
});
const result = await validateResourceProof(proof, token, 'POST', RESOURCE);
expect(result.ok).toBe(true);
});
});
describe('validateResourceProof — rejects each single defect', () => {
async function expectRejected(
build: Parameters<typeof buildDpopProof>[0],
method = 'POST',
tokenJktOverride?: string
): Promise<string> {
const kp = (build.keyPair as DpopKeyPair) ?? (await generateDpopKeyPair());
const token = await boundToken(kp, tokenJktOverride);
const proof = await buildDpopProof({ ...build, keyPair: kp });
const result = await validateResourceProof(proof, token, method, RESOURCE);
expect(result.ok).toBe(false);
return result.ok ? '' : result.error;
}
it('rejects a missing proof', async () => {
const kp = await generateDpopKeyPair();
const token = await boundToken(kp);
const result = await validateResourceProof(
undefined,
token,
'POST',
RESOURCE
);
expect(result.ok).toBe(false);
});
it('rejects a wrong typ', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: RESOURCE,
typ: 'jwt'
})
).toMatch(/typ/);
});
it('rejects a symmetric algorithm', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: RESOURCE,
symmetric: true
})
).toMatch(/alg/);
});
it('rejects a private key embedded in the jwk header', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: RESOURCE,
embedPrivateKey: true
})
).toMatch(/private key/);
});
it('rejects a tampered signature', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: RESOURCE,
tamperSignature: true
})
).toMatch(/signature/);
});
it('rejects a missing jti', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: RESOURCE,
omit: ['jti']
})
).toMatch(/jti/);
});
it('rejects an htm that does not match the request method', async () => {
const kp = await generateDpopKeyPair();
// proof says GET, request is POST
expect(
await expectRejected({ keyPair: kp, htm: 'GET', htu: RESOURCE }, 'POST')
).toMatch(/htm/);
});
it('rejects an htu that does not match the request URI', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: 'https://elsewhere.example.com/mcp'
})
).toMatch(/htu/);
});
it('rejects a stale iat', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: RESOURCE,
iat: Math.floor(Date.now() / 1000) - 3600
})
).toMatch(/iat/);
});
it('rejects a wrong ath', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: RESOURCE,
athOverride: 'not-the-token-hash'
})
).toMatch(/ath/);
});
it('rejects a token bound to a different key (cnf.jkt mismatch)', async () => {
const kp = await generateDpopKeyPair();
const foreign = await generateDpopKeyPair();
const token = await boundToken(kp, foreign.thumbprint);
const proof = await buildDpopProof({
keyPair: kp,
htm: 'POST',
htu: RESOURCE,
accessToken: token
});
const result = await validateResourceProof(proof, token, 'POST', RESOURCE);
expect(result.ok).toBe(false);
expect(result.ok ? '' : result.error).toMatch(/cnf\.jkt/);
});
it('rejects an htu containing a query string (RFC 9449 §4.2)', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({ keyPair: kp, htm: 'POST', htu: `${RESOURCE}?x=1` })
).toMatch(/query or fragment/);
});
it('rejects an htu containing a fragment (RFC 9449 §4.2)', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: `${RESOURCE}#frag`
})
).toMatch(/query or fragment/);
});
it('rejects two comma-joined proofs — multiple DPoP headers (RFC 9449 §4.2)', async () => {
const kp = await generateDpopKeyPair();
const token = await boundToken(kp);
const proof = await buildDpopProof({
keyPair: kp,
htm: 'POST',
htu: RESOURCE,
accessToken: token
});
// Node joins duplicate DPoP headers into one comma-separated value.
const result = await validateResourceProof(
`${proof}, ${proof}`,
token,
'POST',
RESOURCE
);
expect(result.ok).toBe(false);
expect(result.ok ? '' : result.error).toMatch(
/multiple DPoP proof headers/
);
});
});

View file

@ -0,0 +1,279 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import * as jose from 'jose';
import { createHash } from 'node:crypto';
/** Fixed nonce the judge hands out when `requireNonce` is set (RFC 9449 §9). */
const RS_DPOP_NONCE = 'conformance-rs-dpop-nonce';
/** Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 / §11.6). */
const DPOP_ASYMMETRIC_ALGS = [
'ES256',
'ES384',
'ES512',
'RS256',
'RS384',
'RS512',
'PS256',
'PS384',
'PS512',
'EdDSA'
];
/**
* Accumulated observations of how the client presented its access token and
* per-request DPoP proof. The scenario turns these into the two
* sep-1932-client-* checks in getChecks() — freshness needs cross-request
* state (unique `jti` per request), so we accumulate rather than emit inline.
*/
export interface DpopClientObservations {
authenticatedRequests: number;
nonDpopSchemeSeen: boolean;
observedSchemes: string[];
jtisSeen: string[];
replayDetected: boolean;
allProofsWellFormed: boolean;
proofError?: string;
/** The judge issued a `use_dpop_nonce` challenge (RFC 9449 §9). */
rsNonceChallengeIssued: boolean;
/** The client retried the request carrying the correct nonce. */
rsNonceHonored: boolean;
}
export function newDpopClientObservations(): DpopClientObservations {
return {
authenticatedRequests: 0,
nonDpopSchemeSeen: false,
observedSchemes: [],
jtisSeen: [],
replayDetected: false,
allProofsWellFormed: true,
rsNonceChallengeIssued: false,
rsNonceHonored: false
};
}
/**
* Test MCP server DPoP judge (SEP-1932 / RFC 9449), passed to `createServer`
* via its `options.authMiddleware` hook. An unauthenticated request gets a
* `401 DPoP` discovery challenge; an authenticated one is observed (scheme +
* per-request proof) and allowed through so the MCP session can complete and
* multiple requests can be examined for proof freshness.
*
* Proof validation is hand-rolled with jose — deliberately an INDEPENDENT code
* path from the suite's proof builder, so a shared bug surfaces rather than hides.
*/
export function createDpopResourceAuth(
obs: DpopClientObservations,
getResourceUrl: () => string,
getPrmUrl: () => string,
requireNonce = false
): RequestHandler {
return async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
const authorization = req.headers.authorization;
if (!authorization) {
res
.status(401)
.set('WWW-Authenticate', `DPoP resource_metadata="${getPrmUrl()}"`)
.json({ error: 'unauthorized' });
return;
}
obs.authenticatedRequests++;
const { scheme, token } = splitAuthorization(authorization);
obs.observedSchemes.push(scheme);
if (scheme.toLowerCase() !== 'dpop') {
obs.nonDpopSchemeSeen = true;
}
const proofHeader = req.headers['dpop'];
// Node collapses duplicate DPoP request headers into one comma-joined value;
// validateResourceProof rejects a comma (RFC 9449 §4.2 — at most one proof).
const proofValue = Array.isArray(proofHeader)
? proofHeader.join(', ')
: proofHeader;
const result = await validateResourceProof(
proofValue,
token,
req.method,
getResourceUrl()
);
if (!result.ok) {
obs.allProofsWellFormed = false;
obs.proofError ??= result.error;
next();
return;
}
// Record proof freshness on ANY valid proof, before the nonce gate. A
// nonce-challenged (but otherwise valid) proof still demonstrates freshness,
// so fresh-proof is never asserted on zero `jti` evidence, and a client that
// never honours the nonce fails only rs-nonce (not fresh-proof as well).
//
// Deliberate consequence: a client that re-sends the *identical* proof
// across the challenge/retry boundary (same `jti`) trips replay detection.
// That is a genuine RFC 9449 §4.2 violation (`jti` MUST be unique per proof)
// — a conformant client mints a fresh proof, carrying the nonce, on retry.
if (obs.jtisSeen.includes(result.jti)) {
obs.replayDetected = true;
} else {
obs.jtisSeen.push(result.jti);
}
// RFC 9449 §9: require a server-provided nonce. A valid proof without the
// correct nonce is challenged (401 use_dpop_nonce + DPoP-Nonce); the client
// is expected to retry with it.
if (requireNonce) {
let proofNonce: unknown;
try {
proofNonce = proofValue ? jose.decodeJwt(proofValue).nonce : undefined;
} catch {
proofNonce = undefined;
}
if (proofNonce !== RS_DPOP_NONCE) {
obs.rsNonceChallengeIssued = true;
res
.status(401)
.set(
'WWW-Authenticate',
`DPoP error="use_dpop_nonce", resource_metadata="${getPrmUrl()}"`
)
.set('DPoP-Nonce', RS_DPOP_NONCE)
.json({ error: 'use_dpop_nonce' });
return;
}
obs.rsNonceHonored = true;
}
next();
};
}
function splitAuthorization(authorization: string): {
scheme: string;
token: string;
} {
const idx = authorization.indexOf(' ');
if (idx === -1) return { scheme: authorization, token: '' };
return {
scheme: authorization.slice(0, idx),
token: authorization.slice(idx + 1).trim()
};
}
/**
* Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based
* normalization): lowercase scheme/host, drop the default port, ignore a
* trailing slash. Query/fragment are rejected by the caller (RFC 9449 §4.2),
* not silently stripped here.
*/
function normalizeHtu(u: string): string {
try {
const url = new URL(u);
// Tolerate a single trailing slash only; `/mcp//` is a distinct path.
return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}`;
} catch {
return u;
}
}
/**
* Validate a per-request DPoP proof presented alongside an access token
* (RFC 9449 §4.3, resource-request subset): well-formed `dpop+jwt`, asymmetric
* alg, embedded public jwk, htm/htu match, `ath` binds the token, signature
* verifies, and the proof key's thumbprint matches the token's `cnf.jkt`.
*/
export async function validateResourceProof(
proof: string | undefined,
token: string,
method: string,
resourceUrl: string
): Promise<{ ok: true; jti: string } | { ok: false; error: string }> {
if (!proof) return { ok: false, error: 'missing DPoP proof header' };
// RFC 9449 §4.2: at most one DPoP header. A single proof JWT has no comma, so
// a comma means duplicate headers were sent (Node joins them with ", ").
if (proof.includes(',')) {
return { ok: false, error: 'multiple DPoP proof headers' };
}
let header: jose.ProtectedHeaderParameters;
try {
header = jose.decodeProtectedHeader(proof);
} catch {
return { ok: false, error: 'DPoP proof is not a well-formed JWT' };
}
if (header.typ !== 'dpop+jwt')
return { ok: false, error: 'typ must be dpop+jwt' };
if (!header.alg || !DPOP_ASYMMETRIC_ALGS.includes(header.alg)) {
return { ok: false, error: 'alg must be a supported asymmetric algorithm' };
}
const jwk = header.jwk as jose.JWK | undefined;
if (!jwk) return { ok: false, error: 'missing jwk header parameter' };
if ((jwk as Record<string, unknown>).d !== undefined) {
return { ok: false, error: 'jwk must not contain a private key' };
}
let claims: jose.JWTPayload;
try {
const key = await jose.importJWK(jwk, header.alg);
claims = (
await jose.jwtVerify(proof, key, { algorithms: DPOP_ASYMMETRIC_ALGS })
).payload;
} catch {
return { ok: false, error: 'DPoP proof signature does not verify' };
}
if (typeof claims.jti !== 'string')
return { ok: false, error: 'missing jti claim' };
if (claims.htm !== method)
return { ok: false, error: 'htm does not match the request method' };
if (typeof claims.htu !== 'string') {
return { ok: false, error: 'htu does not match the request URI' };
}
if (claims.htu.includes('?') || claims.htu.includes('#')) {
return {
ok: false,
error: 'htu MUST NOT contain a query or fragment (RFC 9449 §4.2)'
};
}
if (normalizeHtu(claims.htu) !== normalizeHtu(resourceUrl)) {
return { ok: false, error: 'htu does not match the request URI' };
}
if (
typeof claims.iat !== 'number' ||
Math.abs(Math.floor(Date.now() / 1000) - claims.iat) > 300
) {
return { ok: false, error: 'iat outside the acceptable window' };
}
// Recompute ath and the thumbprint inline (jose + node crypto) rather than
// via the proof builder's helpers, so this validator stays a fully
// independent path.
const expectedAth = createHash('sha256')
.update(token, 'ascii')
.digest('base64url');
if (typeof claims.ath !== 'string' || claims.ath !== expectedAth) {
return {
ok: false,
error: 'ath does not match the presented access token'
};
}
// Possession: the proof key must be the key the token is bound to.
try {
const tokenClaims = jose.decodeJwt(token);
const cnf = tokenClaims.cnf as { jkt?: unknown } | undefined;
const thumbprint = await jose.calculateJwkThumbprint(jwk, 'sha256');
if (!cnf || cnf.jkt !== thumbprint) {
return {
ok: false,
error: 'proof key thumbprint does not match the token cnf.jkt'
};
}
} catch {
return { ok: false, error: 'access token is not a decodable JWT' };
}
return { ok: true, jti: claims.jti };
}

View file

@ -0,0 +1,158 @@
import { webcrypto } from 'node:crypto';
import * as jose from 'jose';
import type { JWK } from 'jose';
import {
generateDpopKeyPair,
buildDpopProof,
accessTokenHash
} from './dpopProof';
import { generateIssuerKey, mintDpopBoundToken } from './dpopToken';
/** Independent ES256 verification via Node WebCrypto (a different path from jose). */
async function verifyEs256Independently(
jwt: string,
publicJwk: JWK
): Promise<boolean> {
const [h, p, s] = jwt.split('.');
const key = await webcrypto.subtle.importKey(
'jwk',
publicJwk as JsonWebKey,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['verify']
);
const signature = new Uint8Array(Buffer.from(s, 'base64url'));
const data = new TextEncoder().encode(`${h}.${p}`);
return webcrypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
signature,
data
);
}
const ISSUER = 'https://auth.example.com';
const AUDIENCE = 'https://mcp.example.com/mcp';
describe('DPoP token minter — valid bound token', () => {
it('mints a token bound to the given thumbprint with correct claims', async () => {
const issuerKey = await generateIssuerKey();
const kp = await generateDpopKeyPair();
const token = await mintDpopBoundToken({
issuerKey,
issuer: ISSUER,
audience: AUDIENCE,
jkt: kp.thumbprint
});
const claims = jose.decodeJwt(token);
expect(claims.iss).toBe(ISSUER);
expect(claims.aud).toBe(AUDIENCE);
expect(typeof claims.sub).toBe('string');
expect(typeof claims.iat).toBe('number');
expect(typeof claims.exp).toBe('number');
expect((claims.exp as number) > (claims.iat as number)).toBe(true);
expect((claims.cnf as { jkt: string }).jkt).toBe(kp.thumbprint);
expect(jose.decodeProtectedHeader(token).typ).toBe('at+jwt');
});
it('is signed by the issuer key (independent WebCrypto verification, Layer 2)', async () => {
const issuerKey = await generateIssuerKey();
const kp = await generateDpopKeyPair();
const token = await mintDpopBoundToken({
issuerKey,
issuer: ISSUER,
audience: AUDIENCE,
jkt: kp.thumbprint
});
expect(await verifyEs256Independently(token, issuerKey.publicJwk)).toBe(
true
);
});
});
describe('DPoP token minter — proof/token binding agreement (integration)', () => {
it('proof.ath hashes the minted token and token.cnf.jkt equals the proof key thumbprint', async () => {
const issuerKey = await generateIssuerKey();
const kp = await generateDpopKeyPair();
const token = await mintDpopBoundToken({
issuerKey,
issuer: ISSUER,
audience: AUDIENCE,
jkt: kp.thumbprint
});
const proof = await buildDpopProof({
keyPair: kp,
htm: 'POST',
htu: AUDIENCE,
accessToken: token
});
// The two halves of the sender-constraint must line up:
const tokenJkt = (jose.decodeJwt(token).cnf as { jkt: string }).jkt;
const proofJwkThumbprint = kp.thumbprint;
expect(tokenJkt).toBe(proofJwkThumbprint); // token bound to the proof's key
const proofAth = jose.decodeJwt(proof).ath;
expect(proofAth).toBe(accessTokenHash(token)); // proof attests to this token
});
});
describe('DPoP token minter — invalid variants', () => {
const base = { issuer: ISSUER, audience: AUDIENCE };
it('omitCnf produces an unbound token (no cnf)', async () => {
const issuerKey = await generateIssuerKey();
const kp = await generateDpopKeyPair();
const token = await mintDpopBoundToken({
issuerKey,
...base,
jkt: kp.thumbprint,
omitCnf: true
});
expect(jose.decodeJwt(token).cnf).toBeUndefined();
});
it('jktOverride binds to a foreign key (cnf.jkt mismatch case)', async () => {
const issuerKey = await generateIssuerKey();
const kp = await generateDpopKeyPair();
const foreign = await generateDpopKeyPair();
const token = await mintDpopBoundToken({
issuerKey,
...base,
jkt: kp.thumbprint,
jktOverride: foreign.thumbprint
});
const boundJkt = (jose.decodeJwt(token).cnf as { jkt: string }).jkt;
expect(boundJkt).toBe(foreign.thumbprint);
expect(boundJkt).not.toBe(kp.thumbprint);
});
it('wrong-audience token still binds correctly (audience-validation case)', async () => {
const issuerKey = await generateIssuerKey();
const kp = await generateDpopKeyPair();
const token = await mintDpopBoundToken({
issuerKey,
issuer: ISSUER,
audience: 'https://other.example.com/mcp',
jkt: kp.thumbprint
});
expect(jose.decodeJwt(token).aud).toBe('https://other.example.com/mcp');
});
it('expired produces a token whose exp is in the past', async () => {
const issuerKey = await generateIssuerKey();
const kp = await generateDpopKeyPair();
const token = await mintDpopBoundToken({
issuerKey,
...base,
jkt: kp.thumbprint,
expired: true
});
const claims = jose.decodeJwt(token);
expect((claims.exp as number) < (claims.iat as number)).toBe(true);
});
});

View file

@ -0,0 +1,110 @@
import * as jose from 'jose';
import type { JWK, CryptoKey } from 'jose';
/**
* DPoP-bound access-token minting (RFC 9449 §6).
*
* Transport-free: this issues a signed JWT access token carrying the
* `cnf.jkt` confirmation that binds the token to a DPoP key. It is the
* token-issuing *core* shared across the DPoP scenarios:
*
* - #369 server: the scenario (acting as client) mints a bound token to
* PRESENT to the server under test.
* - #370 AS / #368 client: the compliant/test authorization-server fixtures
* call this to ISSUE bound tokens (wrapped by an HTTP token endpoint).
*
* `jkt` is a parameter (not derived here) so the same function works whether
* the bound key is ours (#369/#370) or extracted from a client's proof (#368).
*/
const DEFAULT_ALG = 'ES256';
export interface TokenIssuerKey {
privateKey: CryptoKey;
publicKey: CryptoKey;
publicJwk: JWK;
alg: string;
}
/** Generate an asymmetric signing key for the (test) token issuer. */
export async function generateIssuerKey(
alg: string = DEFAULT_ALG
): Promise<TokenIssuerKey> {
const { publicKey, privateKey } = await jose.generateKeyPair(alg, {
extractable: true
});
const publicJwk = await jose.exportJWK(publicKey);
return { privateKey, publicKey, publicJwk, alg };
}
/**
* Reconstruct a {@link TokenIssuerKey} from a private JWK (e.g. supplied to a
* scenario via env), deriving the public JWK by stripping the private scalar.
* EC/OKP keys only (sufficient for the ES256 test issuer).
*/
export async function importIssuerKey(
privateJwk: JWK,
alg: string = DEFAULT_ALG
): Promise<TokenIssuerKey> {
const privateKey = (await jose.importJWK(privateJwk, alg)) as CryptoKey;
const publicJwk: JWK = { ...privateJwk };
delete (publicJwk as Record<string, unknown>).d;
const publicKey = (await jose.importJWK(publicJwk, alg)) as CryptoKey;
return { privateKey, publicKey, publicJwk, alg };
}
export interface MintDpopBoundTokenOptions {
/** Issuer signing key (the server under test must trust its public key). */
issuerKey: TokenIssuerKey;
/** `iss` claim — the token issuer identifier. */
issuer: string;
/** `aud` claim — the resource the token is for (the MCP server's canonical URI). */
audience: string;
/** Thumbprint bound as `cnf.jkt` (RFC 7638 JWK thumbprint of the DPoP public key). */
jkt: string;
/** `sub` claim. Defaults to a fixed test subject. */
subject?: string;
/** `scope` claim, if any. */
scope?: string;
/** `iat` in epoch seconds. Defaults to now. */
iat?: number;
/** Token lifetime in seconds. Defaults to 3600. */
expiresInSeconds?: number;
// --- override knobs for crafting deliberately-invalid variants ---
/** Omit `cnf` entirely — an unbound (bearer-style) token. */
omitCnf?: boolean;
/** Bind to a different (foreign/wrong) thumbprint than the presented proof. */
jktOverride?: string;
/** Issue an already-expired token (`exp` in the past). */
expired?: boolean;
/** Additional claims to merge into the payload. */
extraClaims?: Record<string, unknown>;
}
/**
* Mint a signed JWT access token. With no override knobs set, produces a
* valid DPoP-bound token whose `cnf.jkt` equals {@link MintDpopBoundTokenOptions.jkt}.
*/
export async function mintDpopBoundToken(
options: MintDpopBoundTokenOptions
): Promise<string> {
const iat = options.iat ?? Math.floor(Date.now() / 1000);
const lifetime = options.expiresInSeconds ?? 3600;
const exp = options.expired ? iat - 60 : iat + lifetime;
const payload: Record<string, unknown> = { ...(options.extraClaims ?? {}) };
if (options.scope !== undefined) payload.scope = options.scope;
if (!options.omitCnf) {
payload.cnf = { jkt: options.jktOverride ?? options.jkt };
}
return new jose.SignJWT(payload)
.setProtectedHeader({ alg: options.issuerKey.alg, typ: 'at+jwt' })
.setIssuer(options.issuer)
.setAudience(options.audience)
.setSubject(options.subject ?? 'conformance-test-subject')
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(options.issuerKey.privateKey);
}

View file

@ -0,0 +1,193 @@
import { describe, it, expect } from 'vitest';
import {
generateDpopKeyPair,
buildDpopProof,
type DpopKeyPair
} from './dpopProof';
import { validateDpopProofAtTokenEndpoint } from './createAuthServer';
const TOKEN_ENDPOINT = 'https://auth.example.com/token';
/**
* The token-endpoint validator is an INDEPENDENT copy of the resource-side
* validator (dpopResourceAuth). These tests keep the two from drifting: they
* cover the token-request subset of RFC 9449 §4.3 (no `ath`/`cnf` — there is no
* access token yet at the token request).
*/
describe('validateDpopProofAtTokenEndpoint — accepts a valid token-request proof', () => {
it('accepts a well-formed proof and returns the JWK thumbprint', async () => {
const kp = await generateDpopKeyPair();
const proof = await buildDpopProof({
keyPair: kp,
htm: 'POST',
htu: TOKEN_ENDPOINT
});
const result = await validateDpopProofAtTokenEndpoint(
proof,
TOKEN_ENDPOINT
);
expect(result.ok).toBe(true);
expect(result.ok ? result.jkt : '').toBe(kp.thumbprint);
});
it('accepts an htu differing only by a single trailing slash', async () => {
const kp = await generateDpopKeyPair();
const proof = await buildDpopProof({
keyPair: kp,
htm: 'POST',
htu: `${TOKEN_ENDPOINT}/`
});
const result = await validateDpopProofAtTokenEndpoint(
proof,
TOKEN_ENDPOINT
);
expect(result.ok).toBe(true);
});
});
describe('validateDpopProofAtTokenEndpoint — rejects each single defect', () => {
async function expectRejected(
build: Parameters<typeof buildDpopProof>[0]
): Promise<string> {
const kp = (build.keyPair as DpopKeyPair) ?? (await generateDpopKeyPair());
const proof = await buildDpopProof({ ...build, keyPair: kp });
const result = await validateDpopProofAtTokenEndpoint(
proof,
TOKEN_ENDPOINT
);
expect(result.ok).toBe(false);
return result.ok ? '' : result.error;
}
it('rejects a non-JWT', async () => {
const result = await validateDpopProofAtTokenEndpoint(
'this-is-not-a-jwt',
TOKEN_ENDPOINT
);
expect(result.ok).toBe(false);
});
it('rejects a wrong typ', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: TOKEN_ENDPOINT,
typ: 'jwt'
})
).toMatch(/typ/);
});
it('rejects a symmetric algorithm', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: TOKEN_ENDPOINT,
symmetric: true
})
).toMatch(/alg/);
});
it('rejects an unsigned (alg=none) proof', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: TOKEN_ENDPOINT,
unsigned: true
})
).toMatch(/alg/);
});
it('rejects a private key embedded in the jwk header', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: TOKEN_ENDPOINT,
embedPrivateKey: true
})
).toMatch(/private key/);
});
it('rejects a tampered signature', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: TOKEN_ENDPOINT,
tamperSignature: true
})
).toMatch(/signature/);
});
it('rejects a missing jti', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: TOKEN_ENDPOINT,
omit: ['jti']
})
).toMatch(/jti/);
});
it('rejects an htm that is not POST', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({ keyPair: kp, htm: 'GET', htu: TOKEN_ENDPOINT })
).toMatch(/htm/);
});
it('rejects an htu that does not match the token endpoint', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: 'https://wrong.example.com/token'
})
).toMatch(/htu/);
});
it('rejects an htu containing a query string (RFC 9449 §4.2)', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: `${TOKEN_ENDPOINT}?tenant=x`
})
).toMatch(/query or fragment/);
});
it('rejects an htu containing a fragment (RFC 9449 §4.2)', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: `${TOKEN_ENDPOINT}#frag`
})
).toMatch(/query or fragment/);
});
it('rejects a stale iat', async () => {
const kp = await generateDpopKeyPair();
expect(
await expectRejected({
keyPair: kp,
htm: 'POST',
htu: TOKEN_ENDPOINT,
iat: Math.floor(Date.now() / 1000) - 3600
})
).toMatch(/iat/);
});
});

View file

@ -28,6 +28,13 @@ import { runClient as noAppTypeClient } from '../../../../examples/clients/types
import { runClient as noIssValidationClient } from '../../../../examples/clients/typescript/auth-test';
import { runClient as issNormalizeClient } from '../../../../examples/clients/typescript/auth-test-iss-normalize';
import { runClient as echoScopeClient } from '../../../../examples/clients/typescript/auth-test-echo-scope';
import { runClient as dpopBearerClient } from '../../../../examples/clients/typescript/auth-test-dpop-bearer';
import { runClient as dpopReplayClient } from '../../../../examples/clients/typescript/auth-test-dpop-replay';
import { runClient as dpopNoTokenProofClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-token-proof';
import { runClient as dpopNoAsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-as-nonce';
import { runClient as dpopNoRsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-rs-nonce';
import { runClient as dpopNoNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-nonce';
import { runClient as dpopClient } from '../../../../examples/clients/typescript/auth-test-dpop';
import { getHandler } from '../../../../examples/clients/typescript/everything-client';
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger';
import { DRAFT_PROTOCOL_VERSION } from '../../../types';
@ -339,3 +346,100 @@ describe('WIF JWT-bearer negative tests', () => {
});
});
});
// DPoP (SEP-1932): the compliant paths for both postures (auth/dpop and
// auth/dpop-nonce) are covered by the Client Draft Scenarios loop above; these
// are the negative cases, via deliberately-broken example clients. The baseline
// checks (scheme, replay, token-request-proof) are nonce-independent, so those
// negatives run against auth/dpop; the two nonce checks only fire when a
// challenge is issued, so their negatives run against auth/dpop-nonce.
describe('DPoP client negative tests (SEP-1932)', () => {
test('auth/dpop: client presents the token with the Bearer scheme', async () => {
const runner = new InlineClientRunner(dpopBearerClient);
await runClientAgainstScenario(runner, 'auth/dpop', {
expectedFailureSlugs: ['sep-1932-client-dpop-auth-scheme']
});
});
test('auth/dpop: client reuses a DPoP proof across requests', async () => {
const runner = new InlineClientRunner(dpopReplayClient);
await runClientAgainstScenario(runner, 'auth/dpop', {
expectedFailureSlugs: ['sep-1932-client-fresh-proof']
});
});
test('auth/dpop: client never requests a sender-constrained token', async () => {
// No token-endpoint proof → the AS issues an unbound Bearer token, so both
// the token-request check and (as a consequence of the unbound token) the
// resource binding check fail.
const runner = new InlineClientRunner(dpopNoTokenProofClient);
await runClientAgainstScenario(runner, 'auth/dpop', {
expectedFailureSlugs: [
'sep-1932-client-token-request-proof',
'sep-1932-client-fresh-proof'
]
});
});
test('auth/dpop-nonce: client ignores the authorization-server nonce challenge', async () => {
const runner = new InlineClientRunner(dpopNoAsNonceClient);
// The client presents a valid proof but never retries with the nonce, so it
// never obtains a token — as-nonce fails and the downstream token-dependent
// checks legitimately cascade. But token-request-proof MUST stay SUCCESS:
// the client DID present a valid proof at the token endpoint (regression
// guard for the "never completed a token request" misattribution).
await runClientAgainstScenario(runner, 'auth/dpop-nonce', {
expectedFailureSlugs: [
'sep-1932-client-as-nonce',
'sep-1932-client-dpop-auth-scheme',
'sep-1932-client-fresh-proof',
'sep-1932-client-rs-nonce'
],
expectedSuccessSlugs: ['sep-1932-client-token-request-proof']
});
});
test('auth/dpop-nonce: client ignores the MCP-server nonce challenge', async () => {
const runner = new InlineClientRunner(dpopNoRsNonceClient);
// Clean one-defect isolation: the client obtains a token and presents a
// valid fresh proof (recorded before the nonce gate), so only rs-nonce
// fails — everything else stays SUCCESS.
await runClientAgainstScenario(runner, 'auth/dpop-nonce', {
expectedFailureSlugs: ['sep-1932-client-rs-nonce'],
expectedSuccessSlugs: [
'sep-1932-client-token-request-proof',
'sep-1932-client-dpop-auth-scheme',
'sep-1932-client-fresh-proof',
'sep-1932-client-as-nonce'
]
});
});
});
// DPoP nonce-less baseline (SEP-1932): a client that implements NO nonce
// handling still completes DPoP successfully when the server does not require a
// nonce (the common case — server nonces are OPTIONAL, RFC 9449 §8/§9). The
// nonce-capable compliant path is covered by the Client Draft Scenarios loop
// above (auth/dpop is in draftScenariosList).
describe('DPoP client nonce-less baseline (SEP-1932)', () => {
test('auth/dpop: nonce-incapable client passes the baseline', async () => {
const runner = new InlineClientRunner(dpopNoNonceClient);
// No expectedFailureSlugs → asserts every emitted check is SUCCESS (the
// three baseline checks; no as-nonce/rs-nonce checks are emitted here).
await runClientAgainstScenario(runner, 'auth/dpop');
});
test('auth/dpop-nonce: the §8/§9 double-POST is collapsed to one shared check each', async () => {
// Pins that collapseDuplicateChecks is actually wired into getChecks for the
// nonce posture: the challenge→retry re-POST records token-request/pkce
// twice, so without the collapse these would appear 2×. Guards against the
// gating being deleted or inverted.
const runner = new InlineClientRunner(dpopClient);
const checks = await runClientAgainstScenario(runner, 'auth/dpop-nonce');
const count = (id: string): number =>
checks.filter((c) => c.id === id).length;
expect(count('token-request')).toBe(1);
expect(count('pkce-code-verifier-sent')).toBe(1);
expect(count('pkce-verifier-matches-challenge')).toBe(1);
});
});

Some files were not shown because too many files have changed in this diff Show more