From 2699bb161f414975982d0cafdef0d55152d998f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Mu=C5=A1tar?= Date: Fri, 5 Dec 2025 14:00:22 +0100 Subject: [PATCH 1/3] Add MCP OAuth 2.1 authentication support Implements OAuth 2.1 authentication for MCP servers, including discovery, PKCE flow, token management, and UI integration. Adds new services, stores, and types for OAuth, updates AddServerForm and ServerCard components for authentication flow, and injects tokens into server requests. Conversation page now uses authenticated servers. MCP server deletion cleans up OAuth data. --- src/lib/components/mcp/AddServerForm.svelte | 202 +++++++++- src/lib/components/mcp/ServerCard.svelte | 132 +++++++ src/lib/services/mcpOAuthService.ts | 363 ++++++++++++++++++ src/lib/stores/mcpOAuthTokens.ts | 250 ++++++++++++ src/lib/stores/mcpServers.ts | 57 ++- src/lib/types/McpOAuth.ts | 94 +++++ src/lib/types/Tool.ts | 2 + src/lib/utils/pkce.ts | 69 ++++ .../api/mcp/oauth/callback/+page.svelte | 163 ++++++++ src/routes/conversation/[id]/+page.svelte | 6 +- 10 files changed, 1330 insertions(+), 8 deletions(-) create mode 100644 src/lib/services/mcpOAuthService.ts create mode 100644 src/lib/stores/mcpOAuthTokens.ts create mode 100644 src/lib/types/McpOAuth.ts create mode 100644 src/lib/utils/pkce.ts create mode 100644 src/routes/api/mcp/oauth/callback/+page.svelte diff --git a/src/lib/components/mcp/AddServerForm.svelte b/src/lib/components/mcp/AddServerForm.svelte index 96a389b5202..26f93025a19 100644 --- a/src/lib/components/mcp/AddServerForm.svelte +++ b/src/lib/components/mcp/AddServerForm.svelte @@ -1,18 +1,30 @@ @@ -128,11 +269,64 @@ placeholder="https://example.com/mcp" class="mt-1.5 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" /> - + + {#if isCheckingOAuth} +
+ + + Checking authentication requirements... + +
+ {:else if oauthRequired} +
+
+ {#if oauthCompleted} +
+ +
+
+

Authenticated

+

+ OAuth authentication completed successfully. You can now add this server. +

+
+ {:else} +
+ +
+
+

Authentication Required

+

+ This server requires OAuth authentication to access its tools. +

+ {#if oauthError} +

+ {oauthError} +

+ {/if} + +
+ {/if} +
+
+ {/if} +
diff --git a/src/lib/components/mcp/ServerCard.svelte b/src/lib/components/mcp/ServerCard.svelte index 694cd1ee803..ba3a8b519f8 100644 --- a/src/lib/components/mcp/ServerCard.svelte +++ b/src/lib/components/mcp/ServerCard.svelte @@ -1,6 +1,9 @@
{/if} + + {#if oauthError} +
+
+ {oauthError} +
+
+ {/if} + + + {#if tokenStatus === "expired" || tokenStatus === "missing"} +
+
+ + + Authentication expired + + +
+
+ {:else if tokenStatus === "expiring"} +
+
+ + Token expires soon +
+
+ {/if} +
+ {/if} +
+
+ diff --git a/src/routes/conversation/[id]/+page.svelte b/src/routes/conversation/[id]/+page.svelte index 16a01a0d20c..627fd42a672 100644 --- a/src/routes/conversation/[id]/+page.svelte +++ b/src/routes/conversation/[id]/+page.svelte @@ -17,7 +17,7 @@ import { fetchMessageUpdates } from "$lib/utils/messageUpdates"; import type { v4 } from "uuid"; import { useSettingsStore } from "$lib/stores/settings.js"; - import { enabledServers } from "$lib/stores/mcpServers"; + import { getServersWithAuth } from "$lib/stores/mcpServers"; import { browser } from "$app/environment"; import { addBackgroundGeneration, @@ -220,8 +220,8 @@ messageId, isRetry, files: isRetry ? userMessage?.files : base64Files, - selectedMcpServerNames: $enabledServers.map((s) => s.name), - selectedMcpServers: $enabledServers.map((s) => ({ + selectedMcpServerNames: getServersWithAuth().map((s) => s.name), + selectedMcpServers: getServersWithAuth().map((s) => ({ name: s.name, url: s.url, headers: s.headers, From 9ce1b903d014290e3461898ee9ce8acde90ef369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Mu=C5=A1tar?= Date: Fri, 5 Dec 2025 14:57:32 +0100 Subject: [PATCH 2/3] Refactor OAuth handling and server auth injection Centralizes OAuth header injection logic in mcpServers store, improves token expiry checks, and streamlines OAuth flow handling in AddServerForm and ServerCard components. Also optimizes usage of getServersWithAuth in conversation page and cleans up redundant code and comments for maintainability. --- src/lib/components/mcp/AddServerForm.svelte | 36 +++++------ src/lib/components/mcp/ServerCard.svelte | 39 +++++++----- src/lib/services/mcpOAuthService.ts | 29 +-------- src/lib/stores/mcpOAuthTokens.ts | 56 +---------------- src/lib/stores/mcpServers.ts | 67 +++++++++++---------- src/routes/conversation/[id]/+page.svelte | 5 +- 6 files changed, 84 insertions(+), 148 deletions(-) diff --git a/src/lib/components/mcp/AddServerForm.svelte b/src/lib/components/mcp/AddServerForm.svelte index 26f93025a19..fddfe07213c 100644 --- a/src/lib/components/mcp/AddServerForm.svelte +++ b/src/lib/components/mcp/AddServerForm.svelte @@ -20,6 +20,7 @@ interface Props { onsubmit: (server: { + id?: string; name: string; url: string; headers?: KeyValuePair[]; @@ -74,14 +75,14 @@ async function checkServerOAuth() { if (!url.trim()) return; - const urlValidation = validateMcpServerUrl(url); - if (!urlValidation) return; + const validUrl = validateMcpServerUrl(url); + if (!validUrl) return; isCheckingOAuth = true; oauthError = null; try { - const result = await checkOAuthRequired(url); + const result = await checkOAuthRequired(validUrl); oauthRequired = result.required; oauthMetadata = result.metadata ?? null; @@ -140,20 +141,20 @@ if (event.origin !== window.location.origin) return; if (event.data?.type === "mcp-oauth-complete") { - if (event.data.serverId === pendingServerId || pendingServerId) { - if (popupPollTimer) { - clearInterval(popupPollTimer); - popupPollTimer = undefined; - } - oauthPopup = null; - - if (event.data.success) { - oauthCompleted = true; - oauthError = null; - reloadFromStorage(); - } else { - oauthError = event.data.error || "Authentication failed"; - } + if (event.data.serverId !== pendingServerId) return; + + if (popupPollTimer) { + clearInterval(popupPollTimer); + popupPollTimer = undefined; + } + oauthPopup = null; + + if (event.data.success) { + oauthCompleted = true; + oauthError = null; + reloadFromStorage(); + } else { + oauthError = event.data.error || "Authentication failed"; } } } @@ -231,6 +232,7 @@ const filteredHeaders = headers.filter((h) => h.key.trim() && h.value.trim()); onsubmit({ + id: pendingServerId ?? undefined, name: name.trim(), url: url.trim(), headers: filteredHeaders.length > 0 ? filteredHeaders : undefined, diff --git a/src/lib/components/mcp/ServerCard.svelte b/src/lib/components/mcp/ServerCard.svelte index ba3a8b519f8..2a1eb5d4098 100644 --- a/src/lib/components/mcp/ServerCard.svelte +++ b/src/lib/components/mcp/ServerCard.svelte @@ -2,7 +2,13 @@ import { onMount, onDestroy } from "svelte"; import type { MCPServer } from "$lib/types/Tool"; import { toggleServer, healthCheckServer, deleteCustomServer } from "$lib/stores/mcpServers"; - import { mcpOAuthTokens, mcpOAuthConfigs, reloadFromStorage } from "$lib/stores/mcpOAuthTokens"; + import { + mcpOAuthTokens, + mcpOAuthConfigs, + reloadFromStorage, + isTokenExpired, + isTokenNearExpiry, + } from "$lib/stores/mcpOAuthTokens"; import { discoverOAuthMetadata, startOAuthFlow } from "$lib/services/mcpOAuthService"; import IconCheckmark from "~icons/carbon/checkmark-filled"; import IconWarning from "~icons/carbon/warning-filled"; @@ -42,8 +48,8 @@ const token = tokens.get(server.id); if (!token) return "missing"; - if (Date.now() > token.expiresAt - 5 * 60 * 1000) return "expired"; - if (Date.now() > token.expiresAt - 10 * 60 * 1000) return "expiring"; + if (isTokenExpired(token)) return "expired"; + if (isTokenNearExpiry(token)) return "expiring"; return "valid"; }); @@ -67,18 +73,23 @@ oauthError = null; try { const metadata = await discoverOAuthMetadata(server.url); - if (metadata) { - oauthPopup = await startOAuthFlow( - server.id, - server.url, - server.name, - metadata, - window.location.href - ); + if (!metadata) { + oauthError = "This server does not support OAuth authentication."; + isReauthenticating = false; + return; + } + oauthPopup = await startOAuthFlow( + server.id, + server.url, + server.name, + metadata, + window.location.href + ); - if (oauthPopup) { - startPopupPolling(); - } + if (oauthPopup) { + startPopupPolling(); + } else { + isReauthenticating = false; } } catch (err) { console.error("Re-authentication failed:", err); diff --git a/src/lib/services/mcpOAuthService.ts b/src/lib/services/mcpOAuthService.ts index 68be2268755..aac24f945ce 100644 --- a/src/lib/services/mcpOAuthService.ts +++ b/src/lib/services/mcpOAuthService.ts @@ -74,9 +74,6 @@ export async function discoverOAuthMetadata( } } -/** - * Check if an MCP server requires OAuth authentication - */ export async function checkOAuthRequired(serverUrl: string): Promise { try { const metadata = await discoverOAuthMetadata(serverUrl); @@ -132,16 +129,10 @@ export async function registerOAuthClient( return `${window.location.origin}${base}/.well-known/oauth-cimd`; } -/** - * Build the callback URL for OAuth redirects - */ export function getCallbackUrl(): string { return `${window.location.origin}${base}${CALLBACK_PATH}`; } -/** - * Open a centered popup window for OAuth - */ function openOAuthPopup(url: string, width: number, height: number): Window | null { const left = Math.round(window.screenX + (window.innerWidth - width) / 2); const top = Math.round(window.screenY + (window.innerHeight - height) / 2); @@ -153,11 +144,7 @@ function openOAuthPopup(url: string, width: number, height: number): Window | nu ); } -/** - * Start OAuth flow for an MCP server - * Opens a popup for better UX (preserves app state), falls back to redirect if popup blocked - * Returns the popup window reference if opened, null if using redirect fallback - */ +// Opens popup for OAuth, falls back to redirect if blocked export async function startOAuthFlow( serverId: string, serverUrl: string, @@ -216,10 +203,6 @@ export async function startOAuthFlow( return null; } -/** - * Exchange authorization code for tokens - * Called from the callback page after OAuth redirect - */ export async function exchangeCodeForTokens( code: string, flowState: McpOAuthFlowState @@ -273,9 +256,6 @@ export async function exchangeCodeForTokens( return token; } -/** - * Refresh an expired OAuth token - */ export async function refreshOAuthToken(serverId: string): Promise { const config = getOAuthConfig(serverId); const token = getToken(serverId); @@ -319,9 +299,6 @@ export async function refreshOAuthToken(serverId: string): Promise { const token = getToken(serverId); diff --git a/src/lib/stores/mcpOAuthTokens.ts b/src/lib/stores/mcpOAuthTokens.ts index d68c7e04b16..3043770c01b 100644 --- a/src/lib/stores/mcpOAuthTokens.ts +++ b/src/lib/stores/mcpOAuthTokens.ts @@ -27,9 +27,6 @@ const EXPIRY_BUFFER_MS = 5 * 60 * 1000; // Near expiry warning: 10 minutes before expiry const NEAR_EXPIRY_MS = 10 * 60 * 1000; -/** - * Load tokens from localStorage - */ function loadTokens(): Map { if (!browser) return new Map(); @@ -44,9 +41,6 @@ function loadTokens(): Map { } } -/** - * Save tokens to localStorage - */ function saveTokens(tokens: Map) { if (!browser) return; @@ -58,9 +52,6 @@ function saveTokens(tokens: Map) { } } -/** - * Load OAuth configs from localStorage - */ function loadConfigs(): Map { if (!browser) return new Map(); @@ -75,9 +66,6 @@ function loadConfigs(): Map { } } -/** - * Save OAuth configs to localStorage - */ function saveConfigs(configs: Map) { if (!browser) return; @@ -100,9 +88,6 @@ if (browser) { mcpOAuthConfigs.subscribe(saveConfigs); } -/** - * Save OAuth flow state (persisted to survive redirects) - */ export function saveFlowState(state: McpOAuthFlowState) { if (!browser) return; @@ -114,9 +99,6 @@ export function saveFlowState(state: McpOAuthFlowState) { } } -/** - * Load OAuth flow state from localStorage - */ export function loadFlowState(): McpOAuthFlowState | null { if (!browser) return null; @@ -132,9 +114,6 @@ export function loadFlowState(): McpOAuthFlowState | null { } } -/** - * Clear OAuth flow state - */ export function clearFlowState() { if (!browser) return; @@ -146,9 +125,6 @@ export function clearFlowState() { } } -/** - * Set a token for a server - */ export function setToken(serverId: string, token: McpOAuthToken) { mcpOAuthTokens.update((tokens) => { tokens.set(serverId, token); @@ -156,16 +132,10 @@ export function setToken(serverId: string, token: McpOAuthToken) { }); } -/** - * Get a token for a server - */ export function getToken(serverId: string): McpOAuthToken | undefined { return get(mcpOAuthTokens).get(serverId); } -/** - * Remove a token for a server - */ export function removeToken(serverId: string) { mcpOAuthTokens.update((tokens) => { tokens.delete(serverId); @@ -173,9 +143,6 @@ export function removeToken(serverId: string) { }); } -/** - * Set OAuth config for a server - */ export function setOAuthConfig(serverId: string, config: McpOAuthConfig) { mcpOAuthConfigs.update((configs) => { configs.set(serverId, config); @@ -183,16 +150,10 @@ export function setOAuthConfig(serverId: string, config: McpOAuthConfig) { }); } -/** - * Get OAuth config for a server - */ export function getOAuthConfig(serverId: string): McpOAuthConfig | undefined { return get(mcpOAuthConfigs).get(serverId); } -/** - * Remove OAuth config for a server - */ export function removeOAuthConfig(serverId: string) { mcpOAuthConfigs.update((configs) => { configs.delete(serverId); @@ -200,23 +161,14 @@ export function removeOAuthConfig(serverId: string) { }); } -/** - * Check if a token is expired (with buffer) - */ export function isTokenExpired(token: McpOAuthToken): boolean { return Date.now() > token.expiresAt - EXPIRY_BUFFER_MS; } -/** - * Check if a token is near expiry (for warning UI) - */ export function isTokenNearExpiry(token: McpOAuthToken): boolean { return Date.now() > token.expiresAt - NEAR_EXPIRY_MS; } -/** - * Get token status for a server - */ export function getTokenStatus( serverId: string ): "none" | "valid" | "expiring" | "expired" | "missing" { @@ -230,18 +182,12 @@ export function getTokenStatus( return "valid"; } -/** - * Clean up tokens and configs for a deleted server - */ export function cleanupServerOAuth(serverId: string) { removeToken(serverId); removeOAuthConfig(serverId); } -/** - * Reload tokens and configs from localStorage - * Used to sync after OAuth popup completes (popup has separate in-memory stores) - */ +// Sync stores from localStorage after OAuth popup completes export function reloadFromStorage() { if (!browser) return; diff --git a/src/lib/stores/mcpServers.ts b/src/lib/stores/mcpServers.ts index 0dab7bb6c1f..01e9bbffb87 100644 --- a/src/lib/stores/mcpServers.ts +++ b/src/lib/stores/mcpServers.ts @@ -8,7 +8,8 @@ import { writable, derived, get } from "svelte/store"; import { base } from "$app/paths"; import { env as publicEnv } from "$env/dynamic/public"; import { browser } from "$app/environment"; -import type { MCPServer, ServerStatus, MCPTool } from "$lib/types/Tool"; +import type { MCPServer, ServerStatus, MCPTool, KeyValuePair } from "$lib/types/Tool"; +import type { McpOAuthToken } from "$lib/types/McpOAuth"; import { getToken, isTokenExpired, cleanupServerOAuth } from "$lib/stores/mcpOAuthTokens"; // Namespace storage by app identity to avoid collisions across apps @@ -138,6 +139,29 @@ export const allBaseServersEnabled = derived( // Note: Authorization overlay (with user's HF token) for the Hugging Face MCP host // is applied server-side when enabled via MCP_FORWARD_HF_USER_TOKEN. +/** + * Inject OAuth token into headers as Authorization header + */ +function injectOAuthHeader( + headers: KeyValuePair[] | undefined, + token: McpOAuthToken +): KeyValuePair[] { + const result = headers ? [...headers] : []; + const authHeaderIndex = result.findIndex((h) => h.key.toLowerCase() === "authorization"); + const tokenType = token.tokenType.charAt(0).toUpperCase() + token.tokenType.slice(1); + const authHeader = { + key: "Authorization", + value: `${tokenType} ${token.accessToken}`, + }; + + if (authHeaderIndex >= 0) { + result[authHeaderIndex] = authHeader; + } else { + result.push(authHeader); + } + return result; +} + /** * Get enabled servers with OAuth tokens injected into headers */ @@ -152,20 +176,7 @@ export function getServersWithAuth(): MCPServer[] { return { ...server, authRequired: true }; } - const headers = server.headers ? [...server.headers] : []; - const authHeaderIndex = headers.findIndex((h) => h.key.toLowerCase() === "authorization"); - const tokenType = token.tokenType.charAt(0).toUpperCase() + token.tokenType.slice(1); - const authHeader = { - key: "Authorization", - value: `${tokenType} ${token.accessToken}`, - }; - - if (authHeaderIndex >= 0) { - headers[authHeaderIndex] = authHeader; - } else { - headers.push(authHeader); - } - + const headers = injectOAuthHeader(server.headers, token); return { ...server, headers, authRequired: false, oauthEnabled: true }; } @@ -269,10 +280,12 @@ export function disableAllServers() { /** * Add a custom MCP server */ -export function addCustomServer(server: Omit): string { +export function addCustomServer( + server: Omit & { id?: string } +): string { const newServer: MCPServer = { ...server, - id: crypto.randomUUID(), + id: server.id || crypto.randomUUID(), type: "custom", status: "disconnected", }; @@ -354,23 +367,11 @@ export async function healthCheckServer( try { updateServerStatus(server.id, "connecting"); - const headers = server.headers ? [...server.headers] : []; const token = getToken(server.id); - - if (token && !isTokenExpired(token)) { - const authHeaderIndex = headers.findIndex((h) => h.key.toLowerCase() === "authorization"); - const tokenType = token.tokenType.charAt(0).toUpperCase() + token.tokenType.slice(1); - const authHeader = { - key: "Authorization", - value: `${tokenType} ${token.accessToken}`, - }; - - if (authHeaderIndex >= 0) { - headers[authHeaderIndex] = authHeader; - } else { - headers.push(authHeader); - } - } + const headers = + token && !isTokenExpired(token) + ? injectOAuthHeader(server.headers, token) + : (server.headers ?? []); const response = await fetch(`${base}/api/mcp/health`, { method: "POST", diff --git a/src/routes/conversation/[id]/+page.svelte b/src/routes/conversation/[id]/+page.svelte index 627fd42a672..bd694cf0e79 100644 --- a/src/routes/conversation/[id]/+page.svelte +++ b/src/routes/conversation/[id]/+page.svelte @@ -212,6 +212,7 @@ const messageUpdatesAbortController = new AbortController(); + const mcpServers = getServersWithAuth(); const messageUpdatesIterator = await fetchMessageUpdates( page.params.id, { @@ -220,8 +221,8 @@ messageId, isRetry, files: isRetry ? userMessage?.files : base64Files, - selectedMcpServerNames: getServersWithAuth().map((s) => s.name), - selectedMcpServers: getServersWithAuth().map((s) => ({ + selectedMcpServerNames: mcpServers.map((s) => s.name), + selectedMcpServers: mcpServers.map((s) => ({ name: s.name, url: s.url, headers: s.headers, From 622ae1f83971ce105bfc209e17c49a9618dbcf89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Mu=C5=A1tar?= Date: Fri, 5 Dec 2025 15:43:34 +0100 Subject: [PATCH 3/3] Add server-side OAuth discovery proxy and improve auth UI Introduces a server-side endpoint for OAuth metadata discovery to avoid CORS issues and support RFC 9728 probing. Updates the client service to use this proxy, adds ProtectedResourceMetadata type, and improves ServerCard UI to better indicate authentication requirements. --- src/lib/components/mcp/ServerCard.svelte | 14 ++- src/lib/services/mcpOAuthService.ts | 43 ++----- src/lib/types/McpOAuth.ts | 12 ++ src/routes/api/mcp/oauth/discover/+server.ts | 124 +++++++++++++++++++ 4 files changed, 157 insertions(+), 36 deletions(-) create mode 100644 src/routes/api/mcp/oauth/discover/+server.ts diff --git a/src/lib/components/mcp/ServerCard.svelte b/src/lib/components/mcp/ServerCard.svelte index 2a1eb5d4098..64c06a98912 100644 --- a/src/lib/components/mcp/ServerCard.svelte +++ b/src/lib/components/mcp/ServerCard.svelte @@ -44,7 +44,11 @@ const hasToken = tokens.has(server.id); const hasConfig = configs.has(server.id); - if (!server.oauthEnabled && !hasToken && !hasConfig) return "none"; + if (!server.oauthEnabled && !hasToken && !hasConfig) { + // Health check returned 401 but OAuth wasn't set up - prompt for authentication + if (server.authRequired) return "missing"; + return "none"; + } const token = tokens.get(server.id); if (!token) return "missing"; @@ -269,14 +273,18 @@
- Authentication expired + {tokenStatus === "expired" ? "Authentication expired" : "Authentication required"}
diff --git a/src/lib/services/mcpOAuthService.ts b/src/lib/services/mcpOAuthService.ts index aac24f945ce..cf081ae2bf2 100644 --- a/src/lib/services/mcpOAuthService.ts +++ b/src/lib/services/mcpOAuthService.ts @@ -28,48 +28,25 @@ const FLOW_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes /** * Discover OAuth server metadata from MCP server URL - * Checks /.well-known/oauth-authorization-server per RFC 8414 + * Uses server-side proxy to avoid CORS issues with RFC 9728 discovery */ export async function discoverOAuthMetadata( serverUrl: string ): Promise { try { - const url = new URL(serverUrl); - // Per MCP spec: discovery at authorization base URL (server URL with path removed) - const wellKnownUrl = `${url.origin}/.well-known/oauth-authorization-server`; - - const response = await fetch(wellKnownUrl, { - headers: { Accept: "application/json" }, - // Short timeout for discovery - signal: AbortSignal.timeout(10000), + const response = await fetch(`${base}/api/mcp/oauth/discover`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: serverUrl }), + signal: AbortSignal.timeout(20000), }); - if (!response.ok) { - // Server doesn't support OAuth discovery - not an error - return null; - } - - const metadata = (await response.json()) as OAuthServerMetadata; - - // Validate required fields per OAuth 2.0 Authorization Server Metadata - if (!metadata.authorization_endpoint || !metadata.token_endpoint) { - console.warn("OAuth metadata missing required endpoints"); - return null; - } - - // Validate PKCE support (required by MCP OAuth 2.1) - if ( - metadata.code_challenge_methods_supported && - !metadata.code_challenge_methods_supported.includes("S256") - ) { - console.warn("OAuth server does not support required S256 PKCE method"); - return null; - } + if (!response.ok) return null; - return metadata; + const data = await response.json(); + return data.metadata ?? null; } catch (error) { - // Discovery failure is not an error - server may not require OAuth - console.debug("OAuth discovery failed (server may not require OAuth):", error); + console.debug("OAuth discovery failed:", error); return null; } } diff --git a/src/lib/types/McpOAuth.ts b/src/lib/types/McpOAuth.ts index 82e946cff84..c48c84ee07e 100644 --- a/src/lib/types/McpOAuth.ts +++ b/src/lib/types/McpOAuth.ts @@ -92,3 +92,15 @@ export interface ClientRegistrationResponse { client_id_issued_at?: number; client_secret_expires_at?: number; } + +/** + * Protected Resource Metadata (RFC 9728) + */ +export interface ProtectedResourceMetadata { + resource: string; + authorization_servers?: string[]; + scopes_supported?: string[]; + bearer_methods_supported?: string[]; + resource_name?: string; + resource_documentation?: string; +} diff --git a/src/routes/api/mcp/oauth/discover/+server.ts b/src/routes/api/mcp/oauth/discover/+server.ts new file mode 100644 index 00000000000..ef827a013b2 --- /dev/null +++ b/src/routes/api/mcp/oauth/discover/+server.ts @@ -0,0 +1,124 @@ +import type { RequestHandler } from "./$types"; +import { isValidUrl } from "$lib/server/urlSafety"; + +interface DiscoveryRequest { + url: string; +} + +interface OAuthServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint?: string; + scopes_supported?: string[]; + response_types_supported?: string[]; + code_challenge_methods_supported?: string[]; + token_endpoint_auth_methods_supported?: string[]; + grant_types_supported?: string[]; +} + +interface ProtectedResourceMetadata { + resource: string; + authorization_servers?: string[]; + scopes_supported?: string[]; +} + +function parseResourceMetadataUrl(wwwAuthenticate: string): string | null { + const match = wwwAuthenticate.match(/resource_metadata="([^"]+)"/); + return match?.[1] ?? null; +} + +async function fetchJson(url: string, signal: AbortSignal): Promise { + try { + const response = await fetch(url, { + headers: { Accept: "application/json" }, + signal, + }); + if (!response.ok) return null; + return (await response.json()) as T; + } catch { + return null; + } +} + +async function fetchAuthServerMetadata( + issuerUrl: string, + signal: AbortSignal +): Promise { + const url = new URL(issuerUrl); + const wellKnownUrl = `${url.origin}/.well-known/oauth-authorization-server`; + const metadata = await fetchJson(wellKnownUrl, signal); + + if (!metadata?.authorization_endpoint || !metadata?.token_endpoint) return null; + if ( + metadata.code_challenge_methods_supported && + !metadata.code_challenge_methods_supported.includes("S256") + ) { + return null; + } + return metadata; +} + +export const POST: RequestHandler = async ({ request }) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + try { + const body: DiscoveryRequest = await request.json(); + const { url } = body; + + if (!url || !isValidUrl(url)) { + return new Response(JSON.stringify({ error: "Invalid URL" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + // RFC 9728: Probe the server to get resource_metadata from WWW-Authenticate + const probeResponse = await fetch(url, { + method: "GET", + signal: controller.signal, + }); + + if (probeResponse.status === 401) { + const wwwAuth = probeResponse.headers.get("www-authenticate") ?? ""; + const prmUrl = parseResourceMetadataUrl(wwwAuth); + + if (prmUrl) { + const prm = await fetchJson(prmUrl, controller.signal); + if (prm?.authorization_servers?.[0]) { + const metadata = await fetchAuthServerMetadata( + prm.authorization_servers[0], + controller.signal + ); + if (metadata) { + clearTimeout(timeoutId); + return new Response(JSON.stringify({ metadata }), { + headers: { "Content-Type": "application/json" }, + }); + } + } + } + } + + // Fallback: same-origin well-known + const metadata = await fetchAuthServerMetadata(url, controller.signal); + clearTimeout(timeoutId); + + if (metadata) { + return new Response(JSON.stringify({ metadata }), { + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ metadata: null }), { + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + clearTimeout(timeoutId); + return new Response( + JSON.stringify({ error: error instanceof Error ? error.message : "Discovery failed" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +};