A baseline entry names a scenario, but a scenario is many checks:
server-stateless is 29. Excusing the one or two a spec change breaks
means baselining the whole scenario, which stops enforcing the rest.
An entry may now be '<scenario>:<check-id>', in which case every failing
check in that scenario is judged on its own. Bare scenario entries are
unchanged, and YAML parses '- a:b' as a plain string, so every existing
baseline keeps working untouched.
loadExpectedFailures now owns the entry grammar and returns parsed
BaselineEntry values, so evaluateBaseline only matches and cannot throw.
Rejecting a bad entry and interpreting a good one no longer live in
separate layers.
A check id matches all of its occurrences, since ids repeat within a run;
they collapse to one verdict, most-severe first. Absent and SKIPPED are
tolerated, matching how the runner already treats them as green.
Move collapseDuplicateChecks to src/checks/collapse.ts with its tests:
expected-failures.ts needs the reducer and cannot import dpop.ts's auth
server stack. No behavior change.
Reject two configs that previously coerced to an entry matching nothing:
a mapping ('- scenario: check-id', with a space) and an empty list item.
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
import type { ScenarioContext } from '../../../mock-server';
|
||
import type {
|
||
Scenario,
|
||
ConformanceCheck,
|
||
CheckStatus,
|
||
SpecReference
|
||
} from '../../../types';
|
||
import { ScenarioUrls } 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';
|
||
import { collapseDuplicateChecks } from '../../../checks/collapse';
|
||
|
||
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
|
||
};
|
||
}
|
||
|
||
export class DPoPClientScenario implements Scenario {
|
||
readonly name: string;
|
||
readonly source = {
|
||
extensionId: 'io.modelcontextprotocol/auth/dpop'
|
||
} 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 } : {})
|
||
};
|
||
}
|
||
}
|