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';
|
} from './helpers/withOAuthRetry.js';
|
||||||
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
|
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
|
||||||
import { runClient as issValidationClient } from './auth-test-iss-validation.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';
|
import { logger } from './helpers/logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -854,6 +855,13 @@ registerScenario(
|
||||||
runEnterpriseManagedAuthorization
|
runEnterpriseManagedAuthorization
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DPoP client conformance (SEP-1932)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
registerScenario('auth/dpop', dpopClient);
|
||||||
|
registerScenario('auth/dpop-nonce', dpopClient);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MRTR client conformance (SEP-2322)
|
// 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 { createRequestLogger } from '../../../request-logger';
|
||||||
import { SpecReferences } from '../spec-references';
|
import { SpecReferences } from '../spec-references';
|
||||||
import { MockTokenVerifier } from './mockTokenVerifier';
|
import { MockTokenVerifier } from './mockTokenVerifier';
|
||||||
|
import * as jose from 'jose';
|
||||||
|
import {
|
||||||
|
generateIssuerKey,
|
||||||
|
mintDpopBoundToken,
|
||||||
|
type TokenIssuerKey
|
||||||
|
} from './dpopToken';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute S256 code challenge from a code verifier.
|
* Compute S256 code challenge from a code verifier.
|
||||||
|
|
@ -20,6 +26,105 @@ function computeS256Challenge(codeVerifier: string): string {
|
||||||
.replace(/=+$/, '');
|
.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 {
|
export interface TokenRequestResult {
|
||||||
token: string;
|
token: string;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
|
|
@ -31,6 +136,24 @@ export interface TokenRequestError {
|
||||||
statusCode?: number;
|
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 {
|
export interface AuthServerOptions {
|
||||||
metadataPath?: string;
|
metadataPath?: string;
|
||||||
isOpenIdConfiguration?: boolean;
|
isOpenIdConfiguration?: boolean;
|
||||||
|
|
@ -63,6 +186,34 @@ export interface AuthServerOptions {
|
||||||
* after `start()`.
|
* after `start()`.
|
||||||
*/
|
*/
|
||||||
metadataIssuer?: string | (() => string);
|
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;
|
tokenVerifier?: MockTokenVerifier;
|
||||||
onTokenRequest?: (requestData: {
|
onTokenRequest?: (requestData: {
|
||||||
scope?: string;
|
scope?: string;
|
||||||
|
|
@ -110,6 +261,10 @@ export function createAuthServer(
|
||||||
issParameterSupported = true,
|
issParameterSupported = true,
|
||||||
issInRedirect = 'correct',
|
issInRedirect = 'correct',
|
||||||
metadataIssuer,
|
metadataIssuer,
|
||||||
|
dpopSigningAlgValuesSupported,
|
||||||
|
dpopMisbehavior,
|
||||||
|
dpopTokenRequestObs,
|
||||||
|
dpopRequireNonce = false,
|
||||||
tokenVerifier,
|
tokenVerifier,
|
||||||
onTokenRequest,
|
onTokenRequest,
|
||||||
onAuthorizationRequest,
|
onAuthorizationRequest,
|
||||||
|
|
@ -120,6 +275,34 @@ export function createAuthServer(
|
||||||
let lastAuthorizationScopes: string[] = [];
|
let lastAuthorizationScopes: string[] = [];
|
||||||
// Track PKCE code_challenge for verification in token request
|
// Track PKCE code_challenge for verification in token request
|
||||||
let storedCodeChallenge: string | undefined;
|
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 = {
|
const authRoutes = {
|
||||||
authorization_endpoint: `${routePrefix}/authorize`,
|
authorization_endpoint: `${routePrefix}/authorize`,
|
||||||
|
|
@ -185,7 +368,21 @@ export function createAuthServer(
|
||||||
...(tokenEndpointAuthSigningAlgValuesSupported && {
|
...(tokenEndpointAuthSigningAlgValuesSupported && {
|
||||||
token_endpoint_auth_signing_alg_values_supported:
|
token_endpoint_auth_signing_alg_values_supported:
|
||||||
tokenEndpointAuthSigningAlgValuesSupported
|
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
|
// 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 token = `test-token-${Date.now()}`;
|
||||||
let scopes: string[] = lastAuthorizationScopes;
|
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 noIssValidationClient } from '../../../../examples/clients/typescript/auth-test';
|
||||||
import { runClient as issNormalizeClient } from '../../../../examples/clients/typescript/auth-test-iss-normalize';
|
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 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 { getHandler } from '../../../../examples/clients/typescript/everything-client';
|
||||||
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger';
|
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger';
|
||||||
import { DRAFT_PROTOCOL_VERSION } from '../../../types';
|
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