* refactor: replace specVersions list with introducedIn/removedIn range tagging Replaces per-scenario `specVersions: SpecVersion[]` arrays with `introducedIn` and optional `removedIn` range fields on all three scenario interfaces. Extension scenarios get an orthogonal `extension?: boolean` flag instead of the former `'extension'` tag in the versions list. matchesSpecVersion() is rewritten as a range comparison (introducedIn <= target && (!removedIn || target < removedIn)), with draft sorting after all dated versions. getScenarioSpecVersions() reconstructs the effective version list from the range for backward-compat with tier-check. Closes #256. * refactor: nest spec-version range under `source: ScenarioSource` union The flat `introducedIn`/`removedIn`/`extension?` shape forced extension scenarios to set a placeholder `introducedIn` that was never read. Nesting the version-range fields inside a `source` property typed as a union lets the compiler enforce introducedIn XOR extensionId — no runtime invariant test needed. type ScenarioSource = | { introducedIn: SpecVersion; removedIn?: SpecVersion } | { extensionId: ExtensionId }; A class can't `implements` a union type directly, so the union lives inside a property and `Scenario` stays a plain interface — `class implements Scenario` keeps working everywhere. `EXTENSION_IDS` is a const array mirroring the `DATED_SPEC_VERSIONS` pattern. Also renames the cross-app-access scenario to enterprise-managed-auth to match the extension's canonical name: file, class, slug, context schema literal, and the everything-client runner. * refactor: use canonical SEP-2133 extension identifiers EXTENSION_IDS now holds the wire identifiers from the spec's extension docs (client-matrix.mdx, docs/extensions/auth/*.mdx) so extensionId values can be cross-referenced against capabilities.extensions keys directly: io.modelcontextprotocol/oauth-client-credentials io.modelcontextprotocol/enterprise-managed-authorization The previous commit used a shortened 'enterprise-managed-auth' which doesn't match the canonical '-authorization'. Re-renames the scenario file/class/slug and downstream references to align. --------- Co-authored-by: Claude <noreply@anthropic.com>
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
import type { Scenario, ConformanceCheck } from '../../../types';
|
|
import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types';
|
|
import { createAuthServer } from './helpers/createAuthServer';
|
|
import { createServer } from './helpers/createServer';
|
|
import { ServerLifecycle } from './helpers/serverLifecycle';
|
|
import { SpecReferences } from './spec-references';
|
|
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
|
|
|
|
/**
|
|
* Scenario: Offline Access Scope (SEP-2207)
|
|
*
|
|
* Tests client behavior when the Authorization Server metadata lists
|
|
* `offline_access` in `scopes_supported`:
|
|
*
|
|
* 1. Client SHOULD include `refresh_token` in `grant_types` client metadata
|
|
* (checked via DCR body or CIMD document, whichever the client uses)
|
|
* 2. Client MAY include `offline_access` in authorization request scope
|
|
*
|
|
* Setup:
|
|
* - AS metadata: scopes_supported includes 'offline_access'
|
|
* - PRM: scopes_supported does NOT include 'offline_access' (per SEP-2207 server guidance)
|
|
* - Both CIMD and DCR paths available
|
|
*/
|
|
export class OfflineAccessScopeScenario implements Scenario {
|
|
name = 'auth/offline-access-scope';
|
|
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
|
|
description =
|
|
'Tests that a client that wants a refresh token handles offline_access scope and refresh_token grant type when AS supports them (SEP-2207)';
|
|
|
|
private authServer = new ServerLifecycle();
|
|
private server = new ServerLifecycle();
|
|
private checks: ConformanceCheck[] = [];
|
|
private grantTypesChecked = false;
|
|
private capturedCimdUrl: string | undefined;
|
|
|
|
async start(): Promise<ScenarioUrls> {
|
|
this.checks = [];
|
|
this.grantTypesChecked = false;
|
|
this.capturedCimdUrl = undefined;
|
|
|
|
const tokenVerifier = new MockTokenVerifier(this.checks, ['mcp:basic']);
|
|
|
|
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
|
|
tokenVerifier,
|
|
scopesSupported: ['mcp:basic', 'offline_access'],
|
|
clientIdMetadataDocumentSupported: true,
|
|
onRegistrationRequest: (req) => {
|
|
// DCR path: inspect grant_types in registration body
|
|
const grantTypes: string[] = req.body.grant_types || [];
|
|
const hasRefreshToken = grantTypes.includes('refresh_token');
|
|
this.grantTypesChecked = true;
|
|
this.checks.push({
|
|
id: 'sep-2207-client-metadata-grant-types',
|
|
name: 'Client metadata includes refresh_token grant type (DCR)',
|
|
description: hasRefreshToken
|
|
? 'Client correctly included refresh_token in grant_types during dynamic client registration'
|
|
: 'Client SHOULD include refresh_token in grant_types client metadata (SEP-2207)',
|
|
status: hasRefreshToken ? 'SUCCESS' : 'WARNING',
|
|
timestamp: new Date().toISOString(),
|
|
specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE],
|
|
details: {
|
|
registrationMethod: 'DCR',
|
|
grantTypes: grantTypes.length > 0 ? grantTypes.join(' ') : 'none'
|
|
}
|
|
});
|
|
|
|
const clientId = `test-client-${Date.now()}`;
|
|
return {
|
|
clientId,
|
|
clientSecret: undefined,
|
|
tokenEndpointAuthMethod: 'none'
|
|
};
|
|
},
|
|
onAuthorizationRequest: (data) => {
|
|
// Capture CIMD URL if client used URL-based client_id
|
|
if (data.clientId && data.clientId.startsWith('http')) {
|
|
this.capturedCimdUrl = data.clientId;
|
|
}
|
|
|
|
// Check if client included offline_access in scope
|
|
const requestedScopes = data.scope ? data.scope.split(' ') : [];
|
|
const hasOfflineAccess = requestedScopes.includes('offline_access');
|
|
this.checks.push({
|
|
id: 'sep-2207-offline-access-requested',
|
|
name: 'Client requests offline_access scope',
|
|
description: hasOfflineAccess
|
|
? 'Client included offline_access in authorization request scope when AS lists it in scopes_supported'
|
|
: 'Client MAY include offline_access in scope when AS metadata lists it in scopes_supported (SEP-2207). Client chose not to request it.',
|
|
status: hasOfflineAccess ? 'SUCCESS' : 'INFO',
|
|
timestamp: data.timestamp,
|
|
specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE],
|
|
details: {
|
|
asScopesSupported: 'mcp:basic offline_access',
|
|
requestedScope: data.scope || 'none'
|
|
}
|
|
});
|
|
}
|
|
});
|
|
await this.authServer.start(authApp);
|
|
|
|
// PRM does NOT include offline_access (per SEP-2207 server guidance:
|
|
// servers SHOULD NOT include offline_access in PRM scopes_supported)
|
|
const app = createServer(
|
|
this.checks,
|
|
this.server.getUrl,
|
|
this.authServer.getUrl,
|
|
{
|
|
prmPath: '/.well-known/oauth-protected-resource/mcp',
|
|
requiredScopes: ['mcp:basic'],
|
|
scopesSupported: ['mcp:basic'],
|
|
tokenVerifier
|
|
}
|
|
);
|
|
await this.server.start(app);
|
|
|
|
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
|
}
|
|
|
|
async stop() {
|
|
// If client used CIMD and we haven't checked grant_types yet,
|
|
// attempt to fetch the CIMD URL to inspect the metadata document
|
|
if (this.capturedCimdUrl && !this.grantTypesChecked) {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
const response = await fetch(this.capturedCimdUrl, {
|
|
signal: controller.signal
|
|
});
|
|
clearTimeout(timeout);
|
|
|
|
if (response.ok) {
|
|
const metadata = await response.json();
|
|
const grantTypes: string[] = metadata.grant_types || [];
|
|
const hasRefreshToken = grantTypes.includes('refresh_token');
|
|
this.grantTypesChecked = true;
|
|
this.checks.push({
|
|
id: 'sep-2207-client-metadata-grant-types',
|
|
name: 'Client metadata includes refresh_token grant type (CIMD)',
|
|
description: hasRefreshToken
|
|
? 'Client metadata document includes refresh_token in grant_types'
|
|
: 'Client SHOULD include refresh_token in grant_types client metadata (SEP-2207)',
|
|
status: hasRefreshToken ? 'SUCCESS' : 'WARNING',
|
|
timestamp: new Date().toISOString(),
|
|
specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE],
|
|
details: {
|
|
registrationMethod: 'CIMD',
|
|
cimdUrl: this.capturedCimdUrl,
|
|
grantTypes: grantTypes.length > 0 ? grantTypes.join(' ') : 'none'
|
|
}
|
|
});
|
|
}
|
|
} catch {
|
|
// CIMD URL didn't resolve - emit info check
|
|
this.grantTypesChecked = true;
|
|
this.checks.push({
|
|
id: 'sep-2207-client-metadata-grant-types',
|
|
name: 'Client metadata includes refresh_token grant type (CIMD)',
|
|
description:
|
|
'Client used CIMD but metadata URL could not be fetched to verify grant_types',
|
|
status: 'INFO',
|
|
timestamp: new Date().toISOString(),
|
|
specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE],
|
|
details: {
|
|
registrationMethod: 'CIMD',
|
|
cimdUrl: this.capturedCimdUrl,
|
|
fetchFailed: true
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
await this.authServer.stop();
|
|
await this.server.stop();
|
|
}
|
|
|
|
getChecks(): ConformanceCheck[] {
|
|
const timestamp = new Date().toISOString();
|
|
|
|
// If grant_types was never checked (no DCR, no CIMD, possibly pre-registered)
|
|
if (!this.grantTypesChecked) {
|
|
this.checks.push({
|
|
id: 'sep-2207-client-metadata-grant-types',
|
|
name: 'Client metadata includes refresh_token grant type',
|
|
description:
|
|
'Client did not use DCR or fetchable CIMD — grant_types could not be inspected',
|
|
status: 'INFO',
|
|
timestamp,
|
|
specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE],
|
|
details: {
|
|
registrationMethod: 'unknown'
|
|
}
|
|
});
|
|
}
|
|
|
|
// If offline_access check never ran, the authorization flow didn't complete
|
|
if (
|
|
!this.checks.some((c) => c.id === 'sep-2207-offline-access-requested')
|
|
) {
|
|
this.checks.push({
|
|
id: 'sep-2207-offline-access-requested',
|
|
name: 'Client requests offline_access scope',
|
|
description:
|
|
'Client did not complete authorization flow — offline_access scope check could not be performed',
|
|
status: 'FAILURE',
|
|
timestamp,
|
|
specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE]
|
|
});
|
|
}
|
|
|
|
return this.checks;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scenario: Offline Access Not Supported (SEP-2207)
|
|
*
|
|
* Tests that clients do NOT include `offline_access` in the authorization
|
|
* request scope when the AS metadata does NOT list it in `scopes_supported`.
|
|
*
|
|
* Per SEP-2207, clients MAY add offline_access only "when the Authorization
|
|
* Server's metadata lists it in scopes_supported". If the AS doesn't support
|
|
* it, requesting it is an error (requesting an unsupported scope).
|
|
*
|
|
* Setup:
|
|
* - AS metadata: scopes_supported does NOT include 'offline_access'
|
|
* - PRM: standard scopes
|
|
*/
|
|
export class OfflineAccessNotSupportedScenario implements Scenario {
|
|
name = 'auth/offline-access-not-supported';
|
|
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
|
|
description =
|
|
'Tests that client does not request offline_access when AS does not list it in scopes_supported (SEP-2207)';
|
|
|
|
private authServer = new ServerLifecycle();
|
|
private server = new ServerLifecycle();
|
|
private checks: ConformanceCheck[] = [];
|
|
|
|
async start(): Promise<ScenarioUrls> {
|
|
this.checks = [];
|
|
|
|
const tokenVerifier = new MockTokenVerifier(this.checks, [
|
|
'mcp:basic',
|
|
'mcp:read'
|
|
]);
|
|
|
|
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
|
|
tokenVerifier,
|
|
scopesSupported: ['mcp:basic', 'mcp:read'],
|
|
onAuthorizationRequest: (data) => {
|
|
const requestedScopes = data.scope ? data.scope.split(' ') : [];
|
|
const hasOfflineAccess = requestedScopes.includes('offline_access');
|
|
this.checks.push({
|
|
id: 'sep-2207-offline-access-not-requested',
|
|
name: 'Client does not request unsupported offline_access',
|
|
description: hasOfflineAccess
|
|
? 'Client MUST NOT request offline_access when it is not listed in AS scopes_supported (SEP-2207)'
|
|
: 'Client correctly did not request offline_access when AS does not list it in scopes_supported',
|
|
status: hasOfflineAccess ? 'FAILURE' : 'SUCCESS',
|
|
timestamp: data.timestamp,
|
|
specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE],
|
|
details: {
|
|
asScopesSupported: 'mcp:basic mcp:read',
|
|
requestedScope: data.scope || 'none'
|
|
}
|
|
});
|
|
}
|
|
});
|
|
await this.authServer.start(authApp);
|
|
|
|
const app = createServer(
|
|
this.checks,
|
|
this.server.getUrl,
|
|
this.authServer.getUrl,
|
|
{
|
|
prmPath: '/.well-known/oauth-protected-resource/mcp',
|
|
requiredScopes: ['mcp:basic', 'mcp:read'],
|
|
scopesSupported: ['mcp:basic', 'mcp:read'],
|
|
tokenVerifier
|
|
}
|
|
);
|
|
await this.server.start(app);
|
|
|
|
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
|
}
|
|
|
|
async stop() {
|
|
await this.authServer.stop();
|
|
await this.server.stop();
|
|
}
|
|
|
|
getChecks(): ConformanceCheck[] {
|
|
if (
|
|
!this.checks.some((c) => c.id === 'sep-2207-offline-access-not-requested')
|
|
) {
|
|
this.checks.push({
|
|
id: 'sep-2207-offline-access-not-requested',
|
|
name: 'Client does not request unsupported offline_access',
|
|
description:
|
|
'Client did not complete authorization flow — offline_access scope check could not be performed',
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE]
|
|
});
|
|
}
|
|
|
|
return this.checks;
|
|
}
|
|
}
|