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:
parent
22f5c8bb1c
commit
02a5f1c41d
7 changed files with 356 additions and 4 deletions
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
];
|
||||
|
|
|
|||
308
src/scenarios/client/auth/offline-access.ts
Normal file
308
src/scenarios/client/auth/offline-access.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue