mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 21:48:36 +02:00
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:
@@ -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) ──────────────────── */
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user