diff --git a/src/main/services/mcpOAuthService.ts b/src/main/services/mcpOAuthService.ts index e05bb3d..f3e9bb8 100644 --- a/src/main/services/mcpOAuthService.ts +++ b/src/main/services/mcpOAuthService.ts @@ -5,7 +5,7 @@ import { shell } from 'electron'; import type { McpOauthStaticClientConfig } from '@shared/domain/mcpAuth'; -import { storeToken, type McpOAuthToken } from './mcpTokenStore'; +import { storeToken, buildWellKnownUrl, type McpOAuthToken } from './mcpTokenStore'; /* ── Public API ──────────────────────────────────────────────── */ @@ -39,8 +39,7 @@ export async function requiresOAuth(serverUrl: string): Promise { return false; } - const base = serverUrl.replace(/\/+$/, ''); - const prmUrl = `${base}/.well-known/oauth-protected-resource`; + const prmUrl = buildWellKnownUrl(serverUrl, 'oauth-protected-resource'); console.log(`[aryx oauth] Checking PRM at ${prmUrl}…`); const prmResponse = await fetch(prmUrl, { signal: AbortSignal.timeout(5_000), @@ -128,8 +127,7 @@ interface AuthServerMetadata { } async function discoverAuthorizationServer(serverUrl: string): Promise { - const base = serverUrl.replace(/\/+$/, ''); - const prmUrl = `${base}/.well-known/oauth-protected-resource`; + const prmUrl = buildWellKnownUrl(serverUrl, 'oauth-protected-resource'); const response = await fetch(prmUrl); if (!response.ok) { @@ -146,8 +144,7 @@ async function discoverAuthorizationServer(serverUrl: string): Promise { } async function fetchAuthServerMetadata(authServerUrl: string): Promise { - const base = authServerUrl.replace(/\/+$/, ''); - const metadataUrl = `${base}/.well-known/oauth-authorization-server`; + const metadataUrl = buildWellKnownUrl(authServerUrl, 'oauth-authorization-server'); const response = await fetch(metadataUrl); if (!response.ok) { diff --git a/src/main/services/mcpTokenStore.ts b/src/main/services/mcpTokenStore.ts index e6f106b..6252df1 100644 --- a/src/main/services/mcpTokenStore.ts +++ b/src/main/services/mcpTokenStore.ts @@ -47,3 +47,16 @@ function normalizeUrl(url: string): string { return url.toLowerCase().replace(/\/+$/, ''); } } + +/** + * Constructs a well-known URL per RFC 9728 Section 3. + * The `.well-known/{suffix}` segment is inserted between the origin and the path. + * + * Example: `buildWellKnownUrl('https://api.example.com/mcp/', 'oauth-protected-resource')` + * → `https://api.example.com/.well-known/oauth-protected-resource/mcp/` + */ +export function buildWellKnownUrl(baseUrl: string, wellKnownSuffix: string): string { + const parsed = new URL(baseUrl); + const path = parsed.pathname === '/' ? '' : parsed.pathname; + return `${parsed.origin}/.well-known/${wellKnownSuffix}${path}`; +} diff --git a/tests/main/mcpOAuthDiscovery.test.ts b/tests/main/mcpOAuthDiscovery.test.ts new file mode 100644 index 0000000..9b19e8c --- /dev/null +++ b/tests/main/mcpOAuthDiscovery.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test'; + +import { buildWellKnownUrl } from '@main/services/mcpTokenStore'; + +describe('buildWellKnownUrl', () => { + test('inserts well-known segment after origin for URL with path', () => { + expect(buildWellKnownUrl('https://api.githubcopilot.com/mcp/', 'oauth-protected-resource')) + .toBe('https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp/'); + }); + + test('inserts well-known segment for URL with multi-level path', () => { + expect(buildWellKnownUrl('https://example.com/v1/mcp/api', 'oauth-protected-resource')) + .toBe('https://example.com/.well-known/oauth-protected-resource/v1/mcp/api'); + }); + + test('handles origin-only URL without path', () => { + expect(buildWellKnownUrl('https://auth.example.com', 'oauth-authorization-server')) + .toBe('https://auth.example.com/.well-known/oauth-authorization-server'); + }); + + test('handles origin-only URL with trailing slash', () => { + expect(buildWellKnownUrl('https://auth.example.com/', 'oauth-authorization-server')) + .toBe('https://auth.example.com/.well-known/oauth-authorization-server'); + }); + + test('handles URL with port', () => { + expect(buildWellKnownUrl('https://mcp.example.com:8443/v1/', 'oauth-protected-resource')) + .toBe('https://mcp.example.com:8443/.well-known/oauth-protected-resource/v1/'); + }); + + test('preserves path without trailing slash', () => { + expect(buildWellKnownUrl('https://example.com/mcp', 'oauth-protected-resource')) + .toBe('https://example.com/.well-known/oauth-protected-resource/mcp'); + }); +});