diff --git a/README.md b/README.md index 36e9f3dee..9dc41d40c 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,33 @@ You can paste the Server Entry into your existing `mcp.json` file under your cho ### Authentication -The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. You can override the header name using the input field in the sidebar. +The inspector supports multiple authentication methods for SSE and Streamable HTTP connections: + +#### Bearer Token + +Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. You can override the header name using the input field in the sidebar. + +#### OAuth 2.0 with Client Secret + +Select **Client Secret** as the authentication method in the OAuth section. Provide your Client ID, optional Client Secret, and Scope. The inspector handles the full OAuth 2.0 Authorization Code flow including PKCE. + +#### OAuth 2.0 with Client Certificate + +Select **Client Certificate** as the authentication method for certificate-based OAuth (`private_key_jwt`). This uses a signed JWT assertion backed by an X.509 certificate instead of a client secret. + +**Required fields:** + +- **Client ID** — Your OAuth application's client ID +- **Certificate File Path** — Path to the X.509 certificate PEM file on the server +- **Private Key File Path** — Path to the RSA private key PEM file on the server +- **Scope** — OAuth scopes (space-separated) + +**OAuth endpoint discovery:** + +- **Auto-discover from metadata** (default) — The inspector fetches the MCP server's protected resource metadata (`.well-known/oauth-protected-resource`) and resolves authorization/token endpoints via OIDC discovery (`.well-known/openid-configuration`). This works with any OAuth provider that publishes standard metadata. +- **Enter manually** — Provide the Authorization Endpoint URL and Token Endpoint URL directly. + +Certificate auth works with any OAuth 2.0 provider supporting `private_key_jwt` client authentication (RFC 7523), including Azure AD/Entra ID, Okta, Auth0, and Keycloak. The JWT assertion includes both `x5t` (thumbprint) and `x5c` (certificate chain) claims for broad compatibility. ### Security Considerations diff --git a/client/src/App.tsx b/client/src/App.tsx index 12e9a7bd0..2c08a3429 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -31,8 +31,12 @@ import { isReservedMetaKey, } from "@/utils/metaUtils"; import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"; -import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; -import { OAuthStateMachine } from "./lib/oauth-state-machine"; +import { + AuthDebuggerState, + EMPTY_DEBUGGER_STATE, + OAuthClientAuthMethod, +} from "./lib/auth-types"; +import { OAuthStateMachine, CertAuthConfig } from "./lib/oauth-state-machine"; import { cacheToolOutputSchemas } from "./utils/schemaUtils"; import { cleanParams } from "./utils/paramUtils"; import type { JsonSchemaType } from "./utils/jsonUtils"; @@ -210,6 +214,32 @@ const App = () => { return localStorage.getItem("lastOauthClientSecret") || ""; }); + const [oauthAuthMethod, setOauthAuthMethod] = useState( + () => { + return ( + (localStorage.getItem( + "lastOauthAuthMethod", + ) as OAuthClientAuthMethod) || "secret" + ); + }, + ); + + const [oauthCertPath, setOauthCertPath] = useState(() => { + return localStorage.getItem("lastOauthCertPath") || ""; + }); + + const [oauthKeyPath, setOauthKeyPath] = useState(() => { + return localStorage.getItem("lastOauthKeyPath") || ""; + }); + + const [oauthTokenEndpoint, setOauthTokenEndpoint] = useState(() => { + return localStorage.getItem("lastOauthTokenEndpoint") || ""; + }); + + const [oauthAuthEndpoint, setOauthAuthEndpoint] = useState(() => { + return localStorage.getItem("lastOauthAuthEndpoint") || ""; + }); + // Custom headers state with migration from legacy auth const [customHeaders, setCustomHeaders] = useState(() => { const savedHeaders = localStorage.getItem("lastCustomHeaders"); @@ -395,6 +425,11 @@ const App = () => { oauthClientId, oauthClientSecret, oauthScope, + oauthAuthMethod, + oauthCertPath, + oauthKeyPath, + oauthTokenEndpoint, + oauthAuthEndpoint, config, connectionType, onNotification: (notification) => { @@ -579,6 +614,26 @@ const App = () => { localStorage.setItem("lastOauthClientSecret", oauthClientSecret); }, [oauthClientSecret]); + useEffect(() => { + localStorage.setItem("lastOauthAuthMethod", oauthAuthMethod); + }, [oauthAuthMethod]); + + useEffect(() => { + localStorage.setItem("lastOauthCertPath", oauthCertPath); + }, [oauthCertPath]); + + useEffect(() => { + localStorage.setItem("lastOauthKeyPath", oauthKeyPath); + }, [oauthKeyPath]); + + useEffect(() => { + localStorage.setItem("lastOauthTokenEndpoint", oauthTokenEndpoint); + }, [oauthTokenEndpoint]); + + useEffect(() => { + localStorage.setItem("lastOauthAuthEndpoint", oauthAuthEndpoint); + }, [oauthAuthEndpoint]); + useEffect(() => { saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); }, [config]); @@ -622,9 +677,30 @@ const App = () => { }; try { - const stateMachine = new OAuthStateMachine(sseUrl, (updates) => { - currentState = { ...currentState, ...updates }; - }); + const certConfig: CertAuthConfig | undefined = + oauthAuthMethod === "certificate" + ? { + authMethod: oauthAuthMethod, + certPath: oauthCertPath, + keyPath: oauthKeyPath, + tokenEndpointUrl: oauthTokenEndpoint, + authEndpointUrl: oauthAuthEndpoint, + } + : undefined; + const proxyAddress = getMCPProxyAddress(config); + const { token: proxyToken, header: proxyHeader } = + getMCPProxyAuthToken(config); + + const stateMachine = new OAuthStateMachine( + sseUrl, + (updates) => { + currentState = { ...currentState, ...updates }; + }, + certConfig, + proxyAddress, + proxyToken, + proxyHeader, + ); while ( currentState.oauthStep !== "complete" && @@ -662,7 +738,15 @@ const App = () => { }); } }, - [sseUrl], + [ + sseUrl, + config, + oauthAuthMethod, + oauthCertPath, + oauthKeyPath, + oauthTokenEndpoint, + oauthAuthEndpoint, + ], ); useEffect(() => { @@ -1264,6 +1348,14 @@ const App = () => { onBack={() => setIsAuthDebuggerVisible(false)} authState={authState} updateAuthState={updateAuthState} + oauthAuthMethod={oauthAuthMethod} + oauthCertPath={oauthCertPath} + oauthKeyPath={oauthKeyPath} + oauthTokenEndpoint={oauthTokenEndpoint} + oauthAuthEndpoint={oauthAuthEndpoint} + proxyAddress={getMCPProxyAddress(config)} + proxyAuthToken={getMCPProxyAuthToken(config).token} + proxyAuthHeader={getMCPProxyAuthToken(config).header} /> ); @@ -1323,6 +1415,16 @@ const App = () => { setOauthClientSecret={setOauthClientSecret} oauthScope={oauthScope} setOauthScope={setOauthScope} + oauthAuthMethod={oauthAuthMethod} + setOauthAuthMethod={setOauthAuthMethod} + oauthCertPath={oauthCertPath} + setOauthCertPath={setOauthCertPath} + oauthKeyPath={oauthKeyPath} + setOauthKeyPath={setOauthKeyPath} + oauthTokenEndpoint={oauthTokenEndpoint} + setOauthTokenEndpoint={setOauthTokenEndpoint} + oauthAuthEndpoint={oauthAuthEndpoint} + setOauthAuthEndpoint={setOauthAuthEndpoint} onConnect={connectMcpServer} onDisconnect={disconnectMcpServer} logLevel={logLevel} diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 6252c1161..19d367b76 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -2,9 +2,13 @@ import { useCallback, useMemo, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { DebugInspectorOAuthClientProvider } from "../lib/auth"; import { AlertCircle } from "lucide-react"; -import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types"; +import { + AuthDebuggerState, + EMPTY_DEBUGGER_STATE, + OAuthClientAuthMethod, +} from "../lib/auth-types"; import { OAuthFlowProgress } from "./OAuthFlowProgress"; -import { OAuthStateMachine } from "../lib/oauth-state-machine"; +import { OAuthStateMachine, CertAuthConfig } from "../lib/oauth-state-machine"; import { SESSION_KEYS } from "../lib/constants"; import { validateRedirectUrl } from "@/utils/urlValidation"; @@ -13,6 +17,14 @@ export interface AuthDebuggerProps { onBack: () => void; authState: AuthDebuggerState; updateAuthState: (updates: Partial) => void; + oauthAuthMethod?: OAuthClientAuthMethod; + oauthCertPath?: string; + oauthKeyPath?: string; + oauthTokenEndpoint?: string; + oauthAuthEndpoint?: string; + proxyAddress?: string; + proxyAuthToken?: string; + proxyAuthHeader?: string; } interface StatusMessageProps { @@ -60,6 +72,14 @@ const AuthDebugger = ({ onBack, authState, updateAuthState, + oauthAuthMethod, + oauthCertPath, + oauthKeyPath, + oauthTokenEndpoint, + oauthAuthEndpoint, + proxyAddress, + proxyAuthToken, + proxyAuthHeader, }: AuthDebuggerProps) => { // Check for existing tokens on mount useEffect(() => { @@ -102,9 +122,44 @@ const AuthDebugger = ({ }); }, [serverUrl, updateAuthState]); + const certConfig: CertAuthConfig | undefined = useMemo( + () => + oauthAuthMethod === "certificate" + ? { + authMethod: oauthAuthMethod, + certPath: oauthCertPath || "", + keyPath: oauthKeyPath || "", + tokenEndpointUrl: oauthTokenEndpoint || "", + authEndpointUrl: oauthAuthEndpoint || "", + } + : undefined, + [ + oauthAuthMethod, + oauthCertPath, + oauthKeyPath, + oauthTokenEndpoint, + oauthAuthEndpoint, + ], + ); + const stateMachine = useMemo( - () => new OAuthStateMachine(serverUrl, updateAuthState), - [serverUrl, updateAuthState], + () => + new OAuthStateMachine( + serverUrl, + updateAuthState, + certConfig, + proxyAddress, + proxyAuthToken, + proxyAuthHeader, + ), + [ + serverUrl, + updateAuthState, + certConfig, + proxyAddress, + proxyAuthToken, + proxyAuthHeader, + ], ); const proceedToNextStep = useCallback(async () => { @@ -150,11 +205,18 @@ 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 + }, + certConfig, + proxyAddress, + proxyAuthToken, + proxyAuthHeader, + ); // Manually step through each stage of the OAuth flow while (currentState.oauthStep !== "complete") { @@ -214,7 +276,15 @@ const AuthDebugger = ({ } finally { updateAuthState({ isInitiatingAuth: false }); } - }, [serverUrl, updateAuthState, authState]); + }, [ + serverUrl, + updateAuthState, + authState, + certConfig, + proxyAddress, + proxyAuthToken, + proxyAuthHeader, + ]); const handleClearOAuth = useCallback(() => { if (serverUrl) { diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx index ccfd6d928..411b30f32 100644 --- a/client/src/components/OAuthCallback.tsx +++ b/client/src/components/OAuthCallback.tsx @@ -2,11 +2,19 @@ import { useEffect, useRef } from "react"; import { InspectorOAuthClientProvider } from "../lib/auth"; import { SESSION_KEYS } from "../lib/constants"; import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import { useToast } from "@/lib/hooks/useToast"; import { generateOAuthErrorDescription, parseOAuthCallbackParams, } from "@/utils/oauthUtils.ts"; +import { + getMCPProxyAddress, + getMCPProxyAuthToken, + initializeInspectorConfig, +} from "@/utils/configUtils"; + +const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; interface OAuthCallbackProps { onConnect: (serverUrl: string) => void; @@ -41,33 +49,122 @@ 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); + const authMethod = localStorage.getItem("lastOauthAuthMethod"); - result = await auth(serverAuthProvider, { - serverUrl, - authorizationCode: params.code, - }); - } catch (error) { - console.error("OAuth callback error:", error); - return notifyError(`Unexpected error occurred: ${error}`); - } + if (authMethod === "certificate") { + // Certificate-based token exchange via server proxy + try { + const clientId = localStorage.getItem("lastOauthClientId") || ""; + const certPath = localStorage.getItem("lastOauthCertPath") || ""; + const keyPath = localStorage.getItem("lastOauthKeyPath") || ""; + const tokenEndpointUrl = + localStorage.getItem("lastOauthTokenEndpoint") || ""; + const scope = localStorage.getItem("lastOauthScope") || ""; - if (result !== "AUTHORIZED") { - return notifyError( - `Expected to be authorized after providing auth code, got: ${result}`, - ); - } + // Validate required fields before sending + const missingFields: string[] = []; + if (!clientId) missingFields.push("Client ID"); + if (!certPath) missingFields.push("Certificate Path"); + if (!keyPath) missingFields.push("Private Key Path"); + if (!tokenEndpointUrl) missingFields.push("Token Endpoint URL"); + + if (missingFields.length > 0) { + return notifyError( + `Certificate auth configuration incomplete. Missing: ${missingFields.join(", ")}. Please fill in these fields before connecting.`, + ); + } + + const serverAuthProvider = new InspectorOAuthClientProvider( + serverUrl, + ); + let codeVerifier: string | undefined; + try { + codeVerifier = serverAuthProvider.codeVerifier(); + } catch { + // Code verifier may not be available + } + + const config = initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY); + const proxyAddress = getMCPProxyAddress(config); + const { token: proxyToken, header: proxyAuthHeader } = + getMCPProxyAuthToken(config); - // Finally, trigger auto-connect - toast({ - title: "Success", - description: "Successfully authenticated with OAuth", - variant: "default", - }); - onConnect(serverUrl); + const headers: Record = { + "Content-Type": "application/json", + }; + if (proxyToken) { + headers[proxyAuthHeader] = `Bearer ${proxyToken}`; + } + + const response = await fetch( + `${proxyAddress}/oauth/token/certificate`, + { + method: "POST", + headers, + body: JSON.stringify({ + clientId, + tokenEndpointUrl, + certPath, + keyPath, + authorizationCode: params.code, + redirectUri: window.location.origin + "/oauth/callback", + codeVerifier, + scope: scope || undefined, + }), + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + return notifyError( + `Certificate token exchange failed: ${errorData.message || errorData.error || response.statusText}`, + ); + } + + const tokenData = await response.json(); + const tokens = await OAuthTokensSchema.parseAsync(tokenData); + serverAuthProvider.saveTokens(tokens); + + toast({ + title: "Success", + description: + "Successfully authenticated with OAuth (certificate auth)", + variant: "default", + }); + onConnect(serverUrl); + } catch (error) { + console.error("Certificate OAuth callback error:", error); + return notifyError(`Certificate auth error: ${error}`); + } + } else { + // Standard flow using SDK's auth() + 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}`, + ); + } + + toast({ + title: "Success", + description: "Successfully authenticated with OAuth", + variant: "default", + }); + onConnect(serverUrl); + } }; handleCallback().finally(() => { diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 13a4f24f4..c8a5affff 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -31,6 +31,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { InspectorConfig } from "@/lib/configurationTypes"; import { ConnectionStatus } from "@/lib/constants"; +import { OAuthClientAuthMethod } from "@/lib/auth-types"; import useTheme from "../lib/hooks/useTheme"; import { version } from "../../../package.json"; import { @@ -64,6 +65,16 @@ interface SidebarProps { setOauthClientSecret: (secret: string) => void; oauthScope: string; setOauthScope: (scope: string) => void; + oauthAuthMethod: OAuthClientAuthMethod; + setOauthAuthMethod: (method: OAuthClientAuthMethod) => void; + oauthCertPath: string; + setOauthCertPath: (path: string) => void; + oauthKeyPath: string; + setOauthKeyPath: (path: string) => void; + oauthTokenEndpoint: string; + setOauthTokenEndpoint: (url: string) => void; + oauthAuthEndpoint: string; + setOauthAuthEndpoint: (url: string) => void; onConnect: () => void; onDisconnect: () => void; logLevel: LoggingLevel; @@ -98,6 +109,16 @@ const Sidebar = ({ setOauthClientSecret, oauthScope, setOauthScope, + oauthAuthMethod, + setOauthAuthMethod, + oauthCertPath, + setOauthCertPath, + oauthKeyPath, + setOauthKeyPath, + oauthTokenEndpoint, + setOauthTokenEndpoint, + oauthAuthEndpoint, + setOauthAuthEndpoint, onConnect, onDisconnect, logLevel, @@ -117,6 +138,9 @@ const Sidebar = ({ const [showClientSecret, setShowClientSecret] = useState(false); const [copiedServerEntry, setCopiedServerEntry] = useState(false); const [copiedServerFile, setCopiedServerFile] = useState(false); + const [endpointSource, setEndpointSource] = useState<"auto" | "manual">( + oauthAuthEndpoint || oauthTokenEndpoint ? "manual" : "auto", + ); const { toast } = useToast(); const connectionTypeTip = @@ -567,37 +591,143 @@ const Sidebar = ({ className="font-mono" /> -
- setOauthClientSecret(e.target.value)} - value={oauthClientSecret} - data-testid="oauth-client-secret-input" - className="font-mono" - /> - +
+ + ) : ( + <> + + + {endpointSource === "manual" && ( + <> + + + setOauthAuthEndpoint(e.target.value) + } + value={oauthAuthEndpoint} + data-testid="oauth-auth-endpoint-input" + className="font-mono" + /> + + + setOauthTokenEndpoint(e.target.value) + } + value={oauthTokenEndpoint} + data-testid="oauth-token-endpoint-input" + className="font-mono" + /> + )} - - + + setOauthCertPath(e.target.value)} + value={oauthCertPath} + data-testid="oauth-cert-path-input" + className="font-mono" + /> + + setOauthKeyPath(e.target.value)} + value={oauthKeyPath} + data-testid="oauth-key-path-input" + className="font-mono" + /> + + )} diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 03e898ca9..33a3896d6 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -48,6 +48,16 @@ describe("Sidebar", () => { setOauthClientSecret: jest.fn(), oauthScope: "", setOauthScope: jest.fn(), + oauthAuthMethod: "secret" as const, + setOauthAuthMethod: jest.fn(), + oauthCertPath: "", + setOauthCertPath: jest.fn(), + oauthKeyPath: "", + setOauthKeyPath: jest.fn(), + oauthTokenEndpoint: "", + setOauthTokenEndpoint: jest.fn(), + oauthAuthEndpoint: "", + setOauthAuthEndpoint: jest.fn(), env: {}, setEnv: jest.fn(), customHeaders: [], @@ -1046,4 +1056,171 @@ describe("Sidebar", () => { ); }); }); + + describe("Certificate Authentication", () => { + const openAuthSection = () => { + const button = screen.getByTestId("auth-button"); + fireEvent.click(button); + }; + + it("should show auth method selector when OAuth section is visible", () => { + renderSidebar({ transportType: "sse" }); + openAuthSection(); + + const authMethodSelect = screen.getByTestId("oauth-auth-method-select"); + expect(authMethodSelect).toBeInTheDocument(); + }); + + it("should show client secret field when secret method is selected", () => { + renderSidebar({ + transportType: "sse", + oauthAuthMethod: "secret", + }); + openAuthSection(); + + expect( + screen.getByTestId("oauth-client-secret-input"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("oauth-cert-path-input"), + ).not.toBeInTheDocument(); + }); + + it("should show certificate fields when certificate method is selected", () => { + renderSidebar({ + transportType: "sse", + oauthAuthMethod: "certificate", + }); + openAuthSection(); + + expect(screen.getByTestId("oauth-cert-path-input")).toBeInTheDocument(); + expect(screen.getByTestId("oauth-key-path-input")).toBeInTheDocument(); + expect( + screen.queryByTestId("oauth-client-secret-input"), + ).not.toBeInTheDocument(); + }); + + it("should show endpoint source selector for certificate auth", () => { + renderSidebar({ + transportType: "sse", + oauthAuthMethod: "certificate", + }); + openAuthSection(); + + expect( + screen.getByTestId("oauth-endpoint-source-select"), + ).toBeInTheDocument(); + }); + + it("should hide manual endpoint fields when auto-discover is selected", () => { + renderSidebar({ + transportType: "sse", + oauthAuthMethod: "certificate", + oauthAuthEndpoint: "", + oauthTokenEndpoint: "", + }); + openAuthSection(); + + expect( + screen.queryByTestId("oauth-auth-endpoint-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("oauth-token-endpoint-input"), + ).not.toBeInTheDocument(); + }); + + it("should show manual endpoint fields when endpoints are provided", () => { + renderSidebar({ + transportType: "sse", + oauthAuthMethod: "certificate", + oauthAuthEndpoint: "https://auth.example.com/authorize", + oauthTokenEndpoint: "https://auth.example.com/token", + }); + openAuthSection(); + + const authEndpointInput = screen.getByTestId("oauth-auth-endpoint-input"); + const tokenEndpointInput = screen.getByTestId( + "oauth-token-endpoint-input", + ); + expect(authEndpointInput).toHaveValue( + "https://auth.example.com/authorize", + ); + expect(tokenEndpointInput).toHaveValue("https://auth.example.com/token"); + }); + + it("should call setOauthCertPath when cert path input changes", () => { + const setOauthCertPath = jest.fn(); + renderSidebar({ + transportType: "sse", + oauthAuthMethod: "certificate", + setOauthCertPath, + }); + openAuthSection(); + + const certInput = screen.getByTestId("oauth-cert-path-input"); + fireEvent.change(certInput, { + target: { value: "/path/to/cert.pem" }, + }); + expect(setOauthCertPath).toHaveBeenCalledWith("/path/to/cert.pem"); + }); + + it("should call setOauthKeyPath when key path input changes", () => { + const setOauthKeyPath = jest.fn(); + renderSidebar({ + transportType: "sse", + oauthAuthMethod: "certificate", + setOauthKeyPath, + }); + openAuthSection(); + + const keyInput = screen.getByTestId("oauth-key-path-input"); + fireEvent.change(keyInput, { + target: { value: "/path/to/key.pem" }, + }); + expect(setOauthKeyPath).toHaveBeenCalledWith("/path/to/key.pem"); + }); + + it("should call setOauthAuthMethod when auth method changes", () => { + const setOauthAuthMethod = jest.fn(); + renderSidebar({ + transportType: "sse", + oauthAuthMethod: "secret", + setOauthAuthMethod, + }); + openAuthSection(); + + const authMethodSelect = screen.getByTestId("oauth-auth-method-select"); + fireEvent.click(authMethodSelect); + + const certOption = screen.getByText("Client Certificate"); + fireEvent.click(certOption); + + expect(setOauthAuthMethod).toHaveBeenCalledWith("certificate"); + }); + + it("should clear endpoints when switching to auto-discover", () => { + const setOauthAuthEndpoint = jest.fn(); + const setOauthTokenEndpoint = jest.fn(); + renderSidebar({ + transportType: "sse", + oauthAuthMethod: "certificate", + oauthAuthEndpoint: "https://auth.example.com/authorize", + oauthTokenEndpoint: "https://auth.example.com/token", + setOauthAuthEndpoint, + setOauthTokenEndpoint, + }); + openAuthSection(); + + const endpointSourceSelect = screen.getByTestId( + "oauth-endpoint-source-select", + ); + fireEvent.click(endpointSourceSelect); + + const autoOption = screen.getByText("Auto-discover from metadata"); + fireEvent.click(autoOption); + + expect(setOauthAuthEndpoint).toHaveBeenCalledWith(""); + expect(setOauthTokenEndpoint).toHaveBeenCalledWith(""); + }); + }); }); diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index aaa834286..c64b1071b 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -6,6 +6,9 @@ import { OAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; +// OAuth client authentication method at the token endpoint +export type OAuthClientAuthMethod = "secret" | "certificate"; + // OAuth flow steps export type OAuthStep = | "metadata_discovery" diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index 6cb1a02cc..60c389aa9 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -18,6 +18,10 @@ export const SESSION_KEYS = { SERVER_METADATA: "mcp_server_metadata", AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state", SCOPE: "mcp_scope", + OAUTH_AUTH_METHOD: "mcp_oauth_auth_method", + OAUTH_CERT_PATH: "mcp_oauth_cert_path", + OAUTH_KEY_PATH: "mcp_oauth_key_path", + OAUTH_TOKEN_ENDPOINT: "mcp_oauth_token_endpoint", } 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 e14d1037f..9ba1ac1cf 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -54,8 +54,10 @@ import { ConnectionStatus, CLIENT_IDENTITY } from "../constants"; import { Notification } from "../notificationTypes"; import { auth, + startAuthorization, discoverOAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/client/auth.js"; +import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import { clearClientInformationFromSessionStorage, InspectorOAuthClientProvider, @@ -75,6 +77,7 @@ import { getMCPServerRequestTimeout } from "@/utils/configUtils"; import { InspectorConfig } from "../configurationTypes"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { CustomHeaders } from "../types/customHeaders"; +import { OAuthClientAuthMethod } from "../auth-types"; import { resolveRefsInMessage } from "@/utils/schemaUtils"; interface UseConnectionOptions { @@ -88,6 +91,11 @@ interface UseConnectionOptions { oauthClientId?: string; oauthClientSecret?: string; oauthScope?: string; + oauthAuthMethod?: OAuthClientAuthMethod; + oauthCertPath?: string; + oauthKeyPath?: string; + oauthTokenEndpoint?: string; + oauthAuthEndpoint?: string; config: InspectorConfig; connectionType?: "direct" | "proxy"; onNotification?: (notification: Notification) => void; @@ -113,6 +121,11 @@ export function useConnection({ oauthClientId, oauthClientSecret, oauthScope, + oauthAuthMethod, + oauthCertPath, + oauthKeyPath, + oauthTokenEndpoint, + oauthAuthEndpoint, config, connectionType = "proxy", onNotification, @@ -404,9 +417,8 @@ export function useConnection({ // Only discover resource metadata when we need to discover scopes let resourceMetadata; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata( - new URL("/", sseUrl), - ); + resourceMetadata = + await discoverOAuthProtectedResourceMetadata(sseUrl); } catch { // Resource metadata is optional, continue without it } @@ -416,6 +428,144 @@ export function useConnection({ saveScopeToSessionStorage(sseUrl, scope); const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); + // Certificate auth: build authorization URL manually, skipping SDK discovery + if (oauthAuthMethod === "certificate") { + try { + let authEndpoint = oauthAuthEndpoint; + let tokenEndpoint = oauthTokenEndpoint; + + // Auto-discover endpoints if not manually provided + if (!authEndpoint || !tokenEndpoint) { + try { + // Use proxy server for discovery to avoid CORS issues with remote servers + const proxyAddress = getMCPProxyAddress(config); + const { token: proxyAuthToken, header: proxyAuthTokenHeader } = + getMCPProxyAuthToken(config); + const discoveryHeaders: Record = {}; + if (proxyAuthToken) { + discoveryHeaders[proxyAuthTokenHeader] = + `Bearer ${proxyAuthToken}`; + } + const discoveryUrl = new URL(`${proxyAddress}/oauth/discover`); + discoveryUrl.searchParams.set("url", sseUrl); + const discoveryResponse = await fetch(discoveryUrl.href, { + headers: discoveryHeaders, + }); + + if (discoveryResponse.ok) { + const metadata = await discoveryResponse.json(); + // Use pre-discovered OIDC endpoints if available + if (metadata._discovered_endpoints) { + if ( + !authEndpoint && + metadata._discovered_endpoints.authorization_endpoint + ) { + authEndpoint = + metadata._discovered_endpoints.authorization_endpoint; + } + if ( + !tokenEndpoint && + metadata._discovered_endpoints.token_endpoint + ) { + tokenEndpoint = + metadata._discovered_endpoints.token_endpoint; + } + } else if (metadata.authorization_servers?.length) { + // Fallback: fetch OIDC config directly (works for same-origin) + const authServerUrl = + metadata.authorization_servers[0].replace(/\/+$/, ""); + const oidcResponse = await fetch( + `${authServerUrl}/.well-known/openid-configuration`, + ); + if (oidcResponse.ok) { + const oidcConfig = await oidcResponse.json(); + if (!authEndpoint && oidcConfig.authorization_endpoint) { + authEndpoint = oidcConfig.authorization_endpoint; + } + if (!tokenEndpoint && oidcConfig.token_endpoint) { + tokenEndpoint = oidcConfig.token_endpoint; + } + } + } + } + } catch { + // Discovery failed, fall through to check if we have endpoints + } + } + + if (!authEndpoint) { + toast({ + title: "Certificate Auth Configuration Required", + description: + "Could not discover OAuth endpoints. Please provide the Authorization Endpoint URL.", + variant: "destructive", + }); + return false; + } + + // Build metadata for startAuthorization + const metadata = await OAuthMetadataSchema.parseAsync({ + issuer: tokenEndpoint || authEndpoint, + authorization_endpoint: authEndpoint, + token_endpoint: tokenEndpoint || authEndpoint, + response_types_supported: ["code"], + }); + + const clientInformation = + await serverAuthProvider.clientInformation(); + if (!clientInformation) { + toast({ + title: "Client ID Required", + description: + "Please provide a Client ID for certificate authentication.", + variant: "destructive", + }); + return false; + } + + // Use SDK's startAuthorization but WITHOUT the resource parameter + const { authorizationUrl, codeVerifier } = await startAuthorization( + authEndpoint, + { + metadata, + clientInformation, + redirectUrl: serverAuthProvider.redirectUrl, + scope, + state: await serverAuthProvider.state(), + resource: undefined, // Explicitly omit resource for Azure AD + }, + ); + + serverAuthProvider.saveCodeVerifier(codeVerifier); + + // Explicitly save cert config to localStorage before redirect + // so OAuthCallback can read them on the return trip. + // Use discovered endpoints (tokenEndpoint/authEndpoint) not the + // original props, since they may have been auto-discovered via OIDC. + localStorage.setItem("lastOauthAuthMethod", "certificate"); + localStorage.setItem("lastOauthClientId", oauthClientId || ""); + localStorage.setItem("lastOauthCertPath", oauthCertPath || ""); + localStorage.setItem("lastOauthKeyPath", oauthKeyPath || ""); + localStorage.setItem("lastOauthTokenEndpoint", tokenEndpoint || ""); + localStorage.setItem("lastOauthAuthEndpoint", authEndpoint || ""); + if (scope) localStorage.setItem("lastOauthScope", scope); + + window.location.href = authorizationUrl.href; + return false; + } catch (authError) { + toast({ + title: "Certificate OAuth Failed", + description: + authError instanceof Error + ? authError.message + : String(authError), + variant: "destructive", + }); + return false; + } + } + + // Standard auth flow using SDK try { const result = await auth(serverAuthProvider, { serverUrl: sseUrl, @@ -499,6 +649,13 @@ export function useConnection({ // Create an auth provider with the current server URL const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); + // For certificate auth, don't pass authProvider to the transport. + // This prevents the SDK from running its own discovery/auth flow + // (which fails with CORS on remote servers and adds the 'resource' param + // that Azure AD rejects). Instead, let the 401 bubble up to handleAuthError. + const transportAuthProvider = + oauthAuthMethod === "certificate" ? undefined : serverAuthProvider; + // Use custom headers (migration is handled in App.tsx) let finalHeaders: CustomHeaders = customHeaders || []; @@ -584,7 +741,7 @@ export function useConnection({ requestHeaders["Accept"] = "text/event-stream"; requestHeaders["content-type"] = "application/json"; transportOptions = { - authProvider: serverAuthProvider, + authProvider: transportAuthProvider, fetch: async ( url: string | URL | globalThis.Request, init?: RequestInit, @@ -606,7 +763,7 @@ export function useConnection({ case "streamable-http": transportOptions = { - authProvider: serverAuthProvider, + authProvider: transportAuthProvider, fetch: async ( url: string | URL | globalThis.Request, init?: RequestInit, @@ -664,7 +821,7 @@ export function useConnection({ ); } transportOptions = { - authProvider: serverAuthProvider, + authProvider: transportAuthProvider, eventSourceInit: { fetch: ( url: string | URL | globalThis.Request, @@ -695,7 +852,7 @@ export function useConnection({ ); } transportOptions = { - authProvider: serverAuthProvider, + authProvider: transportAuthProvider, eventSourceInit: { fetch: ( url: string | URL | globalThis.Request, @@ -717,7 +874,7 @@ export function useConnection({ mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); mcpProxyServerUrl.searchParams.append("url", sseUrl); transportOptions = { - authProvider: serverAuthProvider, + authProvider: transportAuthProvider, eventSourceInit: { fetch: ( url: string | URL | globalThis.Request, diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 8dc9da8f9..4a71eca75 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -1,4 +1,8 @@ -import { OAuthStep, AuthDebuggerState } from "./auth-types"; +import { + OAuthStep, + AuthDebuggerState, + OAuthClientAuthMethod, +} from "./auth-types"; import { DebugInspectorOAuthClientProvider, discoverScopes } from "./auth"; import { discoverAuthorizationServerMetadata, @@ -11,6 +15,7 @@ import { import { OAuthMetadataSchema, OAuthProtectedResourceMetadata, + OAuthTokensSchema, } from "@modelcontextprotocol/sdk/shared/auth.js"; import { generateOAuthState } from "@/utils/oauthUtils"; @@ -19,6 +24,18 @@ export interface StateMachineContext { serverUrl: string; provider: DebugInspectorOAuthClientProvider; updateState: (updates: Partial) => void; + certAuthConfig?: CertAuthConfig; + proxyAddress?: string; + proxyAuthToken?: string; + proxyAuthHeader?: string; +} + +export interface CertAuthConfig { + authMethod: OAuthClientAuthMethod; + certPath: string; + keyPath: string; + tokenEndpointUrl: string; + authEndpointUrl: string; } export interface StateTransition { @@ -31,7 +48,68 @@ export const oauthTransitions: Record = { metadata_discovery: { canTransition: async () => true, execute: async (context) => { - // Default to discovering from the server's URL + // Certificate auth: auto-discover via protected resource metadata + OIDC, + // with manual endpoints as fallback + if (context.certAuthConfig?.authMethod === "certificate") { + let authEndpointUrl = context.certAuthConfig.authEndpointUrl; + let tokenEndpointUrl = context.certAuthConfig.tokenEndpointUrl; + + // Try auto-discovery from MCP server's protected resource metadata + if (!authEndpointUrl || !tokenEndpointUrl) { + try { + const resourceMetadata = + await discoverOAuthProtectedResourceMetadata(context.serverUrl); + if (resourceMetadata?.authorization_servers?.length) { + const authServerUrl = resourceMetadata.authorization_servers[0]; + + // Try OpenID Connect discovery (works with Azure AD, Auth0, etc.) + const oidcUrl = + authServerUrl.replace(/\/+$/, "") + + "/.well-known/openid-configuration"; + const oidcResponse = await fetch(oidcUrl); + if (oidcResponse.ok) { + const oidcConfig = await oidcResponse.json(); + if (!authEndpointUrl && oidcConfig.authorization_endpoint) { + authEndpointUrl = oidcConfig.authorization_endpoint; + } + if (!tokenEndpointUrl && oidcConfig.token_endpoint) { + tokenEndpointUrl = oidcConfig.token_endpoint; + } + } + } + } catch (e) { + console.debug("Auto-discovery failed, using manual endpoints:", e); + } + } + + if (!authEndpointUrl || !tokenEndpointUrl) { + throw new Error( + "Could not discover OAuth endpoints. Please provide the Authorization Endpoint URL and Token Endpoint URL manually.", + ); + } + + const manualMetadata = { + issuer: tokenEndpointUrl, + authorization_endpoint: authEndpointUrl, + token_endpoint: tokenEndpointUrl, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + }; + const parsedMetadata = + await OAuthMetadataSchema.parseAsync(manualMetadata); + context.provider.saveServerMetadata(parsedMetadata); + context.updateState({ + resourceMetadata: null, + resource: undefined, + resourceMetadataError: null, + authServerUrl: new URL(authEndpointUrl), + oauthMetadata: parsedMetadata, + oauthStep: "client_registration", + }); + return; + } + + // Standard flow: discover from the server's URL let authServerUrl = new URL("/", context.serverUrl); let resourceMetadata: OAuthProtectedResourceMetadata | null = null; let resourceMetadataError: Error | null = null; @@ -77,6 +155,22 @@ export const oauthTransitions: Record = { client_registration: { canTransition: async (context) => !!context.state.oauthMetadata, execute: async (context) => { + // When cert auth with manual endpoints is configured, skip DCR + // and use pre-registered client info directly + if (context.certAuthConfig?.authMethod === "certificate") { + const fullInformation = await context.provider.clientInformation(); + if (!fullInformation) { + throw new Error( + "Client ID is required for certificate authentication", + ); + } + context.updateState({ + oauthClientInfo: fullInformation, + oauthStep: "authorization_redirect", + }); + return; + } + const metadata = context.state.oauthMetadata!; const clientMetadata = context.provider.clientMetadata; @@ -175,27 +269,73 @@ export const oauthTransitions: Record = { }, execute: async (context) => { const codeVerifier = context.provider.codeVerifier(); - const metadata = context.provider.getServerMetadata()!; const clientInformation = (await context.provider.clientInformation())!; - const tokens = await exchangeAuthorization(context.serverUrl, { - metadata, - clientInformation, - authorizationCode: context.state.authorizationCode, - codeVerifier, - redirectUri: context.provider.redirectUrl, - resource: context.state.resource - ? context.state.resource instanceof URL - ? context.state.resource - : new URL(context.state.resource) - : undefined, - }); + // Check if certificate auth is active + if (context.certAuthConfig?.authMethod === "certificate") { + const { certPath, keyPath, tokenEndpointUrl } = context.certAuthConfig; - context.provider.saveTokens(tokens); - context.updateState({ - oauthTokens: tokens, - oauthStep: "complete", - }); + const headers: Record = { + "Content-Type": "application/json", + }; + if (context.proxyAuthToken && context.proxyAuthHeader) { + headers[context.proxyAuthHeader] = `Bearer ${context.proxyAuthToken}`; + } + + const response = await fetch( + `${context.proxyAddress}/oauth/token/certificate`, + { + method: "POST", + headers, + body: JSON.stringify({ + clientId: clientInformation.client_id, + tokenEndpointUrl, + certPath, + keyPath, + authorizationCode: context.state.authorizationCode, + redirectUri: context.provider.redirectUrl, + codeVerifier, + scope: context.provider.scope || undefined, + }), + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Certificate token exchange failed: ${errorData.message || errorData.error || response.statusText}`, + ); + } + + const tokenData = await response.json(); + const tokens = await OAuthTokensSchema.parseAsync(tokenData); + context.provider.saveTokens(tokens); + context.updateState({ + oauthTokens: tokens, + oauthStep: "complete", + }); + } else { + // Standard token exchange + const metadata = context.provider.getServerMetadata()!; + const tokens = await exchangeAuthorization(context.serverUrl, { + metadata, + clientInformation, + authorizationCode: context.state.authorizationCode, + codeVerifier, + redirectUri: context.provider.redirectUrl, + resource: context.state.resource + ? context.state.resource instanceof URL + ? context.state.resource + : new URL(context.state.resource) + : undefined, + }); + + context.provider.saveTokens(tokens); + context.updateState({ + oauthTokens: tokens, + oauthStep: "complete", + }); + } }, }, @@ -211,6 +351,10 @@ export class OAuthStateMachine { constructor( private serverUrl: string, private updateState: (updates: Partial) => void, + private certAuthConfig?: CertAuthConfig, + private proxyAddress?: string, + private proxyAuthToken?: string, + private proxyAuthHeader?: string, ) {} async executeStep(state: AuthDebuggerState): Promise { @@ -220,6 +364,10 @@ export class OAuthStateMachine { serverUrl: this.serverUrl, provider, updateState: this.updateState, + certAuthConfig: this.certAuthConfig, + proxyAddress: this.proxyAddress, + proxyAuthToken: this.proxyAuthToken, + proxyAuthHeader: this.proxyAuthHeader, }; const transition = oauthTransitions[state.oauthStep]; diff --git a/server/src/index.ts b/server/src/index.ts index 4d1fffa29..3c5b29e77 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -28,10 +28,16 @@ import express from "express"; import rateLimit from "express-rate-limit"; import { findActualExecutable } from "spawn-rx"; import mcpProxy from "./mcpProxy.js"; -import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto"; +import { + randomUUID, + randomBytes, + timingSafeEqual, + createSign, + X509Certificate, +} from "node:crypto"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; -import { readFileSync } from "fs"; +import { readFileSync, accessSync, constants as fsConstants } from "fs"; const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277"; @@ -781,6 +787,297 @@ app.get("/health", (req, res) => { }); }); +/** + * Builds a JWT client assertion for OAuth 2.0 certificate-based authentication. + * Uses the private_key_jwt method (RFC 7523) with RS256 signing. + */ +const buildClientAssertion = ( + clientId: string, + tokenEndpointUrl: string, + certPem: string, + keyPem: string, +): string => { + // Parse certificate and compute SHA-1 thumbprint + const cert = new X509Certificate(certPem); + const thumbprintHex = cert.fingerprint.replace(/:/g, ""); + const thumbprintBuffer = Buffer.from(thumbprintHex, "hex"); + const x5t = thumbprintBuffer + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + // Extract DER-encoded certificate for x5c claim (standard base64, not base64url) + // This enables SNI (Subject Name/Issuer) validation required by some providers like Azure AD + const certDer = cert.raw; + const x5c = certDer.toString("base64"); + + // Build JWT header and payload + const header = { + alg: "RS256" as const, + typ: "JWT" as const, + x5t, + x5c: [x5c], + }; + + const now = Math.floor(Date.now() / 1000); + const payload = { + iss: clientId, + sub: clientId, + aud: tokenEndpointUrl, + jti: randomUUID(), + iat: now, + nbf: now, + exp: now + 300, // 5 minute expiry + }; + + // Encode and sign + const encodePart = (obj: object) => + Buffer.from(JSON.stringify(obj)) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const headerB64 = encodePart(header); + const payloadB64 = encodePart(payload); + const signingInput = `${headerB64}.${payloadB64}`; + + const sign = createSign("RSA-SHA256"); + sign.update(signingInput); + const signature = sign + .sign(keyPem, "base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + return `${signingInput}.${signature}`; +}; + +/** + * Proxy endpoint for OAuth endpoint discovery. + * Fetches protected resource metadata and OIDC config server-side to avoid CORS issues. + */ +app.get( + "/oauth/discover", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + try { + const serverUrl = req.query.url as string; + if (!serverUrl) { + res.status(400).json({ error: "Missing 'url' query parameter" }); + return; + } + + const parsedUrl = new URL(serverUrl); + const pathSegment = parsedUrl.pathname.replace(/\/+$/, ""); + + // Try multiple well-known URL patterns for protected resource metadata: + // 1. Path-prepended: /v1/.well-known/oauth-protected-resource (MCP style) + // 2. Path-appended: /.well-known/oauth-protected-resource/v1 (RFC 8414 style) + // 3. Root fallback: /.well-known/oauth-protected-resource + const candidateUrls = []; + if (pathSegment) { + candidateUrls.push( + new URL( + `${pathSegment}/.well-known/oauth-protected-resource`, + parsedUrl.origin, + ).href, + ); + candidateUrls.push( + new URL( + `/.well-known/oauth-protected-resource${pathSegment}`, + parsedUrl.origin, + ).href, + ); + } + candidateUrls.push( + new URL("/.well-known/oauth-protected-resource", parsedUrl.origin).href, + ); + + let metadata = null; + for (const url of candidateUrls) { + try { + const response = await fetch(url); + if (response.ok) { + metadata = await response.json(); + break; + } + } catch { + // Try next URL + } + } + + if (!metadata) { + res + .status(404) + .json({ error: "Protected resource metadata not found" }); + return; + } + + // If authorization_servers found, also fetch OIDC config + if (metadata.authorization_servers?.length) { + const authServerUrl = metadata.authorization_servers[0].replace( + /\/+$/, + "", + ); + try { + const oidcResponse = await fetch( + `${authServerUrl}/.well-known/openid-configuration`, + ); + if (oidcResponse.ok) { + const oidcConfig = await oidcResponse.json(); + metadata._discovered_endpoints = { + authorization_endpoint: oidcConfig.authorization_endpoint, + token_endpoint: oidcConfig.token_endpoint, + }; + } + } catch { + // OIDC discovery optional + } + } + + res.json(metadata); + } catch (error) { + res.status(500).json({ + error: "Discovery failed", + message: error instanceof Error ? error.message : String(error), + }); + } + }, +); + +/** + * Certificate-based OAuth token exchange endpoint. + * Accepts authorization code + certificate info, builds a JWT client assertion, + * and exchanges the code for tokens at the specified token endpoint. + */ +app.post( + "/oauth/token/certificate", + originValidationMiddleware, + authMiddleware, + express.json(), + async (req, res) => { + try { + const { + clientId, + tokenEndpointUrl, + certPath, + keyPath, + authorizationCode, + redirectUri, + codeVerifier, + scope, + } = req.body; + + // Validate required fields + if (!clientId || !tokenEndpointUrl || !certPath || !keyPath) { + res.status(400).json({ + error: "Missing required fields", + message: + "clientId, tokenEndpointUrl, certPath, and keyPath are required", + }); + return; + } + + if (!authorizationCode) { + res.status(400).json({ + error: "Missing authorization code", + message: "authorizationCode is required for token exchange", + }); + return; + } + + // Validate file paths are accessible + try { + accessSync(certPath, fsConstants.R_OK); + } catch { + res.status(400).json({ + error: "Certificate file not readable", + message: `Cannot read certificate file at: ${certPath}`, + }); + return; + } + + try { + accessSync(keyPath, fsConstants.R_OK); + } catch { + res.status(400).json({ + error: "Private key file not readable", + message: `Cannot read private key file at: ${keyPath}`, + }); + return; + } + + // Read certificate and key + const certPem = readFileSync(certPath, "utf-8"); + const keyPem = readFileSync(keyPath, "utf-8"); + + // Build the JWT client assertion + const clientAssertion = buildClientAssertion( + clientId, + tokenEndpointUrl, + certPem, + keyPem, + ); + + // Build token request body + const tokenParams = new URLSearchParams({ + grant_type: "authorization_code", + code: authorizationCode, + client_id: clientId, + client_assertion_type: + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: clientAssertion, + }); + + if (redirectUri) { + tokenParams.set("redirect_uri", redirectUri); + } + if (codeVerifier) { + tokenParams.set("code_verifier", codeVerifier); + } + // Note: scope is intentionally omitted from the token exchange. + // The authorization code already encodes the granted scopes, and + // providers like Azure AD reject mismatched scope parameters. + + console.log( + `Certificate auth token exchange: clientId=${clientId}, tokenEndpoint=${tokenEndpointUrl}`, + ); + + // Exchange the authorization code for tokens + const tokenResponse = await fetch(tokenEndpointUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: tokenParams.toString(), + } as any); + + const tokenData = await (tokenResponse as any).json(); + + if (!tokenResponse.ok) { + console.error("Token exchange failed:", tokenData); + res.status(tokenResponse.status).json({ + error: "Token exchange failed", + details: tokenData, + }); + return; + } + + console.log("Certificate auth token exchange successful"); + res.json(tokenData); + } catch (error) { + console.error("Error in certificate token exchange:", error); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : String(error), + }); + } + }, +); + app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { try { res.json({