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:
parent
5ad96dbfbc
commit
6e726734f5
24 changed files with 2713 additions and 4 deletions
21
examples/clients/typescript/auth-test-dpop-bearer.ts
Normal file
21
examples/clients/typescript/auth-test-dpop-bearer.ts
Normal 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>');
|
||||
22
examples/clients/typescript/auth-test-dpop-no-as-nonce.ts
Normal file
22
examples/clients/typescript/auth-test-dpop-no-as-nonce.ts
Normal 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>');
|
||||
25
examples/clients/typescript/auth-test-dpop-no-nonce.ts
Normal file
25
examples/clients/typescript/auth-test-dpop-no-nonce.ts
Normal 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>');
|
||||
22
examples/clients/typescript/auth-test-dpop-no-rs-nonce.ts
Normal file
22
examples/clients/typescript/auth-test-dpop-no-rs-nonce.ts
Normal 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>');
|
||||
27
examples/clients/typescript/auth-test-dpop-no-token-proof.ts
Normal file
27
examples/clients/typescript/auth-test-dpop-no-token-proof.ts
Normal 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>'
|
||||
);
|
||||
21
examples/clients/typescript/auth-test-dpop-replay.ts
Normal file
21
examples/clients/typescript/auth-test-dpop-replay.ts
Normal 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>');
|
||||
20
examples/clients/typescript/auth-test-dpop.ts
Normal file
20
examples/clients/typescript/auth-test-dpop.ts
Normal 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>');
|
||||
|
|
@ -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)
|
||||
// ============================================================================
|
||||
|
|
|
|||
234
examples/clients/typescript/helpers/dpopClientFlow.ts
Normal file
234
examples/clients/typescript/helpers/dpopClientFlow.ts
Normal 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}`;
|
||||
}
|
||||
85
src/scenarios/client/auth/dpop.test.ts
Normal file
85
src/scenarios/client/auth/dpop.test.ts
Normal 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'
|
||||
]);
|
||||
});
|
||||
});
|
||||
362
src/scenarios/client/auth/dpop.ts
Normal file
362
src/scenarios/client/auth/dpop.ts
Normal 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 } : {})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
180
src/scenarios/client/auth/helpers/dpopProof.test.ts
Normal file
180
src/scenarios/client/auth/helpers/dpopProof.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
178
src/scenarios/client/auth/helpers/dpopProof.ts
Normal file
178
src/scenarios/client/auth/helpers/dpopProof.ts
Normal 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);
|
||||
}
|
||||
239
src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts
Normal file
239
src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts
Normal 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/
|
||||
);
|
||||
});
|
||||
});
|
||||
279
src/scenarios/client/auth/helpers/dpopResourceAuth.ts
Normal file
279
src/scenarios/client/auth/helpers/dpopResourceAuth.ts
Normal 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 };
|
||||
}
|
||||
158
src/scenarios/client/auth/helpers/dpopToken.test.ts
Normal file
158
src/scenarios/client/auth/helpers/dpopToken.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
110
src/scenarios/client/auth/helpers/dpopToken.ts
Normal file
110
src/scenarios/client/auth/helpers/dpopToken.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
Loading…
Reference in a new issue