mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-25 20:33:58 +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 type { McpOauthStaticClientConfig } from '@shared/domain/mcpAuth';
|
||||||
|
|
||||||
import { storeToken, buildWellKnownUrl, type McpOAuthToken } from './mcpTokenStore';
|
import { storeToken, buildWellKnownUrl, buildWellKnownUrlFallback, type McpOAuthToken } from './mcpTokenStore';
|
||||||
|
|
||||||
/* ── Public API ──────────────────────────────────────────────── */
|
/* ── Public API ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -27,20 +27,14 @@ export interface McpOAuthFlowResult {
|
|||||||
*/
|
*/
|
||||||
export async function requiresOAuth(serverUrl: string): Promise<boolean> {
|
export async function requiresOAuth(serverUrl: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const prmUrl = buildWellKnownUrl(serverUrl, 'oauth-protected-resource');
|
const metadata = await fetchWellKnownMetadata(serverUrl, 'oauth-protected-resource');
|
||||||
console.log(`[aryx oauth] Checking PRM at ${prmUrl}…`);
|
if (!metadata) {
|
||||||
const prmResponse = await fetch(prmUrl, {
|
console.log(`[aryx oauth] No PRM found for ${serverUrl}`);
|
||||||
signal: AbortSignal.timeout(5_000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!prmResponse.ok) {
|
|
||||||
console.log(`[aryx oauth] PRM returned ${prmResponse.status} — no OAuth needed`);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = await prmResponse.json();
|
const hasAuthServers = Array.isArray(metadata.authorization_servers) && metadata.authorization_servers.length > 0;
|
||||||
const hasAuthServers = Array.isArray(metadata?.authorization_servers) && metadata.authorization_servers.length > 0;
|
console.log(`[aryx oauth] PRM found for ${serverUrl}: authorization_servers=${hasAuthServers}`);
|
||||||
console.log(`[aryx oauth] PRM found: authorization_servers=${hasAuthServers}`);
|
|
||||||
return hasAuthServers;
|
return hasAuthServers;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[aryx oauth] Probe failed for ${serverUrl}:`, err instanceof Error ? err.message : err);
|
console.warn(`[aryx oauth] Probe failed for ${serverUrl}:`, err instanceof Error ? err.message : err);
|
||||||
@@ -121,16 +115,43 @@ interface AuthServerMetadata {
|
|||||||
scopes_supported?: string[];
|
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);
|
for (const url of urls) {
|
||||||
if (!response.ok) {
|
try {
|
||||||
throw new Error(`Protected Resource Metadata discovery failed: ${response.status} ${response.statusText}`);
|
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();
|
return undefined;
|
||||||
const authServer = metadata.authorization_servers?.[0];
|
}
|
||||||
|
|
||||||
|
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) {
|
if (!authServer) {
|
||||||
throw new Error('No authorization server found in Protected Resource Metadata');
|
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> {
|
async function fetchAuthServerMetadata(authServerUrl: string): Promise<AuthServerMetadata> {
|
||||||
const metadataUrl = buildWellKnownUrl(authServerUrl, 'oauth-authorization-server');
|
const metadata = await fetchWellKnownMetadata(authServerUrl, 'oauth-authorization-server');
|
||||||
|
if (!metadata) {
|
||||||
const response = await fetch(metadataUrl);
|
throw new Error('Authorization Server Metadata fetch failed: no well-known endpoint found');
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Authorization Server Metadata fetch failed: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata: AuthServerMetadata = await response.json();
|
const asMeta = metadata as unknown as AuthServerMetadata;
|
||||||
if (!metadata.authorization_endpoint || !metadata.token_endpoint) {
|
if (!asMeta.authorization_endpoint || !asMeta.token_endpoint) {
|
||||||
throw new Error('Authorization server metadata is missing required endpoints');
|
throw new Error('Authorization server metadata is missing required endpoints');
|
||||||
}
|
}
|
||||||
|
|
||||||
return metadata;
|
return asMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dynamic Client Registration (RFC 7591) ──────────────────── */
|
/* ── Dynamic Client Registration (RFC 7591) ──────────────────── */
|
||||||
|
|||||||
@@ -49,14 +49,20 @@ function normalizeUrl(url: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a well-known URL per RFC 9728 Section 3.
|
* Constructs well-known URL candidates for a given base URL.
|
||||||
* The `.well-known/{suffix}` segment is inserted between the origin and the path.
|
* 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')`
|
* RFC 9728: `https://example.com/.well-known/oauth-protected-resource/mcp/`
|
||||||
* → `https://api.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 {
|
export function buildWellKnownUrl(baseUrl: string, wellKnownSuffix: string): string {
|
||||||
const parsed = new URL(baseUrl);
|
const parsed = new URL(baseUrl);
|
||||||
const path = parsed.pathname === '/' ? '' : parsed.pathname;
|
const path = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||||
return `${parsed.origin}/.well-known/${wellKnownSuffix}${path}`;
|
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 { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import { buildWellKnownUrl } from '@main/services/mcpTokenStore';
|
import { buildWellKnownUrl, buildWellKnownUrlFallback } from '@main/services/mcpTokenStore';
|
||||||
|
|
||||||
describe('buildWellKnownUrl', () => {
|
describe('buildWellKnownUrl', () => {
|
||||||
test('inserts well-known segment after origin for URL with path', () => {
|
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');
|
.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