diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 744d8e7db2..e23c25d07d 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -39,6 +39,10 @@ import type { import { MCPOAuthProvider } from './oauth-provider.js'; import type { OAuthToken } from './token-storage/types.js'; import { MCPOAuthTokenStorage } from './oauth-token-storage.js'; +import type { + OAuthAuthorizationServerMetadata, + OAuthProtectedResourceMetadata, +} from './oauth-utils.js'; // Mock fetch globally const mockFetch = vi.fn(); @@ -329,8 +333,11 @@ describe('MCPOAuthProvider', () => { ); }); - it('should perform dynamic client registration when no client ID provided', async () => { - const configWithoutClient = { ...mockConfig }; + it('should perform dynamic client registration when no client ID is provided but registration URL is provided', async () => { + const configWithoutClient: MCPOAuthConfig = { + ...mockConfig, + registrationUrl: 'https://auth.example.com/register', + }; delete configWithoutClient.clientId; const mockRegistrationResponse: OAuthClientRegistrationResponse = { @@ -342,7 +349,80 @@ describe('MCPOAuthProvider', () => { token_endpoint_auth_method: 'none', }; - const mockAuthServerMetadata = { + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockRegistrationResponse), + json: mockRegistrationResponse, + }), + ); + + // Setup callback handler + let callbackHandler: unknown; + vi.mocked(http.createServer).mockImplementation((handler) => { + callbackHandler = handler; + return mockHttpServer as unknown as http.Server; + }); + + mockHttpServer.listen.mockImplementation((port, callback) => { + callback?.(); + setTimeout(() => { + const mockReq = { + url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw', + }; + const mockRes = { + writeHead: vi.fn(), + end: vi.fn(), + }; + (callbackHandler as (req: unknown, res: unknown) => void)( + mockReq, + mockRes, + ); + }, 10); + }); + + // Mock token exchange + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockTokenResponse), + json: mockTokenResponse, + }), + ); + + const authProvider = new MCPOAuthProvider(); + const result = await authProvider.authenticate( + 'test-server', + configWithoutClient, + ); + + expect(result).toBeDefined(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://auth.example.com/register', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + + it('should perform OAuth discovery and dynamic client registration when no client ID or registration URL provided', async () => { + const configWithoutClient: MCPOAuthConfig = { ...mockConfig }; + delete configWithoutClient.clientId; + + const mockRegistrationResponse: OAuthClientRegistrationResponse = { + client_id: 'dynamic_client_id', + client_secret: 'dynamic_client_secret', + redirect_uris: ['http://localhost:7777/oauth/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + + const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', authorization_endpoint: 'https://auth.example.com/authorize', token_endpoint: 'https://auth.example.com/token', registration_endpoint: 'https://auth.example.com/register', @@ -416,6 +496,117 @@ describe('MCPOAuthProvider', () => { ); }); + it('should perform OAuth discovery once and dynamic client registration when no client ID, authorization URL or registration URL provided', async () => { + const configWithoutClientAndAuthorizationUrl: MCPOAuthConfig = { + ...mockConfig, + }; + delete configWithoutClientAndAuthorizationUrl.clientId; + delete configWithoutClientAndAuthorizationUrl.authorizationUrl; + + const mockResourceMetadata: OAuthProtectedResourceMetadata = { + resource: 'https://api.example.com', + authorization_servers: ['https://auth.example.com'], + }; + + const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + }; + + const mockRegistrationResponse: OAuthClientRegistrationResponse = { + client_id: 'dynamic_client_id', + client_secret: 'dynamic_client_secret', + redirect_uris: ['http://localhost:7777/oauth/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + + mockFetch + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + }), + ) + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockResourceMetadata), + json: mockResourceMetadata, + }), + ) + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockAuthServerMetadata), + json: mockAuthServerMetadata, + }), + ) + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockRegistrationResponse), + json: mockRegistrationResponse, + }), + ); + + // Setup callback handler + let callbackHandler: unknown; + vi.mocked(http.createServer).mockImplementation((handler) => { + callbackHandler = handler; + return mockHttpServer as unknown as http.Server; + }); + + mockHttpServer.listen.mockImplementation((port, callback) => { + callback?.(); + setTimeout(() => { + const mockReq = { + url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw', + }; + const mockRes = { + writeHead: vi.fn(), + end: vi.fn(), + }; + (callbackHandler as (req: unknown, res: unknown) => void)( + mockReq, + mockRes, + ); + }, 10); + }); + + // Mock token exchange + mockFetch.mockResolvedValueOnce( + createMockResponse({ + ok: true, + contentType: 'application/json', + text: JSON.stringify(mockTokenResponse), + json: mockTokenResponse, + }), + ); + + const authProvider = new MCPOAuthProvider(); + const result = await authProvider.authenticate( + 'test-server', + configWithoutClientAndAuthorizationUrl, + 'https://api.example.com', + ); + + expect(result).toBeDefined(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://auth.example.com/register', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + it('should handle OAuth callback errors', async () => { let callbackHandler: unknown; vi.mocked(http.createServer).mockImplementation((handler) => { diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 645f44fa02..bf40c8ebff 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -29,6 +29,7 @@ export interface MCPOAuthConfig { audiences?: string[]; redirectUri?: string; tokenParamName?: string; // For SSE connections, specifies the query parameter name for the token + registrationUrl?: string; } /** @@ -651,6 +652,7 @@ export class MCPOAuthProvider { authorizationUrl: discoveredConfig.authorizationUrl, tokenUrl: discoveredConfig.tokenUrl, scopes: discoveredConfig.scopes || config.scopes || [], + registrationUrl: discoveredConfig.registrationUrl, // Preserve existing client credentials clientId: config.clientId, clientSecret: config.clientSecret, @@ -665,38 +667,38 @@ export class MCPOAuthProvider { // If no client ID is provided, try dynamic client registration if (!config.clientId) { - // Extract server URL from authorization URL - if (!config.authorizationUrl) { - throw new Error( - 'Cannot perform dynamic registration without authorization URL', - ); - } + let registrationUrl = config.registrationUrl; - const authUrl = new URL(config.authorizationUrl); - const serverUrl = `${authUrl.protocol}//${authUrl.host}`; + // If no registration URL was previously discovered, try to discover it + if (!registrationUrl) { + // Extract server URL from authorization URL + if (!config.authorizationUrl) { + throw new Error( + 'Cannot perform dynamic registration without authorization URL', + ); + } - console.debug('→ Attempting dynamic client registration...'); + const authUrl = new URL(config.authorizationUrl); + const serverUrl = `${authUrl.protocol}//${authUrl.host}`; - // Get the authorization server metadata for registration - const authServerMetadataUrl = new URL( - '/.well-known/oauth-authorization-server', - serverUrl, - ).toString(); + console.debug('→ Attempting dynamic client registration...'); - const authServerMetadata = - await OAuthUtils.fetchAuthorizationServerMetadata( - authServerMetadataUrl, - ); - if (!authServerMetadata) { - throw new Error( - 'Failed to fetch authorization server metadata for client registration', - ); + // Get the authorization server metadata for registration + const authServerMetadata = + await OAuthUtils.discoverAuthorizationServerMetadata(serverUrl); + + if (!authServerMetadata) { + throw new Error( + 'Failed to fetch authorization server metadata for client registration', + ); + } + registrationUrl = authServerMetadata.registration_endpoint; } // Register client if registration endpoint is available - if (authServerMetadata.registration_endpoint) { + if (registrationUrl) { const clientRegistration = await this.registerClient( - authServerMetadata.registration_endpoint, + registrationUrl, config, ); diff --git a/packages/core/src/mcp/oauth-utils.ts b/packages/core/src/mcp/oauth-utils.ts index abaa74d5bb..47ef1d3666 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -137,6 +137,7 @@ export class OAuthUtils { authorizationUrl: metadata.authorization_endpoint, tokenUrl: metadata.token_endpoint, scopes: metadata.scopes_supported || [], + registrationUrl: metadata.registration_endpoint, }; }