From c33a0da1df8db3583d25648bb22a39c0d24e6f22 Mon Sep 17 00:00:00 2001 From: Amy He Date: Tue, 26 Aug 2025 09:06:26 -0700 Subject: [PATCH] feat(mcp): Add ODIC fallback to OAuth metadata look up (#6863) Co-authored-by: cornmander --- packages/core/src/mcp/oauth-utils.test.ts | 68 +++++++++++ packages/core/src/mcp/oauth-utils.ts | 140 ++++++++++++---------- 2 files changed, 146 insertions(+), 62 deletions(-) diff --git a/packages/core/src/mcp/oauth-utils.test.ts b/packages/core/src/mcp/oauth-utils.test.ts index 8ac51697..710afe21 100644 --- a/packages/core/src/mcp/oauth-utils.test.ts +++ b/packages/core/src/mcp/oauth-utils.test.ts @@ -142,6 +142,74 @@ describe('OAuthUtils', () => { }); }); + describe('discoverAuthorizationServerMetadata', () => { + const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + scopes_supported: ['read', 'write'], + }; + + it('should handle URLs without path components correctly', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockAuthServerMetadata), + }); + + const result = await OAuthUtils.discoverAuthorizationServerMetadata( + 'https://auth.example.com/', + ); + + expect(result).toEqual(mockAuthServerMetadata); + + expect(mockFetch).nthCalledWith( + 1, + 'https://auth.example.com/.well-known/oauth-authorization-server', + ); + expect(mockFetch).nthCalledWith( + 2, + 'https://auth.example.com/.well-known/openid-configuration', + ); + }); + + it('should handle URLs with path components correctly', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + }) + .mockResolvedValueOnce({ + ok: false, + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockAuthServerMetadata), + }); + + const result = await OAuthUtils.discoverAuthorizationServerMetadata( + 'https://auth.example.com/mcp', + ); + + expect(result).toEqual(mockAuthServerMetadata); + + expect(mockFetch).nthCalledWith( + 1, + 'https://auth.example.com/.well-known/oauth-authorization-server/mcp', + ); + expect(mockFetch).nthCalledWith( + 2, + 'https://auth.example.com/.well-known/openid-configuration/mcp', + ); + expect(mockFetch).nthCalledWith( + 3, + 'https://auth.example.com/mcp/.well-known/openid-configuration', + ); + }); + }); + describe('metadataToOAuthConfig', () => { it('should convert metadata to OAuth config', () => { const metadata: OAuthAuthorizationServerMetadata = { diff --git a/packages/core/src/mcp/oauth-utils.ts b/packages/core/src/mcp/oauth-utils.ts index d6c589a6..abaa74d5 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -140,6 +140,76 @@ export class OAuthUtils { }; } + /** + * Discover Oauth Authorization server metadata given an Auth server URL, by + * trying the standard well-known endpoints. + * + * @param authServerUrl The authorization server URL + * @returns The authorization server metadata or null if not found + */ + static async discoverAuthorizationServerMetadata( + authServerUrl: string, + ): Promise { + const authServerUrlObj = new URL(authServerUrl); + const base = `${authServerUrlObj.protocol}//${authServerUrlObj.host}`; + + const endpointsToTry: string[] = []; + + // With issuer URLs with path components, try the following well-known + // endpoints in order: + if (authServerUrlObj.pathname !== '/') { + // 1. OAuth 2.0 Authorization Server Metadata with path insertion + endpointsToTry.push( + new URL( + `/.well-known/oauth-authorization-server${authServerUrlObj.pathname}`, + base, + ).toString(), + ); + + // 2. OpenID Connect Discovery 1.0 with path insertion + endpointsToTry.push( + new URL( + `/.well-known/openid-configuration${authServerUrlObj.pathname}`, + base, + ).toString(), + ); + + // 3. OpenID Connect Discovery 1.0 with path appending + endpointsToTry.push( + new URL( + `${authServerUrlObj.pathname}/.well-known/openid-configuration`, + base, + ).toString(), + ); + } + + // With issuer URLs without path components, and those that failed previous + // discoveries, try the following well-known endpoints in order: + + // 1. OAuth 2.0 Authorization Server Metadata + endpointsToTry.push( + new URL('/.well-known/oauth-authorization-server', base).toString(), + ); + + // 2. OpenID Connect Discovery 1.0 + endpointsToTry.push( + new URL('/.well-known/openid-configuration', base).toString(), + ); + + for (const endpoint of endpointsToTry) { + const authServerMetadata = + await this.fetchAuthorizationServerMetadata(endpoint); + if (authServerMetadata) { + return authServerMetadata; + } + } + + console.debug( + `Metadata discovery failed for authorization server ${authServerUrl}`, + ); + return null; + } + /** * Discover OAuth configuration using the standard well-known endpoints. * @@ -172,33 +242,8 @@ export class OAuthUtils { if (resourceMetadata?.authorization_servers?.length) { // Use the first authorization server const authServerUrl = resourceMetadata.authorization_servers[0]; - - // The authorization server URL may include a path (e.g., https://github.com/login/oauth) - // We need to preserve this path when constructing the metadata URL - const authServerUrlObj = new URL(authServerUrl); - const authServerPath = - authServerUrlObj.pathname === '/' ? '' : authServerUrlObj.pathname; - - // Try with the authorization server's path first - let authServerMetadataUrl = new URL( - `/.well-known/oauth-authorization-server${authServerPath}`, - `${authServerUrlObj.protocol}//${authServerUrlObj.host}`, - ).toString(); - - let authServerMetadata = await this.fetchAuthorizationServerMetadata( - authServerMetadataUrl, - ); - - // If that fails, try root as fallback - if (!authServerMetadata && authServerPath) { - authServerMetadataUrl = new URL( - '/.well-known/oauth-authorization-server', - `${authServerUrlObj.protocol}//${authServerUrlObj.host}`, - ).toString(); - authServerMetadata = await this.fetchAuthorizationServerMetadata( - authServerMetadataUrl, - ); - } + const authServerMetadata = + await this.discoverAuthorizationServerMetadata(authServerUrl); if (authServerMetadata) { const config = this.metadataToOAuthConfig(authServerMetadata); @@ -212,13 +257,10 @@ export class OAuthUtils { } } - // Fallback: try /.well-known/oauth-authorization-server at the base URL - console.debug( - `Trying OAuth discovery fallback at ${wellKnownUrls.authorizationServer}`, - ); - const authServerMetadata = await this.fetchAuthorizationServerMetadata( - wellKnownUrls.authorizationServer, - ); + // Fallback: try well-known endpoints at the base URL + console.debug(`Trying OAuth discovery fallback at ${serverUrl}`); + const authServerMetadata = + await this.discoverAuthorizationServerMetadata(serverUrl); if (authServerMetadata) { const config = this.metadataToOAuthConfig(authServerMetadata); @@ -277,34 +319,8 @@ export class OAuthUtils { } const authServerUrl = resourceMetadata.authorization_servers[0]; - - // The authorization server URL may include a path (e.g., https://github.com/login/oauth) - // We need to preserve this path when constructing the metadata URL - const authServerUrlObj = new URL(authServerUrl); - const authServerPath = - authServerUrlObj.pathname === '/' ? '' : authServerUrlObj.pathname; - - // Build auth server metadata URL with the authorization server's path - const authServerMetadataUrl = new URL( - `/.well-known/oauth-authorization-server${authServerPath}`, - `${authServerUrlObj.protocol}//${authServerUrlObj.host}`, - ).toString(); - - let authServerMetadata = await this.fetchAuthorizationServerMetadata( - authServerMetadataUrl, - ); - - // If that fails and we have a path, also try the root path as a fallback - if (!authServerMetadata && authServerPath) { - const rootAuthServerMetadataUrl = new URL( - '/.well-known/oauth-authorization-server', - `${authServerUrlObj.protocol}//${authServerUrlObj.host}`, - ).toString(); - - authServerMetadata = await this.fetchAuthorizationServerMetadata( - rootAuthServerMetadataUrl, - ); - } + const authServerMetadata = + await this.discoverAuthorizationServerMetadata(authServerUrl); if (authServerMetadata) { return this.metadataToOAuthConfig(authServerMetadata);