fix(auth): add issuer to pre-registration context (#423)

This commit is contained in:
Josh Abbott 2026-09-06 13:44:29 -04:00 • committed by GitHub
commit 4ff474c20d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 50 additions and 3 deletions

View file

@ -741,6 +741,10 @@ export async function runPreRegistration(serverUrl: string): Promise<void> {
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,

View file

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

View file

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

View file

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