feat: add pre-registration conformance test (#120)
* feat: add pre-registration conformance test Add conformance test for OAuth pre-registration flow where the server does NOT support Dynamic Client Registration (DCR) and clients must use pre-configured static credentials. This addresses issue #34 (Client Registration Methods) - specifically the pre-registration section that was previously not covered. Changes: - Add disableDynamicRegistration option to createAuthServer helper - Add auth/pre-registration scenario with pre-registered client creds - Add MCP_PREREGISTRATION spec reference - Add context schema for pre-registration credentials - Add withOAuthRetryWithProvider helper for pre-configured providers - Add negative test client (auth-test-attempts-dcr.ts) that ignores pre-registered credentials and fails The scenario verifies that when a server does not advertise registration_endpoint in its OAuth metadata, compliant clients use pre-registered credentials passed via context instead of attempting DCR. Closes #34 * fix: add redirect_uris to saveClientInformation call * chore: remove unused WithOAuthRetryOptions interface
This commit is contained in:
parent
5dca74be9a
commit
245e44e882
8 changed files with 304 additions and 4 deletions
50
examples/clients/typescript/auth-test-attempts-dcr.ts
Normal file
50
examples/clients/typescript/auth-test-attempts-dcr.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#!/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 ignores pre-registered credentials and attempts DCR.
|
||||
*
|
||||
* This client intentionally ignores the client_id and client_secret passed via
|
||||
* MCP_CONFORMANCE_CONTEXT and instead attempts to do Dynamic Client Registration.
|
||||
* When run against a server that does not support DCR (no registration_endpoint),
|
||||
* this client will fail.
|
||||
*
|
||||
* Used to test that conformance checks detect clients that don't properly
|
||||
* use pre-registered credentials when server doesn't support DCR.
|
||||
*/
|
||||
export async function runClient(serverUrl: string): Promise<void> {
|
||||
const client = new Client(
|
||||
{ name: 'test-auth-client-attempts-dcr', version: '1.0.0' },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
// Non-compliant: ignores pre-registered credentials from context
|
||||
// and creates a fresh provider that will attempt DCR
|
||||
const oauthFetch = withOAuthRetry(
|
||||
'test-auth-client-attempts-dcr',
|
||||
new URL(serverUrl)
|
||||
)(fetch);
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
fetch: oauthFetch
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Connected to MCP server (attempted DCR instead of pre-reg)');
|
||||
|
||||
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-attempts-dcr <server-url>');
|
||||
|
|
@ -21,7 +21,12 @@ import {
|
|||
} from '@modelcontextprotocol/sdk/client/auth-extensions.js';
|
||||
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { ClientConformanceContextSchema } from '../../../src/schemas/context.js';
|
||||
import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry.js';
|
||||
import {
|
||||
withOAuthRetry,
|
||||
withOAuthRetryWithProvider,
|
||||
handle401
|
||||
} from './helpers/withOAuthRetry.js';
|
||||
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
|
||||
import { logger } from './helpers/logger.js';
|
||||
|
||||
/**
|
||||
|
|
@ -300,6 +305,69 @@ export async function runClientCredentialsBasic(
|
|||
|
||||
registerScenario('auth/client-credentials-basic', runClientCredentialsBasic);
|
||||
|
||||
// ============================================================================
|
||||
// Pre-registration scenario
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Pre-registration: client uses pre-registered credentials (no DCR).
|
||||
*
|
||||
* Server does not advertise registration_endpoint, so client must use
|
||||
* pre-configured client_id and client_secret passed via context.
|
||||
*/
|
||||
export async function runPreRegistration(serverUrl: string): Promise<void> {
|
||||
const ctx = parseContext();
|
||||
if (ctx.name !== 'auth/pre-registration') {
|
||||
throw new Error(`Expected pre-registration context, got ${ctx.name}`);
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'conformance-pre-registration', version: '1.0.0' },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
// Create provider with pre-registered credentials
|
||||
const provider = new ConformanceOAuthProvider(
|
||||
'http://localhost:3000/callback',
|
||||
{
|
||||
client_name: 'conformance-pre-registration',
|
||||
redirect_uris: ['http://localhost:3000/callback']
|
||||
}
|
||||
);
|
||||
|
||||
// Pre-set the client information so the SDK won't attempt DCR
|
||||
provider.saveClientInformation({
|
||||
client_id: ctx.client_id,
|
||||
client_secret: ctx.client_secret,
|
||||
redirect_uris: ['http://localhost:3000/callback']
|
||||
});
|
||||
|
||||
// Use the provider-based middleware
|
||||
const oauthFetch = withOAuthRetryWithProvider(
|
||||
provider,
|
||||
new URL(serverUrl),
|
||||
handle401
|
||||
)(fetch);
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
fetch: oauthFetch
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected with pre-registered credentials');
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
registerScenario('auth/pre-registration', runPreRegistration);
|
||||
|
||||
// ============================================================================
|
||||
// Main entry point
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export const handle401 = async (
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a fetch wrapper that handles OAuth authentication with retry logic.
|
||||
*
|
||||
|
|
@ -53,8 +54,10 @@ export const handle401 = async (
|
|||
* - Does not throw UnauthorizedError on redirect, but instead retries
|
||||
* - Calls next() instead of throwing for redirect-based auth
|
||||
*
|
||||
* @param provider - OAuth client provider for authentication
|
||||
* @param clientName - Client name for OAuth registration
|
||||
* @param baseUrl - Base URL for OAuth server discovery (defaults to request URL domain)
|
||||
* @param handle401Fn - Custom 401 handler function
|
||||
* @param clientMetadataUrl - Optional CIMD URL for URL-based client IDs
|
||||
* @returns A fetch middleware function
|
||||
*/
|
||||
export const withOAuthRetry = (
|
||||
|
|
@ -71,6 +74,18 @@ export const withOAuthRetry = (
|
|||
},
|
||||
clientMetadataUrl
|
||||
);
|
||||
return withOAuthRetryWithProvider(provider, baseUrl, handle401Fn);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a fetch wrapper using a pre-configured OAuth provider.
|
||||
* Use this when you need to pre-set client credentials (e.g., for pre-registration tests).
|
||||
*/
|
||||
export const withOAuthRetryWithProvider = (
|
||||
provider: ConformanceOAuthProvider,
|
||||
baseUrl?: string | URL,
|
||||
handle401Fn: typeof handle401 = handle401
|
||||
): Middleware => {
|
||||
return (next: FetchLike) => {
|
||||
return async (
|
||||
input: string | URL,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export interface AuthServerOptions {
|
|||
tokenEndpointAuthMethodsSupported?: string[];
|
||||
tokenEndpointAuthSigningAlgValuesSupported?: string[];
|
||||
clientIdMetadataDocumentSupported?: boolean;
|
||||
/** Set to true to NOT advertise registration_endpoint (for pre-registration tests) */
|
||||
disableDynamicRegistration?: boolean;
|
||||
tokenVerifier?: MockTokenVerifier;
|
||||
onTokenRequest?: (requestData: {
|
||||
scope?: string;
|
||||
|
|
@ -65,6 +67,7 @@ export function createAuthServer(
|
|||
tokenEndpointAuthMethodsSupported = ['none'],
|
||||
tokenEndpointAuthSigningAlgValuesSupported,
|
||||
clientIdMetadataDocumentSupported,
|
||||
disableDynamicRegistration = false,
|
||||
tokenVerifier,
|
||||
onTokenRequest,
|
||||
onAuthorizationRequest,
|
||||
|
|
@ -114,7 +117,9 @@ export function createAuthServer(
|
|||
issuer: getAuthBaseUrl(),
|
||||
authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`,
|
||||
token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`,
|
||||
registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`,
|
||||
...(!disableDynamicRegistration && {
|
||||
registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`
|
||||
}),
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: grantTypesSupported,
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
ClientCredentialsJwtScenario,
|
||||
ClientCredentialsBasicScenario
|
||||
} from './client-credentials';
|
||||
import { PreRegistrationScenario } from './pre-registration';
|
||||
|
||||
// Auth scenarios (required for tier 1)
|
||||
export const authScenariosList: Scenario[] = [
|
||||
|
|
@ -35,7 +36,8 @@ export const authScenariosList: Scenario[] = [
|
|||
new ScopeRetryLimitScenario(),
|
||||
new ClientSecretBasicAuthScenario(),
|
||||
new ClientSecretPostAuthScenario(),
|
||||
new PublicClientAuthScenario()
|
||||
new PublicClientAuthScenario(),
|
||||
new PreRegistrationScenario()
|
||||
];
|
||||
|
||||
// Extension scenarios (optional for tier 1 - protocol extensions)
|
||||
|
|
|
|||
151
src/scenarios/client/auth/pre-registration.ts
Normal file
151
src/scenarios/client/auth/pre-registration.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import type { Scenario, ConformanceCheck, 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';
|
||||
|
||||
const PRE_REGISTERED_CLIENT_ID = 'pre-registered-client';
|
||||
const PRE_REGISTERED_CLIENT_SECRET = 'pre-registered-secret';
|
||||
|
||||
/**
|
||||
* Scenario: Pre-registration (static client credentials)
|
||||
*
|
||||
* Tests OAuth flow where the server does NOT support Dynamic Client Registration.
|
||||
* Clients must use pre-registered credentials passed via context.
|
||||
*
|
||||
* This tests the pre-registration approach described in the MCP spec:
|
||||
* https://modelcontextprotocol.io/specification/draft/basic/authorization#preregistration
|
||||
*/
|
||||
export class PreRegistrationScenario implements Scenario {
|
||||
name = 'auth/pre-registration';
|
||||
description =
|
||||
'Tests OAuth flow with pre-registered client credentials. Server does not support DCR.';
|
||||
|
||||
private authServer = new ServerLifecycle();
|
||||
private server = new ServerLifecycle();
|
||||
private checks: ConformanceCheck[] = [];
|
||||
|
||||
async start(): Promise<ScenarioUrls> {
|
||||
this.checks = [];
|
||||
const tokenVerifier = new MockTokenVerifier(this.checks, []);
|
||||
|
||||
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
|
||||
tokenVerifier,
|
||||
disableDynamicRegistration: true,
|
||||
tokenEndpointAuthMethodsSupported: ['client_secret_basic'],
|
||||
onTokenRequest: ({ authorizationHeader, timestamp }) => {
|
||||
// Verify client used pre-registered credentials via Basic auth
|
||||
if (!authorizationHeader?.startsWith('Basic ')) {
|
||||
this.checks.push({
|
||||
id: 'pre-registration-auth',
|
||||
name: 'Pre-registration authentication',
|
||||
description:
|
||||
'Client did not use Basic authentication with pre-registered credentials',
|
||||
status: 'FAILURE',
|
||||
timestamp,
|
||||
specReferences: [SpecReferences.MCP_PREREGISTRATION]
|
||||
});
|
||||
return {
|
||||
error: 'invalid_client',
|
||||
errorDescription: 'Missing or invalid Authorization header',
|
||||
statusCode: 401
|
||||
};
|
||||
}
|
||||
|
||||
const base64Credentials = authorizationHeader.slice(6);
|
||||
const credentials = Buffer.from(base64Credentials, 'base64').toString(
|
||||
'utf-8'
|
||||
);
|
||||
const [clientId, clientSecret] = credentials.split(':');
|
||||
|
||||
if (
|
||||
clientId !== PRE_REGISTERED_CLIENT_ID ||
|
||||
clientSecret !== PRE_REGISTERED_CLIENT_SECRET
|
||||
) {
|
||||
this.checks.push({
|
||||
id: 'pre-registration-auth',
|
||||
name: 'Pre-registration authentication',
|
||||
description: `Client used incorrect pre-registered credentials. Expected client_id '${PRE_REGISTERED_CLIENT_ID}', got '${clientId}'`,
|
||||
status: 'FAILURE',
|
||||
timestamp,
|
||||
specReferences: [SpecReferences.MCP_PREREGISTRATION],
|
||||
details: {
|
||||
expectedClientId: PRE_REGISTERED_CLIENT_ID,
|
||||
actualClientId: clientId
|
||||
}
|
||||
});
|
||||
return {
|
||||
error: 'invalid_client',
|
||||
errorDescription: 'Invalid pre-registered credentials',
|
||||
statusCode: 401
|
||||
};
|
||||
}
|
||||
|
||||
// Success - client used correct pre-registered credentials
|
||||
this.checks.push({
|
||||
id: 'pre-registration-auth',
|
||||
name: 'Pre-registration authentication',
|
||||
description:
|
||||
'Client correctly used pre-registered credentials when server does not support DCR',
|
||||
status: 'SUCCESS',
|
||||
timestamp,
|
||||
specReferences: [SpecReferences.MCP_PREREGISTRATION],
|
||||
details: { clientId }
|
||||
});
|
||||
|
||||
return {
|
||||
token: `test-token-prereg-${Date.now()}`,
|
||||
scopes: []
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
await this.authServer.start(authApp);
|
||||
|
||||
const app = createServer(
|
||||
this.checks,
|
||||
this.server.getUrl,
|
||||
this.authServer.getUrl,
|
||||
{
|
||||
prmPath: '/.well-known/oauth-protected-resource/mcp',
|
||||
requiredScopes: [],
|
||||
tokenVerifier
|
||||
}
|
||||
);
|
||||
|
||||
await this.server.start(app);
|
||||
|
||||
return {
|
||||
serverUrl: `${this.server.getUrl()}/mcp`,
|
||||
context: {
|
||||
client_id: PRE_REGISTERED_CLIENT_ID,
|
||||
client_secret: PRE_REGISTERED_CLIENT_SECRET
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.authServer.stop();
|
||||
await this.server.stop();
|
||||
}
|
||||
|
||||
getChecks(): ConformanceCheck[] {
|
||||
// Ensure we have the pre-registration check
|
||||
const hasPreRegCheck = this.checks.some(
|
||||
(c) => c.id === 'pre-registration-auth'
|
||||
);
|
||||
if (!hasPreRegCheck) {
|
||||
this.checks.push({
|
||||
id: 'pre-registration-auth',
|
||||
name: 'Pre-registration authentication',
|
||||
description: 'Client did not make a token request',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
specReferences: [SpecReferences.MCP_PREREGISTRATION]
|
||||
});
|
||||
}
|
||||
|
||||
return this.checks;
|
||||
}
|
||||
}
|
||||
|
|
@ -72,5 +72,9 @@ export const SpecReferences: { [key: string]: SpecReference } = {
|
|||
SEP_1046_CLIENT_CREDENTIALS: {
|
||||
id: 'SEP-1046-Client-Credentials',
|
||||
url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx'
|
||||
},
|
||||
MCP_PREREGISTRATION: {
|
||||
id: 'MCP-Preregistration',
|
||||
url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration'
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [
|
|||
name: z.literal('auth/client-credentials-basic'),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string()
|
||||
}),
|
||||
z.object({
|
||||
name: z.literal('auth/pre-registration'),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string()
|
||||
})
|
||||
]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue