From 9b7dc3db85cce977f6b957428a00bf0168414761 Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 23 Feb 2026 17:05:58 -0800 Subject: [PATCH] feat: add sep-2207 client checks --- .../clients/typescript/everything-client.ts | 5 +- src/index.ts | 4 +- src/scenarios/client/auth/index.test.ts | 21 +- src/scenarios/client/auth/index.ts | 12 +- src/scenarios/client/auth/offline-access.ts | 308 ++++++++++++++++++ src/scenarios/client/auth/spec-references.ts | 4 + src/scenarios/index.ts | 6 + 7 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 src/scenarios/client/auth/offline-access.ts diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 21804a8..3f86339 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -146,7 +146,10 @@ registerScenarios( 'auth/token-endpoint-auth-post', 'auth/token-endpoint-auth-none', // Resource mismatch (client should error when PRM resource doesn't match) - 'auth/resource-mismatch' + 'auth/resource-mismatch', + // SEP-2207: Offline access / refresh token guidance (draft) + 'auth/offline-access-scope', + 'auth/offline-access-not-supported' ], runAuthClient ); diff --git a/src/index.ts b/src/index.ts index 537fb16..e2d6e02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { listCoreScenarios, listExtensionScenarios, listBackcompatScenarios, + listDraftScenarios, listScenariosForSpec, listClientScenariosForSpec, getScenarioSpecVersions, @@ -112,6 +113,7 @@ program backcompat: listBackcompatScenarios, auth: listAuthScenarios, metadata: listMetadataScenarios, + draft: listDraftScenarios, 'sep-835': () => listAuthScenarios().filter((name) => name.startsWith('auth/scope-')) }; @@ -230,7 +232,7 @@ program console.error('\nAvailable client scenarios:'); listScenarios().forEach((s) => console.error(` - ${s}`)); console.error( - '\nAvailable suites: all, core, extensions, backcompat, auth, metadata, sep-835' + '\nAvailable suites: all, core, extensions, backcompat, auth, metadata, draft, sep-835' ); process.exit(1); } diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index e43f0d3..e18a467 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -1,4 +1,8 @@ -import { authScenariosList, backcompatScenariosList } from './index'; +import { + authScenariosList, + backcompatScenariosList, + draftScenariosList +} from './index'; import { runClientAgainstScenario, InlineClientRunner @@ -61,6 +65,21 @@ describe('Client Back-compat Scenarios', () => { } }); +describe('Client Draft Scenarios', () => { + for (const scenario of draftScenariosList) { + test(`${scenario.name} passes`, async () => { + const clientFn = getHandler(scenario.name); + if (!clientFn) { + throw new Error(`No handler registered for scenario: ${scenario.name}`); + } + const runner = new InlineClientRunner(clientFn); + await runClientAgainstScenario(runner, scenario.name, { + allowClientError: allowClientErrorScenarios.has(scenario.name) + }); + }); + } +}); + describe('Negative tests', () => { test('bad client requests root PRM location', async () => { const runner = new InlineClientRunner(badPrmClient); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 73c9ddb..7f65aa0 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -24,6 +24,10 @@ import { import { ResourceMismatchScenario } from './resource-mismatch'; import { PreRegistrationScenario } from './pre-registration'; import { CrossAppAccessCompleteFlowScenario } from './cross-app-access'; +import { + OfflineAccessScopeScenario, + OfflineAccessNotSupportedScenario +} from './offline-access'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -37,7 +41,6 @@ export const authScenariosList: Scenario[] = [ new ClientSecretBasicAuthScenario(), new ClientSecretPostAuthScenario(), new PublicClientAuthScenario(), - new ResourceMismatchScenario(), new PreRegistrationScenario() ]; @@ -53,3 +56,10 @@ export const extensionScenariosList: Scenario[] = [ new ClientCredentialsBasicScenario(), new CrossAppAccessCompleteFlowScenario() ]; + +// Draft scenarios (informational - not scored for tier assessment) +export const draftScenariosList: Scenario[] = [ + new ResourceMismatchScenario(), + new OfflineAccessScopeScenario(), + new OfflineAccessNotSupportedScenario() +]; diff --git a/src/scenarios/client/auth/offline-access.ts b/src/scenarios/client/auth/offline-access.ts new file mode 100644 index 0000000..4903785 --- /dev/null +++ b/src/scenarios/client/auth/offline-access.ts @@ -0,0 +1,308 @@ +import type { Scenario, ConformanceCheck } from '../../../types'; +import { ScenarioUrls, SpecVersion } 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'; + specVersions: SpecVersion[] = ['draft']; + description = + 'Tests that client 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 { + 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'; + specVersions: SpecVersion[] = ['draft']; + 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 { + 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; + } +} diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 4020bfc..768dd65 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -100,5 +100,9 @@ export const SpecReferences: { [key: string]: SpecReference } = { SEP_990_ENTERPRISE_OAUTH: { id: 'SEP-990-Enterprise-Managed-OAuth', url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-oauth.mdx' + }, + SEP_2207_REFRESH_TOKEN_GUIDANCE: { + id: 'SEP-2207-Refresh-Token-Guidance', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207' } }; diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index d67fae4..36c8f32 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -56,6 +56,7 @@ import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; import { authScenariosList, backcompatScenariosList, + draftScenariosList, extensionScenariosList } from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; @@ -149,6 +150,7 @@ const scenariosList: Scenario[] = [ new SSERetryScenario(), ...authScenariosList, ...backcompatScenariosList, + ...draftScenariosList, ...extensionScenariosList ]; @@ -210,6 +212,10 @@ export function listBackcompatScenarios(): string[] { return backcompatScenariosList.map((scenario) => scenario.name); } +export function listDraftScenarios(): string[] { + return draftScenariosList.map((scenario) => scenario.name); +} + export { listMetadataScenarios }; // All valid spec versions, used by the CLI to validate --spec-version input.