diff --git a/apps/web/src/lib/connectors/__tests__/oauth-metadata.test.ts b/apps/web/src/lib/connectors/__tests__/oauth-metadata.test.ts index d68f99e5..ecfeea67 100644 --- a/apps/web/src/lib/connectors/__tests__/oauth-metadata.test.ts +++ b/apps/web/src/lib/connectors/__tests__/oauth-metadata.test.ts @@ -93,6 +93,31 @@ describe('oauth-metadata', () => { tokenEndpoint: 'https://example.com/token', registrationEndpoint: 'https://example.com/register', }) + // RFC 8414: server URLs with a path component are discovered through the + // path-aware well-known document first. + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith('https://example.com/.well-known/oauth-authorization-server/mcp', { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + }) + + vi.unstubAllGlobals() + }) + + it('queries the root well-known document directly when the server URL has no path', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + authorization_endpoint: 'https://example.com/auth', + token_endpoint: 'https://example.com/token', + }), + }) + vi.stubGlobal('fetch', mockFetch) + + const { discoverOAuthMetadata } = await import('@/lib/connectors/oauth-metadata') + await discoverOAuthMetadata('https://example.com/') + expect(mockFetch).toHaveBeenCalledTimes(1) expect(mockFetch).toHaveBeenCalledWith('https://example.com/.well-known/oauth-authorization-server', { method: 'GET', headers: { Accept: 'application/json' }, @@ -102,6 +127,57 @@ describe('oauth-metadata', () => { vi.unstubAllGlobals() }) + it('falls back to the root well-known document when the path-aware document is missing', async () => { + const mockFetch = vi.fn() + .mockResolvedValueOnce({ ok: false, status: 404 }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/auth', + token_endpoint: 'https://example.com/token', + }), + }) + vi.stubGlobal('fetch', mockFetch) + + const { discoverOAuthMetadata } = await import('@/lib/connectors/oauth-metadata') + const result = await discoverOAuthMetadata('https://example.com/mcp') + expect(result.authorizationEndpoint).toBe('https://example.com/auth') + expect(mockFetch).toHaveBeenNthCalledWith(1, 'https://example.com/.well-known/oauth-authorization-server/mcp', expect.any(Object)) + expect(mockFetch).toHaveBeenNthCalledWith(2, 'https://example.com/.well-known/oauth-authorization-server', expect.any(Object)) + + vi.unstubAllGlobals() + }) + + it('uses path-aware metadata for servers hosted under a path (Meta Ads MCP shape)', async () => { + const mockFetch = vi.fn(async (url: string) => { + if (url === 'https://mcp.facebook.com/.well-known/oauth-authorization-server/ads') { + return { + ok: true, + json: async () => ({ + issuer: 'https://www.facebook.com', + authorization_endpoint: 'https://www.facebook.com/v26.0/dialog/oauth', + token_endpoint: 'https://graph.facebook.com/v26.0/oauth/access_token', + registration_endpoint: 'https://mcp.facebook.com/.well-known/register/ads', + }), + } + } + return { ok: false, status: 404 } + }) + vi.stubGlobal('fetch', mockFetch) + + const { discoverOAuthMetadata } = await import('@/lib/connectors/oauth-metadata') + const result = await discoverOAuthMetadata('https://mcp.facebook.com/ads') + expect(result).toEqual({ + issuer: 'https://www.facebook.com', + authorizationEndpoint: 'https://www.facebook.com/v26.0/dialog/oauth', + tokenEndpoint: 'https://graph.facebook.com/v26.0/oauth/access_token', + registrationEndpoint: 'https://mcp.facebook.com/.well-known/register/ads', + }) + + vi.unstubAllGlobals() + }) + it('throws when metadata response is missing required fields', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/apps/web/src/lib/connectors/oauth-metadata.ts b/apps/web/src/lib/connectors/oauth-metadata.ts index 8ac92d1a..e1334cd6 100644 --- a/apps/web/src/lib/connectors/oauth-metadata.ts +++ b/apps/web/src/lib/connectors/oauth-metadata.ts @@ -30,37 +30,59 @@ export async function sanitizeOAuthMetadata(metadata: OAuthServerMetadata): Prom } } +function buildWellKnownMetadataUrls(serverUrl: URL): string[] { + const authorizationBase = `${serverUrl.protocol}//${serverUrl.host}` + const rootMetadataUrl = `${authorizationBase}/.well-known/oauth-authorization-server` + const serverPath = serverUrl.pathname.replace(/\/+$/, '') + + // RFC 8414 ยง3.1: when the issuer has a path component, the well-known suffix + // is inserted between the host and the path (e.g. https://mcp.facebook.com/ads + // publishes metadata at /.well-known/oauth-authorization-server/ads). + // The root document is kept as a fallback for servers that only publish there. + if (serverPath) { + return [`${rootMetadataUrl}${serverPath}`, rootMetadataUrl] + } + + return [rootMetadataUrl] +} + export async function discoverOAuthMetadata(mcpServerUrl: string): Promise { const serverUrl = new URL(mcpServerUrl) const authorizationBase = `${serverUrl.protocol}//${serverUrl.host}` - const metadataUrl = `${authorizationBase}/.well-known/oauth-authorization-server` + const metadataUrls = buildWellKnownMetadataUrls(serverUrl) - const metadataResponse = await fetch(metadataUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - }, - cache: 'no-store', - }).catch(() => null) + for (const [index, metadataUrl] of metadataUrls.entries()) { + const isLastCandidate = index === metadataUrls.length - 1 - if (metadataResponse && metadataResponse.ok) { - const data = (await metadataResponse.json().catch(() => null)) as Record | null - const authorizationEndpoint = getString(data?.authorization_endpoint) - const tokenEndpoint = getString(data?.token_endpoint) - if (!authorizationEndpoint || !tokenEndpoint) { - throw new Error('oauth_discovery_failed:invalid_metadata') - } + const metadataResponse = await fetch(metadataUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + cache: 'no-store', + }).catch(() => null) + + if (metadataResponse && metadataResponse.ok) { + const data = (await metadataResponse.json().catch(() => null)) as Record | null + const authorizationEndpoint = getString(data?.authorization_endpoint) + const tokenEndpoint = getString(data?.token_endpoint) + if (!authorizationEndpoint || !tokenEndpoint) { + throw new Error('oauth_discovery_failed:invalid_metadata') + } - return { - issuer: getString(data?.issuer), - authorizationEndpoint, - tokenEndpoint, - registrationEndpoint: getString(data?.registration_endpoint), + return { + issuer: getString(data?.issuer), + authorizationEndpoint, + tokenEndpoint, + registrationEndpoint: getString(data?.registration_endpoint), + } } - } - if (metadataResponse && metadataResponse.status !== 404) { - throw new Error(`oauth_discovery_failed:${metadataResponse.status}`) + // Non-404 errors on intermediate candidates fall through to the next + // candidate; only the final (root) document keeps strict error semantics. + if (isLastCandidate && metadataResponse && metadataResponse.status !== 404) { + throw new Error(`oauth_discovery_failed:${metadataResponse.status}`) + } } return {