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:
Paul Carleton 2026-01-23 11:43:56 +00:00 • committed by GitHub
commit 245e44e882
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 304 additions and 4 deletions

View file

@ -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'],

View file

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

View 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;
}
}

View file

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

View file

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