From bd07d9a54c51e2f02b3f36244473c8702c2a63ac Mon Sep 17 00:00:00 2001 From: Josh Abbott Date: Tue, 28 Jul 2026 23:26:54 -0400 Subject: [PATCH] fix(auth): add issuer to pre-registration context --- .../clients/typescript/everything-client.ts | 4 +++ src/scenarios/client/auth/index.test.ts | 33 +++++++++++++++++++ src/scenarios/client/auth/pre-registration.ts | 13 ++++++-- src/schemas/context.ts | 3 +- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index c91d0b8b..479feee2 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -678,6 +678,10 @@ export async function runPreRegistration(serverUrl: string): Promise { redirect_uris: ['http://localhost:3000/callback'] }); + // Associate the pre-registered credentials with the AS that issued them, + // keyed by its issuer (Authorization Server Binding). + provider.bindIssuer(ctx.issuer); + // Use the provider-based middleware const oauthFetch = withOAuthRetryWithProvider( provider, diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 57ff27ac..3ba876e7 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -38,6 +38,8 @@ import { runClient as dpopClient } from '../../../../examples/clients/typescript import { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; import { DRAFT_PROTOCOL_VERSION } from '../../../types'; +import { testScenarioContext } from '../../../mock-server/testing'; +import { ClientConformanceContextSchema } from '../../../schemas/context'; beforeAll(() => { setLogLevel('error'); @@ -112,6 +114,37 @@ describe('Client Draft Scenarios', () => { } }); +describe('auth/pre-registration context', () => { + // Authorization Server Binding: the context issuer is only usable as a + // binding key if it equals the issuer the mock AS publishes in its metadata. + test('supplies the issuer the mock AS publishes in its metadata', async () => { + const scenario = authScenariosList.find( + (s) => s.name === 'auth/pre-registration' + ); + if (!scenario) { + throw new Error('auth/pre-registration scenario not found'); + } + const urls = await scenario.start(testScenarioContext()); + try { + const context = ClientConformanceContextSchema.parse({ + name: 'auth/pre-registration', + ...urls.context + }); + if (context.name !== 'auth/pre-registration') { + throw new Error(`Unexpected context variant: ${context.name}`); + } + const res = await fetch( + `${context.issuer}/.well-known/oauth-authorization-server` + ); + expect(res.ok).toBe(true); + const metadata = (await res.json()) as { issuer?: string }; + expect(metadata.issuer).toBe(context.issuer); + } finally { + await scenario.stop(); + } + }); +}); + describe('Negative tests', () => { test('bad client requests root PRM location', async () => { const runner = new InlineClientRunner(badPrmClient); diff --git a/src/scenarios/client/auth/pre-registration.ts b/src/scenarios/client/auth/pre-registration.ts index 11f82690..7e4ff545 100644 --- a/src/scenarios/client/auth/pre-registration.ts +++ b/src/scenarios/client/auth/pre-registration.ts @@ -13,7 +13,12 @@ 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. + * Clients must use pre-registered credentials passed via context. The context + * also carries the mock AS's `issuer` identifier: clients targeting the + * 2026-07-28 spec MUST associate pre-registered credentials with the + * authorization server that issued them, keyed by that issuer + * (Authorization Server Binding): + * https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#authorization-server-binding * * This tests the pre-registration approach described in the MCP spec: * https://modelcontextprotocol.io/specification/draft/basic/authorization#preregistration @@ -123,7 +128,11 @@ export class PreRegistrationScenario implements Scenario { serverUrl: `${this.server.getUrl()}/mcp`, context: { client_id: PRE_REGISTERED_CLIENT_ID, - client_secret: PRE_REGISTERED_CLIENT_SECRET + client_secret: PRE_REGISTERED_CLIENT_SECRET, + // The `issuer` the mock AS publishes in its metadata — the scenario + // sets neither `metadataIssuer` nor `routePrefix`, so createAuthServer + // resolves the published issuer to exactly this URL. + issuer: this.authServer.getUrl() } }; } diff --git a/src/schemas/context.ts b/src/schemas/context.ts index cea338a7..ecfe5248 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -21,7 +21,8 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ z.object({ name: z.literal('auth/pre-registration'), client_id: z.string(), - client_secret: z.string() + client_secret: z.string(), + issuer: z.string() }), z.object({ name: z.literal('auth/enterprise-managed-authorization'),