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
1 changed files with 2713 additions and 4 deletions
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/
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue