fix: try both RFC 9728 and appended well-known URL paths for MCP OAuth discovery

Some MCP servers place .well-known metadata at the appended path
(e.g. /v1/.well-known/oauth-protected-resource) instead of the
RFC 9728 compliant inserted path. Now tries the RFC-compliant URL
first and falls back to the appended form.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-27 20:00:50 +01:00
co-authored by Copilot
parent 6c6b49fde4
commit 0fd7a04a51
3 changed files with 74 additions and 32 deletions
+46 -27
View File
@@ -5,7 +5,7 @@ import { shell } from 'electron';
import type { McpOauthStaticClientConfig } from '@shared/domain/mcpAuth';
import { storeToken, buildWellKnownUrl, type McpOAuthToken } from './mcpTokenStore';
import { storeToken, buildWellKnownUrl, buildWellKnownUrlFallback, type McpOAuthToken } from './mcpTokenStore';
/* ── Public API ──────────────────────────────────────────────── */
@@ -27,20 +27,14 @@ export interface McpOAuthFlowResult {
*/
export async function requiresOAuth(serverUrl: string): Promise<boolean> {
try {
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),
});
if (!prmResponse.ok) {
console.log(`[aryx oauth] PRM returned ${prmResponse.status} — no OAuth needed`);
const metadata = await fetchWellKnownMetadata(serverUrl, 'oauth-protected-resource');
if (!metadata) {
console.log(`[aryx oauth] No PRM found for ${serverUrl}`);
return false;
}
const metadata = await prmResponse.json();
const hasAuthServers = Array.isArray(metadata?.authorization_servers) && metadata.authorization_servers.length > 0;
console.log(`[aryx oauth] PRM found: authorization_servers=${hasAuthServers}`);
const hasAuthServers = Array.isArray(metadata.authorization_servers) && metadata.authorization_servers.length > 0;
console.log(`[aryx oauth] PRM found for ${serverUrl}: authorization_servers=${hasAuthServers}`);
return hasAuthServers;
} catch (err) {
console.warn(`[aryx oauth] Probe failed for ${serverUrl}:`, err instanceof Error ? err.message : err);
@@ -121,16 +115,43 @@ interface AuthServerMetadata {
scopes_supported?: string[];
}
async function discoverAuthorizationServer(serverUrl: string): Promise<string> {
const prmUrl = buildWellKnownUrl(serverUrl, 'oauth-protected-resource');
/**
* Tries to fetch a well-known metadata document from a base URL.
* Attempts the RFC 9728 compliant path first (inserted after origin),
* then falls back to the appended path (used by some servers).
* Returns the parsed JSON or undefined if neither endpoint responds.
*/
async function fetchWellKnownMetadata(baseUrl: string, suffix: string): Promise<Record<string, unknown> | undefined> {
const rfcUrl = buildWellKnownUrl(baseUrl, suffix);
const fallbackUrl = buildWellKnownUrlFallback(baseUrl, suffix);
const urls = rfcUrl === fallbackUrl ? [rfcUrl] : [rfcUrl, fallbackUrl];
const response = await fetch(prmUrl);
if (!response.ok) {
throw new Error(`Protected Resource Metadata discovery failed: ${response.status} ${response.statusText}`);
for (const url of urls) {
try {
console.log(`[aryx oauth] Trying well-known at ${url}`);
const response = await fetch(url, { signal: AbortSignal.timeout(5_000) });
if (response.ok) {
const data = await response.json();
console.log(`[aryx oauth] Found well-known metadata at ${url}`);
return data;
}
console.log(`[aryx oauth] ${url} returned ${response.status}`);
} catch {
console.log(`[aryx oauth] ${url} unreachable`);
}
}
const metadata: ProtectedResourceMetadata = await response.json();
const authServer = metadata.authorization_servers?.[0];
return undefined;
}
async function discoverAuthorizationServer(serverUrl: string): Promise<string> {
const metadata = await fetchWellKnownMetadata(serverUrl, 'oauth-protected-resource');
if (!metadata) {
throw new Error('Protected Resource Metadata discovery failed: no well-known endpoint found');
}
const prm = metadata as unknown as ProtectedResourceMetadata;
const authServer = prm.authorization_servers?.[0];
if (!authServer) {
throw new Error('No authorization server found in Protected Resource Metadata');
}
@@ -139,19 +160,17 @@ async function discoverAuthorizationServer(serverUrl: string): Promise<string> {
}
async function fetchAuthServerMetadata(authServerUrl: string): Promise<AuthServerMetadata> {
const metadataUrl = buildWellKnownUrl(authServerUrl, 'oauth-authorization-server');
const response = await fetch(metadataUrl);
if (!response.ok) {
throw new Error(`Authorization Server Metadata fetch failed: ${response.status} ${response.statusText}`);
const metadata = await fetchWellKnownMetadata(authServerUrl, 'oauth-authorization-server');
if (!metadata) {
throw new Error('Authorization Server Metadata fetch failed: no well-known endpoint found');
}
const metadata: AuthServerMetadata = await response.json();
if (!metadata.authorization_endpoint || !metadata.token_endpoint) {
const asMeta = metadata as unknown as AuthServerMetadata;
if (!asMeta.authorization_endpoint || !asMeta.token_endpoint) {
throw new Error('Authorization server metadata is missing required endpoints');
}
return metadata;
return asMeta;
}
/* ── Dynamic Client Registration (RFC 7591) ──────────────────── */
+10 -4
View File
@@ -49,14 +49,20 @@ function normalizeUrl(url: string): string {
}
/**
* Constructs a well-known URL per RFC 9728 Section 3.
* The `.well-known/{suffix}` segment is inserted between the origin and the path.
* Constructs well-known URL candidates for a given base URL.
* Returns the RFC 9728 compliant URL first (inserted after origin),
* then the appended fallback (some servers use this instead).
*
* Example: `buildWellKnownUrl('https://api.example.com/mcp/', 'oauth-protected-resource')`
* `https://api.example.com/.well-known/oauth-protected-resource/mcp/`
* RFC 9728: `https://example.com/.well-known/oauth-protected-resource/mcp/`
* Fallback: `https://example.com/mcp/.well-known/oauth-protected-resource`
*/
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}`;
}
export function buildWellKnownUrlFallback(baseUrl: string, wellKnownSuffix: string): string {
const base = baseUrl.replace(/\/+$/, '');
return `${base}/.well-known/${wellKnownSuffix}`;
}
+18 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test';
import { buildWellKnownUrl } from '@main/services/mcpTokenStore';
import { buildWellKnownUrl, buildWellKnownUrlFallback } from '@main/services/mcpTokenStore';
describe('buildWellKnownUrl', () => {
test('inserts well-known segment after origin for URL with path', () => {
@@ -33,3 +33,20 @@ describe('buildWellKnownUrl', () => {
.toBe('https://example.com/.well-known/oauth-protected-resource/mcp');
});
});
describe('buildWellKnownUrlFallback', () => {
test('appends well-known segment to the full URL path', () => {
expect(buildWellKnownUrlFallback('https://icm-mcp-prod.azure-api.net/v1/', 'oauth-protected-resource'))
.toBe('https://icm-mcp-prod.azure-api.net/v1/.well-known/oauth-protected-resource');
});
test('handles URL without trailing slash', () => {
expect(buildWellKnownUrlFallback('https://example.com/mcp', 'oauth-protected-resource'))
.toBe('https://example.com/mcp/.well-known/oauth-protected-resource');
});
test('handles origin-only URL', () => {
expect(buildWellKnownUrlFallback('https://auth.example.com/', 'oauth-authorization-server'))
.toBe('https://auth.example.com/.well-known/oauth-authorization-server');
});
});