[auth] Implement CIMD conformance test (#37)

* add cimd test and negative test

* Improve auth-test-no-cimd.ts documentation for clarity

Address review feedback: expanded comments to better explain what CIMD is,
why this client is non-compliant, and what behavior it's testing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove .js extensions from relative imports

tsx doesn't require .js extensions for TypeScript imports.
Keeps .js for @modelcontextprotocol/sdk imports as those are external packages.

Also improves auth-test-no-cimd.ts documentation for clarity.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Skip auth/basic-cimd test until SDK support lands

Waiting on typescript-sdk PR #1127 for CIMD support.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix trailing whitespace in auth-test-no-cimd.ts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Carleton 2025-11-20 21:00:13 +00:00 • committed by GitHub
commit d303013e5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 301 additions and 110 deletions

View file

@ -7,10 +7,10 @@ import {
UnauthorizedError
} from '@modelcontextprotocol/sdk/client/auth.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
import { runAsCli } from './helpers/cliRunner.js';
import { logger } from './helpers/logger.js';
import { withOAuthRetry } from './helpers/withOAuthRetry';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider';
import { runAsCli } from './helpers/cliRunner';
import { logger } from './helpers/logger';
/**
* Broken client that always uses root-based PRM discovery.

View file

@ -8,10 +8,10 @@ import {
UnauthorizedError
} from '@modelcontextprotocol/sdk/client/auth.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
import { runAsCli } from './helpers/cliRunner.js';
import { logger } from './helpers/logger.js';
import { withOAuthRetry } from './helpers/withOAuthRetry';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider';
import { runAsCli } from './helpers/cliRunner';
import { logger } from './helpers/logger';
/**
* Broken client that only responds to 401, not 403.

View file

@ -8,10 +8,10 @@ import {
UnauthorizedError
} from '@modelcontextprotocol/sdk/client/auth.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
import { runAsCli } from './helpers/cliRunner.js';
import { logger } from './helpers/logger.js';
import { withOAuthRetry } from './helpers/withOAuthRetry';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider';
import { runAsCli } from './helpers/cliRunner';
import { logger } from './helpers/logger';
/**
* Broken client that ignores the scope from WWW-Authenticate header.

View file

@ -0,0 +1,51 @@
#!/usr/bin/env node
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { withOAuthRetry } from './helpers/withOAuthRetry';
import { runAsCli } from './helpers/cliRunner';
import { logger } from './helpers/logger';
/**
* Non-compliant client that doesn't use CIMD (Client ID Metadata Document).
*
* This client intentionally omits the clientMetadataUrl parameter when the server
* advertises client_id_metadata_document_supported=true. A compliant client should
* use CIMD when the server supports it, but this client falls back to DCR (Dynamic
* Client Registration) instead.
*
* Used to test that conformance checks detect clients that don't properly
* implement CIMD support.
*/
export async function runClient(serverUrl: string): Promise<void> {
const client = new Client(
{ name: 'test-auth-client-no-cimd', version: '1.0.0' },
{ capabilities: {} }
);
// Non-compliant: omitting clientMetadataUrl causes fallback to DCR
// A compliant client would pass a clientMetadataUrl here when the server
// advertises client_id_metadata_document_supported=true
const oauthFetch = withOAuthRetry(
'test-auth-client-no-cimd',
new URL(serverUrl)
)(fetch);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
await client.connect(transport);
logger.debug('Connected to MCP server (without CIMD)');
await client.listTools();
logger.debug('Successfully listed tools');
await client.callTool({ name: 'test-tool', arguments: {} });
logger.debug('Successfully called tool');
await transport.close();
logger.debug('Connection closed successfully');
}
runAsCli(runClient, import.meta.url, 'auth-test-no-cimd <server-url>');

View file

@ -8,10 +8,10 @@ import {
UnauthorizedError
} from '@modelcontextprotocol/sdk/client/auth.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
import { runAsCli } from './helpers/cliRunner.js';
import { logger } from './helpers/logger.js';
import { withOAuthRetry } from './helpers/withOAuthRetry';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider';
import { runAsCli } from './helpers/cliRunner';
import { logger } from './helpers/logger';
/**
* Broken client that only requests a subset of scopes.

View file

@ -2,9 +2,17 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
import { runAsCli } from './helpers/cliRunner.js';
import { logger } from './helpers/logger.js';
import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry';
import { runAsCli } from './helpers/cliRunner';
import { logger } from './helpers/logger';
/**
* Fixed client metadata URL for CIMD conformance tests.
* When server supports client_id_metadata_document_supported, this URL
* will be used as the client_id instead of doing dynamic registration.
*/
const CIMD_CLIENT_METADATA_URL =
'https://conformance-test.local/client-metadata.json';
/**
* Well-behaved auth client that follows all OAuth protocols correctly.
@ -17,7 +25,9 @@ export async function runClient(serverUrl: string): Promise<void> {
const oauthFetch = withOAuthRetry(
'test-auth-client',
new URL(serverUrl)
new URL(serverUrl),
handle401,
CIMD_CLIENT_METADATA_URL
)(fetch);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {

View file

@ -27,13 +27,11 @@ export class ConformanceOAuthProvider implements OAuthClientProvider {
return this._clientMetadata;
}
get clientMetadataUrl(): string | undefined {
return this._clientMetadataUrl?.toString();
}
clientInformation(): OAuthClientInformation | undefined {
if (this._clientMetadataUrl) {
console.log('Using client ID metadata URL');
return {
client_id: this._clientMetadataUrl.toString()
};
}
return this._clientInformation;
}

View file

@ -60,14 +60,16 @@ export const handle401 = async (
export const withOAuthRetry = (
clientName: string,
baseUrl?: string | URL,
handle401Fn: typeof handle401 = handle401
handle401Fn: typeof handle401 = handle401,
clientMetadataUrl?: string
): Middleware => {
const provider = new ConformanceOAuthProvider(
'http://localhost:3000/callback',
{
client_name: clientName,
redirect_uris: ['http://localhost:3000/callback']
}
},
clientMetadataUrl
);
return (next: FetchLike) => {
return async (

View file

@ -1,4 +1,4 @@
import { ConformanceCheck, CheckStatus } from '../types.js';
import { ConformanceCheck, CheckStatus } from '../types';
export function createServerInfoCheck(serverInfo: {
name: string;

View file

@ -1,4 +1,4 @@
// Namespaced exports for client checks
import * as client from './client.js';
import * as client from './client';
export const clientChecks = client;

View file

@ -0,0 +1,99 @@
import type { Scenario, ConformanceCheck } from '../../../types';
import { ScenarioUrls } from '../../../types';
import { createAuthServer } from './helpers/createAuthServer';
import { createServer } from './helpers/createServer';
import { ServerLifecycle } from './helpers/serverLifecycle';
import { SpecReferences } from './spec-references';
/**
* Fixed client metadata URL that clients should use for CIMD tests.
* This URL doesn't need to resolve - the server will accept it as-is
* and use hardcoded metadata.
*/
export const CIMD_CLIENT_METADATA_URL =
'https://conformance-test.local/client-metadata.json';
/**
* Scenario: Client ID Metadata Documents (SEP-991/URL-based client IDs)
*
* Tests that when a server advertises client_id_metadata_document_supported=true,
* clients SHOULD use a URL as their client_id instead of using dynamic client
* registration.
*/
export class AuthBasicCIMDScenario implements Scenario {
name = 'auth/basic-cimd';
description =
'Tests OAuth flow with Client ID Metadata Documents (SEP-991/URL-based client IDs). Server advertises client_id_metadata_document_supported=true and client should use URL as client_id instead of DCR.';
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
async start(): Promise<ScenarioUrls> {
this.checks = [];
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
clientIdMetadataDocumentSupported: true,
onAuthorizationRequest: (data) => {
// Check if client used URL-based client ID
const usedUrlClientId = data.clientId === CIMD_CLIENT_METADATA_URL;
this.checks.push({
id: 'cimd-client-id-used',
name: 'Client ID Metadata Document Usage',
description: usedUrlClientId
? 'Client correctly used URL-based client ID when server supports client_id_metadata_document_supported'
: 'Client SHOULD use URL-based client ID when server advertises client_id_metadata_document_supported=true',
status: usedUrlClientId ? 'SUCCESS' : 'WARNING',
timestamp: data.timestamp,
specReferences: [
SpecReferences.MCP_CLIENT_ID_METADATA_DOCUMENTS,
SpecReferences.IETF_CIMD
],
details: {
expectedClientId: CIMD_CLIENT_METADATA_URL,
actualClientId: data.clientId || 'none'
}
});
}
});
await this.authServer.start(authApp);
const app = createServer(
this.checks,
this.server.getUrl,
this.authServer.getUrl
);
await this.server.start(app);
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop() {
await this.authServer.stop();
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
// Ensure we have the CIMD check - if not, the client didn't make an auth request
const hasCimdCheck = this.checks.some(
(c) => c.id === 'cimd-client-id-used'
);
if (!hasCimdCheck) {
this.checks.push({
id: 'cimd-client-id-used',
name: 'Client ID Metadata Document Usage',
description:
'Client did not make an authorization request to test CIMD support',
status: 'FAILURE',
timestamp: new Date().toISOString(),
specReferences: [
SpecReferences.MCP_CLIENT_ID_METADATA_DOCUMENTS,
SpecReferences.IETF_CIMD
]
});
}
return this.checks;
}
}

View file

@ -6,12 +6,12 @@
* generated from them.
*/
import type { Scenario, ConformanceCheck } from '../../../types.js';
import { ScenarioUrls } from '../../../types.js';
import { createAuthServer } from './helpers/createAuthServer.js';
import { createServer } from './helpers/createServer.js';
import { ServerLifecycle } from './helpers/serverLifecycle.js';
import { SpecReferences } from './spec-references.js';
import type { Scenario, ConformanceCheck } from '../../../types';
import { ScenarioUrls } from '../../../types';
import { createAuthServer } from './helpers/createAuthServer';
import { createServer } from './helpers/createServer';
import { ServerLifecycle } from './helpers/serverLifecycle';
import { SpecReferences } from './spec-references';
import { Request, Response } from 'express';
/**

View file

@ -1,8 +1,8 @@
import express, { Request, Response } from 'express';
import type { ConformanceCheck } from '../../../../types.js';
import { createRequestLogger } from '../../../request-logger.js';
import { SpecReferences } from '../spec-references.js';
import { MockTokenVerifier } from './mockTokenVerifier.js';
import type { ConformanceCheck } from '../../../../types';
import { createRequestLogger } from '../../../request-logger';
import { SpecReferences } from '../spec-references';
import { MockTokenVerifier } from './mockTokenVerifier';
export interface AuthServerOptions {
metadataPath?: string;
@ -10,6 +10,7 @@ export interface AuthServerOptions {
loggingEnabled?: boolean;
routePrefix?: string;
scopesSupported?: string[];
clientIdMetadataDocumentSupported?: boolean;
tokenVerifier?: MockTokenVerifier;
onTokenRequest?: (requestData: {
scope?: string;
@ -17,6 +18,7 @@ export interface AuthServerOptions {
timestamp: string;
}) => { token: string; scopes: string[] };
onAuthorizationRequest?: (requestData: {
clientId?: string;
scope?: string;
timestamp: string;
}) => void;
@ -33,6 +35,7 @@ export function createAuthServer(
loggingEnabled = true,
routePrefix = '',
scopesSupported,
clientIdMetadataDocumentSupported,
tokenVerifier,
onTokenRequest,
onAuthorizationRequest
@ -93,6 +96,12 @@ export function createAuthServer(
metadata.scopes_supported = scopesSupported;
}
// Add client_id_metadata_document_supported if provided
if (clientIdMetadataDocumentSupported !== undefined) {
metadata.client_id_metadata_document_supported =
clientIdMetadataDocumentSupported;
}
// Add OpenID Configuration specific fields
if (isOpenIdConfiguration) {
metadata.jwks_uri = `${getAuthBaseUrl()}/.well-known/jwks.json`;
@ -123,6 +132,7 @@ export function createAuthServer(
if (onAuthorizationRequest) {
onAuthorizationRequest({
clientId: req.query.client_id as string | undefined,
scope: scopeParam,
timestamp
});

View file

@ -9,10 +9,10 @@ import {
} from '@modelcontextprotocol/sdk/types.js';
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
import express, { Request, Response, NextFunction } from 'express';
import type { ConformanceCheck } from '../../../../types.js';
import { createRequestLogger } from '../../../request-logger.js';
import { MockTokenVerifier } from './mockTokenVerifier.js';
import { SpecReferences } from '../spec-references.js';
import type { ConformanceCheck } from '../../../../types';
import { createRequestLogger } from '../../../request-logger';
import { MockTokenVerifier } from './mockTokenVerifier';
import { SpecReferences } from '../spec-references';
export interface ServerOptions {
prmPath?: string | null;

View file

@ -1,7 +1,7 @@
import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js';
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import type { ConformanceCheck } from '../../../../types.js';
import { SpecReferences } from '../spec-references.js';
import type { ConformanceCheck } from '../../../../types';
import { SpecReferences } from '../spec-references';
export class MockTokenVerifier implements OAuthTokenVerifier {
private tokenScopes: Map<string, string[]> = new Map();

View file

@ -1,14 +1,15 @@
import { authScenariosList } from './index.js';
import { authScenariosList } from './index';
import {
runClientAgainstScenario,
InlineClientRunner
} from './test_helpers/testClient.js';
import { runClient as goodClient } from '../../../../examples/clients/typescript/auth-test.js';
import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm.js';
import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope.js';
import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes.js';
import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403.js';
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger.js';
} from './test_helpers/testClient';
import { runClient as goodClient } from '../../../../examples/clients/typescript/auth-test';
import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm';
import { runClient as noCimdClient } from '../../../../examples/clients/typescript/auth-test-no-cimd';
import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope';
import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes';
import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403';
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger';
beforeAll(() => {
setLogLevel('error');
@ -22,7 +23,10 @@ const skipScenarios = new Set<string>([
// Waiting on typescript-sdk support for using scopes_supported from PRM
// to request scopes.
// https://github.com/modelcontextprotocol/typescript-sdk/pull/1133
'auth/scope-from-scopes-supported'
'auth/scope-from-scopes-supported',
// Waiting on typescript-sdk support for CIMD
// https://github.com/modelcontextprotocol/typescript-sdk/pull/1127
'auth/basic-cimd'
]);
describe('Client Auth Scenarios', () => {
@ -76,4 +80,11 @@ describe('Negative tests', () => {
'scope-step-up-escalation'
]);
});
test('client uses DCR instead of CIMD when server supports it', async () => {
const runner = new InlineClientRunner(noCimdClient);
await runClientAgainstScenario(runner, 'auth/basic-cimd', [
'cimd-client-id-used'
]);
});
});

View file

@ -1,18 +1,20 @@
import { Scenario } from '../../../types';
import { metadataScenarios } from './discovery-metadata.js';
import { metadataScenarios } from './discovery-metadata';
import { AuthBasicCIMDScenario } from './basic-cimd';
import {
Auth20250326OAuthMetadataBackcompatScenario,
Auth20250326OEndpointFallbackScenario
} from './march-spec-backcompat.js';
} from './march-spec-backcompat';
import {
ScopeFromWwwAuthenticateScenario,
ScopeFromScopesSupportedScenario,
ScopeOmittedWhenUndefinedScenario,
ScopeStepUpAuthScenario
} from './scope-handling.js';
} from './scope-handling';
export const authScenariosList: Scenario[] = [
...metadataScenarios,
new AuthBasicCIMDScenario(),
new Auth20250326OAuthMetadataBackcompatScenario(),
new Auth20250326OEndpointFallbackScenario(),
new ScopeFromWwwAuthenticateScenario(),

View file

@ -1,10 +1,10 @@
import type { Scenario, ConformanceCheck } from '../../../types.js';
import { ScenarioUrls } from '../../../types.js';
import { createAuthServer } from './helpers/createAuthServer.js';
import { createServer } from './helpers/createServer.js';
import { ServerLifecycle } from './helpers/serverLifecycle.js';
import type { Scenario, ConformanceCheck } from '../../../types';
import { ScenarioUrls } from '../../../types';
import { createAuthServer } from './helpers/createAuthServer';
import { createServer } from './helpers/createServer';
import { ServerLifecycle } from './helpers/serverLifecycle';
import express, { Request, Response } from 'express';
import { SpecReferences } from './spec-references.js';
import { SpecReferences } from './spec-references';
export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario {
name = 'auth/2025-03-26-oauth-metadata-backcompat';

View file

@ -1,10 +1,10 @@
import type { Scenario, ConformanceCheck } from '../../../types.js';
import { ScenarioUrls } from '../../../types.js';
import { createAuthServer } from './helpers/createAuthServer.js';
import { createServer } from './helpers/createServer.js';
import { ServerLifecycle } from './helpers/serverLifecycle.js';
import { SpecReferences } from './spec-references.js';
import { MockTokenVerifier } from './helpers/mockTokenVerifier.js';
import type { Scenario, ConformanceCheck } from '../../../types';
import { ScenarioUrls } 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';
import type { Request, Response, NextFunction } from 'express';
/**

View file

@ -52,5 +52,13 @@ export const SpecReferences: { [key: string]: SpecReference } = {
MCP_AUTH_ERROR_HANDLING: {
id: 'MCP-Auth-error-handling',
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#error-handling'
},
MCP_CLIENT_ID_METADATA_DOCUMENTS: {
id: 'MCP-Client-ID-Metadata-Documents',
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents'
},
IETF_CIMD: {
id: 'IETF-OAuth-Client-ID-Metadata-Document',
url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00'
}
};

Some files were not shown because too many files have changed in this diff Show more