fix: gate SEP-2350 scope-union check to 2026-07-28+ (#337)
The auth/scope-step-up scenario emitted sep-2350-scope-union-on-reauth at every spec version, but the set-wise union requirement (SEP-2350) was only introduced in 2026-07-28. This wrongly warned clients that are conformant at 2025-11-25 and drop the previously-granted scope on re-authorization (e.g. python-sdk v1, surfaced while wiring up its runner in #329). Gate the check behind specVersionAtLeast(specVersion, DRAFT_PROTOCOL_VERSION), a small timeline-ordering helper added to types.ts (mirrors the ordering used by scenario applicability). Add tests pinning both sides of the branch: enforced at draft, silent at 2025-11-25. Follow-up to #329. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7a620cb192
commit
8445dd3aa0
3 changed files with 76 additions and 26 deletions
|
|
@ -130,8 +130,12 @@ describe('Negative tests', () => {
|
|||
});
|
||||
|
||||
test('client only responds to 401, not 403', async () => {
|
||||
// Run at draft so the SEP-2350 union check is also emitted: a client that
|
||||
// never makes the second authorization request fails both the escalation
|
||||
// check and the union check.
|
||||
const runner = new InlineClientRunner(ignore403Client);
|
||||
await runClientAgainstScenario(runner, 'auth/scope-step-up', {
|
||||
specVersion: DRAFT_PROTOCOL_VERSION,
|
||||
expectedFailureSlugs: [
|
||||
'scope-step-up-escalation',
|
||||
'sep-2350-scope-union-on-reauth'
|
||||
|
|
@ -139,13 +143,26 @@ describe('Negative tests', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('client echoes challenge scope without accumulating prior grant (SEP-2350)', async () => {
|
||||
test('client echoes challenge scope without accumulating prior grant (SEP-2350) at draft', async () => {
|
||||
// The set-wise union requirement (SEP-2350) was added in the draft spec
|
||||
// (2026-07-28); a client that echoes only the challenged scope fails it.
|
||||
const runner = new InlineClientRunner(echoScopeClient);
|
||||
await runClientAgainstScenario(runner, 'auth/scope-step-up', {
|
||||
specVersion: DRAFT_PROTOCOL_VERSION,
|
||||
expectedFailureSlugs: ['sep-2350-scope-union-on-reauth']
|
||||
});
|
||||
});
|
||||
|
||||
test('client echoes challenge scope without accumulating prior grant (SEP-2350) at 2025-11-25', async () => {
|
||||
// At dated spec versions that predate SEP-2350, the union check is not
|
||||
// emitted at all, so a client that drops the previously-granted scope on
|
||||
// re-auth passes cleanly.
|
||||
const runner = new InlineClientRunner(echoScopeClient);
|
||||
await runClientAgainstScenario(runner, 'auth/scope-step-up', {
|
||||
specVersion: '2025-11-25'
|
||||
});
|
||||
});
|
||||
|
||||
test('client uses DCR instead of CIMD when server supports it', async () => {
|
||||
const runner = new InlineClientRunner(noCimdClient);
|
||||
await runClientAgainstScenario(runner, 'auth/basic-cimd', {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { createServer } from './helpers/createServer';
|
|||
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||
import { SpecReferences } from './spec-references';
|
||||
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
|
||||
import { DRAFT_PROTOCOL_VERSION, specVersionAtLeast } from '../../../types';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
|
|
@ -294,9 +295,17 @@ export class ScopeStepUpAuthScenario implements Scenario {
|
|||
private authServer = new ServerLifecycle();
|
||||
private server = new ServerLifecycle();
|
||||
private checks: ConformanceCheck[] = [];
|
||||
// SEP-2350's set-wise union requirement was introduced in 2026-07-28 (the
|
||||
// current draft); it was not a requirement at 2025-11-25, where
|
||||
// non-accumulating re-auth is conformant. Gate the union check accordingly.
|
||||
private unionRequired = false;
|
||||
|
||||
async start(ctx: ScenarioContext): Promise<ScenarioUrls> {
|
||||
this.checks = [];
|
||||
this.unionRequired = specVersionAtLeast(
|
||||
ctx.specVersion,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
);
|
||||
|
||||
const initialScope = 'mcp:basic';
|
||||
// tools/call gates on mcp:write only (not the union) so the scenario can
|
||||
|
|
@ -352,22 +361,24 @@ export class ScopeStepUpAuthScenario implements Scenario {
|
|||
}
|
||||
});
|
||||
|
||||
const retainedPrior = requestedScopes.includes(initialScope);
|
||||
this.checks.push({
|
||||
id: 'sep-2350-scope-union-on-reauth',
|
||||
name: 'Client accumulates previously-granted scopes on re-authorization',
|
||||
description: retainedPrior
|
||||
? 'Client included previously-granted scopes alongside the newly challenged scope when re-authorizing'
|
||||
: 'Client SHOULD compute the union of previously requested scopes and newly challenged scopes when initiating re-authorization (SEP-2350); previously-granted scope was dropped',
|
||||
status: retainedPrior ? 'SUCCESS' : 'WARNING',
|
||||
timestamp: data.timestamp,
|
||||
specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING],
|
||||
details: {
|
||||
previouslyGranted: initialScope,
|
||||
challengedScope: stepUpScope,
|
||||
requestedScope: data.scope || 'none'
|
||||
}
|
||||
});
|
||||
if (this.unionRequired) {
|
||||
const retainedPrior = requestedScopes.includes(initialScope);
|
||||
this.checks.push({
|
||||
id: 'sep-2350-scope-union-on-reauth',
|
||||
name: 'Client accumulates previously-granted scopes on re-authorization',
|
||||
description: retainedPrior
|
||||
? 'Client included previously-granted scopes alongside the newly challenged scope when re-authorizing'
|
||||
: 'Client SHOULD compute the union of previously requested scopes and newly challenged scopes when initiating re-authorization (SEP-2350); previously-granted scope was dropped',
|
||||
status: retainedPrior ? 'SUCCESS' : 'WARNING',
|
||||
timestamp: data.timestamp,
|
||||
specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING],
|
||||
details: {
|
||||
previouslyGranted: initialScope,
|
||||
challengedScope: stepUpScope,
|
||||
requestedScope: data.scope || 'none'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -500,15 +511,17 @@ export class ScopeStepUpAuthScenario implements Scenario {
|
|||
timestamp: new Date().toISOString(),
|
||||
specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY]
|
||||
});
|
||||
this.checks.push({
|
||||
id: 'sep-2350-scope-union-on-reauth',
|
||||
name: 'Client accumulates previously-granted scopes on re-authorization',
|
||||
description:
|
||||
'Client did not make a second authorization request - scope union check could not be performed',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING]
|
||||
});
|
||||
if (this.unionRequired) {
|
||||
this.checks.push({
|
||||
id: 'sep-2350-scope-union-on-reauth',
|
||||
name: 'Client accumulates previously-granted scopes on re-authorization',
|
||||
description:
|
||||
'Client did not make a second authorization request - scope union check could not be performed',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.checks;
|
||||
|
|
|
|||
20
src/types.ts
20
src/types.ts
|
|
@ -57,6 +57,26 @@ export const NEGOTIABLE_PROTOCOL_VERSIONS: readonly string[] = [
|
|||
*/
|
||||
export type SpecVersion = DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION;
|
||||
|
||||
/** Spec versions in timeline order, dated revisions followed by the draft. */
|
||||
const SPEC_VERSION_TIMELINE: readonly SpecVersion[] = [
|
||||
...DATED_SPEC_VERSIONS,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
];
|
||||
|
||||
/**
|
||||
* True when `v` is at or after `threshold` on the spec timeline. Lets a check
|
||||
* gate itself to the version that introduced its requirement (e.g. a draft-only
|
||||
* requirement passes `DRAFT_PROTOCOL_VERSION` as the threshold).
|
||||
*/
|
||||
export function specVersionAtLeast(
|
||||
v: SpecVersion,
|
||||
threshold: SpecVersion
|
||||
): boolean {
|
||||
return (
|
||||
SPEC_VERSION_TIMELINE.indexOf(v) >= SPEC_VERSION_TIMELINE.indexOf(threshold)
|
||||
);
|
||||
}
|
||||
|
||||
// Scenarios may also be tagged 'extension' to mark them as off-timeline
|
||||
// (selectable via --suite extensions, never via --spec-version). See #256.
|
||||
export type ScenarioSpecTag = SpecVersion | 'extension';
|
||||
|
|
|
|||
Loading…
Reference in a new issue