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
14 changed files with 2713 additions and 4 deletions
85
src/scenarios/client/auth/dpop.test.ts
Normal file
85
src/scenarios/client/auth/dpop.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { collapseDuplicateChecks } from './dpop';
|
||||
import type { ConformanceCheck, CheckStatus } from '../../../types';
|
||||
|
||||
/** Minimal check factory for the dedupe unit tests. */
|
||||
function chk(id: string, status: CheckStatus, tag?: string): ConformanceCheck {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: tag ?? id,
|
||||
status,
|
||||
timestamp: '2026-07-06T00:00:00.000Z',
|
||||
specReferences: []
|
||||
};
|
||||
}
|
||||
|
||||
describe('collapseDuplicateChecks (DPoP nonce-posture shared-check dedupe)', () => {
|
||||
it('collapses duplicate SUCCESS ids to a single entry', () => {
|
||||
const out = collapseDuplicateChecks([
|
||||
chk('token-request', 'SUCCESS'),
|
||||
chk('pkce-code-verifier-sent', 'SUCCESS'),
|
||||
chk('token-request', 'SUCCESS')
|
||||
]);
|
||||
expect(out.map((c) => c.id)).toEqual([
|
||||
'pkce-code-verifier-sent',
|
||||
'token-request'
|
||||
]);
|
||||
});
|
||||
|
||||
it('never masks a FAILURE — keeps the failing occurrence over a later SUCCESS', () => {
|
||||
// The regression this guards: a client that fails a check on the challenged
|
||||
// POST but passes on the nonce retry must still be reported FAILURE.
|
||||
const out = collapseDuplicateChecks([
|
||||
chk('pkce-verifier-matches-challenge', 'FAILURE', 'first attempt'),
|
||||
chk('pkce-verifier-matches-challenge', 'SUCCESS', 'retry')
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].status).toBe('FAILURE');
|
||||
expect(out[0].description).toBe('first attempt');
|
||||
});
|
||||
|
||||
it('keeps the failing occurrence regardless of order (SUCCESS then FAILURE)', () => {
|
||||
const out = collapseDuplicateChecks([
|
||||
chk('token-request', 'SUCCESS'),
|
||||
chk('token-request', 'FAILURE', 'later failure')
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].status).toBe('FAILURE');
|
||||
expect(out[0].description).toBe('later failure');
|
||||
});
|
||||
|
||||
it('prefers WARNING over SUCCESS but FAILURE over WARNING', () => {
|
||||
expect(
|
||||
collapseDuplicateChecks([chk('x', 'SUCCESS'), chk('x', 'WARNING')])[0]
|
||||
.status
|
||||
).toBe('WARNING');
|
||||
expect(
|
||||
collapseDuplicateChecks([chk('x', 'WARNING'), chk('x', 'FAILURE')])[0]
|
||||
.status
|
||||
).toBe('FAILURE');
|
||||
});
|
||||
|
||||
it('keeps every INFO log entry, even duplicates', () => {
|
||||
const out = collapseDuplicateChecks([
|
||||
chk('incoming-request', 'INFO'),
|
||||
chk('incoming-request', 'INFO'),
|
||||
chk('incoming-request', 'INFO')
|
||||
]);
|
||||
expect(out).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('preserves order and leaves a duplicate-free list untouched', () => {
|
||||
const input = [
|
||||
chk('a', 'SUCCESS'),
|
||||
chk('b', 'FAILURE'),
|
||||
chk('incoming', 'INFO'),
|
||||
chk('c', 'SUCCESS')
|
||||
];
|
||||
expect(collapseDuplicateChecks(input).map((c) => c.id)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'incoming',
|
||||
'c'
|
||||
]);
|
||||
});
|
||||
});
|
||||
362
src/scenarios/client/auth/dpop.ts
Normal file
362
src/scenarios/client/auth/dpop.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import type { ScenarioContext } from '../../../mock-server';
|
||||
import type {
|
||||
Scenario,
|
||||
ConformanceCheck,
|
||||
CheckStatus,
|
||||
SpecReference
|
||||
} from '../../../types';
|
||||
import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types';
|
||||
import {
|
||||
createAuthServer,
|
||||
type DpopTokenRequestObservation
|
||||
} from './helpers/createAuthServer';
|
||||
import { createServer } from './helpers/createServer';
|
||||
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||
import {
|
||||
createDpopResourceAuth,
|
||||
newDpopClientObservations,
|
||||
type DpopClientObservations
|
||||
} from './helpers/dpopResourceAuth';
|
||||
import { SpecReferences } from './spec-references';
|
||||
|
||||
const PRM_PATH = '/.well-known/oauth-protected-resource/mcp';
|
||||
|
||||
/** Static id → (name, description, spec references) for each emitted check. */
|
||||
const CHECK_DEFS: Record<
|
||||
string,
|
||||
{ name: string; description: string; specReferences: SpecReference[] }
|
||||
> = {
|
||||
'sep-1932-client-dpop-auth-scheme': {
|
||||
name: 'DpopAuthScheme',
|
||||
description:
|
||||
'Client presents the access token with the DPoP Authorization scheme (not Bearer)',
|
||||
specReferences: [
|
||||
SpecReferences.SEP_1932_DPOP,
|
||||
SpecReferences.DPOP_EXTENSION,
|
||||
SpecReferences.RFC_9449_AUTH_SCHEME
|
||||
]
|
||||
},
|
||||
'sep-1932-client-fresh-proof': {
|
||||
name: 'DpopFreshProof',
|
||||
description:
|
||||
'Client includes a fresh, well-formed DPoP proof in the DPoP header on each MCP POST request',
|
||||
specReferences: [
|
||||
SpecReferences.RFC_9449_CHECKING_PROOFS,
|
||||
SpecReferences.RFC_9449_PROOF_SYNTAX,
|
||||
SpecReferences.DPOP_EXTENSION
|
||||
]
|
||||
},
|
||||
'sep-1932-client-token-request-proof': {
|
||||
name: 'DpopTokenRequestProof',
|
||||
description:
|
||||
'Client includes a valid DPoP proof in the DPoP header of its token request — the prerequisite for obtaining a DPoP-bound access token (RFC 9449 §5)',
|
||||
specReferences: [
|
||||
SpecReferences.SEP_1932_DPOP,
|
||||
SpecReferences.DPOP_EXTENSION,
|
||||
SpecReferences.RFC_9449_TOKEN_REQUEST
|
||||
]
|
||||
},
|
||||
'sep-1932-client-as-nonce': {
|
||||
name: 'DpopAsNonce',
|
||||
description:
|
||||
'On a use_dpop_nonce challenge from the token endpoint, the client retries the token request with the server-supplied nonce (RFC 9449 §8)',
|
||||
specReferences: [
|
||||
SpecReferences.SEP_1932_DPOP,
|
||||
SpecReferences.DPOP_EXTENSION,
|
||||
SpecReferences.RFC_9449_AS_NONCE
|
||||
]
|
||||
},
|
||||
'sep-1932-client-rs-nonce': {
|
||||
name: 'DpopRsNonce',
|
||||
description:
|
||||
'On a use_dpop_nonce challenge from the MCP server, the client retries the request with the server-supplied nonce (RFC 9449 §9)',
|
||||
specReferences: [
|
||||
SpecReferences.SEP_1932_DPOP,
|
||||
SpecReferences.DPOP_EXTENSION,
|
||||
SpecReferences.RFC_9449_RS_NONCE
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: DPoP sender-constrained tokens — MCP client (SEP-1932 / RFC 9449).
|
||||
*
|
||||
* The test authorization server (DPoP-capable `createAuthServer`) issues a
|
||||
* DPoP-bound token; the test MCP server judges how the client presents it.
|
||||
*
|
||||
* Registered in two postures, because server-provided nonces are OPTIONAL in
|
||||
* RFC 9449 (AS §8 "MAY", RS §9 "can also choose") and the two are mutually
|
||||
* exclusive for a given run:
|
||||
*
|
||||
* - `auth/dpop` (`requireNonce = false`) — the common, nonce-less baseline.
|
||||
* Neither the AS nor the MCP server issues a nonce challenge; the client
|
||||
* completes the flow with plain proofs. Emits three checks:
|
||||
* · token acquisition — a valid DPoP proof at the token request, obtaining
|
||||
* a sender-constrained token (RFC 9449 §5);
|
||||
* · the token is presented with the `DPoP` Authorization scheme (§7.1);
|
||||
* · a fresh, well-formed DPoP proof accompanies each request (unique `jti`).
|
||||
*
|
||||
* - `auth/dpop-nonce` (`requireNonce = true`) — the AS and MCP server both
|
||||
* require a server-provided nonce (§8/§9), exercising the client's nonce
|
||||
* handling. Emits the three baseline checks plus two more:
|
||||
* · the client retries the token request with the AS-supplied nonce (§8);
|
||||
* · the client retries the MCP request with the server-supplied nonce (§9).
|
||||
*/
|
||||
function newTokenReqObs(): DpopTokenRequestObservation {
|
||||
return {
|
||||
recorded: false,
|
||||
validProof: false,
|
||||
asNonceChallengeIssued: false,
|
||||
asNonceHonored: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse duplicate non-INFO check IDs to a single entry, preferring the
|
||||
* MOST-SEVERE occurrence (FAILURE > WARNING > SUCCESS > any other status, e.g.
|
||||
* SKIPPED) so a real failure is never masked. Equal-severity ties keep the LAST
|
||||
* occurrence (for the nonce round-trip that is the retry's diagnostic, which is
|
||||
* status-equivalent to the first). Per-request INFO log entries are always kept.
|
||||
*
|
||||
* The RFC 9449 §8/§9 nonce round-trip re-POSTs /token (challenge → retry), so
|
||||
* the shared token-flow conformance checks (`token-request`, `pkce-*`) are
|
||||
* appended twice; this reports each once without hiding a failure recorded on
|
||||
* either attempt. Exported so the behaviour is unit-tested directly.
|
||||
*/
|
||||
export function collapseDuplicateChecks(
|
||||
checks: ConformanceCheck[]
|
||||
): ConformanceCheck[] {
|
||||
const severity = (s: CheckStatus): number =>
|
||||
s === 'FAILURE' ? 3 : s === 'WARNING' ? 2 : s === 'SUCCESS' ? 1 : 0;
|
||||
// Winning index per non-INFO id: highest severity, ties → last occurrence.
|
||||
const winner = new Map<string, number>();
|
||||
checks.forEach((c, i) => {
|
||||
if (c.status === 'INFO') return;
|
||||
const cur = winner.get(c.id);
|
||||
if (
|
||||
cur === undefined ||
|
||||
severity(c.status) >= severity(checks[cur].status)
|
||||
) {
|
||||
winner.set(c.id, i);
|
||||
}
|
||||
});
|
||||
return checks.filter((c, i) => c.status === 'INFO' || winner.get(c.id) === i);
|
||||
}
|
||||
|
||||
export class DPoPClientScenario implements Scenario {
|
||||
readonly name: string;
|
||||
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
|
||||
readonly description: string;
|
||||
|
||||
private authServer = new ServerLifecycle();
|
||||
private server = new ServerLifecycle();
|
||||
private checks: ConformanceCheck[] = [];
|
||||
private obs: DpopClientObservations = newDpopClientObservations();
|
||||
private tokenReqObs: DpopTokenRequestObservation = newTokenReqObs();
|
||||
|
||||
/**
|
||||
* @param requireNonce when true (`auth/dpop-nonce`) the test AS and MCP server
|
||||
* both demand a server-provided nonce (RFC 9449 §8/§9); when false
|
||||
* (`auth/dpop`) neither challenges and the client completes with plain
|
||||
* proofs — the common, nonce-less baseline.
|
||||
*/
|
||||
constructor(private readonly requireNonce: boolean) {
|
||||
this.name = requireNonce ? 'auth/dpop-nonce' : 'auth/dpop';
|
||||
this.description = requireNonce
|
||||
? 'Tests that an MCP client, when the authorization server and MCP server require a DPoP nonce, retries the token request and the MCP request with the server-supplied nonce (RFC 9449 §8/§9) — on top of requesting a DPoP-bound token and presenting it with the DPoP Authorization scheme and a fresh proof per request (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).'
|
||||
: 'Tests that an MCP client requests a DPoP-bound access token (a valid DPoP proof at the token request) and presents it using the DPoP Authorization scheme (not Bearer) with a fresh, well-formed DPoP proof on each POST /mcp request, when the server does not require a nonce (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).';
|
||||
}
|
||||
|
||||
async start(ctx: ScenarioContext): Promise<ScenarioUrls> {
|
||||
this.checks = [];
|
||||
this.obs = newDpopClientObservations();
|
||||
this.tokenReqObs = newTokenReqObs();
|
||||
|
||||
const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, {
|
||||
dpopSigningAlgValuesSupported: ['ES256'],
|
||||
dpopTokenRequestObs: this.tokenReqObs,
|
||||
dpopRequireNonce: this.requireNonce
|
||||
});
|
||||
await this.authServer.start(authApp);
|
||||
|
||||
const app = createServer(
|
||||
ctx,
|
||||
this.checks,
|
||||
this.server.getUrl,
|
||||
this.authServer.getUrl,
|
||||
{
|
||||
authMiddleware: createDpopResourceAuth(
|
||||
this.obs,
|
||||
() => `${this.server.getUrl()}/mcp`,
|
||||
() => `${this.server.getUrl()}${PRM_PATH}`,
|
||||
this.requireNonce
|
||||
)
|
||||
}
|
||||
);
|
||||
await this.server.start(app);
|
||||
|
||||
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.authServer.stop();
|
||||
await this.server.stop();
|
||||
}
|
||||
|
||||
getChecks(): ConformanceCheck[] {
|
||||
// Only the nonce posture re-POSTs /token (challenge → retry), which
|
||||
// duplicates the shared token-flow checks (token-request, pkce-*); collapse
|
||||
// those. The baseline (`auth/dpop`) is left untouched so genuinely distinct
|
||||
// repeated attempts (e.g. a restarted authorization flow) keep both entries.
|
||||
const shared = this.requireNonce
|
||||
? collapseDuplicateChecks(this.checks)
|
||||
: this.checks;
|
||||
const checks: ConformanceCheck[] = [
|
||||
...shared,
|
||||
this.tokenRequestProofCheck(),
|
||||
this.authSchemeCheck(),
|
||||
this.freshProofCheck()
|
||||
];
|
||||
// The nonce checks only apply to the nonce-requiring posture: in the
|
||||
// baseline (`auth/dpop`) neither server issues a `use_dpop_nonce`
|
||||
// challenge, so there is no nonce behaviour to assert.
|
||||
if (this.requireNonce) {
|
||||
checks.push(this.asNonceCheck(), this.rsNonceCheck());
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
private asNonceCheck(): ConformanceCheck {
|
||||
const challenged = this.tokenReqObs.asNonceChallengeIssued;
|
||||
const honored = this.tokenReqObs.asNonceHonored;
|
||||
// SUCCESS requires that a challenge was actually issued AND then honored —
|
||||
// grading `honored` alone would let a client that pre-sends the nonce
|
||||
// (never challenged) pass vacuously.
|
||||
const pass = challenged && honored;
|
||||
return this.build(
|
||||
'sep-1932-client-as-nonce',
|
||||
pass ? 'SUCCESS' : 'FAILURE',
|
||||
{
|
||||
errorMessage: pass
|
||||
? undefined
|
||||
: challenged
|
||||
? 'Client did not complete the token request with the server-supplied nonce after a use_dpop_nonce challenge (it either did not retry or the retried proof was rejected)'
|
||||
: 'Client never presented a valid proof that was answered with a use_dpop_nonce challenge',
|
||||
details: {
|
||||
challengeIssued: challenged,
|
||||
nonceHonored: honored
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private rsNonceCheck(): ConformanceCheck {
|
||||
const challenged = this.obs.rsNonceChallengeIssued;
|
||||
const honored = this.obs.rsNonceHonored;
|
||||
// SUCCESS requires that a challenge was actually issued AND then honored —
|
||||
// see asNonceCheck; grading `honored` alone permits a vacuous pass.
|
||||
const pass = challenged && honored;
|
||||
return this.build(
|
||||
'sep-1932-client-rs-nonce',
|
||||
pass ? 'SUCCESS' : 'FAILURE',
|
||||
{
|
||||
errorMessage: pass
|
||||
? undefined
|
||||
: challenged
|
||||
? 'Client did not complete the MCP request with the server-supplied nonce after a use_dpop_nonce challenge (it either did not retry or the retried proof was rejected)'
|
||||
: 'Client never made an MCP request with a valid proof that was answered with a use_dpop_nonce challenge',
|
||||
details: {
|
||||
challengeIssued: challenged,
|
||||
nonceHonored: honored
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private tokenRequestProofCheck(): ConformanceCheck {
|
||||
let status: CheckStatus;
|
||||
let errorMessage: string | undefined;
|
||||
if (!this.tokenReqObs.recorded) {
|
||||
status = 'FAILURE';
|
||||
errorMessage =
|
||||
'Client never completed an authorization_code token request, so it obtained no DPoP-bound token';
|
||||
} else if (!this.tokenReqObs.validProof) {
|
||||
status = 'FAILURE';
|
||||
errorMessage = `Client did not present a valid DPoP proof at the token endpoint: ${this.tokenReqObs.error}`;
|
||||
} else {
|
||||
status = 'SUCCESS';
|
||||
}
|
||||
return this.build('sep-1932-client-token-request-proof', status, {
|
||||
errorMessage,
|
||||
details: {
|
||||
tokenRequestObserved: this.tokenReqObs.recorded,
|
||||
validProofPresented: this.tokenReqObs.validProof
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private authSchemeCheck(): ConformanceCheck {
|
||||
let status: CheckStatus;
|
||||
let errorMessage: string | undefined;
|
||||
if (this.obs.authenticatedRequests === 0) {
|
||||
status = 'FAILURE';
|
||||
errorMessage = 'Client never presented an access token to the MCP server';
|
||||
} else if (this.obs.nonDpopSchemeSeen) {
|
||||
status = 'FAILURE';
|
||||
errorMessage = `Client presented the token with a non-DPoP Authorization scheme (${[...new Set(this.obs.observedSchemes)].join(', ')})`;
|
||||
} else {
|
||||
status = 'SUCCESS';
|
||||
}
|
||||
return this.build('sep-1932-client-dpop-auth-scheme', status, {
|
||||
errorMessage,
|
||||
details: {
|
||||
authenticatedRequests: this.obs.authenticatedRequests,
|
||||
observedSchemes: [...new Set(this.obs.observedSchemes)]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private freshProofCheck(): ConformanceCheck {
|
||||
let status: CheckStatus;
|
||||
let errorMessage: string | undefined;
|
||||
if (this.obs.authenticatedRequests === 0) {
|
||||
status = 'FAILURE';
|
||||
errorMessage = 'Client never presented an access token to the MCP server';
|
||||
} else if (!this.obs.allProofsWellFormed) {
|
||||
status = 'FAILURE';
|
||||
errorMessage = `DPoP proof was missing or malformed: ${this.obs.proofError}`;
|
||||
} else if (this.obs.replayDetected) {
|
||||
status = 'FAILURE';
|
||||
errorMessage =
|
||||
'Client reused a DPoP proof (duplicate jti) across requests instead of sending a fresh one';
|
||||
} else {
|
||||
status = 'SUCCESS';
|
||||
}
|
||||
return this.build('sep-1932-client-fresh-proof', status, {
|
||||
errorMessage,
|
||||
details: {
|
||||
authenticatedRequests: this.obs.authenticatedRequests,
|
||||
distinctJtis: this.obs.jtisSeen.length,
|
||||
replayDetected: this.obs.replayDetected
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private build(
|
||||
id: string,
|
||||
status: CheckStatus,
|
||||
opts: { errorMessage?: string; details?: Record<string, unknown> } = {}
|
||||
): ConformanceCheck {
|
||||
const def = CHECK_DEFS[id];
|
||||
return {
|
||||
id,
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
specReferences: def.specReferences,
|
||||
...(opts.errorMessage ? { errorMessage: opts.errorMessage } : {}),
|
||||
...(opts.details ? { details: opts.details } : {})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,12 @@ import { isStatefulVersion } from '../../../../connection/select';
|
|||
import { createRequestLogger } from '../../../request-logger';
|
||||
import { SpecReferences } from '../spec-references';
|
||||
import { MockTokenVerifier } from './mockTokenVerifier';
|
||||
import * as jose from 'jose';
|
||||
import {
|
||||
generateIssuerKey,
|
||||
mintDpopBoundToken,
|
||||
type TokenIssuerKey
|
||||
} from './dpopToken';
|
||||
|
||||
/**
|
||||
* Compute S256 code challenge from a code verifier.
|
||||
|
|
@ -20,6 +26,105 @@ function computeS256Challenge(codeVerifier: string): string {
|
|||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// Fixed nonce the test AS hands out when `dpopRequireNonce` is set (RFC 9449 §8).
|
||||
const AS_DPOP_NONCE = 'conformance-as-dpop-nonce';
|
||||
|
||||
// Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3, §11.6).
|
||||
const DPOP_ASYMMETRIC_ALGS = [
|
||||
'ES256',
|
||||
'ES384',
|
||||
'ES512',
|
||||
'RS256',
|
||||
'RS384',
|
||||
'RS512',
|
||||
'PS256',
|
||||
'PS384',
|
||||
'PS512',
|
||||
'EdDSA'
|
||||
];
|
||||
|
||||
/**
|
||||
* Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based
|
||||
* normalization): lowercase scheme/host, drop the default port, ignore a
|
||||
* trailing slash. Query/fragment are rejected by the caller (RFC 9449 §4.2),
|
||||
* not silently stripped here.
|
||||
*/
|
||||
function normalizeHtu(u: string): string {
|
||||
try {
|
||||
const url = new URL(u);
|
||||
// Tolerate a single trailing slash only; `/mcp//` is a distinct path.
|
||||
return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}`;
|
||||
} catch {
|
||||
return u;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a DPoP proof presented at the token endpoint (the token-request
|
||||
* subset of RFC 9449 §4.3). Hand-rolled with jose primitives — deliberately an
|
||||
* INDEPENDENT code path from the suite's proof builder, so a shared bug
|
||||
* surfaces rather than hides. Returns the JWK thumbprint to bind on success.
|
||||
*/
|
||||
export async function validateDpopProofAtTokenEndpoint(
|
||||
proof: string,
|
||||
tokenEndpointUrl: string
|
||||
): Promise<{ ok: true; jkt: string } | { ok: false; error: string }> {
|
||||
let header: jose.ProtectedHeaderParameters;
|
||||
try {
|
||||
header = jose.decodeProtectedHeader(proof);
|
||||
} catch {
|
||||
return { ok: false, error: 'DPoP proof is not a well-formed JWT' };
|
||||
}
|
||||
if (header.typ !== 'dpop+jwt') {
|
||||
return { ok: false, error: 'typ must be dpop+jwt' };
|
||||
}
|
||||
if (!header.alg || !DPOP_ASYMMETRIC_ALGS.includes(header.alg)) {
|
||||
return { ok: false, error: 'alg must be a supported asymmetric algorithm' };
|
||||
}
|
||||
const jwk = header.jwk as jose.JWK | undefined;
|
||||
if (!jwk) {
|
||||
return { ok: false, error: 'missing jwk header parameter' };
|
||||
}
|
||||
if ((jwk as Record<string, unknown>).d !== undefined) {
|
||||
return { ok: false, error: 'jwk must not contain a private key' };
|
||||
}
|
||||
let claims: jose.JWTPayload;
|
||||
try {
|
||||
const key = await jose.importJWK(jwk, header.alg);
|
||||
claims = (
|
||||
await jose.jwtVerify(proof, key, { algorithms: DPOP_ASYMMETRIC_ALGS })
|
||||
).payload;
|
||||
} catch {
|
||||
return { ok: false, error: 'DPoP proof signature does not verify' };
|
||||
}
|
||||
if (typeof claims.jti !== 'string') {
|
||||
return { ok: false, error: 'missing jti claim' };
|
||||
}
|
||||
if (claims.htm !== 'POST') {
|
||||
return { ok: false, error: 'htm does not match POST' };
|
||||
}
|
||||
if (typeof claims.htu !== 'string') {
|
||||
return { ok: false, error: 'htu does not match the token endpoint' };
|
||||
}
|
||||
if (claims.htu.includes('?') || claims.htu.includes('#')) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'htu MUST NOT contain a query or fragment (RFC 9449 §4.2)'
|
||||
};
|
||||
}
|
||||
if (normalizeHtu(claims.htu) !== normalizeHtu(tokenEndpointUrl)) {
|
||||
return { ok: false, error: 'htu does not match the token endpoint' };
|
||||
}
|
||||
if (
|
||||
typeof claims.iat !== 'number' ||
|
||||
Math.abs(Math.floor(Date.now() / 1000) - claims.iat) > 300
|
||||
) {
|
||||
return { ok: false, error: 'iat outside the acceptable window' };
|
||||
}
|
||||
const jkt = await jose.calculateJwkThumbprint(jwk, 'sha256');
|
||||
return { ok: true, jkt };
|
||||
}
|
||||
|
||||
export interface TokenRequestResult {
|
||||
token: string;
|
||||
scopes: string[];
|
||||
|
|
@ -31,6 +136,24 @@ export interface TokenRequestError {
|
|||
statusCode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sink for the DPoP token-request observation (RFC 9449 §5). The AS writes to
|
||||
* it on the authorization_code exchange (sticky-failure: once any exchange
|
||||
* lacks a valid proof it stays FAILURE; refresh grants are ignored) so the
|
||||
* client scenario can emit sep-1932-client-token-request-proof unconditionally
|
||||
* — FAILURE by default when the client never reaches the token endpoint,
|
||||
* matching its sibling checks.
|
||||
*/
|
||||
export interface DpopTokenRequestObservation {
|
||||
recorded: boolean;
|
||||
validProof: boolean;
|
||||
error?: string;
|
||||
/** The token endpoint issued a `use_dpop_nonce` challenge (RFC 9449 §8). */
|
||||
asNonceChallengeIssued: boolean;
|
||||
/** The client retried the token request carrying the correct nonce. */
|
||||
asNonceHonored: boolean;
|
||||
}
|
||||
|
||||
export interface AuthServerOptions {
|
||||
metadataPath?: string;
|
||||
isOpenIdConfiguration?: boolean;
|
||||
|
|
@ -63,6 +186,34 @@ export interface AuthServerOptions {
|
|||
* after `start()`.
|
||||
*/
|
||||
metadataIssuer?: string | (() => string);
|
||||
/**
|
||||
* DPoP (SEP-1932 / RFC 9449) — opt-in; absent ⇒ no DPoP behaviour at all.
|
||||
* When set, the AS advertises `dpop_signing_alg_values_supported` in its
|
||||
* metadata and validates+binds a DPoP proof presented at the token endpoint.
|
||||
*/
|
||||
dpopSigningAlgValuesSupported?: string[];
|
||||
/**
|
||||
* Negative-test mode: make the AS misbehave in one specific way so a check
|
||||
* can be proven to FAIL.
|
||||
* - 'omit-alg-values' — drop `dpop_signing_alg_values_supported` entirely
|
||||
* - 'empty-alg-values' — advertise the field as an empty array
|
||||
* - 'include-none' — list `none` among the supported proof algs
|
||||
* - 'unbound-token' — issue a Bearer token ignoring a valid proof
|
||||
*/
|
||||
dpopMisbehavior?:
|
||||
| 'omit-alg-values'
|
||||
| 'empty-alg-values'
|
||||
| 'include-none'
|
||||
| 'unbound-token';
|
||||
/** Sink for the DPoP token-request observation; see the interface docstring. */
|
||||
dpopTokenRequestObs?: DpopTokenRequestObservation;
|
||||
/**
|
||||
* When true, the token endpoint requires a DPoP nonce (RFC 9449 §8): a
|
||||
* proof-bearing request without the correct `nonce` claim is answered with
|
||||
* `400 use_dpop_nonce` + a `DPoP-Nonce` header, and the retry carrying the
|
||||
* nonce is accepted. Used to exercise the client's nonce handling.
|
||||
*/
|
||||
dpopRequireNonce?: boolean;
|
||||
tokenVerifier?: MockTokenVerifier;
|
||||
onTokenRequest?: (requestData: {
|
||||
scope?: string;
|
||||
|
|
@ -110,6 +261,10 @@ export function createAuthServer(
|
|||
issParameterSupported = true,
|
||||
issInRedirect = 'correct',
|
||||
metadataIssuer,
|
||||
dpopSigningAlgValuesSupported,
|
||||
dpopMisbehavior,
|
||||
dpopTokenRequestObs,
|
||||
dpopRequireNonce = false,
|
||||
tokenVerifier,
|
||||
onTokenRequest,
|
||||
onAuthorizationRequest,
|
||||
|
|
@ -120,6 +275,34 @@ export function createAuthServer(
|
|||
let lastAuthorizationScopes: string[] = [];
|
||||
// Track PKCE code_challenge for verification in token request
|
||||
let storedCodeChallenge: string | undefined;
|
||||
// Lazily-created issuer key for minting DPoP-bound JWT access tokens.
|
||||
let dpopIssuerKey: TokenIssuerKey | undefined;
|
||||
// DPoP behaviour is active only when the caller opts in (any DPoP option).
|
||||
const dpopEnabled =
|
||||
dpopSigningAlgValuesSupported !== undefined ||
|
||||
dpopMisbehavior !== undefined;
|
||||
|
||||
// Records whether the client presented a valid DPoP proof at its
|
||||
// authorization_code token request (RFC 9449 §5) into the caller's observation
|
||||
// sink. Sticky-failure: FAILURE if ANY exchange lacked a valid proof, SUCCESS
|
||||
// only if every one had one — so a later proof-less exchange (e.g. a refresh
|
||||
// done as authorization_code) can't overwrite an earlier failure, nor vice
|
||||
// versa. Refresh grants are ignored entirely. The client scenario reads this
|
||||
// to emit sep-1932-client-token-request-proof unconditionally (FAILURE by
|
||||
// default when the client never reaches the token endpoint).
|
||||
const recordTokenRequestProof = (
|
||||
grantType: string,
|
||||
ok: boolean,
|
||||
detail?: string
|
||||
): void => {
|
||||
if (grantType !== 'authorization_code' || !dpopTokenRequestObs) return;
|
||||
const valid = dpopTokenRequestObs.recorded
|
||||
? dpopTokenRequestObs.validProof && ok
|
||||
: ok;
|
||||
dpopTokenRequestObs.recorded = true;
|
||||
dpopTokenRequestObs.validProof = valid;
|
||||
if (!valid && detail) dpopTokenRequestObs.error ??= detail;
|
||||
};
|
||||
|
||||
const authRoutes = {
|
||||
authorization_endpoint: `${routePrefix}/authorize`,
|
||||
|
|
@ -185,7 +368,21 @@ export function createAuthServer(
|
|||
...(tokenEndpointAuthSigningAlgValuesSupported && {
|
||||
token_endpoint_auth_signing_alg_values_supported:
|
||||
tokenEndpointAuthSigningAlgValuesSupported
|
||||
})
|
||||
}),
|
||||
// DPoP AS-metadata support signal (SEP-1932 / RFC 9449 §5.1). Opt-in;
|
||||
// misbehaviour modes alter it: 'omit-alg-values' drops the field,
|
||||
// 'empty-alg-values' advertises an empty array, 'include-none' adds the
|
||||
// forbidden `none` alg. (dpop_bound_access_tokens is client-registration
|
||||
// metadata per RFC 9449 §5.2, so it is deliberately NOT advertised here.)
|
||||
...(dpopMisbehavior !== 'omit-alg-values' &&
|
||||
dpopSigningAlgValuesSupported !== undefined && {
|
||||
dpop_signing_alg_values_supported:
|
||||
dpopMisbehavior === 'empty-alg-values'
|
||||
? []
|
||||
: dpopMisbehavior === 'include-none'
|
||||
? [...dpopSigningAlgValuesSupported, 'none']
|
||||
: dpopSigningAlgValuesSupported
|
||||
})
|
||||
};
|
||||
|
||||
// Add scopes_supported if provided
|
||||
|
|
@ -370,6 +567,122 @@ export function createAuthServer(
|
|||
});
|
||||
}
|
||||
|
||||
// ---- DPoP token binding (SEP-1932 / RFC 9449) — opt-in ----
|
||||
if (dpopEnabled) {
|
||||
const proofHeader = req.headers['dpop'];
|
||||
const proofValue = Array.isArray(proofHeader)
|
||||
? proofHeader.join(', ')
|
||||
: proofHeader;
|
||||
// RFC 9449 §4.2: at most one DPoP header field. Node collapses duplicate
|
||||
// request headers into one comma-joined value; a valid proof JWT contains
|
||||
// no comma, so a comma means more than one proof was sent.
|
||||
if (typeof proofValue === 'string' && proofValue.includes(',')) {
|
||||
recordTokenRequestProof(
|
||||
grantType,
|
||||
false,
|
||||
'multiple DPoP proof headers'
|
||||
);
|
||||
res.status(400).json({
|
||||
error: 'invalid_dpop_proof',
|
||||
error_description: 'Multiple DPoP proof headers'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const proof = proofValue;
|
||||
const tokenEndpointUrl = `${getAuthBaseUrl()}${authRoutes.token_endpoint}`;
|
||||
const grantedScopes = requestedScope
|
||||
? requestedScope.split(' ')
|
||||
: lastAuthorizationScopes;
|
||||
|
||||
// No proof ⇒ DPoP is not being exercised here; fall through to a Bearer
|
||||
// token. (Requiring a proof is a per-client `dpop_bound_access_tokens`
|
||||
// registration policy — RFC 9449 §5.2 — not a global AS behaviour.) The
|
||||
// client-side check still records that the client failed to ask for a
|
||||
// bound token.
|
||||
if (proof) {
|
||||
const result = await validateDpopProofAtTokenEndpoint(
|
||||
proof,
|
||||
tokenEndpointUrl
|
||||
);
|
||||
if (!result.ok) {
|
||||
recordTokenRequestProof(grantType, false, result.error);
|
||||
res.status(400).json({
|
||||
error: 'invalid_dpop_proof',
|
||||
error_description: result.error
|
||||
});
|
||||
return;
|
||||
}
|
||||
// The proof itself is valid — record that now, BEFORE any nonce
|
||||
// challenge, so a client that is challenged but does not retry is not
|
||||
// mis-reported by token-request-proof as "never completed a token
|
||||
// request" (its proof was just verified). Sticky + gated on
|
||||
// authorization_code inside recordTokenRequestProof.
|
||||
recordTokenRequestProof(grantType, true);
|
||||
|
||||
// RFC 9449 §8: require a server-provided nonce. A proof without the
|
||||
// correct nonce is challenged (400 use_dpop_nonce + DPoP-Nonce); the
|
||||
// client is expected to retry with it. The nonce observation is gated
|
||||
// on the authorization_code exchange, matching recordTokenRequestProof
|
||||
// (honoring a challenge on a refresh exchange must not satisfy §8).
|
||||
if (dpopRequireNonce) {
|
||||
let proofNonce: unknown;
|
||||
try {
|
||||
proofNonce = jose.decodeJwt(proof).nonce;
|
||||
} catch {
|
||||
proofNonce = undefined;
|
||||
}
|
||||
if (proofNonce !== AS_DPOP_NONCE) {
|
||||
if (grantType === 'authorization_code' && dpopTokenRequestObs)
|
||||
dpopTokenRequestObs.asNonceChallengeIssued = true;
|
||||
res.status(400).set('DPoP-Nonce', AS_DPOP_NONCE).json({
|
||||
error: 'use_dpop_nonce',
|
||||
error_description: 'Authorization server requires a DPoP nonce'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (grantType === 'authorization_code' && dpopTokenRequestObs)
|
||||
dpopTokenRequestObs.asNonceHonored = true;
|
||||
}
|
||||
|
||||
if (dpopMisbehavior === 'unbound-token') {
|
||||
// Misbehaviour: ignore the binding and issue a plain Bearer token.
|
||||
const bearer = `test-token-${Date.now()}`;
|
||||
if (tokenVerifier) tokenVerifier.registerToken(bearer, grantedScopes);
|
||||
res.json({
|
||||
access_token: bearer,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dpopIssuerKey) {
|
||||
dpopIssuerKey = await generateIssuerKey();
|
||||
}
|
||||
const boundToken = await mintDpopBoundToken({
|
||||
issuerKey: dpopIssuerKey,
|
||||
issuer: resolveIssuer(),
|
||||
audience:
|
||||
(req.body.resource as string) || 'urn:conformance-test-resource',
|
||||
jkt: result.jkt,
|
||||
...(requestedScope && { scope: requestedScope })
|
||||
});
|
||||
res.json({
|
||||
access_token: boundToken,
|
||||
token_type: 'DPoP',
|
||||
expires_in: 3600,
|
||||
...(requestedScope && { scope: requestedScope })
|
||||
});
|
||||
return;
|
||||
}
|
||||
// dpopEnabled but the token request carried no DPoP proof.
|
||||
recordTokenRequestProof(
|
||||
grantType,
|
||||
false,
|
||||
'no DPoP proof in the token request'
|
||||
);
|
||||
}
|
||||
|
||||
let token = `test-token-${Date.now()}`;
|
||||
let scopes: string[] = lastAuthorizationScopes;
|
||||
|
||||
|
|
|
|||
180
src/scenarios/client/auth/helpers/dpopProof.test.ts
Normal file
180
src/scenarios/client/auth/helpers/dpopProof.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { webcrypto } from 'node:crypto';
|
||||
import * as jose from 'jose';
|
||||
import type { JWK } from 'jose';
|
||||
import {
|
||||
generateDpopKeyPair,
|
||||
accessTokenHash,
|
||||
jwkThumbprint,
|
||||
buildDpopProof
|
||||
} from './dpopProof';
|
||||
|
||||
/**
|
||||
* Independent ES256 signature verification using Node's native WebCrypto —
|
||||
* a different code path from jose's signer, so a signing bug can't be masked
|
||||
* by symmetric use of one library (validation Layer 2).
|
||||
*/
|
||||
async function verifyEs256Independently(
|
||||
jwt: string,
|
||||
publicJwk: JWK
|
||||
): Promise<boolean> {
|
||||
const [h, p, s] = jwt.split('.');
|
||||
const key = await webcrypto.subtle.importKey(
|
||||
'jwk',
|
||||
publicJwk as JsonWebKey,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
const signature = new Uint8Array(Buffer.from(s, 'base64url'));
|
||||
const data = new TextEncoder().encode(`${h}.${p}`);
|
||||
return webcrypto.subtle.verify(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
signature,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
describe('DPoP proof helper — RFC 9449 published vectors (Layer 1)', () => {
|
||||
// RFC 9449 §4 / §6.1 example: this EC public key's JWK thumbprint is the
|
||||
// bound `cnf.jkt` value shown in the spec.
|
||||
const RFC9449_EC_JWK: JWK = {
|
||||
kty: 'EC',
|
||||
x: 'l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs',
|
||||
y: '9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA',
|
||||
crv: 'P-256'
|
||||
};
|
||||
const RFC9449_EXPECTED_JKT = '0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I';
|
||||
|
||||
// RFC 9449 §4.1 example: ath = base64url(SHA-256(ASCII access token)).
|
||||
const RFC9449_ACCESS_TOKEN = 'Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU';
|
||||
const RFC9449_EXPECTED_ATH = 'fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo';
|
||||
|
||||
it('reproduces the RFC 9449 JWK thumbprint vector', async () => {
|
||||
expect(await jwkThumbprint(RFC9449_EC_JWK)).toBe(RFC9449_EXPECTED_JKT);
|
||||
});
|
||||
|
||||
it('reproduces the RFC 9449 access-token-hash (ath) vector', () => {
|
||||
expect(accessTokenHash(RFC9449_ACCESS_TOKEN)).toBe(RFC9449_EXPECTED_ATH);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DPoP proof helper — key pair', () => {
|
||||
it('generates an extractable P-256 public JWK with a consistent thumbprint', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(kp.publicJwk.kty).toBe('EC');
|
||||
expect(kp.publicJwk.crv).toBe('P-256');
|
||||
expect(kp.publicJwk.d).toBeUndefined(); // public JWK must not carry the private scalar
|
||||
expect(kp.thumbprint).toBe(
|
||||
await jose.calculateJwkThumbprint(kp.publicJwk, 'sha256')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DPoP proof helper — valid proof', () => {
|
||||
it('builds a well-formed dpop+jwt with the required header and claims', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const accessToken = 'example-access-token-value';
|
||||
const jwt = await buildDpopProof({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: 'https://mcp.example.com/mcp',
|
||||
accessToken
|
||||
});
|
||||
|
||||
const header = jose.decodeProtectedHeader(jwt);
|
||||
expect(header.typ).toBe('dpop+jwt');
|
||||
expect(header.alg).toBe('ES256');
|
||||
expect((header.jwk as JWK).kty).toBe('EC');
|
||||
expect((header.jwk as JWK).d).toBeUndefined();
|
||||
|
||||
const claims = jose.decodeJwt(jwt);
|
||||
expect(typeof claims.jti).toBe('string');
|
||||
expect(claims.htm).toBe('POST');
|
||||
expect(claims.htu).toBe('https://mcp.example.com/mcp');
|
||||
expect(typeof claims.iat).toBe('number');
|
||||
expect(claims.ath).toBe(accessTokenHash(accessToken));
|
||||
});
|
||||
|
||||
it('signature verifies under an independent WebCrypto verifier (Layer 2)', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const jwt = await buildDpopProof({
|
||||
keyPair: kp,
|
||||
htm: 'GET',
|
||||
htu: 'https://mcp.example.com/mcp'
|
||||
});
|
||||
expect(await verifyEs256Independently(jwt, kp.publicJwk)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DPoP proof helper — invalid variants isolate exactly one defect (Layer 3)', () => {
|
||||
const base = { htm: 'POST', htu: 'https://mcp.example.com/mcp' };
|
||||
|
||||
it('a tampered signature fails verification while header and claims are unchanged', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const good = await buildDpopProof({ keyPair: kp, ...base });
|
||||
// Derive the bad proof from the good one so ONLY the signature differs
|
||||
// (each build has a fresh jti and ECDSA signatures are randomized, so two
|
||||
// independent builds would differ in their claims too).
|
||||
const [h, p, s] = good.split('.');
|
||||
// Flip the FIRST signature char (fully significant); the last char carries
|
||||
// base64url padding bits that can decode identically and stay valid.
|
||||
const bad = [h, p, (s[0] === 'A' ? 'B' : 'A') + s.slice(1)].join('.');
|
||||
|
||||
expect(bad.split('.').slice(0, 2)).toEqual([h, p]);
|
||||
expect(await verifyEs256Independently(good, kp.publicJwk)).toBe(true);
|
||||
expect(await verifyEs256Independently(bad, kp.publicJwk)).toBe(false);
|
||||
});
|
||||
|
||||
it('the builder tamperSignature option yields a proof that fails verification', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const jwt = await buildDpopProof({
|
||||
keyPair: kp,
|
||||
...base,
|
||||
tamperSignature: true
|
||||
});
|
||||
expect(await verifyEs256Independently(jwt, kp.publicJwk)).toBe(false);
|
||||
});
|
||||
|
||||
it('omitting jti drops only the jti claim', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const jwt = await buildDpopProof({ keyPair: kp, ...base, omit: ['jti'] });
|
||||
const claims = jose.decodeJwt(jwt);
|
||||
expect(claims.jti).toBeUndefined();
|
||||
expect(claims.htm).toBe('POST');
|
||||
expect(claims.htu).toBe('https://mcp.example.com/mcp');
|
||||
expect(typeof claims.iat).toBe('number');
|
||||
});
|
||||
|
||||
it('unsigned variant uses alg=none with an empty signature segment', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const jwt = await buildDpopProof({ keyPair: kp, ...base, unsigned: true });
|
||||
const parts = jwt.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
expect(parts[2]).toBe('');
|
||||
expect(jose.decodeProtectedHeader(jwt).alg).toBe('none');
|
||||
});
|
||||
|
||||
it('symmetric variant signs with HS256 (must be rejected by servers)', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const jwt = await buildDpopProof({ keyPair: kp, ...base, symmetric: true });
|
||||
expect(jose.decodeProtectedHeader(jwt).alg).toBe('HS256');
|
||||
});
|
||||
|
||||
it('embedPrivateKey leaks the private scalar into the jwk header', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const jwt = await buildDpopProof({
|
||||
keyPair: kp,
|
||||
...base,
|
||||
embedPrivateKey: true
|
||||
});
|
||||
expect((jose.decodeProtectedHeader(jwt).jwk as JWK).d).toBeDefined();
|
||||
});
|
||||
|
||||
it('iat override produces a stale proof', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const staleIat = Math.floor(Date.now() / 1000) - 3600;
|
||||
const jwt = await buildDpopProof({ keyPair: kp, ...base, iat: staleIat });
|
||||
expect(jose.decodeJwt(jwt).iat).toBe(staleIat);
|
||||
});
|
||||
});
|
||||
178
src/scenarios/client/auth/helpers/dpopProof.ts
Normal file
178
src/scenarios/client/auth/helpers/dpopProof.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import * as jose from 'jose';
|
||||
import type { JWK, CryptoKey } from 'jose';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* DPoP proof construction helpers (RFC 9449).
|
||||
*
|
||||
* Shared across the DPoP client, server, and authorization-server conformance
|
||||
* scenarios. The framework uses these to act as a DPoP client: it generates a
|
||||
* key pair, builds a well-formed `dpop+jwt` proof for the happy path, and builds
|
||||
* deliberately-malformed variants (one defect at a time) for the negative checks.
|
||||
*
|
||||
* Correctness of this module is anchored to published RFC test vectors in
|
||||
* `proof.test.ts` (RFC 9449 §4 examples), not to the conformance servers that
|
||||
* consume it — see the validation strategy in the project notes.
|
||||
*/
|
||||
|
||||
const DEFAULT_ALG = 'ES256';
|
||||
const DPOP_TYP = 'dpop+jwt';
|
||||
|
||||
export interface DpopKeyPair {
|
||||
/** Private key used to sign proofs. */
|
||||
privateKey: CryptoKey;
|
||||
/** Public key matching {@link publicJwk}. */
|
||||
publicKey: CryptoKey;
|
||||
/** Public JWK embedded in the proof's `jwk` header parameter. */
|
||||
publicJwk: JWK;
|
||||
/** RFC 7638 JWK SHA-256 thumbprint (the value bound as `cnf.jkt`). */
|
||||
thumbprint: string;
|
||||
}
|
||||
|
||||
/** Generate an asymmetric key pair for DPoP proofs (default ES256 / P-256). */
|
||||
export async function generateDpopKeyPair(
|
||||
alg: string = DEFAULT_ALG
|
||||
): Promise<DpopKeyPair> {
|
||||
const { publicKey, privateKey } = await jose.generateKeyPair(alg, {
|
||||
extractable: true
|
||||
});
|
||||
const publicJwk = await jose.exportJWK(publicKey);
|
||||
const thumbprint = await jose.calculateJwkThumbprint(publicJwk, 'sha256');
|
||||
return { privateKey, publicKey, publicJwk, thumbprint };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the `ath` claim for a DPoP proof presented with an access token:
|
||||
* the base64url-encoded SHA-256 of the ASCII access-token value (RFC 9449 §4.1).
|
||||
*/
|
||||
export function accessTokenHash(accessToken: string): string {
|
||||
return createHash('sha256').update(accessToken, 'ascii').digest('base64url');
|
||||
}
|
||||
|
||||
/** RFC 7638 JWK SHA-256 thumbprint (base64url). */
|
||||
export async function jwkThumbprint(jwk: JWK): Promise<string> {
|
||||
return jose.calculateJwkThumbprint(jwk, 'sha256');
|
||||
}
|
||||
|
||||
export interface DpopProofOptions {
|
||||
/** Key pair whose private key signs the proof and whose public JWK is embedded. */
|
||||
keyPair: DpopKeyPair;
|
||||
/** HTTP method of the target request (`htm` claim). */
|
||||
htm: string;
|
||||
/** HTTP target URI of the request, no query/fragment (`htu` claim). */
|
||||
htu: string;
|
||||
/** When set, adds an `ath` claim bound to this access token. */
|
||||
accessToken?: string;
|
||||
/** When set, adds a `nonce` claim (server-supplied nonce). */
|
||||
nonce?: string;
|
||||
/** `iat` in epoch seconds. Defaults to now. Override to craft stale/future proofs. */
|
||||
iat?: number;
|
||||
/** `jti` value. Defaults to a fresh random id. */
|
||||
jti?: string;
|
||||
|
||||
// --- override knobs for crafting deliberately-invalid variants ---
|
||||
/** Override the `typ` header (default `dpop+jwt`). */
|
||||
typ?: string;
|
||||
/** Override the signing algorithm (default ES256). */
|
||||
alg?: string;
|
||||
/** Embed the PRIVATE JWK in the header instead of the public one (invalid). */
|
||||
embedPrivateKey?: boolean;
|
||||
/** Replace the embedded `jwk` header entirely. */
|
||||
jwkOverride?: JWK;
|
||||
/** Omit specific header params / claims to craft "missing field" variants. */
|
||||
omit?: Array<'jti' | 'htm' | 'htu' | 'iat' | 'typ' | 'jwk'>;
|
||||
/** Force a specific (wrong) `ath` value. */
|
||||
athOverride?: string;
|
||||
/** Sign with a symmetric key (HS256) — must be rejected (asymmetric-only). */
|
||||
symmetric?: boolean;
|
||||
/** Produce an unsigned `alg: none` proof — must be rejected. */
|
||||
unsigned?: boolean;
|
||||
/** Corrupt the signature after signing so verification fails. */
|
||||
tamperSignature?: boolean;
|
||||
}
|
||||
|
||||
function base64urlJson(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DPoP proof JWT. With no override knobs set, produces a well-formed,
|
||||
* RFC 9449-conformant proof. The override knobs each introduce exactly one
|
||||
* defect so a negative check can attribute a rejection to that single cause.
|
||||
*/
|
||||
export async function buildDpopProof(
|
||||
options: DpopProofOptions
|
||||
): Promise<string> {
|
||||
const omit = new Set(options.omit ?? []);
|
||||
const iat = options.iat ?? Math.floor(Date.now() / 1000);
|
||||
const jti = options.jti ?? randomBytes(16).toString('base64url');
|
||||
|
||||
// ----- claims (payload) -----
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (!omit.has('jti')) payload.jti = jti;
|
||||
if (!omit.has('htm')) payload.htm = options.htm;
|
||||
if (!omit.has('htu')) payload.htu = options.htu;
|
||||
if (!omit.has('iat')) payload.iat = iat;
|
||||
if (options.athOverride !== undefined) {
|
||||
payload.ath = options.athOverride;
|
||||
} else if (options.accessToken !== undefined) {
|
||||
payload.ath = accessTokenHash(options.accessToken);
|
||||
}
|
||||
if (options.nonce !== undefined) payload.nonce = options.nonce;
|
||||
|
||||
// ----- protected header -----
|
||||
const unsigned = options.unsigned === true || options.alg === 'none';
|
||||
const alg = unsigned
|
||||
? 'none'
|
||||
: options.symmetric
|
||||
? 'HS256'
|
||||
: (options.alg ?? DEFAULT_ALG);
|
||||
const header: Record<string, unknown> = { alg };
|
||||
if (!omit.has('typ')) header.typ = options.typ ?? DPOP_TYP;
|
||||
if (!omit.has('jwk')) {
|
||||
if (options.jwkOverride !== undefined) {
|
||||
header.jwk = options.jwkOverride;
|
||||
} else if (options.embedPrivateKey === true) {
|
||||
header.jwk = await jose.exportJWK(options.keyPair.privateKey);
|
||||
} else {
|
||||
header.jwk = options.keyPair.publicJwk;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- assembly / signing -----
|
||||
if (unsigned) {
|
||||
// `alg: none` — no signature segment.
|
||||
return `${base64urlJson(header)}.${base64urlJson(payload)}.`;
|
||||
}
|
||||
|
||||
if (options.symmetric) {
|
||||
const secret = new Uint8Array(randomBytes(32));
|
||||
return new jose.SignJWT(payload)
|
||||
.setProtectedHeader(header as jose.JWTHeaderParameters)
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
const jwt = await new jose.SignJWT(payload)
|
||||
.setProtectedHeader(header as jose.JWTHeaderParameters)
|
||||
.sign(options.keyPair.privateKey);
|
||||
|
||||
if (options.tamperSignature) {
|
||||
const parts = jwt.split('.');
|
||||
parts[2] = corruptSegment(parts[2]);
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
return jwt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrupt a base64url signature so it no longer verifies. We flip the FIRST
|
||||
* character (all 6 of its bits are significant): the LAST character of a
|
||||
* 64-byte ECDSA signature carries 4 zero padding bits, so flipping it (e.g.
|
||||
* `A`→`B`) can decode to the same bytes and leave the signature valid.
|
||||
*/
|
||||
function corruptSegment(segment: string): string {
|
||||
const first = segment[0];
|
||||
const replacement = first === 'A' ? 'B' : 'A';
|
||||
return replacement + segment.slice(1);
|
||||
}
|
||||
239
src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts
Normal file
239
src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
generateDpopKeyPair,
|
||||
buildDpopProof,
|
||||
type DpopKeyPair
|
||||
} from './dpopProof';
|
||||
import { generateIssuerKey, mintDpopBoundToken } from './dpopToken';
|
||||
import { validateResourceProof } from './dpopResourceAuth';
|
||||
|
||||
const ISSUER = 'https://auth.example.com';
|
||||
const RESOURCE = 'https://mcp.example.com/mcp';
|
||||
|
||||
/** Mint a DPoP-bound token for `kp`'s key, optionally bound to a foreign key. */
|
||||
async function boundToken(
|
||||
kp: DpopKeyPair,
|
||||
jktOverride?: string
|
||||
): Promise<string> {
|
||||
const issuerKey = await generateIssuerKey();
|
||||
return mintDpopBoundToken({
|
||||
issuerKey,
|
||||
issuer: ISSUER,
|
||||
audience: RESOURCE,
|
||||
jkt: kp.thumbprint,
|
||||
...(jktOverride ? { jktOverride } : {})
|
||||
});
|
||||
}
|
||||
|
||||
describe('validateResourceProof — accepts a well-formed resource proof', () => {
|
||||
it('accepts a valid proof bound to the presented token', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const token = await boundToken(kp);
|
||||
const proof = await buildDpopProof({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: RESOURCE,
|
||||
accessToken: token
|
||||
});
|
||||
const result = await validateResourceProof(proof, token, 'POST', RESOURCE);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an htu that differs only by normalization (trailing slash)', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const token = await boundToken(kp);
|
||||
const proof = await buildDpopProof({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: `${RESOURCE}/`,
|
||||
accessToken: token
|
||||
});
|
||||
const result = await validateResourceProof(proof, token, 'POST', RESOURCE);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateResourceProof — rejects each single defect', () => {
|
||||
async function expectRejected(
|
||||
build: Parameters<typeof buildDpopProof>[0],
|
||||
method = 'POST',
|
||||
tokenJktOverride?: string
|
||||
): Promise<string> {
|
||||
const kp = (build.keyPair as DpopKeyPair) ?? (await generateDpopKeyPair());
|
||||
const token = await boundToken(kp, tokenJktOverride);
|
||||
const proof = await buildDpopProof({ ...build, keyPair: kp });
|
||||
const result = await validateResourceProof(proof, token, method, RESOURCE);
|
||||
expect(result.ok).toBe(false);
|
||||
return result.ok ? '' : result.error;
|
||||
}
|
||||
|
||||
it('rejects a missing proof', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const token = await boundToken(kp);
|
||||
const result = await validateResourceProof(
|
||||
undefined,
|
||||
token,
|
||||
'POST',
|
||||
RESOURCE
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong typ', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: RESOURCE,
|
||||
typ: 'jwt'
|
||||
})
|
||||
).toMatch(/typ/);
|
||||
});
|
||||
|
||||
it('rejects a symmetric algorithm', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: RESOURCE,
|
||||
symmetric: true
|
||||
})
|
||||
).toMatch(/alg/);
|
||||
});
|
||||
|
||||
it('rejects a private key embedded in the jwk header', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: RESOURCE,
|
||||
embedPrivateKey: true
|
||||
})
|
||||
).toMatch(/private key/);
|
||||
});
|
||||
|
||||
it('rejects a tampered signature', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: RESOURCE,
|
||||
tamperSignature: true
|
||||
})
|
||||
).toMatch(/signature/);
|
||||
});
|
||||
|
||||
it('rejects a missing jti', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: RESOURCE,
|
||||
omit: ['jti']
|
||||
})
|
||||
).toMatch(/jti/);
|
||||
});
|
||||
|
||||
it('rejects an htm that does not match the request method', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
// proof says GET, request is POST
|
||||
expect(
|
||||
await expectRejected({ keyPair: kp, htm: 'GET', htu: RESOURCE }, 'POST')
|
||||
).toMatch(/htm/);
|
||||
});
|
||||
|
||||
it('rejects an htu that does not match the request URI', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: 'https://elsewhere.example.com/mcp'
|
||||
})
|
||||
).toMatch(/htu/);
|
||||
});
|
||||
|
||||
it('rejects a stale iat', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: RESOURCE,
|
||||
iat: Math.floor(Date.now() / 1000) - 3600
|
||||
})
|
||||
).toMatch(/iat/);
|
||||
});
|
||||
|
||||
it('rejects a wrong ath', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: RESOURCE,
|
||||
athOverride: 'not-the-token-hash'
|
||||
})
|
||||
).toMatch(/ath/);
|
||||
});
|
||||
|
||||
it('rejects a token bound to a different key (cnf.jkt mismatch)', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const foreign = await generateDpopKeyPair();
|
||||
const token = await boundToken(kp, foreign.thumbprint);
|
||||
const proof = await buildDpopProof({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: RESOURCE,
|
||||
accessToken: token
|
||||
});
|
||||
const result = await validateResourceProof(proof, token, 'POST', RESOURCE);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.ok ? '' : result.error).toMatch(/cnf\.jkt/);
|
||||
});
|
||||
|
||||
it('rejects an htu containing a query string (RFC 9449 §4.2)', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({ keyPair: kp, htm: 'POST', htu: `${RESOURCE}?x=1` })
|
||||
).toMatch(/query or fragment/);
|
||||
});
|
||||
|
||||
it('rejects an htu containing a fragment (RFC 9449 §4.2)', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: `${RESOURCE}#frag`
|
||||
})
|
||||
).toMatch(/query or fragment/);
|
||||
});
|
||||
|
||||
it('rejects two comma-joined proofs — multiple DPoP headers (RFC 9449 §4.2)', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const token = await boundToken(kp);
|
||||
const proof = await buildDpopProof({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: RESOURCE,
|
||||
accessToken: token
|
||||
});
|
||||
// Node joins duplicate DPoP headers into one comma-separated value.
|
||||
const result = await validateResourceProof(
|
||||
`${proof}, ${proof}`,
|
||||
token,
|
||||
'POST',
|
||||
RESOURCE
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.ok ? '' : result.error).toMatch(
|
||||
/multiple DPoP proof headers/
|
||||
);
|
||||
});
|
||||
});
|
||||
279
src/scenarios/client/auth/helpers/dpopResourceAuth.ts
Normal file
279
src/scenarios/client/auth/helpers/dpopResourceAuth.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import * as jose from 'jose';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
/** Fixed nonce the judge hands out when `requireNonce` is set (RFC 9449 §9). */
|
||||
const RS_DPOP_NONCE = 'conformance-rs-dpop-nonce';
|
||||
|
||||
/** Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 / §11.6). */
|
||||
const DPOP_ASYMMETRIC_ALGS = [
|
||||
'ES256',
|
||||
'ES384',
|
||||
'ES512',
|
||||
'RS256',
|
||||
'RS384',
|
||||
'RS512',
|
||||
'PS256',
|
||||
'PS384',
|
||||
'PS512',
|
||||
'EdDSA'
|
||||
];
|
||||
|
||||
/**
|
||||
* Accumulated observations of how the client presented its access token and
|
||||
* per-request DPoP proof. The scenario turns these into the two
|
||||
* sep-1932-client-* checks in getChecks() — freshness needs cross-request
|
||||
* state (unique `jti` per request), so we accumulate rather than emit inline.
|
||||
*/
|
||||
export interface DpopClientObservations {
|
||||
authenticatedRequests: number;
|
||||
nonDpopSchemeSeen: boolean;
|
||||
observedSchemes: string[];
|
||||
jtisSeen: string[];
|
||||
replayDetected: boolean;
|
||||
allProofsWellFormed: boolean;
|
||||
proofError?: string;
|
||||
/** The judge issued a `use_dpop_nonce` challenge (RFC 9449 §9). */
|
||||
rsNonceChallengeIssued: boolean;
|
||||
/** The client retried the request carrying the correct nonce. */
|
||||
rsNonceHonored: boolean;
|
||||
}
|
||||
|
||||
export function newDpopClientObservations(): DpopClientObservations {
|
||||
return {
|
||||
authenticatedRequests: 0,
|
||||
nonDpopSchemeSeen: false,
|
||||
observedSchemes: [],
|
||||
jtisSeen: [],
|
||||
replayDetected: false,
|
||||
allProofsWellFormed: true,
|
||||
rsNonceChallengeIssued: false,
|
||||
rsNonceHonored: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test MCP server DPoP judge (SEP-1932 / RFC 9449), passed to `createServer`
|
||||
* via its `options.authMiddleware` hook. An unauthenticated request gets a
|
||||
* `401 DPoP` discovery challenge; an authenticated one is observed (scheme +
|
||||
* per-request proof) and allowed through so the MCP session can complete and
|
||||
* multiple requests can be examined for proof freshness.
|
||||
*
|
||||
* Proof validation is hand-rolled with jose — deliberately an INDEPENDENT code
|
||||
* path from the suite's proof builder, so a shared bug surfaces rather than hides.
|
||||
*/
|
||||
export function createDpopResourceAuth(
|
||||
obs: DpopClientObservations,
|
||||
getResourceUrl: () => string,
|
||||
getPrmUrl: () => string,
|
||||
requireNonce = false
|
||||
): RequestHandler {
|
||||
return async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
const authorization = req.headers.authorization;
|
||||
if (!authorization) {
|
||||
res
|
||||
.status(401)
|
||||
.set('WWW-Authenticate', `DPoP resource_metadata="${getPrmUrl()}"`)
|
||||
.json({ error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
obs.authenticatedRequests++;
|
||||
const { scheme, token } = splitAuthorization(authorization);
|
||||
obs.observedSchemes.push(scheme);
|
||||
if (scheme.toLowerCase() !== 'dpop') {
|
||||
obs.nonDpopSchemeSeen = true;
|
||||
}
|
||||
|
||||
const proofHeader = req.headers['dpop'];
|
||||
// Node collapses duplicate DPoP request headers into one comma-joined value;
|
||||
// validateResourceProof rejects a comma (RFC 9449 §4.2 — at most one proof).
|
||||
const proofValue = Array.isArray(proofHeader)
|
||||
? proofHeader.join(', ')
|
||||
: proofHeader;
|
||||
const result = await validateResourceProof(
|
||||
proofValue,
|
||||
token,
|
||||
req.method,
|
||||
getResourceUrl()
|
||||
);
|
||||
if (!result.ok) {
|
||||
obs.allProofsWellFormed = false;
|
||||
obs.proofError ??= result.error;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Record proof freshness on ANY valid proof, before the nonce gate. A
|
||||
// nonce-challenged (but otherwise valid) proof still demonstrates freshness,
|
||||
// so fresh-proof is never asserted on zero `jti` evidence, and a client that
|
||||
// never honours the nonce fails only rs-nonce (not fresh-proof as well).
|
||||
//
|
||||
// Deliberate consequence: a client that re-sends the *identical* proof
|
||||
// across the challenge/retry boundary (same `jti`) trips replay detection.
|
||||
// That is a genuine RFC 9449 §4.2 violation (`jti` MUST be unique per proof)
|
||||
// — a conformant client mints a fresh proof, carrying the nonce, on retry.
|
||||
if (obs.jtisSeen.includes(result.jti)) {
|
||||
obs.replayDetected = true;
|
||||
} else {
|
||||
obs.jtisSeen.push(result.jti);
|
||||
}
|
||||
|
||||
// RFC 9449 §9: require a server-provided nonce. A valid proof without the
|
||||
// correct nonce is challenged (401 use_dpop_nonce + DPoP-Nonce); the client
|
||||
// is expected to retry with it.
|
||||
if (requireNonce) {
|
||||
let proofNonce: unknown;
|
||||
try {
|
||||
proofNonce = proofValue ? jose.decodeJwt(proofValue).nonce : undefined;
|
||||
} catch {
|
||||
proofNonce = undefined;
|
||||
}
|
||||
if (proofNonce !== RS_DPOP_NONCE) {
|
||||
obs.rsNonceChallengeIssued = true;
|
||||
res
|
||||
.status(401)
|
||||
.set(
|
||||
'WWW-Authenticate',
|
||||
`DPoP error="use_dpop_nonce", resource_metadata="${getPrmUrl()}"`
|
||||
)
|
||||
.set('DPoP-Nonce', RS_DPOP_NONCE)
|
||||
.json({ error: 'use_dpop_nonce' });
|
||||
return;
|
||||
}
|
||||
obs.rsNonceHonored = true;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function splitAuthorization(authorization: string): {
|
||||
scheme: string;
|
||||
token: string;
|
||||
} {
|
||||
const idx = authorization.indexOf(' ');
|
||||
if (idx === -1) return { scheme: authorization, token: '' };
|
||||
return {
|
||||
scheme: authorization.slice(0, idx),
|
||||
token: authorization.slice(idx + 1).trim()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based
|
||||
* normalization): lowercase scheme/host, drop the default port, ignore a
|
||||
* trailing slash. Query/fragment are rejected by the caller (RFC 9449 §4.2),
|
||||
* not silently stripped here.
|
||||
*/
|
||||
function normalizeHtu(u: string): string {
|
||||
try {
|
||||
const url = new URL(u);
|
||||
// Tolerate a single trailing slash only; `/mcp//` is a distinct path.
|
||||
return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}`;
|
||||
} catch {
|
||||
return u;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a per-request DPoP proof presented alongside an access token
|
||||
* (RFC 9449 §4.3, resource-request subset): well-formed `dpop+jwt`, asymmetric
|
||||
* alg, embedded public jwk, htm/htu match, `ath` binds the token, signature
|
||||
* verifies, and the proof key's thumbprint matches the token's `cnf.jkt`.
|
||||
*/
|
||||
export async function validateResourceProof(
|
||||
proof: string | undefined,
|
||||
token: string,
|
||||
method: string,
|
||||
resourceUrl: string
|
||||
): Promise<{ ok: true; jti: string } | { ok: false; error: string }> {
|
||||
if (!proof) return { ok: false, error: 'missing DPoP proof header' };
|
||||
// RFC 9449 §4.2: at most one DPoP header. A single proof JWT has no comma, so
|
||||
// a comma means duplicate headers were sent (Node joins them with ", ").
|
||||
if (proof.includes(',')) {
|
||||
return { ok: false, error: 'multiple DPoP proof headers' };
|
||||
}
|
||||
|
||||
let header: jose.ProtectedHeaderParameters;
|
||||
try {
|
||||
header = jose.decodeProtectedHeader(proof);
|
||||
} catch {
|
||||
return { ok: false, error: 'DPoP proof is not a well-formed JWT' };
|
||||
}
|
||||
if (header.typ !== 'dpop+jwt')
|
||||
return { ok: false, error: 'typ must be dpop+jwt' };
|
||||
if (!header.alg || !DPOP_ASYMMETRIC_ALGS.includes(header.alg)) {
|
||||
return { ok: false, error: 'alg must be a supported asymmetric algorithm' };
|
||||
}
|
||||
const jwk = header.jwk as jose.JWK | undefined;
|
||||
if (!jwk) return { ok: false, error: 'missing jwk header parameter' };
|
||||
if ((jwk as Record<string, unknown>).d !== undefined) {
|
||||
return { ok: false, error: 'jwk must not contain a private key' };
|
||||
}
|
||||
|
||||
let claims: jose.JWTPayload;
|
||||
try {
|
||||
const key = await jose.importJWK(jwk, header.alg);
|
||||
claims = (
|
||||
await jose.jwtVerify(proof, key, { algorithms: DPOP_ASYMMETRIC_ALGS })
|
||||
).payload;
|
||||
} catch {
|
||||
return { ok: false, error: 'DPoP proof signature does not verify' };
|
||||
}
|
||||
if (typeof claims.jti !== 'string')
|
||||
return { ok: false, error: 'missing jti claim' };
|
||||
if (claims.htm !== method)
|
||||
return { ok: false, error: 'htm does not match the request method' };
|
||||
if (typeof claims.htu !== 'string') {
|
||||
return { ok: false, error: 'htu does not match the request URI' };
|
||||
}
|
||||
if (claims.htu.includes('?') || claims.htu.includes('#')) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'htu MUST NOT contain a query or fragment (RFC 9449 §4.2)'
|
||||
};
|
||||
}
|
||||
if (normalizeHtu(claims.htu) !== normalizeHtu(resourceUrl)) {
|
||||
return { ok: false, error: 'htu does not match the request URI' };
|
||||
}
|
||||
if (
|
||||
typeof claims.iat !== 'number' ||
|
||||
Math.abs(Math.floor(Date.now() / 1000) - claims.iat) > 300
|
||||
) {
|
||||
return { ok: false, error: 'iat outside the acceptable window' };
|
||||
}
|
||||
// Recompute ath and the thumbprint inline (jose + node crypto) rather than
|
||||
// via the proof builder's helpers, so this validator stays a fully
|
||||
// independent path.
|
||||
const expectedAth = createHash('sha256')
|
||||
.update(token, 'ascii')
|
||||
.digest('base64url');
|
||||
if (typeof claims.ath !== 'string' || claims.ath !== expectedAth) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'ath does not match the presented access token'
|
||||
};
|
||||
}
|
||||
|
||||
// Possession: the proof key must be the key the token is bound to.
|
||||
try {
|
||||
const tokenClaims = jose.decodeJwt(token);
|
||||
const cnf = tokenClaims.cnf as { jkt?: unknown } | undefined;
|
||||
const thumbprint = await jose.calculateJwkThumbprint(jwk, 'sha256');
|
||||
if (!cnf || cnf.jkt !== thumbprint) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'proof key thumbprint does not match the token cnf.jkt'
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, error: 'access token is not a decodable JWT' };
|
||||
}
|
||||
|
||||
return { ok: true, jti: claims.jti };
|
||||
}
|
||||
158
src/scenarios/client/auth/helpers/dpopToken.test.ts
Normal file
158
src/scenarios/client/auth/helpers/dpopToken.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { webcrypto } from 'node:crypto';
|
||||
import * as jose from 'jose';
|
||||
import type { JWK } from 'jose';
|
||||
import {
|
||||
generateDpopKeyPair,
|
||||
buildDpopProof,
|
||||
accessTokenHash
|
||||
} from './dpopProof';
|
||||
import { generateIssuerKey, mintDpopBoundToken } from './dpopToken';
|
||||
|
||||
/** Independent ES256 verification via Node WebCrypto (a different path from jose). */
|
||||
async function verifyEs256Independently(
|
||||
jwt: string,
|
||||
publicJwk: JWK
|
||||
): Promise<boolean> {
|
||||
const [h, p, s] = jwt.split('.');
|
||||
const key = await webcrypto.subtle.importKey(
|
||||
'jwk',
|
||||
publicJwk as JsonWebKey,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
const signature = new Uint8Array(Buffer.from(s, 'base64url'));
|
||||
const data = new TextEncoder().encode(`${h}.${p}`);
|
||||
return webcrypto.subtle.verify(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
signature,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
const ISSUER = 'https://auth.example.com';
|
||||
const AUDIENCE = 'https://mcp.example.com/mcp';
|
||||
|
||||
describe('DPoP token minter — valid bound token', () => {
|
||||
it('mints a token bound to the given thumbprint with correct claims', async () => {
|
||||
const issuerKey = await generateIssuerKey();
|
||||
const kp = await generateDpopKeyPair();
|
||||
const token = await mintDpopBoundToken({
|
||||
issuerKey,
|
||||
issuer: ISSUER,
|
||||
audience: AUDIENCE,
|
||||
jkt: kp.thumbprint
|
||||
});
|
||||
|
||||
const claims = jose.decodeJwt(token);
|
||||
expect(claims.iss).toBe(ISSUER);
|
||||
expect(claims.aud).toBe(AUDIENCE);
|
||||
expect(typeof claims.sub).toBe('string');
|
||||
expect(typeof claims.iat).toBe('number');
|
||||
expect(typeof claims.exp).toBe('number');
|
||||
expect((claims.exp as number) > (claims.iat as number)).toBe(true);
|
||||
expect((claims.cnf as { jkt: string }).jkt).toBe(kp.thumbprint);
|
||||
|
||||
expect(jose.decodeProtectedHeader(token).typ).toBe('at+jwt');
|
||||
});
|
||||
|
||||
it('is signed by the issuer key (independent WebCrypto verification, Layer 2)', async () => {
|
||||
const issuerKey = await generateIssuerKey();
|
||||
const kp = await generateDpopKeyPair();
|
||||
const token = await mintDpopBoundToken({
|
||||
issuerKey,
|
||||
issuer: ISSUER,
|
||||
audience: AUDIENCE,
|
||||
jkt: kp.thumbprint
|
||||
});
|
||||
expect(await verifyEs256Independently(token, issuerKey.publicJwk)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DPoP token minter — proof/token binding agreement (integration)', () => {
|
||||
it('proof.ath hashes the minted token and token.cnf.jkt equals the proof key thumbprint', async () => {
|
||||
const issuerKey = await generateIssuerKey();
|
||||
const kp = await generateDpopKeyPair();
|
||||
|
||||
const token = await mintDpopBoundToken({
|
||||
issuerKey,
|
||||
issuer: ISSUER,
|
||||
audience: AUDIENCE,
|
||||
jkt: kp.thumbprint
|
||||
});
|
||||
|
||||
const proof = await buildDpopProof({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: AUDIENCE,
|
||||
accessToken: token
|
||||
});
|
||||
|
||||
// The two halves of the sender-constraint must line up:
|
||||
const tokenJkt = (jose.decodeJwt(token).cnf as { jkt: string }).jkt;
|
||||
const proofJwkThumbprint = kp.thumbprint;
|
||||
expect(tokenJkt).toBe(proofJwkThumbprint); // token bound to the proof's key
|
||||
|
||||
const proofAth = jose.decodeJwt(proof).ath;
|
||||
expect(proofAth).toBe(accessTokenHash(token)); // proof attests to this token
|
||||
});
|
||||
});
|
||||
|
||||
describe('DPoP token minter — invalid variants', () => {
|
||||
const base = { issuer: ISSUER, audience: AUDIENCE };
|
||||
|
||||
it('omitCnf produces an unbound token (no cnf)', async () => {
|
||||
const issuerKey = await generateIssuerKey();
|
||||
const kp = await generateDpopKeyPair();
|
||||
const token = await mintDpopBoundToken({
|
||||
issuerKey,
|
||||
...base,
|
||||
jkt: kp.thumbprint,
|
||||
omitCnf: true
|
||||
});
|
||||
expect(jose.decodeJwt(token).cnf).toBeUndefined();
|
||||
});
|
||||
|
||||
it('jktOverride binds to a foreign key (cnf.jkt mismatch case)', async () => {
|
||||
const issuerKey = await generateIssuerKey();
|
||||
const kp = await generateDpopKeyPair();
|
||||
const foreign = await generateDpopKeyPair();
|
||||
const token = await mintDpopBoundToken({
|
||||
issuerKey,
|
||||
...base,
|
||||
jkt: kp.thumbprint,
|
||||
jktOverride: foreign.thumbprint
|
||||
});
|
||||
const boundJkt = (jose.decodeJwt(token).cnf as { jkt: string }).jkt;
|
||||
expect(boundJkt).toBe(foreign.thumbprint);
|
||||
expect(boundJkt).not.toBe(kp.thumbprint);
|
||||
});
|
||||
|
||||
it('wrong-audience token still binds correctly (audience-validation case)', async () => {
|
||||
const issuerKey = await generateIssuerKey();
|
||||
const kp = await generateDpopKeyPair();
|
||||
const token = await mintDpopBoundToken({
|
||||
issuerKey,
|
||||
issuer: ISSUER,
|
||||
audience: 'https://other.example.com/mcp',
|
||||
jkt: kp.thumbprint
|
||||
});
|
||||
expect(jose.decodeJwt(token).aud).toBe('https://other.example.com/mcp');
|
||||
});
|
||||
|
||||
it('expired produces a token whose exp is in the past', async () => {
|
||||
const issuerKey = await generateIssuerKey();
|
||||
const kp = await generateDpopKeyPair();
|
||||
const token = await mintDpopBoundToken({
|
||||
issuerKey,
|
||||
...base,
|
||||
jkt: kp.thumbprint,
|
||||
expired: true
|
||||
});
|
||||
const claims = jose.decodeJwt(token);
|
||||
expect((claims.exp as number) < (claims.iat as number)).toBe(true);
|
||||
});
|
||||
});
|
||||
110
src/scenarios/client/auth/helpers/dpopToken.ts
Normal file
110
src/scenarios/client/auth/helpers/dpopToken.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import * as jose from 'jose';
|
||||
import type { JWK, CryptoKey } from 'jose';
|
||||
|
||||
/**
|
||||
* DPoP-bound access-token minting (RFC 9449 §6).
|
||||
*
|
||||
* Transport-free: this issues a signed JWT access token carrying the
|
||||
* `cnf.jkt` confirmation that binds the token to a DPoP key. It is the
|
||||
* token-issuing *core* shared across the DPoP scenarios:
|
||||
*
|
||||
* - #369 server: the scenario (acting as client) mints a bound token to
|
||||
* PRESENT to the server under test.
|
||||
* - #370 AS / #368 client: the compliant/test authorization-server fixtures
|
||||
* call this to ISSUE bound tokens (wrapped by an HTTP token endpoint).
|
||||
*
|
||||
* `jkt` is a parameter (not derived here) so the same function works whether
|
||||
* the bound key is ours (#369/#370) or extracted from a client's proof (#368).
|
||||
*/
|
||||
|
||||
const DEFAULT_ALG = 'ES256';
|
||||
|
||||
export interface TokenIssuerKey {
|
||||
privateKey: CryptoKey;
|
||||
publicKey: CryptoKey;
|
||||
publicJwk: JWK;
|
||||
alg: string;
|
||||
}
|
||||
|
||||
/** Generate an asymmetric signing key for the (test) token issuer. */
|
||||
export async function generateIssuerKey(
|
||||
alg: string = DEFAULT_ALG
|
||||
): Promise<TokenIssuerKey> {
|
||||
const { publicKey, privateKey } = await jose.generateKeyPair(alg, {
|
||||
extractable: true
|
||||
});
|
||||
const publicJwk = await jose.exportJWK(publicKey);
|
||||
return { privateKey, publicKey, publicJwk, alg };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a {@link TokenIssuerKey} from a private JWK (e.g. supplied to a
|
||||
* scenario via env), deriving the public JWK by stripping the private scalar.
|
||||
* EC/OKP keys only (sufficient for the ES256 test issuer).
|
||||
*/
|
||||
export async function importIssuerKey(
|
||||
privateJwk: JWK,
|
||||
alg: string = DEFAULT_ALG
|
||||
): Promise<TokenIssuerKey> {
|
||||
const privateKey = (await jose.importJWK(privateJwk, alg)) as CryptoKey;
|
||||
const publicJwk: JWK = { ...privateJwk };
|
||||
delete (publicJwk as Record<string, unknown>).d;
|
||||
const publicKey = (await jose.importJWK(publicJwk, alg)) as CryptoKey;
|
||||
return { privateKey, publicKey, publicJwk, alg };
|
||||
}
|
||||
|
||||
export interface MintDpopBoundTokenOptions {
|
||||
/** Issuer signing key (the server under test must trust its public key). */
|
||||
issuerKey: TokenIssuerKey;
|
||||
/** `iss` claim — the token issuer identifier. */
|
||||
issuer: string;
|
||||
/** `aud` claim — the resource the token is for (the MCP server's canonical URI). */
|
||||
audience: string;
|
||||
/** Thumbprint bound as `cnf.jkt` (RFC 7638 JWK thumbprint of the DPoP public key). */
|
||||
jkt: string;
|
||||
/** `sub` claim. Defaults to a fixed test subject. */
|
||||
subject?: string;
|
||||
/** `scope` claim, if any. */
|
||||
scope?: string;
|
||||
/** `iat` in epoch seconds. Defaults to now. */
|
||||
iat?: number;
|
||||
/** Token lifetime in seconds. Defaults to 3600. */
|
||||
expiresInSeconds?: number;
|
||||
|
||||
// --- override knobs for crafting deliberately-invalid variants ---
|
||||
/** Omit `cnf` entirely — an unbound (bearer-style) token. */
|
||||
omitCnf?: boolean;
|
||||
/** Bind to a different (foreign/wrong) thumbprint than the presented proof. */
|
||||
jktOverride?: string;
|
||||
/** Issue an already-expired token (`exp` in the past). */
|
||||
expired?: boolean;
|
||||
/** Additional claims to merge into the payload. */
|
||||
extraClaims?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a signed JWT access token. With no override knobs set, produces a
|
||||
* valid DPoP-bound token whose `cnf.jkt` equals {@link MintDpopBoundTokenOptions.jkt}.
|
||||
*/
|
||||
export async function mintDpopBoundToken(
|
||||
options: MintDpopBoundTokenOptions
|
||||
): Promise<string> {
|
||||
const iat = options.iat ?? Math.floor(Date.now() / 1000);
|
||||
const lifetime = options.expiresInSeconds ?? 3600;
|
||||
const exp = options.expired ? iat - 60 : iat + lifetime;
|
||||
|
||||
const payload: Record<string, unknown> = { ...(options.extraClaims ?? {}) };
|
||||
if (options.scope !== undefined) payload.scope = options.scope;
|
||||
if (!options.omitCnf) {
|
||||
payload.cnf = { jkt: options.jktOverride ?? options.jkt };
|
||||
}
|
||||
|
||||
return new jose.SignJWT(payload)
|
||||
.setProtectedHeader({ alg: options.issuerKey.alg, typ: 'at+jwt' })
|
||||
.setIssuer(options.issuer)
|
||||
.setAudience(options.audience)
|
||||
.setSubject(options.subject ?? 'conformance-test-subject')
|
||||
.setIssuedAt(iat)
|
||||
.setExpirationTime(exp)
|
||||
.sign(options.issuerKey.privateKey);
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
generateDpopKeyPair,
|
||||
buildDpopProof,
|
||||
type DpopKeyPair
|
||||
} from './dpopProof';
|
||||
import { validateDpopProofAtTokenEndpoint } from './createAuthServer';
|
||||
|
||||
const TOKEN_ENDPOINT = 'https://auth.example.com/token';
|
||||
|
||||
/**
|
||||
* The token-endpoint validator is an INDEPENDENT copy of the resource-side
|
||||
* validator (dpopResourceAuth). These tests keep the two from drifting: they
|
||||
* cover the token-request subset of RFC 9449 §4.3 (no `ath`/`cnf` — there is no
|
||||
* access token yet at the token request).
|
||||
*/
|
||||
describe('validateDpopProofAtTokenEndpoint — accepts a valid token-request proof', () => {
|
||||
it('accepts a well-formed proof and returns the JWK thumbprint', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const proof = await buildDpopProof({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: TOKEN_ENDPOINT
|
||||
});
|
||||
const result = await validateDpopProofAtTokenEndpoint(
|
||||
proof,
|
||||
TOKEN_ENDPOINT
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.ok ? result.jkt : '').toBe(kp.thumbprint);
|
||||
});
|
||||
|
||||
it('accepts an htu differing only by a single trailing slash', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
const proof = await buildDpopProof({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: `${TOKEN_ENDPOINT}/`
|
||||
});
|
||||
const result = await validateDpopProofAtTokenEndpoint(
|
||||
proof,
|
||||
TOKEN_ENDPOINT
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDpopProofAtTokenEndpoint — rejects each single defect', () => {
|
||||
async function expectRejected(
|
||||
build: Parameters<typeof buildDpopProof>[0]
|
||||
): Promise<string> {
|
||||
const kp = (build.keyPair as DpopKeyPair) ?? (await generateDpopKeyPair());
|
||||
const proof = await buildDpopProof({ ...build, keyPair: kp });
|
||||
const result = await validateDpopProofAtTokenEndpoint(
|
||||
proof,
|
||||
TOKEN_ENDPOINT
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
return result.ok ? '' : result.error;
|
||||
}
|
||||
|
||||
it('rejects a non-JWT', async () => {
|
||||
const result = await validateDpopProofAtTokenEndpoint(
|
||||
'this-is-not-a-jwt',
|
||||
TOKEN_ENDPOINT
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong typ', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: TOKEN_ENDPOINT,
|
||||
typ: 'jwt'
|
||||
})
|
||||
).toMatch(/typ/);
|
||||
});
|
||||
|
||||
it('rejects a symmetric algorithm', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: TOKEN_ENDPOINT,
|
||||
symmetric: true
|
||||
})
|
||||
).toMatch(/alg/);
|
||||
});
|
||||
|
||||
it('rejects an unsigned (alg=none) proof', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: TOKEN_ENDPOINT,
|
||||
unsigned: true
|
||||
})
|
||||
).toMatch(/alg/);
|
||||
});
|
||||
|
||||
it('rejects a private key embedded in the jwk header', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: TOKEN_ENDPOINT,
|
||||
embedPrivateKey: true
|
||||
})
|
||||
).toMatch(/private key/);
|
||||
});
|
||||
|
||||
it('rejects a tampered signature', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: TOKEN_ENDPOINT,
|
||||
tamperSignature: true
|
||||
})
|
||||
).toMatch(/signature/);
|
||||
});
|
||||
|
||||
it('rejects a missing jti', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: TOKEN_ENDPOINT,
|
||||
omit: ['jti']
|
||||
})
|
||||
).toMatch(/jti/);
|
||||
});
|
||||
|
||||
it('rejects an htm that is not POST', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({ keyPair: kp, htm: 'GET', htu: TOKEN_ENDPOINT })
|
||||
).toMatch(/htm/);
|
||||
});
|
||||
|
||||
it('rejects an htu that does not match the token endpoint', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: 'https://wrong.example.com/token'
|
||||
})
|
||||
).toMatch(/htu/);
|
||||
});
|
||||
|
||||
it('rejects an htu containing a query string (RFC 9449 §4.2)', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: `${TOKEN_ENDPOINT}?tenant=x`
|
||||
})
|
||||
).toMatch(/query or fragment/);
|
||||
});
|
||||
|
||||
it('rejects an htu containing a fragment (RFC 9449 §4.2)', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: `${TOKEN_ENDPOINT}#frag`
|
||||
})
|
||||
).toMatch(/query or fragment/);
|
||||
});
|
||||
|
||||
it('rejects a stale iat', async () => {
|
||||
const kp = await generateDpopKeyPair();
|
||||
expect(
|
||||
await expectRejected({
|
||||
keyPair: kp,
|
||||
htm: 'POST',
|
||||
htu: TOKEN_ENDPOINT,
|
||||
iat: Math.floor(Date.now() / 1000) - 3600
|
||||
})
|
||||
).toMatch(/iat/);
|
||||
});
|
||||
});
|
||||
|
|
@ -28,6 +28,13 @@ import { runClient as noAppTypeClient } from '../../../../examples/clients/types
|
|||
import { runClient as noIssValidationClient } from '../../../../examples/clients/typescript/auth-test';
|
||||
import { runClient as issNormalizeClient } from '../../../../examples/clients/typescript/auth-test-iss-normalize';
|
||||
import { runClient as echoScopeClient } from '../../../../examples/clients/typescript/auth-test-echo-scope';
|
||||
import { runClient as dpopBearerClient } from '../../../../examples/clients/typescript/auth-test-dpop-bearer';
|
||||
import { runClient as dpopReplayClient } from '../../../../examples/clients/typescript/auth-test-dpop-replay';
|
||||
import { runClient as dpopNoTokenProofClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-token-proof';
|
||||
import { runClient as dpopNoAsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-as-nonce';
|
||||
import { runClient as dpopNoRsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-rs-nonce';
|
||||
import { runClient as dpopNoNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-nonce';
|
||||
import { runClient as dpopClient } from '../../../../examples/clients/typescript/auth-test-dpop';
|
||||
import { getHandler } from '../../../../examples/clients/typescript/everything-client';
|
||||
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger';
|
||||
import { DRAFT_PROTOCOL_VERSION } from '../../../types';
|
||||
|
|
@ -339,3 +346,100 @@ describe('WIF JWT-bearer negative tests', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// DPoP (SEP-1932): the compliant paths for both postures (auth/dpop and
|
||||
// auth/dpop-nonce) are covered by the Client Draft Scenarios loop above; these
|
||||
// are the negative cases, via deliberately-broken example clients. The baseline
|
||||
// checks (scheme, replay, token-request-proof) are nonce-independent, so those
|
||||
// negatives run against auth/dpop; the two nonce checks only fire when a
|
||||
// challenge is issued, so their negatives run against auth/dpop-nonce.
|
||||
describe('DPoP client negative tests (SEP-1932)', () => {
|
||||
test('auth/dpop: client presents the token with the Bearer scheme', async () => {
|
||||
const runner = new InlineClientRunner(dpopBearerClient);
|
||||
await runClientAgainstScenario(runner, 'auth/dpop', {
|
||||
expectedFailureSlugs: ['sep-1932-client-dpop-auth-scheme']
|
||||
});
|
||||
});
|
||||
|
||||
test('auth/dpop: client reuses a DPoP proof across requests', async () => {
|
||||
const runner = new InlineClientRunner(dpopReplayClient);
|
||||
await runClientAgainstScenario(runner, 'auth/dpop', {
|
||||
expectedFailureSlugs: ['sep-1932-client-fresh-proof']
|
||||
});
|
||||
});
|
||||
|
||||
test('auth/dpop: client never requests a sender-constrained token', async () => {
|
||||
// No token-endpoint proof → the AS issues an unbound Bearer token, so both
|
||||
// the token-request check and (as a consequence of the unbound token) the
|
||||
// resource binding check fail.
|
||||
const runner = new InlineClientRunner(dpopNoTokenProofClient);
|
||||
await runClientAgainstScenario(runner, 'auth/dpop', {
|
||||
expectedFailureSlugs: [
|
||||
'sep-1932-client-token-request-proof',
|
||||
'sep-1932-client-fresh-proof'
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
test('auth/dpop-nonce: client ignores the authorization-server nonce challenge', async () => {
|
||||
const runner = new InlineClientRunner(dpopNoAsNonceClient);
|
||||
// The client presents a valid proof but never retries with the nonce, so it
|
||||
// never obtains a token — as-nonce fails and the downstream token-dependent
|
||||
// checks legitimately cascade. But token-request-proof MUST stay SUCCESS:
|
||||
// the client DID present a valid proof at the token endpoint (regression
|
||||
// guard for the "never completed a token request" misattribution).
|
||||
await runClientAgainstScenario(runner, 'auth/dpop-nonce', {
|
||||
expectedFailureSlugs: [
|
||||
'sep-1932-client-as-nonce',
|
||||
'sep-1932-client-dpop-auth-scheme',
|
||||
'sep-1932-client-fresh-proof',
|
||||
'sep-1932-client-rs-nonce'
|
||||
],
|
||||
expectedSuccessSlugs: ['sep-1932-client-token-request-proof']
|
||||
});
|
||||
});
|
||||
|
||||
test('auth/dpop-nonce: client ignores the MCP-server nonce challenge', async () => {
|
||||
const runner = new InlineClientRunner(dpopNoRsNonceClient);
|
||||
// Clean one-defect isolation: the client obtains a token and presents a
|
||||
// valid fresh proof (recorded before the nonce gate), so only rs-nonce
|
||||
// fails — everything else stays SUCCESS.
|
||||
await runClientAgainstScenario(runner, 'auth/dpop-nonce', {
|
||||
expectedFailureSlugs: ['sep-1932-client-rs-nonce'],
|
||||
expectedSuccessSlugs: [
|
||||
'sep-1932-client-token-request-proof',
|
||||
'sep-1932-client-dpop-auth-scheme',
|
||||
'sep-1932-client-fresh-proof',
|
||||
'sep-1932-client-as-nonce'
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// DPoP nonce-less baseline (SEP-1932): a client that implements NO nonce
|
||||
// handling still completes DPoP successfully when the server does not require a
|
||||
// nonce (the common case — server nonces are OPTIONAL, RFC 9449 §8/§9). The
|
||||
// nonce-capable compliant path is covered by the Client Draft Scenarios loop
|
||||
// above (auth/dpop is in draftScenariosList).
|
||||
describe('DPoP client nonce-less baseline (SEP-1932)', () => {
|
||||
test('auth/dpop: nonce-incapable client passes the baseline', async () => {
|
||||
const runner = new InlineClientRunner(dpopNoNonceClient);
|
||||
// No expectedFailureSlugs → asserts every emitted check is SUCCESS (the
|
||||
// three baseline checks; no as-nonce/rs-nonce checks are emitted here).
|
||||
await runClientAgainstScenario(runner, 'auth/dpop');
|
||||
});
|
||||
|
||||
test('auth/dpop-nonce: the §8/§9 double-POST is collapsed to one shared check each', async () => {
|
||||
// Pins that collapseDuplicateChecks is actually wired into getChecks for the
|
||||
// nonce posture: the challenge→retry re-POST records token-request/pkce
|
||||
// twice, so without the collapse these would appear 2×. Guards against the
|
||||
// gating being deleted or inverted.
|
||||
const runner = new InlineClientRunner(dpopClient);
|
||||
const checks = await runClientAgainstScenario(runner, 'auth/dpop-nonce');
|
||||
const count = (id: string): number =>
|
||||
checks.filter((c) => c.id === id).length;
|
||||
expect(count('token-request')).toBe(1);
|
||||
expect(count('pkce-code-verifier-sent')).toBe(1);
|
||||
expect(count('pkce-verifier-matches-challenge')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { ResourceMismatchScenario } from './resource-mismatch';
|
|||
import { PreRegistrationScenario } from './pre-registration';
|
||||
import { EnterpriseManagedAuthorizationScenario } from './enterprise-managed-authorization';
|
||||
import { WifJwtBearerScenario } from './wif-jwt-bearer';
|
||||
import { DPoPClientScenario } from './dpop';
|
||||
import {
|
||||
OfflineAccessScopeScenario,
|
||||
OfflineAccessNotSupportedScenario
|
||||
|
|
@ -81,5 +82,7 @@ export const draftScenariosList: Scenario[] = [
|
|||
new IssParameterUnexpectedScenario(),
|
||||
new IssParameterNormalizedVariantScenario(),
|
||||
new MetadataIssuerMismatchScenario(),
|
||||
new WifJwtBearerScenario()
|
||||
new WifJwtBearerScenario(),
|
||||
new DPoPClientScenario(false), // auth/dpop — nonce-less baseline (common case)
|
||||
new DPoPClientScenario(true) // auth/dpop-nonce — server-required nonce (§8/§9)
|
||||
];
|
||||
|
|
|
|||
|
|
@ -113,5 +113,38 @@ export const SpecReferences: { [key: string]: SpecReference } = {
|
|||
SEP_1933_WIF: {
|
||||
id: 'SEP-1933-Workload-Identity-Federation',
|
||||
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933'
|
||||
},
|
||||
// DPoP (SEP-1932 / RFC 9449) — client concerns.
|
||||
SEP_1932_DPOP: {
|
||||
id: 'SEP-1932-DPoP',
|
||||
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932'
|
||||
},
|
||||
DPOP_EXTENSION: {
|
||||
id: 'MCP-DPoP-Extension',
|
||||
url: 'https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx'
|
||||
},
|
||||
RFC_9449_PROOF_SYNTAX: {
|
||||
id: 'RFC-9449-dpop-proof-jwt-syntax',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2'
|
||||
},
|
||||
RFC_9449_CHECKING_PROOFS: {
|
||||
id: 'RFC-9449-checking-dpop-proofs',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-4.3'
|
||||
},
|
||||
RFC_9449_AUTH_SCHEME: {
|
||||
id: 'RFC-9449-dpop-authentication-scheme',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-7.1'
|
||||
},
|
||||
RFC_9449_TOKEN_REQUEST: {
|
||||
id: 'RFC-9449-dpop-access-token-request',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-5'
|
||||
},
|
||||
RFC_9449_AS_NONCE: {
|
||||
id: 'RFC-9449-authorization-server-provided-nonce',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-8'
|
||||
},
|
||||
RFC_9449_RS_NONCE: {
|
||||
id: 'RFC-9449-resource-server-provided-nonce',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-9'
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { getScenario } from '../../../index';
|
||||
import { testScenarioContext } from '../../../../mock-server/testing';
|
||||
import type { SpecVersion } from '../../../../types';
|
||||
import type { SpecVersion, ConformanceCheck } from '../../../../types';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const CLIENT_TIMEOUT = 10000; // 10 seconds for client to complete
|
||||
|
|
@ -87,6 +87,13 @@ export class InlineClientRunner implements ClientRunner {
|
|||
|
||||
export interface RunClientOptions {
|
||||
expectedFailureSlugs?: string[];
|
||||
/**
|
||||
* Slugs that MUST be present and SUCCESS. Use alongside expectedFailureSlugs
|
||||
* to pin that a specific check is *not* collateral damage of the injected
|
||||
* defect (e.g. a client that skips the nonce retry still passes
|
||||
* token-request-proof because it did present a valid proof).
|
||||
*/
|
||||
expectedSuccessSlugs?: string[];
|
||||
allowClientError?: boolean;
|
||||
/**
|
||||
* Spec version to run the scenario at. Defaults to the latest dated spec
|
||||
|
|
@ -100,9 +107,10 @@ export async function runClientAgainstScenario(
|
|||
clientRunner: ClientRunner,
|
||||
scenarioName: string,
|
||||
options: RunClientOptions = {}
|
||||
): Promise<void> {
|
||||
): Promise<ConformanceCheck[]> {
|
||||
const {
|
||||
expectedFailureSlugs = [],
|
||||
expectedSuccessSlugs = [],
|
||||
allowClientError = false,
|
||||
specVersion
|
||||
} = options;
|
||||
|
|
@ -119,6 +127,10 @@ export async function runClientAgainstScenario(
|
|||
const urls = await scenario.start(ctx);
|
||||
const serverUrl = urls.serverUrl;
|
||||
|
||||
// The emitted checks, returned to the caller for assertions the standard
|
||||
// pass/fail options don't cover (e.g. counting occurrences of a shared check).
|
||||
let collected: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
// Set environment variables for inline clients
|
||||
// These mirror what src/runner/client.ts does for spawned processes
|
||||
|
|
@ -143,6 +155,7 @@ export async function runClientAgainstScenario(
|
|||
|
||||
// Get checks from the scenario
|
||||
const checks = scenario.getChecks();
|
||||
collected = checks;
|
||||
|
||||
// Verify checks were returned
|
||||
if (checks.length === 0) {
|
||||
|
|
@ -152,6 +165,15 @@ export async function runClientAgainstScenario(
|
|||
// Filter out INFO checks
|
||||
const nonInfoChecks = checks.filter((c) => c.status !== 'INFO');
|
||||
|
||||
// Slugs that must be present and SUCCESS (independent of the failure set).
|
||||
for (const slug of expectedSuccessSlugs) {
|
||||
const check = checks.find((c) => c.id === slug);
|
||||
if (!check) {
|
||||
throw new Error(`Expected-success check ${slug} not found`);
|
||||
}
|
||||
expect(check.status).toBe('SUCCESS');
|
||||
}
|
||||
|
||||
// Check for expected failures
|
||||
if (expectedFailureSlugs.length > 0) {
|
||||
// Verify that the expected failures are present
|
||||
|
|
@ -198,4 +220,6 @@ export async function runClientAgainstScenario(
|
|||
// Stop the scenario server
|
||||
await scenario.stop();
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue