feat (auth): add sep-2207 client checks (#166)

* feat: add sep-2207 client checks

* Update description for the offline access test

Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com>

---------

Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com>
This commit is contained in:
Wils Dawson 2026-03-18 01:34:17 -07:00 • committed by GitHub
commit 02a5f1c41d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 356 additions and 4 deletions

View file

@ -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
);

View file

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

View file

@ -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);

View file

@ -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()
];

View file

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

View file

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

View file

@ -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.