diff --git a/client/src/App.tsx b/client/src/App.tsx index 5937d7385..c72ec2523 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -21,6 +21,10 @@ import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; import { OAuthStateMachine } from "./lib/oauth-state-machine"; +import { + createOAuthProviderForServer, + setOAuthMode, +} from "./lib/oauth/provider-factory"; import { cacheToolOutputSchemas } from "./utils/schemaUtils"; import { cleanParams } from "./utils/paramUtils"; import type { JsonSchemaType } from "./utils/jsonUtils"; @@ -145,6 +149,12 @@ const App = () => { return localStorage.getItem("lastOauthClientSecret") || ""; }); + const [oauthMode, setOauthMode] = useState<"direct" | "proxy">(() => { + return ( + (localStorage.getItem("lastOauthMode") as "direct" | "proxy") || "direct" + ); + }); + // Custom headers state with migration from legacy auth const [customHeaders, setCustomHeaders] = useState(() => { const savedHeaders = localStorage.getItem("lastCustomHeaders"); @@ -399,6 +409,18 @@ const App = () => { localStorage.setItem("lastOauthScope", oauthScope); }, [oauthScope]); + useEffect(() => { + localStorage.setItem("lastOauthMode", oauthMode); + }, [oauthMode]); + + // Sync OAuth mode to sessionStorage when server URL changes + useEffect(() => { + if (sseUrl) { + const key = getServerSpecificKey(SESSION_KEYS.OAUTH_MODE, sseUrl); + sessionStorage.setItem(key, oauthMode); + } + }, [sseUrl, oauthMode]); + useEffect(() => { localStorage.setItem("lastOauthClientSecret", oauthClientSecret); }, [oauthClientSecret]); @@ -446,9 +468,24 @@ const App = () => { }; try { - const stateMachine = new OAuthStateMachine(sseUrl, (updates) => { - currentState = { ...currentState, ...updates }; - }); + // Set the OAuth mode in sessionStorage before creating the provider + setOAuthMode(oauthMode, sseUrl); + + const proxyAddress = getMCPProxyAddress(config); + const proxyAuthObj = getMCPProxyAuthToken(config); + const oauthProvider = createOAuthProviderForServer( + sseUrl, + proxyAddress, + proxyAuthObj.token, + ); + + const stateMachine = new OAuthStateMachine( + sseUrl, + (updates) => { + currentState = { ...currentState, ...updates }; + }, + oauthProvider, + ); while ( currentState.oauthStep !== "complete" && @@ -486,7 +523,7 @@ const App = () => { }); } }, - [sseUrl], + [sseUrl, oauthMode, config, connectMcpServer], ); useEffect(() => { @@ -854,6 +891,8 @@ const App = () => { onBack={() => setIsAuthDebuggerVisible(false)} authState={authState} updateAuthState={updateAuthState} + config={config} + oauthMode={oauthMode} /> ); @@ -913,6 +952,8 @@ const App = () => { setOauthClientSecret={setOauthClientSecret} oauthScope={oauthScope} setOauthScope={setOauthScope} + oauthMode={oauthMode} + setOauthMode={setOauthMode} onConnect={connectMcpServer} onDisconnect={disconnectMcpServer} logLevel={logLevel} diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 6252c1161..ba45e7a08 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -7,12 +7,20 @@ import { OAuthFlowProgress } from "./OAuthFlowProgress"; import { OAuthStateMachine } from "../lib/oauth-state-machine"; import { SESSION_KEYS } from "../lib/constants"; import { validateRedirectUrl } from "@/utils/urlValidation"; +import { + createOAuthProviderForServer, + setOAuthMode, +} from "../lib/oauth/provider-factory"; +import { InspectorConfig } from "../lib/configurationTypes"; +import { getMCPProxyAddress, getMCPProxyAuthToken } from "@/utils/configUtils"; export interface AuthDebuggerProps { serverUrl: string; onBack: () => void; authState: AuthDebuggerState; updateAuthState: (updates: Partial) => void; + config: InspectorConfig; + oauthMode: "direct" | "proxy"; } interface StatusMessageProps { @@ -60,7 +68,23 @@ const AuthDebugger = ({ onBack, authState, updateAuthState, + config, + oauthMode, }: AuthDebuggerProps) => { + // Create OAuth provider based on mode with proxy credentials + const oauthProvider = useMemo(() => { + // Set the OAuth mode in sessionStorage before creating the provider + setOAuthMode(oauthMode, serverUrl); + + const proxyAddress = getMCPProxyAddress(config); + const proxyAuthObj = getMCPProxyAuthToken(config); + return createOAuthProviderForServer( + serverUrl, + proxyAddress, + proxyAuthObj.token, + ); + }, [serverUrl, config, oauthMode]); + // Check for existing tokens on mount useEffect(() => { if (serverUrl && !authState.oauthTokens) { @@ -103,8 +127,8 @@ const AuthDebugger = ({ }, [serverUrl, updateAuthState]); const stateMachine = useMemo( - () => new OAuthStateMachine(serverUrl, updateAuthState), - [serverUrl, updateAuthState], + () => new OAuthStateMachine(serverUrl, updateAuthState, oauthProvider), + [serverUrl, updateAuthState, oauthProvider], ); const proceedToNextStep = useCallback(async () => { @@ -150,11 +174,15 @@ const AuthDebugger = ({ latestError: null, }; - const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => { - // Update our temporary state during the process - currentState = { ...currentState, ...updates }; - // But don't call updateAuthState yet - }); + const oauthMachine = new OAuthStateMachine( + serverUrl, + (updates) => { + // Update our temporary state during the process + currentState = { ...currentState, ...updates }; + // But don't call updateAuthState yet + }, + oauthProvider, + ); // Manually step through each stage of the OAuth flow while (currentState.oauthStep !== "complete") { diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx index ccfd6d928..b16a30ae0 100644 --- a/client/src/components/OAuthCallback.tsx +++ b/client/src/components/OAuthCallback.tsx @@ -7,6 +7,14 @@ import { generateOAuthErrorDescription, parseOAuthCallbackParams, } from "@/utils/oauthUtils.ts"; +import { createOAuthProviderForServer } from "../lib/oauth/provider-factory"; +import { OAuthStateMachine } from "../lib/oauth-state-machine"; +import { AuthDebuggerState } from "../lib/auth-types"; +import { + getMCPProxyAddress, + getMCPProxyAuthToken, + initializeInspectorConfig, +} from "@/utils/configUtils"; interface OAuthCallbackProps { onConnect: (serverUrl: string) => void; @@ -41,24 +49,97 @@ const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => { return notifyError("Missing Server URL"); } - let result; - try { - // Create an auth provider with the current server URL - const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl); + // Check if there's stored auth state (for proxy mode from Connect button) + const storedAuthState = sessionStorage.getItem( + SESSION_KEYS.AUTH_STATE_FOR_CONNECT, + ); - result = await auth(serverAuthProvider, { - serverUrl, - authorizationCode: params.code, - }); - } catch (error) { - console.error("OAuth callback error:", error); - return notifyError(`Unexpected error occurred: ${error}`); - } + if (storedAuthState) { + // Proxy mode: Complete the OAuth flow using the state machine + try { + let restoredState: AuthDebuggerState = JSON.parse(storedAuthState); + + // Restore URL objects + if ( + restoredState.resource && + typeof restoredState.resource === "string" + ) { + restoredState.resource = new URL(restoredState.resource); + } + if ( + restoredState.authorizationUrl && + typeof restoredState.authorizationUrl === "string" + ) { + restoredState.authorizationUrl = new URL( + restoredState.authorizationUrl, + ); + } + + // Set up state with the authorization code + let currentState: AuthDebuggerState = { + ...restoredState, + authorizationCode: params.code, + oauthStep: "token_request", + }; + + // Get config and create provider + // Use the same config key and initialization as App.tsx + const config = initializeInspectorConfig("inspectorConfig_v1"); + + const proxyAddress = getMCPProxyAddress(config); + const proxyAuthObj = getMCPProxyAuthToken(config); + + const oauthProvider = createOAuthProviderForServer( + serverUrl, + proxyAddress, + proxyAuthObj.token, + ); + + const stateMachine = new OAuthStateMachine( + serverUrl, + (updates) => { + currentState = { ...currentState, ...updates }; + }, + oauthProvider, + false, // use regular redirect URL + ); + + // Complete the token exchange + await stateMachine.executeStep(currentState); + + if (currentState.oauthStep !== "complete") { + return notifyError("Failed to complete OAuth token exchange"); + } + + // Clean up stored state + sessionStorage.removeItem(SESSION_KEYS.AUTH_STATE_FOR_CONNECT); + } catch (error) { + console.error("Proxy OAuth callback error:", error); + sessionStorage.removeItem(SESSION_KEYS.AUTH_STATE_FOR_CONNECT); + return notifyError(`Failed to complete proxy OAuth: ${error}`); + } + } else { + // Direct mode: Use SDK's auth() function + let result; + try { + const serverAuthProvider = new InspectorOAuthClientProvider( + serverUrl, + ); + + result = await auth(serverAuthProvider, { + serverUrl, + authorizationCode: params.code, + }); + } catch (error) { + console.error("OAuth callback error:", error); + return notifyError(`Unexpected error occurred: ${error}`); + } - if (result !== "AUTHORIZED") { - return notifyError( - `Expected to be authorized after providing auth code, got: ${result}`, - ); + if (result !== "AUTHORIZED") { + return notifyError( + `Expected to be authorized after providing auth code, got: ${result}`, + ); + } } // Finally, trigger auto-connect diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index b369a967b..24b07a534 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -62,6 +62,8 @@ interface SidebarProps { setOauthClientSecret: (secret: string) => void; oauthScope: string; setOauthScope: (scope: string) => void; + oauthMode: "direct" | "proxy"; + setOauthMode: (mode: "direct" | "proxy") => void; onConnect: () => void; onDisconnect: () => void; logLevel: LoggingLevel; @@ -93,6 +95,8 @@ const Sidebar = ({ setOauthClientSecret, oauthScope, setOauthScope, + oauthMode, + setOauthMode, onConnect, onDisconnect, logLevel, @@ -552,6 +556,43 @@ const Sidebar = ({ OAuth 2.0 Flow
+
+ + + + + + +

+ Direct: Browser-based OAuth (may + encounter CORS issues) +
+ Via Proxy: Backend-proxied OAuth + to avoid CORS +

+
+
+
+ { onBack: jest.fn(), authState: defaultAuthState, updateAuthState: jest.fn(), + config: DEFAULT_INSPECTOR_CONFIG, + oauthMode: "direct" as const, }; beforeEach(() => { diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 03e898ca9..5a59d1764 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -48,6 +48,8 @@ describe("Sidebar", () => { setOauthClientSecret: jest.fn(), oauthScope: "", setOauthScope: jest.fn(), + oauthMode: "direct" as const, + setOauthMode: jest.fn(), env: {}, setEnv: jest.fn(), customHeaders: [], diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 797501127..40a3b80ee 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -254,10 +254,17 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { // Overrides redirect URL to use the debug endpoint and allows saving server OAuth metadata to // display in debug UI. export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { + constructor( + serverUrl: string, + private useDebugRedirect: boolean = true, + ) { + super(serverUrl); + } + get redirectUrl(): string { - // We can use the debug redirect URL here because it was already registered - // in the parent class's clientMetadata along with the normal redirect URL - return this.debugRedirectUrl; + // Use debug redirect URL by default, or regular redirect URL when configured + // (e.g., when Connect button is clicked in proxy mode) + return this.useDebugRedirect ? this.debugRedirectUrl : super.redirectUrl; } saveServerMetadata(metadata: OAuthMetadata) { diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index e7edc025e..160b3a785 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -17,7 +17,9 @@ export const SESSION_KEYS = { PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information", SERVER_METADATA: "mcp_server_metadata", AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state", + AUTH_STATE_FOR_CONNECT: "mcp_auth_state_for_connect", SCOPE: "mcp_scope", + OAUTH_MODE: "mcp_oauth_mode", } as const; // Generate server-specific session storage keys diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index bd15e080a..41884b5ce 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -35,7 +35,7 @@ import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { useEffect, useState } from "react"; import { useToast } from "@/lib/hooks/useToast"; import { z } from "zod"; -import { ConnectionStatus, CLIENT_IDENTITY } from "../constants"; +import { ConnectionStatus, CLIENT_IDENTITY, SESSION_KEYS } from "../constants"; import { Notification } from "../notificationTypes"; import { auth, @@ -47,16 +47,22 @@ import { saveClientInformationToSessionStorage, saveScopeToSessionStorage, clearScopeFromSessionStorage, - discoverScopes, } from "../auth"; import { getMCPProxyAddress, getMCPServerRequestMaxTotalTimeout, resetRequestTimeoutOnProgress, getMCPProxyAuthToken, + getMCPServerRequestTimeout, } from "@/utils/configUtils"; -import { getMCPServerRequestTimeout } from "@/utils/configUtils"; import { InspectorConfig } from "../configurationTypes"; +import { + createOAuthProviderForServer, + setOAuthMode, +} from "../oauth/provider-factory"; +import { OAuthStateMachine } from "../oauth-state-machine"; +import { AuthDebuggerState } from "../auth-types"; +import { validateRedirectUrl } from "@/utils/urlValidation"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { CustomHeaders } from "../types/customHeaders"; @@ -153,6 +159,13 @@ export function useConnection({ saveScopeToSessionStorage(sseUrl, oauthScope); }, [oauthScope, sseUrl]); + // Sync OAuth mode with connection type + useEffect(() => { + // When connection type is set to proxy, ensure OAuth mode is also proxy + // When connection type is direct, OAuth mode should be direct + setOAuthMode(connectionType, sseUrl); + }, [connectionType, sseUrl]); + const pushHistory = (request: object, response?: object) => { setRequestHistory((prev) => [ ...prev, @@ -344,28 +357,131 @@ export function useConnection({ const handleAuthError = async (error: unknown) => { if (is401Error(error)) { - let scope = oauthScope?.trim(); - if (!scope) { - // Only discover resource metadata when we need to discover scopes - let resourceMetadata; - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata( - new URL("/", sseUrl), + const scope = oauthScope?.trim(); + + // Use connectionType directly instead of reading from session storage + const oauthMode = connectionType; + + if (scope) { + saveScopeToSessionStorage(sseUrl, scope); + } + + if (oauthMode === "proxy") { + // Use proxy mode with state machine approach (to avoid CORS) + // Ensure OAuth mode is set in session storage for callback to use + setOAuthMode("proxy", sseUrl); + + const proxyAddress = getMCPProxyAddress(config); + const proxyAuthObj = getMCPProxyAuthToken(config); + const oauthProvider = createOAuthProviderForServer( + sseUrl, + proxyAddress, + proxyAuthObj.token, + ); + + // Use state machine to step through OAuth flow + let currentState: AuthDebuggerState = { + oauthStep: "metadata_discovery", + authorizationUrl: null, + authorizationCode: "", + oauthMetadata: null, + oauthClientInfo: null, + oauthTokens: null, + resourceMetadata: null, + resourceMetadataError: null, + authServerUrl: null, + resource: null, + validationError: null, + latestError: null, + statusMessage: null, + isInitiatingAuth: false, + }; + + // Use regular redirect URL (not debug) for Connect button + // This will redirect to /oauth/callback which auto-connects + const oauthMachine = new OAuthStateMachine( + sseUrl, + (updates) => { + currentState = { ...currentState, ...updates }; + }, + oauthProvider, + false, // useDebugRedirect = false + ); + + // Step through OAuth flow until we need to redirect + while (currentState.oauthStep !== "complete") { + await oauthMachine.executeStep(currentState); + + // When we reach authorization step, validate and redirect + if ( + currentState.oauthStep === "authorization_code" && + currentState.authorizationUrl + ) { + try { + validateRedirectUrl(currentState.authorizationUrl); + } catch (redirectError) { + console.error("Invalid authorization URL:", redirectError); + return false; + } + + // Store the current auth state before redirecting + // This allows /oauth/callback to complete the token exchange via proxy + sessionStorage.setItem( + SESSION_KEYS.AUTH_STATE_FOR_CONNECT, + JSON.stringify(currentState), + ); + + // Redirect to authorization URL + // Will redirect to /oauth/callback which calls onOAuthConnect + // and auto-connects after OAuth completion + window.location.href = currentState.authorizationUrl.toString(); + return false; // We're redirecting, so connection will be retried after OAuth + } + } + + // If we completed the full flow (shouldn't happen on first connect) + return currentState.oauthStep === "complete"; + } else { + // Direct mode: Use SDK's auth() function + let discoveredScope = scope; + + // Discover scopes if not provided + if (!discoveredScope) { + const proxyAddress = getMCPProxyAddress(config); + const proxyAuthObj = getMCPProxyAuthToken(config); + const oauthProvider = createOAuthProviderForServer( + sseUrl, + proxyAddress, + proxyAuthObj.token, + ); + + // For direct mode, try to get resource metadata + let resourceMetadata; + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + new URL("/", sseUrl), + ); + } catch { + // Resource metadata is optional, continue without it + } + + discoveredScope = await oauthProvider.discoverScopes( + sseUrl, + resourceMetadata, ); - } catch { - // Resource metadata is optional, continue without it + if (discoveredScope) { + saveScopeToSessionStorage(sseUrl, discoveredScope); + } } - scope = await discoverScopes(sseUrl, resourceMetadata); - } - saveScopeToSessionStorage(sseUrl, scope); - const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); + const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); - const result = await auth(serverAuthProvider, { - serverUrl: sseUrl, - scope, - }); - return result === "AUTHORIZED"; + const result = await auth(serverAuthProvider, { + serverUrl: sseUrl, + scope: discoveredScope, + }); + return result === "AUTHORIZED"; + } } return false; diff --git a/client/src/lib/hooks/useProxyConfig.ts b/client/src/lib/hooks/useProxyConfig.ts new file mode 100644 index 000000000..a3080d164 --- /dev/null +++ b/client/src/lib/hooks/useProxyConfig.ts @@ -0,0 +1,22 @@ +/** + * Hook to get proxy configuration from config + */ +import { useMemo } from "react"; +import { InspectorConfig } from "../configurationTypes"; +import { getMCPProxyAddress, getMCPProxyAuthToken } from "@/utils/configUtils"; + +// This hook can be used if we need to get config from context +// For now, it's simpler to just pass the config values directly +export function useProxyConfig(config?: InspectorConfig) { + const proxyFullAddress = useMemo( + () => (config ? getMCPProxyAddress(config) : ""), + [config], + ); + + const proxyAuthToken = useMemo( + () => (config ? getMCPProxyAuthToken(config) : ""), + [config], + ); + + return { proxyFullAddress, proxyAuthToken }; +} diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 8dc9da8f9..45d62473b 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -1,26 +1,37 @@ import { OAuthStep, AuthDebuggerState } from "./auth-types"; -import { DebugInspectorOAuthClientProvider, discoverScopes } from "./auth"; -import { - discoverAuthorizationServerMetadata, - registerClient, - startAuthorization, - exchangeAuthorization, - discoverOAuthProtectedResourceMetadata, - selectResourceURL, -} from "@modelcontextprotocol/sdk/client/auth.js"; -import { - OAuthMetadataSchema, - OAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { DebugInspectorOAuthClientProvider } from "./auth"; +import { selectResourceURL } from "@modelcontextprotocol/sdk/client/auth.js"; import { generateOAuthState } from "@/utils/oauthUtils"; +import { OAuthProvider } from "./oauth/provider-interface"; +import { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; export interface StateMachineContext { state: AuthDebuggerState; serverUrl: string; provider: DebugInspectorOAuthClientProvider; + oauthProvider: OAuthProvider; updateState: (updates: Partial) => void; } +/** + * Helper function to resolve scope - either use user-provided scope or discover it + */ +async function resolveScope( + userScope: string | undefined, + oauthProvider: OAuthProvider, + serverUrl: string, + resourceMetadata?: OAuthProtectedResourceMetadata | null, +): Promise { + if (userScope && userScope.trim() !== "") { + return userScope; + } + + return await oauthProvider.discoverScopes( + serverUrl, + resourceMetadata ?? undefined, + ); +} + export interface StateTransition { canTransition: (context: StateMachineContext) => Promise; execute: (context: StateMachineContext) => Promise; @@ -31,37 +42,31 @@ export const oauthTransitions: Record = { metadata_discovery: { canTransition: async () => true, execute: async (context) => { - // Default to discovering from the server's URL - let authServerUrl = new URL("/", context.serverUrl); - let resourceMetadata: OAuthProtectedResourceMetadata | null = null; let resourceMetadataError: Error | null = null; - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata( - context.serverUrl, - ); - if (resourceMetadata?.authorization_servers?.length) { - authServerUrl = new URL(resourceMetadata.authorization_servers[0]); - } - } catch (e) { - if (e instanceof Error) { - resourceMetadataError = e; - } else { - resourceMetadataError = new Error(String(e)); - } + + // Use the OAuth provider to discover metadata + const discoveryResult = await context.oauthProvider + .discover(context.serverUrl) + .catch((e) => { + resourceMetadataError = e instanceof Error ? e : new Error(String(e)); + throw e; + }); + + const resourceMetadata = discoveryResult.resourceMetadata; + const parsedMetadata = discoveryResult.authServerMetadata; + + // Determine auth server URL from metadata + let authServerUrl = new URL(parsedMetadata.issuer); + if (resourceMetadata?.authorization_servers?.length) { + authServerUrl = new URL(resourceMetadata.authorization_servers[0]); } const resource: URL | undefined = await selectResourceURL( context.serverUrl, context.provider, - // we default to null, so swap it for undefined if not set resourceMetadata ?? undefined, ); - const metadata = await discoverAuthorizationServerMetadata(authServerUrl); - if (!metadata) { - throw new Error("Failed to discover OAuth metadata"); - } - const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); context.provider.saveServerMetadata(parsedMetadata); context.updateState({ resourceMetadata, @@ -81,22 +86,24 @@ export const oauthTransitions: Record = { const clientMetadata = context.provider.clientMetadata; // Priority: user-provided scope > discovered scopes - if (!context.provider.scope || context.provider.scope.trim() === "") { - // Prefer scopes from resource metadata if available - const scopesSupported = - context.state.resourceMetadata?.scopes_supported || - metadata.scopes_supported; - // Add all supported scopes to client registration - if (scopesSupported) { - clientMetadata.scope = scopesSupported.join(" "); - } + const discoveredScope = await resolveScope( + context.provider.scope, + context.oauthProvider, + context.serverUrl, + context.state.resourceMetadata, + ); + + // Add all supported scopes to client registration + if (discoveredScope) { + clientMetadata.scope = discoveredScope; } // Try Static client first, with DCR as fallback let fullInformation = await context.provider.clientInformation(); if (!fullInformation) { - fullInformation = await registerClient(context.serverUrl, { + fullInformation = await context.oauthProvider.registerClient({ metadata, + authServerUrl: context.serverUrl, clientMetadata, }); context.provider.saveClientInformation(fullInformation); @@ -117,25 +124,22 @@ export const oauthTransitions: Record = { const clientInformation = context.state.oauthClientInfo!; // Priority: user-provided scope > discovered scopes - let scope = context.provider.scope; - if (!scope || scope.trim() === "") { - scope = await discoverScopes( - context.serverUrl, - context.state.resourceMetadata ?? undefined, - ); - } - - const { authorizationUrl, codeVerifier } = await startAuthorization( + const scope = await resolveScope( + context.provider.scope, + context.oauthProvider, context.serverUrl, - { + context.state.resourceMetadata, + ); + + const { authorizationUrl, codeVerifier } = + await context.oauthProvider.startAuthorization({ metadata, clientInformation, redirectUrl: context.provider.redirectUrl, - scope, + scope: scope || "", state: generateOAuthState(), resource: context.state.resource ?? undefined, - }, - ); + }); context.provider.saveCodeVerifier(codeVerifier); context.updateState({ @@ -178,7 +182,7 @@ export const oauthTransitions: Record = { const metadata = context.provider.getServerMetadata()!; const clientInformation = (await context.provider.clientInformation())!; - const tokens = await exchangeAuthorization(context.serverUrl, { + const tokens = await context.oauthProvider.exchangeToken({ metadata, clientInformation, authorizationCode: context.state.authorizationCode, @@ -211,14 +215,23 @@ export class OAuthStateMachine { constructor( private serverUrl: string, private updateState: (updates: Partial) => void, + private oauthProvider: OAuthProvider, + private useDebugRedirect: boolean = true, ) {} async executeStep(state: AuthDebuggerState): Promise { - const provider = new DebugInspectorOAuthClientProvider(this.serverUrl); + // Always use DebugInspectorOAuthClientProvider, but it will use + // regular or debug redirect URL based on useDebugRedirect flag + const provider = new DebugInspectorOAuthClientProvider( + this.serverUrl, + this.useDebugRedirect, + ); + const context: StateMachineContext = { state, serverUrl: this.serverUrl, provider, + oauthProvider: this.oauthProvider, updateState: this.updateState, }; diff --git a/client/src/lib/oauth/direct-provider.ts b/client/src/lib/oauth/direct-provider.ts new file mode 100644 index 000000000..3fd1d5fe3 --- /dev/null +++ b/client/src/lib/oauth/direct-provider.ts @@ -0,0 +1,212 @@ +/** + * Direct OAuth Provider - Uses MCP SDK directly (browser-based) + * + * ## Purpose of the Provider Abstraction + * + * The OAuthProvider interface allows the Inspector client to switch between two OAuth strategies: + * + * 1. **DirectOAuthProvider** (this file): Makes OAuth requests directly from the browser to the + * authorization server using the MCP SDK. This is the standard OAuth flow but may encounter + * CORS issues when the auth server doesn't allow browser requests. + * + * 2. **ProxyOAuthProvider**: Routes OAuth requests through the Inspector's backend server to + * avoid CORS issues. The backend acts as a proxy to the authorization server. + * + * ## Benefits of the Abstraction + * + * - **Flexibility**: Users can choose between direct and proxy modes based on their needs + * - **Fallback**: If direct mode fails due to CORS, can switch to proxy mode + * - **Testability**: Each provider can be tested independently + * - **Type Safety**: Both providers implement the same interface + * + * ## Implementation Notes + * + * This provider is essentially a thin wrapper around the MCP SDK's auth functions. While it may + * seem redundant, it serves these purposes: + * - Provides a consistent interface with ProxyOAuthProvider + * - Handles parameter mapping from the provider interface to SDK functions + * - Adds error handling specific to the Inspector's needs + * - Allows for future customization without changing the SDK + * + * ## Refactoring Considerations + * + * If the abstraction proves unnecessary (i.e., if proxy mode is always used or if the wrapper + * adds no value), consider: + * - Using the SDK directly in the client code + * - Keeping only ProxyOAuthProvider for CORS-free operation + * - Simplifying the interface to remove redundant parameter mapping + */ +import { + discoverAuthorizationServerMetadata, + discoverOAuthProtectedResourceMetadata, + registerClient as sdkRegisterClient, + startAuthorization as sdkStartAuthorization, + exchangeAuthorization, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { + OAuthClientInformationFull, + OAuthClientInformation, + OAuthTokens, + OAuthMetadataSchema, + OAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { resolveScopes } from "./utils/scope-resolver.js"; +import { extractClientSecret } from "./utils/client-helpers.js"; +import { + OAuthProvider, + DiscoveryResult, + RegisterClientParams, + AuthorizationParams, + AuthorizationResult, + ExchangeParams, + RefreshParams, +} from "./provider-interface"; + +/** + * DirectOAuthProvider - Uses MCP SDK directly from the browser + * This is the existing implementation, may encounter CORS issues + */ +export class DirectOAuthProvider implements OAuthProvider { + async discover(serverUrl: string): Promise { + // Default to discovering from the server's URL + let authServerUrl = new URL("/", serverUrl); + let resourceMetadata = null; + + try { + resourceMetadata = + await discoverOAuthProtectedResourceMetadata(serverUrl); + if (resourceMetadata?.authorization_servers?.length) { + authServerUrl = new URL(resourceMetadata.authorization_servers[0]); + } + } catch (e) { + console.warn("Failed to discover resource metadata:", e); + } + + const metadata = await discoverAuthorizationServerMetadata(authServerUrl); + if (!metadata) { + throw new Error("Failed to discover OAuth metadata"); + } + + const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); + + return { + resourceMetadata, + authServerMetadata: parsedMetadata, + resourceUrl: resourceMetadata?.resource, + }; + } + + async discoverScopes( + serverUrl: string, + resourceMetadata?: OAuthProtectedResourceMetadata, + ): Promise { + try { + const metadata = await discoverAuthorizationServerMetadata( + new URL("/", serverUrl), + ); + + return resolveScopes(resourceMetadata, metadata); + } catch (error) { + console.debug("OAuth scope discovery failed:", error); + return undefined; + } + } + + async registerClient( + params: RegisterClientParams, + ): Promise { + // If client credentials are provided, use them (static client) + if (params.clientId) { + const clientInfo: OAuthClientInformation = { + client_id: params.clientId, + }; + if (params.clientSecret) { + (clientInfo as OAuthClientInformationFull).client_secret = + params.clientSecret; + return clientInfo as OAuthClientInformationFull; + } + return clientInfo; + } + + // Otherwise, use Dynamic Client Registration + const clientMetadata = params.clientMetadata || {}; + // Ensure redirect_uris is present (required by the SDK) + const finalMetadata = { + redirect_uris: [], + ...clientMetadata, + }; + + const fullInformation = await sdkRegisterClient(params.authServerUrl, { + metadata: params.metadata, + clientMetadata: finalMetadata, + }); + + return fullInformation; + } + + async startAuthorization( + params: AuthorizationParams, + ): Promise { + const result = await sdkStartAuthorization(params.metadata.issuer, { + metadata: params.metadata, + clientInformation: params.clientInformation, + redirectUrl: params.redirectUrl, + scope: params.scope, + state: params.state, + resource: params.resource, + }); + + return { + authorizationUrl: result.authorizationUrl, + codeVerifier: result.codeVerifier, + }; + } + + async exchangeToken(params: ExchangeParams): Promise { + const tokens = await exchangeAuthorization(params.metadata.issuer, { + metadata: params.metadata, + clientInformation: params.clientInformation, + authorizationCode: params.authorizationCode, + codeVerifier: params.codeVerifier, + redirectUri: params.redirectUri, + resource: params.resource, + }); + + return tokens; + } + + async refreshToken(params: RefreshParams): Promise { + // Note: We don't use the SDK's refreshAuthorization here because: + // - It requires discovering the token endpoint from the auth server URL, which can cause CORS + // - We already have the token endpoint in params.metadata + // - Direct fetch gives us better control and avoids extra network requests + + const formData = new URLSearchParams(); + formData.set("grant_type", "refresh_token"); + formData.set("refresh_token", params.refreshToken); + formData.set("client_id", params.clientInformation.client_id); + + const clientSecret = extractClientSecret(params.clientInformation); + if (clientSecret) { + formData.set("client_secret", clientSecret); + } + + const response = await fetch(params.metadata.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formData.toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + const tokens = (await response.json()) as OAuthTokens; + return tokens; + } +} diff --git a/client/src/lib/oauth/provider-factory.ts b/client/src/lib/oauth/provider-factory.ts new file mode 100644 index 000000000..abe568ad4 --- /dev/null +++ b/client/src/lib/oauth/provider-factory.ts @@ -0,0 +1,55 @@ +/** + * OAuth Provider Factory + * Creates the appropriate OAuth provider based on the selected mode + */ +import { OAuthProvider } from "./provider-interface"; +import { DirectOAuthProvider } from "./direct-provider"; +import { ProxyOAuthProvider } from "./proxy-provider"; +import { SESSION_KEYS, getServerSpecificKey } from "../constants"; + +export type OAuthMode = "direct" | "proxy"; + +/** + * Get the OAuth mode for a specific server from sessionStorage + */ +export function getOAuthMode(serverUrl?: string): OAuthMode { + const key = getServerSpecificKey(SESSION_KEYS.OAUTH_MODE, serverUrl); + const mode = sessionStorage.getItem(key); + return (mode as OAuthMode) || "direct"; // Default to direct for backward compatibility +} + +/** + * Set the OAuth mode for a specific server in sessionStorage + */ +export function setOAuthMode(mode: OAuthMode, serverUrl?: string): void { + const key = getServerSpecificKey(SESSION_KEYS.OAUTH_MODE, serverUrl); + sessionStorage.setItem(key, mode); +} + +/** + * Create an OAuth provider based on the selected mode + */ +export function createOAuthProvider( + mode: OAuthMode, + proxyBaseUrl?: string, + proxyAuthToken?: string, +): OAuthProvider { + if (mode === "proxy") { + return new ProxyOAuthProvider(proxyBaseUrl, proxyAuthToken); + } + + return new DirectOAuthProvider(); +} + +/** + * Create an OAuth provider for a specific server + * Reads the mode from sessionStorage + */ +export function createOAuthProviderForServer( + serverUrl?: string, + proxyBaseUrl?: string, + proxyAuthToken?: string, +): OAuthProvider { + const mode = getOAuthMode(serverUrl); + return createOAuthProvider(mode, proxyBaseUrl, proxyAuthToken); +} diff --git a/client/src/lib/oauth/provider-interface.ts b/client/src/lib/oauth/provider-interface.ts new file mode 100644 index 000000000..0c042c2d5 --- /dev/null +++ b/client/src/lib/oauth/provider-interface.ts @@ -0,0 +1,95 @@ +/** + * OAuth Provider Interface - Abstraction for OAuth operations + */ +import { + OAuthMetadata, + OAuthClientInformationFull, + OAuthClientInformation, + OAuthTokens, + OAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +export interface DiscoveryResult { + resourceMetadata: OAuthProtectedResourceMetadata | null; + authServerMetadata: OAuthMetadata; + resourceUrl?: string; +} + +export interface RegisterClientParams { + metadata: OAuthMetadata; + authServerUrl: string; + clientId?: string; + clientSecret?: string; + clientMetadata?: Record; +} + +export interface AuthorizationParams { + metadata: OAuthMetadata; + clientInformation: OAuthClientInformationFull | OAuthClientInformation; + redirectUrl: string; + scope: string; + state: string; + resource?: URL; +} + +export interface AuthorizationResult { + authorizationUrl: URL; + codeVerifier: string; +} + +export interface ExchangeParams { + metadata: OAuthMetadata; + clientInformation: OAuthClientInformationFull | OAuthClientInformation; + authorizationCode: string; + codeVerifier: string; + redirectUri: string; + resource?: URL; +} + +export interface RefreshParams { + metadata: OAuthMetadata; + clientInformation: OAuthClientInformationFull | OAuthClientInformation; + refreshToken: string; +} + +/** + * OAuthProvider interface - defines methods for OAuth operations + * Implementations: DirectOAuthProvider (SDK), ProxyOAuthProvider (Backend API) + */ +export interface OAuthProvider { + /** + * Discover OAuth metadata from server + */ + discover(serverUrl: string, provider?: string): Promise; + + /** + * Discover OAuth scopes from server metadata + * Prefers resource metadata scopes over authorization server scopes + */ + discoverScopes( + serverUrl: string, + resourceMetadata?: OAuthProtectedResourceMetadata, + ): Promise; + + /** + * Register client with OAuth server + */ + registerClient( + params: RegisterClientParams, + ): Promise; + + /** + * Start OAuth authorization flow + */ + startAuthorization(params: AuthorizationParams): Promise; + + /** + * Exchange authorization code for tokens + */ + exchangeToken(params: ExchangeParams): Promise; + + /** + * Refresh access token + */ + refreshToken(params: RefreshParams): Promise; +} diff --git a/client/src/lib/oauth/proxy-provider.ts b/client/src/lib/oauth/proxy-provider.ts new file mode 100644 index 000000000..bc6c37919 --- /dev/null +++ b/client/src/lib/oauth/proxy-provider.ts @@ -0,0 +1,160 @@ +/** + * Proxy OAuth Provider - Uses backend API (avoids CORS) + */ +import { + OAuthClientInformationFull, + OAuthClientInformation, + OAuthTokens, + OAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { + OAuthProvider, + DiscoveryResult, + RegisterClientParams, + AuthorizationParams, + AuthorizationResult, + ExchangeParams, + RefreshParams, +} from "./provider-interface"; +import { DEFAULT_MCP_PROXY_LISTEN_PORT } from "../constants"; +import { extractClientSecret } from "./utils/client-helpers.js"; + +/** + * ProxyOAuthProvider - Makes OAuth requests through the backend proxy + * This avoids CORS issues by proxying all OAuth requests through the backend + */ +export class ProxyOAuthProvider implements OAuthProvider { + private proxyBaseUrl: string; + private proxyAuthToken: string; + + constructor(proxyBaseUrl?: string, proxyAuthToken?: string) { + // Default to localhost with default port + this.proxyBaseUrl = + proxyBaseUrl || `http://localhost:${DEFAULT_MCP_PROXY_LISTEN_PORT}`; + this.proxyAuthToken = proxyAuthToken || ""; + } + + private getHeaders(): HeadersInit { + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + if (this.proxyAuthToken) { + headers["x-mcp-proxy-auth"] = `Bearer ${this.proxyAuthToken}`; + } + + return headers; + } + + private async fetchFromProxy(endpoint: string, body: unknown): Promise { + const url = `${this.proxyBaseUrl}/api/oauth/${endpoint}`; + + const response = await fetch(url, { + method: "POST", + headers: this.getHeaders(), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + `Proxy request failed: ${response.status} ${response.statusText} - ${errorData.message || ""}`, + ); + } + + return (await response.json()) as T; + } + + async discover( + serverUrl: string, + provider?: string, + ): Promise { + return this.fetchFromProxy("discover", { + serverUrl, + provider, + }); + } + + async discoverScopes( + serverUrl: string, + resourceMetadata?: OAuthProtectedResourceMetadata, + ): Promise { + const result = await this.fetchFromProxy<{ scopes?: string }>( + "discover-scopes", + { + serverUrl, + resourceMetadata, + }, + ); + return result.scopes; + } + + async registerClient( + params: RegisterClientParams, + ): Promise { + const result = await this.fetchFromProxy<{ + clientId: string; + clientSecret?: string; + isDynamic: boolean; + }>("register-client", { + authServerUrl: params.authServerUrl, + metadata: params.metadata, + clientId: params.clientId, + clientSecret: params.clientSecret, + clientMetadata: params.clientMetadata, + }); + + if (result.clientSecret) { + return { + client_id: result.clientId, + client_secret: result.clientSecret, + } as OAuthClientInformationFull; + } + + return { + client_id: result.clientId, + } as OAuthClientInformation; + } + + async startAuthorization( + params: AuthorizationParams, + ): Promise { + const result = await this.fetchFromProxy<{ + authorizationUrl: string; + codeVerifier: string; + state: string; + }>("start-authorization", { + authServerUrl: params.metadata.authorization_endpoint, + clientId: params.clientInformation.client_id, + scope: params.scope, + redirectUri: params.redirectUrl, + resource: params.resource?.toString(), + }); + + return { + authorizationUrl: new URL(result.authorizationUrl), + codeVerifier: result.codeVerifier, + }; + } + + async exchangeToken(params: ExchangeParams): Promise { + return this.fetchFromProxy("exchange-token", { + tokenEndpoint: params.metadata.token_endpoint, + code: params.authorizationCode, + codeVerifier: params.codeVerifier, + clientId: params.clientInformation.client_id, + clientSecret: extractClientSecret(params.clientInformation), + redirectUri: params.redirectUri, + resource: params.resource?.toString(), + }); + } + + async refreshToken(params: RefreshParams): Promise { + return this.fetchFromProxy("refresh-token", { + tokenEndpoint: params.metadata.token_endpoint, + refreshToken: params.refreshToken, + clientId: params.clientInformation.client_id, + clientSecret: extractClientSecret(params.clientInformation), + }); + } +} diff --git a/client/src/lib/oauth/utils/client-helpers.ts b/client/src/lib/oauth/utils/client-helpers.ts new file mode 100644 index 000000000..6e0a159a1 --- /dev/null +++ b/client/src/lib/oauth/utils/client-helpers.ts @@ -0,0 +1,20 @@ +/** + * Helper utilities for working with OAuth client information + */ +import { + OAuthClientInformation, + OAuthClientInformationFull, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** + * Safely extracts client secret from client information if available. + * Returns undefined if the client information doesn't have a client_secret. + * + * @param clientInfo - OAuth client information (may or may not have client_secret) + * @returns The client secret if available, undefined otherwise + */ +export function extractClientSecret( + clientInfo: OAuthClientInformation | OAuthClientInformationFull, +): string | undefined { + return "client_secret" in clientInfo ? clientInfo.client_secret : undefined; +} diff --git a/client/src/lib/oauth/utils/scope-resolver.ts b/client/src/lib/oauth/utils/scope-resolver.ts new file mode 100644 index 000000000..240e09b6f --- /dev/null +++ b/client/src/lib/oauth/utils/scope-resolver.ts @@ -0,0 +1,30 @@ +/** + * Shared utility for resolving OAuth scopes from metadata + */ +import { + OAuthProtectedResourceMetadata, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** + * Resolves scopes from resource metadata and OAuth server metadata. + * Prefers resource metadata scopes, falls back to OAuth metadata scopes. + * + * @param resourceMetadata - Optional protected resource metadata + * @param authServerMetadata - OAuth authorization server metadata + * @returns Space-separated scope string, or undefined if no scopes found + */ +export function resolveScopes( + resourceMetadata: OAuthProtectedResourceMetadata | undefined, + authServerMetadata: OAuthMetadata | undefined, +): string | undefined { + const resourceScopes = resourceMetadata?.scopes_supported; + const oauthScopes = authServerMetadata?.scopes_supported; + + const scopesSupported = + resourceScopes && resourceScopes.length > 0 ? resourceScopes : oauthScopes; + + return scopesSupported && scopesSupported.length > 0 + ? scopesSupported.join(" ") + : undefined; +} diff --git a/server/src/index.ts b/server/src/index.ts index 88954ebc5..1fa2c5017 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -25,6 +25,7 @@ import express from "express"; import { findActualExecutable } from "spawn-rx"; import mcpProxy from "./mcpProxy.js"; import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto"; +import oauthRouter from "./oauth/routes.js"; const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277"; @@ -754,6 +755,9 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { } }); +// Mount OAuth routes under /api/oauth/* +app.use("/api/oauth", originValidationMiddleware, authMiddleware, oauthRouter); + const PORT = parseInt( process.env.SERVER_PORT || DEFAULT_MCP_PROXY_LISTEN_PORT, 10, diff --git a/server/src/oauth/authorization.ts b/server/src/oauth/authorization.ts new file mode 100644 index 000000000..f5f5f59cc --- /dev/null +++ b/server/src/oauth/authorization.ts @@ -0,0 +1,56 @@ +/** + * OAuth authorization flow handlers + */ +import { randomBytes } from "node:crypto"; +import pkceChallenge from "pkce-challenge"; +import { AuthorizationRequest, AuthorizationResult } from "./types.js"; + +/** + * Generate PKCE code verifier and challenge using the pkce-challenge package + */ +export async function generatePKCE(): Promise<{ + codeVerifier: string; + codeChallenge: string; +}> { + const { code_verifier, code_challenge } = await pkceChallenge(); + return { + codeVerifier: code_verifier, + codeChallenge: code_challenge, + }; +} + +/** + * Generate a cryptographically secure state parameter + */ +export function generateState(): string { + return randomBytes(32).toString("base64url"); +} + +/** + * Create OAuth authorization URL with PKCE + */ +export async function createAuthorizationUrl( + request: AuthorizationRequest, +): Promise { + const { codeVerifier, codeChallenge } = await generatePKCE(); + const state = generateState(); + + const url = new URL(request.authServerUrl); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", request.clientId); + url.searchParams.set("redirect_uri", request.redirectUri); + url.searchParams.set("scope", request.scope); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + + if (request.resource) { + url.searchParams.set("resource", request.resource); + } + + return { + authorizationUrl: url.toString(), + codeVerifier, + state, + }; +} diff --git a/server/src/oauth/client-registration.ts b/server/src/oauth/client-registration.ts new file mode 100644 index 000000000..cfa92e2b9 --- /dev/null +++ b/server/src/oauth/client-registration.ts @@ -0,0 +1,72 @@ +/** + * OAuth client registration handlers + * Uses the MCP SDK for Dynamic Client Registration with schema validation + */ +import { registerClient as sdkRegisterClient } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + RegisterClientRequest, + ClientInfo, + OAuthClientMetadata, +} from "./types.js"; + +/** + * Register a client with the OAuth authorization server + * Supports both Dynamic Client Registration (RFC 7591) and pre-registered clients + * + * The SDK's registerClient provides: + * - Proper schema validation with Zod + * - Correct OAuth error handling + * - Full response parsing including metadata + */ +export async function registerClient( + request: RegisterClientRequest, +): Promise { + // If client credentials are provided, use them (pre-registered client) + if (request.clientId) { + return { + clientId: request.clientId, + clientSecret: request.clientSecret, + isDynamic: false, + }; + } + + // Otherwise, attempt Dynamic Client Registration using the SDK + if (!request.metadata.registration_endpoint) { + throw new Error( + "No registration endpoint available and no client credentials provided", + ); + } + + try { + // Use the SDK's registerClient function + const clientInfo = await sdkRegisterClient(request.authServerUrl, { + metadata: request.metadata, + clientMetadata: request.clientMetadata || buildClientMetadata(""), + }); + + return { + clientId: clientInfo.client_id, + clientSecret: clientInfo.client_secret, + isDynamic: true, + }; + } catch (error) { + console.error("Error during client registration:", error); + throw error; + } +} + +/** + * Helper to build default client metadata + */ +export function buildClientMetadata( + redirectUri: string, + scope?: string, +): OAuthClientMetadata { + return { + redirect_uris: [redirectUri], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: scope || "", + client_name: "MCP Inspector", + }; +} diff --git a/server/src/oauth/discovery.ts b/server/src/oauth/discovery.ts new file mode 100644 index 000000000..0da0f9d74 --- /dev/null +++ b/server/src/oauth/discovery.ts @@ -0,0 +1,121 @@ +/** + * OAuth discovery endpoint handlers + * Uses the MCP SDK's discovery functions which provide schema validation and proper error handling + */ +import { + discoverOAuthProtectedResourceMetadata as sdkDiscoverResourceMetadata, + discoverAuthorizationServerMetadata as sdkDiscoverAuthServerMetadata, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + DiscoveryResult, + OAuthProtectedResourceMetadata, + AuthorizationServerMetadata, + OAuthMetadata, +} from "./types.js"; + +/** + * Discover OAuth protected resource metadata + * Proxies the .well-known/oauth-protected-resource endpoint with Zod schema validation + */ +export async function discoverResourceMetadata( + serverUrl: string, +): Promise { + try { + const metadata = await sdkDiscoverResourceMetadata(serverUrl); + return metadata || null; + } catch (error) { + console.warn("Error discovering resource metadata:", error); + return null; + } +} + +/** + * Discover OAuth authorization server metadata + * Uses SDK's discovery which tries RFC 8414 OAuth metadata first, then falls back to OpenID Connect Discovery + */ +export async function discoverAuthServerMetadata( + authServerUrl: string, +): Promise { + const metadata = await sdkDiscoverAuthServerMetadata(authServerUrl); + + if (!metadata) { + throw new Error( + `Failed to discover authorization server metadata for ${authServerUrl}`, + ); + } + + return metadata; +} + +/** + * Full discovery flow combining resource and auth server metadata + */ +export async function discover( + serverUrl: string, + provider?: string, +): Promise { + // First try to discover resource metadata + const resourceMetadata = await discoverResourceMetadata(serverUrl); + + // Determine auth server URL + let authServerUrl = new URL("/", serverUrl); + if (resourceMetadata?.authorization_servers?.length) { + authServerUrl = new URL(resourceMetadata.authorization_servers[0]); + } + + // Discover auth server metadata + const authServerMetadata = await discoverAuthServerMetadata( + authServerUrl.toString(), + ); + + return { + resourceMetadata, + authServerMetadata, + resourceUrl: resourceMetadata?.resource, + }; +} + +/** + * Resolves scopes from resource metadata and OAuth server metadata. + * Prefers resource metadata scopes, falls back to OAuth metadata scopes. + */ +function resolveScopes( + resourceMetadata: OAuthProtectedResourceMetadata | undefined, + authServerMetadata: OAuthMetadata | undefined, +): string | undefined { + const resourceScopes = resourceMetadata?.scopes_supported; + const oauthScopes = authServerMetadata?.scopes_supported; + + const scopesSupported = + resourceScopes && resourceScopes.length > 0 ? resourceScopes : oauthScopes; + + return scopesSupported && scopesSupported.length > 0 + ? scopesSupported.join(" ") + : undefined; +} + +/** + * Discover OAuth scopes from server metadata + * Prefers resource metadata scopes over authorization server scopes + */ +export async function discoverScopes( + serverUrl: string, + resourceMetadata?: OAuthProtectedResourceMetadata, +): Promise { + try { + // Determine auth server URL from resource metadata or default to root + let authServerUrl = new URL("/", serverUrl); + if (resourceMetadata?.authorization_servers?.length) { + authServerUrl = new URL(resourceMetadata.authorization_servers[0]); + } + + const authServerMetadata = await discoverAuthServerMetadata( + authServerUrl.toString(), + ); + + return resolveScopes(resourceMetadata, authServerMetadata); + } catch (error) { + console.debug("OAuth scope discovery failed:", error); + return undefined; + } +} diff --git a/server/src/oauth/oidc.ts b/server/src/oauth/oidc.ts new file mode 100644 index 000000000..3a6b54116 --- /dev/null +++ b/server/src/oauth/oidc.ts @@ -0,0 +1,97 @@ +/** + * OpenID Connect (OIDC) support handlers + */ +import { UserInfoRequest, ValidateIdTokenRequest } from "./types.js"; + +/** + * Fetch user information from UserInfo endpoint + */ +export async function fetchUserInfo( + request: UserInfoRequest, +): Promise> { + try { + const response = await fetch(request.userInfoEndpoint, { + method: "GET", + headers: { + Authorization: `Bearer ${request.accessToken}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `UserInfo request failed: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + const userInfo = (await response.json()) as Record; + return userInfo; + } catch (error) { + console.error("Error fetching UserInfo:", error); + throw error; + } +} + +/** + * Validate ID token (INCOMPLETE - signature verification not implemented) + * + * ⚠️ SECURITY WARNING ⚠️ + * This function does NOT verify the JWT signature and should NOT be used in production. + * It only performs basic claims validation (issuer, audience, expiration). + * + * For production use, you MUST use a proper JWT library that verifies signatures: + * - jose (https://github.com/panva/jose) - recommended + * - jsonwebtoken (https://github.com/auth0/node-jsonwebtoken) + * + * @deprecated This function is incomplete and insecure. Use a proper JWT library. + */ +export async function validateIdToken( + request: ValidateIdTokenRequest, +): Promise<{ + valid: boolean; + payload?: Record; + error?: string; + warning?: string; +}> { + try { + // Split the JWT into parts + const parts = request.idToken.split("."); + if (parts.length !== 3) { + return { valid: false, error: "Invalid JWT format" }; + } + + // Decode the payload (middle part) - NO SIGNATURE VERIFICATION + const payload = JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf-8"), + ) as Record; + + // Basic validation checks (claims only) + if (payload.iss !== request.issuer) { + return { valid: false, error: "Invalid issuer" }; + } + + if (payload.aud !== request.clientId) { + return { valid: false, error: "Invalid audience" }; + } + + // Check expiration + const now = Math.floor(Date.now() / 1000); + if (typeof payload.exp === "number" && payload.exp < now) { + return { valid: false, error: "Token expired" }; + } + + // Return with warning about missing signature verification + return { + valid: true, + payload, + warning: + "SECURITY WARNING: Token signature was NOT verified. This validation is incomplete and should not be trusted for security-critical operations.", + }; + } catch (error) { + console.error("Error validating ID token:", error); + return { + valid: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/server/src/oauth/routes.ts b/server/src/oauth/routes.ts new file mode 100644 index 000000000..c7356f71c --- /dev/null +++ b/server/src/oauth/routes.ts @@ -0,0 +1,232 @@ +/** + * OAuth route handlers for Express + */ +import express, { Request, Response } from "express"; +import { discover, discoverScopes } from "./discovery.js"; +import { registerClient } from "./client-registration.js"; +import { createAuthorizationUrl } from "./authorization.js"; +import { exchangeToken } from "./token-exchange.js"; +import { refreshToken } from "./token-refresh.js"; +import { fetchUserInfo, validateIdToken } from "./oidc.js"; +import { + DiscoveryRequest, + RegisterClientRequest, + AuthorizationRequest, + TokenExchangeRequest, + TokenRefreshRequest, + UserInfoRequest, + ValidateIdTokenRequest, + DiscoverScopesRequest, +} from "./types.js"; + +const router = express.Router(); + +// Enable JSON body parsing for all OAuth routes +router.use(express.json()); + +/** + * Wraps an OAuth route handler with consistent error handling + */ +function wrapOAuthHandler( + handler: (request: TRequest) => Promise, + options: { + operationName: string; + validate?: (request: TRequest) => { valid: boolean; error?: string }; + }, +) { + return async (req: Request, res: Response) => { + try { + const request = req.body as TRequest; + + // Run validation if provided + if (options.validate) { + const validation = options.validate(request); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + } + + const result = await handler(request); + res.json(result); + } catch (error) { + console.error(`Error in /api/oauth/${options.operationName}:`, error); + res.status(500).json({ + error: `${options.operationName.charAt(0).toUpperCase() + options.operationName.slice(1).replace(/-/g, " ")} failed`, + message: error instanceof Error ? error.message : String(error), + }); + } + }; +} + +/** + * POST /api/oauth/discover + * Discover OAuth metadata from server + */ +router.post( + "/discover", + wrapOAuthHandler>>( + (request) => discover(request.serverUrl, request.provider), + { + operationName: "discover", + validate: (request) => + request.serverUrl + ? { valid: true } + : { valid: false, error: "serverUrl is required" }, + }, + ), +); + +/** + * POST /api/oauth/discover-scopes + * Discover OAuth scopes from server metadata + */ +router.post( + "/discover-scopes", + wrapOAuthHandler( + async (request) => ({ + scopes: await discoverScopes(request.serverUrl, request.resourceMetadata), + }), + { + operationName: "discover-scopes", + validate: (request) => + request.serverUrl + ? { valid: true } + : { valid: false, error: "serverUrl is required" }, + }, + ), +); + +/** + * POST /api/oauth/register-client + * Register OAuth client + */ +router.post( + "/register-client", + wrapOAuthHandler< + RegisterClientRequest, + Awaited> + >(registerClient, { + operationName: "register-client", + validate: (request) => + request.authServerUrl && request.metadata + ? { valid: true } + : { valid: false, error: "authServerUrl and metadata are required" }, + }), +); + +/** + * POST /api/oauth/start-authorization + * Create authorization URL with PKCE + */ +router.post( + "/start-authorization", + wrapOAuthHandler< + AuthorizationRequest, + Awaited> + >(createAuthorizationUrl, { + operationName: "start-authorization", + validate: (request) => + request.authServerUrl && + request.clientId && + request.redirectUri && + request.scope + ? { valid: true } + : { + valid: false, + error: + "authServerUrl, clientId, redirectUri, and scope are required", + }, + }), +); + +/** + * POST /api/oauth/exchange-token + * Exchange authorization code for tokens + */ +router.post( + "/exchange-token", + wrapOAuthHandler< + TokenExchangeRequest, + Awaited> + >(exchangeToken, { + operationName: "exchange-token", + validate: (request) => + request.tokenEndpoint && + request.code && + request.codeVerifier && + request.clientId && + request.redirectUri + ? { valid: true } + : { + valid: false, + error: + "tokenEndpoint, code, codeVerifier, clientId, and redirectUri are required", + }, + }), +); + +/** + * POST /api/oauth/refresh-token + * Refresh access token + */ +router.post( + "/refresh-token", + wrapOAuthHandler< + TokenRefreshRequest, + Awaited> + >(refreshToken, { + operationName: "refresh-token", + validate: (request) => + request.tokenEndpoint && request.refreshToken && request.clientId + ? { valid: true } + : { + valid: false, + error: "tokenEndpoint, refreshToken, and clientId are required", + }, + }), +); + +/** + * POST /api/oauth/userinfo + * Fetch user info from OIDC UserInfo endpoint + */ +router.post( + "/userinfo", + wrapOAuthHandler>>( + fetchUserInfo, + { + operationName: "userinfo", + validate: (request) => + request.userInfoEndpoint && request.accessToken + ? { valid: true } + : { + valid: false, + error: "userInfoEndpoint and accessToken are required", + }, + }, + ), +); + +/** + * POST /api/oauth/validate-id-token + * Validate OIDC ID token + */ +router.post( + "/validate-id-token", + wrapOAuthHandler< + ValidateIdTokenRequest, + Awaited> + >(validateIdToken, { + operationName: "validate-id-token", + validate: (request) => + request.idToken && request.jwksUri && request.issuer && request.clientId + ? { valid: true } + : { + valid: false, + error: "idToken, jwksUri, issuer, and clientId are required", + }, + }), +); + +export default router; diff --git a/server/src/oauth/token-exchange.ts b/server/src/oauth/token-exchange.ts new file mode 100644 index 000000000..e6d7bb5f8 --- /dev/null +++ b/server/src/oauth/token-exchange.ts @@ -0,0 +1,50 @@ +/** + * OAuth token exchange handlers + */ +import type { TokenExchangeRequest, OAuthTokens } from "./types.js"; + +/** + * Exchange authorization code for access token + * Handles PKCE verification + */ +export async function exchangeToken( + request: TokenExchangeRequest, +): Promise { + const params = new URLSearchParams(); + params.set("grant_type", "authorization_code"); + params.set("code", request.code); + params.set("redirect_uri", request.redirectUri); + params.set("client_id", request.clientId); + params.set("code_verifier", request.codeVerifier); + + if (request.clientSecret) { + params.set("client_secret", request.clientSecret); + } + + if (request.resource) { + params.set("resource", request.resource); + } + + try { + const response = await fetch(request.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + const tokens = (await response.json()) as OAuthTokens; + return tokens; + } catch (error) { + console.error("Error during token exchange:", error); + throw error; + } +} diff --git a/server/src/oauth/token-refresh.ts b/server/src/oauth/token-refresh.ts new file mode 100644 index 000000000..e733f5855 --- /dev/null +++ b/server/src/oauth/token-refresh.ts @@ -0,0 +1,42 @@ +/** + * OAuth token refresh handlers + * Uses the MCP SDK for token refresh with proper client authentication + */ +import { refreshAuthorization as sdkRefreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { TokenRefreshRequest, OAuthTokens } from "./types.js"; +import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** + * Refresh access token using refresh token + * + * The SDK's refreshAuthorization automatically: + * - Selects the best client authentication method + * - Preserves the original refresh token if a new one is not returned + * - Validates the response with Zod schemas + * + * Note: Like exchangeToken, we extract the auth server URL from the token endpoint + */ +export async function refreshToken( + request: TokenRefreshRequest, +): Promise { + try { + // Extract the authorization server base URL from the token endpoint + const tokenUrl = new URL(request.tokenEndpoint); + const authServerUrl = `${tokenUrl.protocol}//${tokenUrl.host}`; + + // Use the SDK's refreshAuthorization function + const tokens = await sdkRefreshAuthorization(authServerUrl, { + clientInformation: { + client_id: request.clientId, + client_secret: request.clientSecret, + }, + refreshToken: request.refreshToken, + }); + + // Validate the response with Zod schema + return OAuthTokensSchema.parse(tokens); + } catch (error) { + console.error("Error during token refresh:", error); + throw error; + } +} diff --git a/server/src/oauth/types.ts b/server/src/oauth/types.ts new file mode 100644 index 000000000..aa40f7a20 --- /dev/null +++ b/server/src/oauth/types.ts @@ -0,0 +1,100 @@ +/** + * Shared TypeScript types for OAuth implementation + * + * Standard OAuth types are imported from the MCP SDK. + * This file contains only custom types specific to the backend proxy API. + */ + +// Import standard OAuth types from MCP SDK +export type { + OAuthProtectedResourceMetadata, + OAuthMetadata, + AuthorizationServerMetadata, + OAuthTokens, + OAuthClientMetadata, + OAuthClientInformation, + OAuthClientInformationFull, + OAuthErrorResponse, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +// Custom types for backend proxy API requests/responses + +export interface DiscoveryRequest { + serverUrl: string; + provider?: string; +} + +export interface DiscoveryResult { + resourceMetadata: OAuthProtectedResourceMetadata | null; + authServerMetadata: AuthorizationServerMetadata; + resourceUrl?: string; +} + +export interface DiscoverScopesRequest { + serverUrl: string; + resourceMetadata?: OAuthProtectedResourceMetadata; +} + +export interface RegisterClientRequest { + authServerUrl: string; + metadata: AuthorizationServerMetadata; + clientId?: string; + clientSecret?: string; + clientMetadata?: OAuthClientMetadata; +} + +export interface ClientInfo { + clientId: string; + clientSecret?: string; + isDynamic: boolean; +} + +// Re-import these types for backwards compatibility with naming +import type { + OAuthProtectedResourceMetadata, + AuthorizationServerMetadata, + OAuthClientMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +export interface AuthorizationRequest { + authServerUrl: string; + clientId: string; + scope: string; + redirectUri: string; + resource?: string; +} + +export interface AuthorizationResult { + authorizationUrl: string; + codeVerifier: string; + state: string; +} + +export interface TokenExchangeRequest { + tokenEndpoint: string; + code: string; + codeVerifier: string; + clientId: string; + clientSecret?: string; + redirectUri: string; + resource?: string; +} + +export interface TokenRefreshRequest { + tokenEndpoint: string; + refreshToken: string; + clientId: string; + clientSecret?: string; +} + +export interface UserInfoRequest { + userInfoEndpoint: string; + accessToken: string; +} + +export interface ValidateIdTokenRequest { + idToken: string; + jwksUri: string; + issuer: string; + clientId: string; +}