conformance/src/scenarios/client/auth/offline-access.ts
Paul Carleton acc70de0f6
refactor: range-based spec version tagging (introducedIn/removedIn) (#265)
* 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>
2026-05-14 16:12:32 +01:00

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;
}
}