From e092519df312253f7b9bcd0dafbf77d8e5e271ff Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 17 Nov 2025 13:44:43 +0000 Subject: [PATCH] feat: url based client metadata registration (SEP 991) --- src/client/auth.test.ts | 307 +++++++++++++++++- src/client/auth.ts | 58 +++- src/examples/README.md | 2 +- src/examples/client/simpleOAuthClient.ts | 32 +- .../client/simpleOAuthClientProvider.ts | 3 +- src/shared/auth.ts | 6 +- 6 files changed, 383 insertions(+), 25 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index d7dd21f7a..aec5b7ff6 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -11,9 +11,10 @@ import { extractWWWAuthenticateParams, auth, type OAuthClientProvider, - selectClientAuthMethod + selectClientAuthMethod, + isHttpsUrl } from './auth.js'; -import { ServerError } from '../server/auth/errors.js'; +import { InvalidClientMetadataError, ServerError } from '../server/auth/errors.js'; import { AuthorizationServerMetadata } from '../shared/auth.js'; import { expect, vi, type Mock } from 'vitest'; @@ -2796,4 +2797,306 @@ describe('OAuth Authorization', () => { }); }); }); + + describe('isHttpsUrl', () => { + it('returns true for valid HTTPS URL with path', () => { + expect(isHttpsUrl('https://example.com/client-metadata.json')).toBe(true); + }); + + it('returns true for HTTPS URL with query params', () => { + expect(isHttpsUrl('https://example.com/metadata?version=1')).toBe(true); + }); + + it('returns false for HTTPS URL without path', () => { + expect(isHttpsUrl('https://example.com')).toBe(false); + expect(isHttpsUrl('https://example.com/')).toBe(false); + }); + + it('returns false for HTTP URL', () => { + expect(isHttpsUrl('http://example.com/metadata')).toBe(false); + }); + + it('returns false for non-URL strings', () => { + expect(isHttpsUrl('not a url')).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isHttpsUrl(undefined)).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isHttpsUrl('')).toBe(false); + }); + + it('returns false for javascript: scheme', () => { + expect(isHttpsUrl('javascript:alert(1)')).toBe(false); + }); + + it('returns false for data: scheme', () => { + expect(isHttpsUrl('data:text/html,')).toBe(false); + }); + }); + + describe('SEP-991: URL-based Client ID fallback logic', () => { + const validClientMetadata = { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client', + client_uri: 'https://example.com/client-metadata.json' + }; + + const mockProvider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + clientMetadataUrl: 'https://example.com/client-metadata.json', + get clientMetadata() { + return validClientMetadata; + }, + clientInformation: vi.fn().mockResolvedValue(undefined), + saveClientInformation: vi.fn().mockResolvedValue(undefined), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn().mockResolvedValue(undefined), + redirectToAuthorization: vi.fn().mockResolvedValue(undefined), + saveCodeVerifier: vi.fn().mockResolvedValue(undefined), + codeVerifier: vi.fn().mockResolvedValue('verifier123') + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses URL-based client ID when server supports it', async () => { + // Mock protected resource metadata discovery (404 to skip) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}) + }); + + // Mock authorization server metadata discovery to return support for URL-based client IDs + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://server.example.com', + authorization_endpoint: 'https://server.example.com/authorize', + token_endpoint: 'https://server.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true // SEP-991 support + }) + }); + + await auth(mockProvider, { + serverUrl: 'https://server.example.com' + }); + + // Should save URL-based client info + expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({ + client_id: 'https://example.com/client-metadata.json' + }); + }); + + it('falls back to DCR when server does not support URL-based client IDs', async () => { + // Mock protected resource metadata discovery (404 to skip) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}) + }); + + // Mock authorization server metadata discovery without SEP-991 support + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://server.example.com', + authorization_endpoint: 'https://server.example.com/authorize', + token_endpoint: 'https://server.example.com/token', + registration_endpoint: 'https://server.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + // No client_id_metadata_document_supported + }) + }); + + // Mock DCR response + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ + client_id: 'generated-uuid', + client_secret: 'generated-secret', + redirect_uris: ['http://localhost:3000/callback'] + }) + }); + + await auth(mockProvider, { + serverUrl: 'https://server.example.com' + }); + + // Should save DCR client info + expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({ + client_id: 'generated-uuid', + client_secret: 'generated-secret', + redirect_uris: ['http://localhost:3000/callback'] + }); + }); + + it('throws an error when clientMetadataUrl is not an HTTPS URL', async () => { + const providerWithInvalidUri = { + ...mockProvider, + clientMetadataUrl: 'http://example.com/metadata' + }; + + // Mock protected resource metadata discovery (404 to skip) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}) + }); + + // Mock authorization server metadata discovery with SEP-991 support + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://server.example.com', + authorization_endpoint: 'https://server.example.com/authorize', + token_endpoint: 'https://server.example.com/token', + registration_endpoint: 'https://server.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true + }) + }); + + await expect( + auth(providerWithInvalidUri, { + serverUrl: 'https://server.example.com' + }) + ).rejects.toThrow(InvalidClientMetadataError); + }); + + it('throws an error when clientMetadataUrl has root pathname', async () => { + const providerWithRootPathname = { + ...mockProvider, + clientMetadataUrl: 'https://example.com/' + }; + + // Mock protected resource metadata discovery (404 to skip) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}) + }); + + // Mock authorization server metadata discovery with SEP-991 support + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://server.example.com', + authorization_endpoint: 'https://server.example.com/authorize', + token_endpoint: 'https://server.example.com/token', + registration_endpoint: 'https://server.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true + }) + }); + + await expect( + auth(providerWithRootPathname, { + serverUrl: 'https://server.example.com' + }) + ).rejects.toThrow(InvalidClientMetadataError); + }); + + it('throws an error when clientMetadataUrl is not a valid URL', async () => { + const providerWithInvalidUrl = { + ...mockProvider, + clientMetadataUrl: 'not-a-valid-url' + }; + + // Mock protected resource metadata discovery (404 to skip) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}) + }); + + // Mock authorization server metadata discovery with SEP-991 support + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://server.example.com', + authorization_endpoint: 'https://server.example.com/authorize', + token_endpoint: 'https://server.example.com/token', + registration_endpoint: 'https://server.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true + }) + }); + + await expect( + auth(providerWithInvalidUrl, { + serverUrl: 'https://server.example.com' + }) + ).rejects.toThrow(InvalidClientMetadataError); + }); + + it('falls back to DCR when client_uri is missing', async () => { + const providerWithoutUri = { + ...mockProvider, + clientMetadataUrl: undefined + }; + + // Mock protected resource metadata discovery (404 to skip) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}) + }); + + // Mock authorization server metadata discovery with SEP-991 support + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://server.example.com', + authorization_endpoint: 'https://server.example.com/authorize', + token_endpoint: 'https://server.example.com/token', + registration_endpoint: 'https://server.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true + }) + }); + + // Mock DCR response + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ + client_id: 'generated-uuid', + client_secret: 'generated-secret', + redirect_uris: ['http://localhost:3000/callback'] + }) + }); + + await auth(providerWithoutUri, { + serverUrl: 'https://server.example.com' + }); + + // Should fall back to DCR + expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({ + client_id: 'generated-uuid', + client_secret: 'generated-secret', + redirect_uris: ['http://localhost:3000/callback'] + }); + }); + }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 882359f44..105d3cad9 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -21,6 +21,7 @@ import { import { checkResourceAllowed, resourceUrlFromServerUrl } from '../shared/auth-utils.js'; import { InvalidClientError, + InvalidClientMetadataError, InvalidGrantError, OAUTH_ERRORS, OAuthError, @@ -42,6 +43,11 @@ export interface OAuthClientProvider { */ get redirectUrl(): string | URL; + /** + * External URL the server should use to fetch client metadata document + */ + clientMetadataUrl?: string; + /** * Metadata about this OAuth client. */ @@ -379,18 +385,38 @@ async function authInternal( throw new Error('Existing OAuth client information is required when exchanging an authorization code'); } - if (!provider.saveClientInformation) { - throw new Error('OAuth client information must be saveable for dynamic registration'); + const supportsUrlBasedClientId = metadata?.client_id_metadata_document_supported === true; + const clientMetadataUrl = provider.clientMetadataUrl; + + if (clientMetadataUrl && !isHttpsUrl(clientMetadataUrl)) { + throw new InvalidClientMetadataError( + `clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${clientMetadataUrl}` + ); } - const fullInformation = await registerClient(authorizationServerUrl, { - metadata, - clientMetadata: provider.clientMetadata, - fetchFn - }); + const shouldUseUrlBasedClientId = supportsUrlBasedClientId && clientMetadataUrl; + + if (shouldUseUrlBasedClientId) { + // SEP-991: URL-based Client IDs + clientInformation = { + client_id: clientMetadataUrl + }; + await provider.saveClientInformation?.(clientInformation); + } else { + // Fallback to dynamic registration + if (!provider.saveClientInformation) { + throw new Error('OAuth client information must be saveable for dynamic registration'); + } + + const fullInformation = await registerClient(authorizationServerUrl, { + metadata, + clientMetadata: provider.clientMetadata, + fetchFn + }); - await provider.saveClientInformation(fullInformation); - clientInformation = fullInformation; + await provider.saveClientInformation(fullInformation); + clientInformation = fullInformation; + } } // Exchange authorization code for tokens @@ -456,6 +482,20 @@ async function authInternal( return 'REDIRECT'; } +/** + * SEP-991: URL-based Client IDs + * Validate that the client_id is a valid URL with https scheme + */ +export function isHttpsUrl(value?: string): boolean { + if (!value) return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && url.pathname !== '/'; + } catch { + return false; + } +} + export async function selectResourceURL( serverUrl: string | URL, provider: OAuthClientProvider, diff --git a/src/examples/README.md b/src/examples/README.md index 3a8e3a211..0dc6867ff 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -39,7 +39,7 @@ npx tsx src/examples/client/simpleStreamableHttp.ts Example client with OAuth: ```bash -npx tsx src/examples/client/simpleOAuthClient.js +npx tsx src/examples/client/simpleOAuthClient.ts ``` ### Backwards Compatible Client diff --git a/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts index 2cb458d3b..21dcae012 100644 --- a/src/examples/client/simpleOAuthClient.ts +++ b/src/examples/client/simpleOAuthClient.ts @@ -27,7 +27,10 @@ class InteractiveOAuthClient { output: process.stdout }); - constructor(private serverUrl: string) {} + constructor( + private serverUrl: string, + private clientMetadataUrl?: string + ) {} /** * Prompts user for input via readline @@ -155,16 +158,20 @@ class InteractiveOAuthClient { redirect_uris: [CALLBACK_URL], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], - token_endpoint_auth_method: 'client_secret_post', - scope: 'mcp:tools' + token_endpoint_auth_method: 'client_secret_post' }; console.log('🔐 Creating OAuth provider...'); - const oauthProvider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { - console.log(`📌 OAuth redirect handler called - opening browser`); - console.log(`Opening browser to: ${redirectUrl.toString()}`); - this.openBrowser(redirectUrl.toString()); - }); + const oauthProvider = new InMemoryOAuthClientProvider( + CALLBACK_URL, + clientMetadata, + (redirectUrl: URL) => { + console.log(`📌 OAuth redirect handler called - opening browser`); + console.log(`Opening browser to: ${redirectUrl.toString()}`); + this.openBrowser(redirectUrl.toString()); + }, + this.clientMetadataUrl + ); console.log('🔐 OAuth provider created'); console.log('👤 Creating MCP client...'); @@ -327,13 +334,18 @@ class InteractiveOAuthClient { * Main entry point */ async function main(): Promise { - const serverUrl = process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL; + const args = process.argv.slice(2); + const serverUrl = args[0] || DEFAULT_SERVER_URL; + const clientMetadataUrl = args[1]; console.log('🚀 Simple MCP OAuth Client'); console.log(`Connecting to: ${serverUrl}`); + if (clientMetadataUrl) { + console.log(`Client Metadata URL: ${clientMetadataUrl}`); + } console.log(); - const client = new InteractiveOAuthClient(serverUrl); + const client = new InteractiveOAuthClient(serverUrl, clientMetadataUrl); // Handle graceful shutdown process.on('SIGINT', () => { diff --git a/src/examples/client/simpleOAuthClientProvider.ts b/src/examples/client/simpleOAuthClientProvider.ts index d33aba161..3f1932c3e 100644 --- a/src/examples/client/simpleOAuthClientProvider.ts +++ b/src/examples/client/simpleOAuthClientProvider.ts @@ -13,7 +13,8 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider { constructor( private readonly _redirectUrl: string | URL, private readonly _clientMetadata: OAuthClientMetadata, - onRedirect?: (url: URL) => void + onRedirect?: (url: URL) => void, + public readonly clientMetadataUrl?: string ) { this._onRedirect = onRedirect || diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 819b33086..1274fcd61 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -69,7 +69,8 @@ export const OAuthMetadataSchema = z introspection_endpoint: z.string().optional(), introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(), introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), - code_challenge_methods_supported: z.array(z.string()).optional() + code_challenge_methods_supported: z.array(z.string()).optional(), + client_id_metadata_document_supported: z.boolean().optional() }) .passthrough(); @@ -113,7 +114,8 @@ export const OpenIdProviderMetadataSchema = z request_uri_parameter_supported: z.boolean().optional(), require_request_uri_registration: z.boolean().optional(), op_policy_uri: SafeUrlSchema.optional(), - op_tos_uri: SafeUrlSchema.optional() + op_tos_uri: SafeUrlSchema.optional(), + client_id_metadata_document_supported: z.boolean().optional() }) .passthrough();