From 3ab71d600dbeabd9b877cb3b6abc20628320cb5b Mon Sep 17 00:00:00 2001 From: Pratham-Mishra04 Date: Thu, 18 Jun 2026 01:34:04 +0530 Subject: [PATCH] feat: adds ui for mcp oauth consent screen --- ui/app/oauth/consent/layout.tsx | 40 +++ ui/app/oauth/consent/page.tsx | 376 ++++++++++++++++++++++ ui/app/workspace/config/views/mcpView.tsx | 241 +++++++++++++- ui/lib/store/apis/index.ts | 1 + ui/lib/store/apis/oauth2ConsentApi.ts | 42 +++ ui/lib/types/config.ts | 6 + ui/lib/utils/loginGoto.ts | 6 +- 7 files changed, 710 insertions(+), 2 deletions(-) create mode 100644 ui/app/oauth/consent/layout.tsx create mode 100644 ui/app/oauth/consent/page.tsx create mode 100644 ui/lib/store/apis/oauth2ConsentApi.ts diff --git a/ui/app/oauth/consent/layout.tsx b/ui/app/oauth/consent/layout.tsx new file mode 100644 index 0000000000..9f665a7e1b --- /dev/null +++ b/ui/app/oauth/consent/layout.tsx @@ -0,0 +1,40 @@ +import TempTokenScope from "@/components/tempTokenScope"; +import { ThemeProvider } from "@/components/themeProvider"; +import { ReduxProvider } from "@/lib/store"; +import { NuqsAdapter } from "nuqs/adapters/tanstack-router"; +import { createFileRoute } from "@tanstack/react-router"; +import { Toaster } from "sonner"; +import OAuth2ConsentPage from "./page"; + +// Public OAuth2 consent page — renders outside the dashboard chrome so +// external users who arrive via `claude mcp add` can pick their identity +// without needing a Bifrost dashboard account. +// +// tempTokenScoped: ClientLayout renders MinimalShell and skips the protected +// config fetch when this flag is set. TempTokenScope extracts the `#t=…` +// fragment minted by /oauth2/authorize and attaches it as +// X-Bifrost-Temp-Token on every API call, letting the consent flow APIs +// authenticate the anonymous visitor. +// +// ThemeProvider, ReduxProvider, NuqsAdapter and Toaster are provided here +// directly because this route sits outside /workspace and does not inherit +// them from ClientLayout. +function RouteComponent() { + return ( + + + + + + + + + + + ); +} + +export const Route = createFileRoute("/oauth/consent")({ + staticData: { tempTokenScoped: true }, + component: RouteComponent, +}); diff --git a/ui/app/oauth/consent/page.tsx b/ui/app/oauth/consent/page.tsx new file mode 100644 index 0000000000..7c2985b99f --- /dev/null +++ b/ui/app/oauth/consent/page.tsx @@ -0,0 +1,376 @@ +import FullPageLoader from "@/components/fullPageLoader"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { toast } from "sonner"; +import { + getErrorMessage, + useGetOAuth2ConsentFlowQuery, + useIsAuthEnabledQuery, + useSubmitOAuth2ConsentFlowMutation, +} from "@/lib/store"; +import { + getActiveTempToken, + setActiveTempToken, + setSuppressGlobal401, +} from "@/lib/store/apis/tempToken"; +import { + Fingerprint, + KeyRound, + Loader2, + LogIn, + ShieldCheck, + UserRound, +} from "lucide-react"; +import { useQueryState } from "nuqs"; +import React, { useEffect, useMemo, useState } from "react"; + +export default function OAuth2ConsentPage() { + const [flowId] = useQueryState("flow"); + + if (!flowId) { + return ( + +
+

Missing flow identifier

+

+ This URL is missing the{" "} + flow{" "} + query parameter. Restart the connection from your MCP client. +

+
+
+ ); + } + + return ; +} + +// isSafeRedirect rejects URLs whose scheme could execute script when assigned to +// location.href (javascript:, data:, …) while allowing http(s) and any native +// custom-scheme redirect a client may have registered. +function isSafeRedirect(url: string): boolean { + try { + const proto = new URL(url, window.location.origin).protocol.toLowerCase(); + return !["javascript:", "data:", "vbscript:", "blob:", "file:"].includes(proto); + } catch { + return false; + } +} + +function ConsentView({ flowId }: { flowId: string }) { + const { data: flow, isLoading, isError, error } = useGetOAuth2ConsentFlowQuery(flowId); + const { data: authState } = useIsAuthEnabledQuery(); + const [submitFlow, { isLoading: submitting }] = useSubmitOAuth2ConsentFlowMutation(); + const [vkValue, setVkValue] = useState(""); + const [selectedMode, setSelectedMode] = useState<"vk" | "session" | "user" | null>(null); + + // Restore a temp token persisted across a login round-trip BEFORE sampling + // usingTempToken, so returning to consent after cancelling login (where the + // module-level token was already cleared) still surfaces the sign-in path. + const [usingTempToken] = useState(() => { + if (typeof sessionStorage !== "undefined") { + const stored = sessionStorage.getItem(`oauth2_consent_token_${flowId}`); + if (stored && !getActiveTempToken()) { + setActiveTempToken(stored); + setSuppressGlobal401(true); + sessionStorage.removeItem(`oauth2_consent_token_${flowId}`); + } + } + return getActiveTempToken() !== null; + }); + + const loginHref = useMemo(() => { + const returnPath = `/oauth/consent?flow=${encodeURIComponent(flowId)}`; + return `/login?goto=${encodeURIComponent(returnPath)}`; + }, [flowId]); + + // Persist the active temp token so it can be restored after a login round-trip. + // Kept in an effect (not the memo above) so it runs exactly once per flowId — + // memo computations are not guaranteed to run in React 18 concurrent mode. + useEffect(() => { + if (typeof sessionStorage === "undefined") return; + const currentToken = getActiveTempToken(); + if (currentToken) { + sessionStorage.setItem(`oauth2_consent_token_${flowId}`, currentToken); + } + }, [flowId]); + + const showLoginOption = + usingTempToken && + authState?.is_auth_enabled === true && + authState.has_valid_token === false; + + const handleSubmit = async (mode: "vk" | "session" | "user") => { + setSelectedMode(mode); + try { + const res = await submitFlow({ + flowId, + body: { mode, value: mode === "vk" ? vkValue : undefined }, + }).unwrap(); + // Defence-in-depth: the server validates redirect_uri against the + // registered client, but never hand a javascript:/data: URL to + // location.href — it would execute in this origin. Block dangerous + // schemes while still allowing http(s) and native custom-scheme + // redirects that clients may register. + if (!isSafeRedirect(res.redirect_url)) { + toast.error("Authentication failed", { description: "Invalid redirect URL" }); + setSelectedMode(null); + return; + } + window.location.href = res.redirect_url; + } catch (err) { + toast.error("Authentication failed", { description: getErrorMessage(err) }); + setSelectedMode(null); + } + }; + + if (isLoading) return ; + + if (isError || !flow) { + const status = (error as { status?: number } | undefined)?.status; + if (status === 401) return ; + return ( + +
+

Link unavailable

+

+ This authorization link may have expired or already been used. + Restart the connection from your MCP client to get a fresh link. +

+
+
+ ); + } + + const hasUser = flow.available_modes.includes("user"); + const hasVK = flow.available_modes.includes("vk"); + const hasSession = flow.available_modes.includes("session"); + const hasAnyMode = hasUser || hasVK || hasSession; + const clientName = flow.client_name || "MCP Client"; + + return ( + + {/* Header */} +
+
+ +
+

+ {clientName} wants to connect +

+

+ Choose how you'd like to identify yourself to Bifrost +

+
+ +
+ {/* No mode available — nothing the user can act on here */} + {!hasAnyMode && ( +
+

+ No authentication options available +

+

+ Restart the connection from your MCP client. +

+
+ )} + + {/* User mode — most prominent when logged in */} + {hasUser && flow.logged_in_user && ( +
+
+
+ +
+
+

+ {flow.logged_in_user.name || flow.logged_in_user.id} +

+

Signed-in account

+
+
+ +
+ )} + + {/* User mode — sign in prompt when not logged in */} + {hasUser && !flow.logged_in_user && showLoginOption && ( +
+
+
+ +
+
+

Sign in with your account

+

+ Requires a Bifrost dashboard account +

+
+
+ +
+ )} + + {/* Divider between user and key options */} + {hasUser && (hasVK || hasSession) && ( +
+ + + or + +
+ )} + + {/* VK mode */} + {hasVK && ( +
+
+
+ +
+
+

Virtual key

+

+ Use an API key from your Bifrost workspace +

+
+
+ setVkValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && vkValue.trim()) { + void handleSubmit("vk"); + } + }} + disabled={submitting} + className="mb-3" + /> + + {hasUser && ( +

+ If this key is linked to a user account, you'll be asked to sign + in to confirm your identity. +

+ )} +
+ )} + + {hasVK && hasSession && ( +
+ + + or + +
+ )} + + {/* Session mode — de-emphasised, last */} + {hasSession && ( + + )} +
+ + {/* Expiry */} +

+ This link expires {formatExpiry(flow.expires_at)} +

+
+ ); +} + +function formatExpiry(iso: string): string { + const ts = new Date(iso).getTime(); + if (Number.isNaN(ts)) return "soon"; + try { + const diffMs = ts - Date.now(); + if (diffMs < 0) return "soon"; + const mins = Math.floor(diffMs / 60_000); + if (mins < 1) return "in less than a minute"; + if (mins === 1) return "in 1 minute"; + return `in ${mins} minutes`; + } catch { + return "soon"; + } +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +function InvalidLinkView() { + return ( + +
+

+ This link is no longer valid +

+

+ The authorization link has expired, been used already, or had its + token stripped. Restart the connection from your MCP client to get a + fresh link. +

+
+
+ ); +} diff --git a/ui/app/workspace/config/views/mcpView.tsx b/ui/app/workspace/config/views/mcpView.tsx index 0bfbc3b3e7..9795daea7c 100644 --- a/ui/app/workspace/config/views/mcpView.tsx +++ b/ui/app/workspace/config/views/mcpView.tsx @@ -48,11 +48,15 @@ export default function MCPView() { mcp_tool_execution_timeout: string; mcp_code_mode_binding_level: string; mcp_tool_sync_interval: string; + oauth2_auth_code_ttl: string; + oauth2_access_token_ttl: string; }>({ mcp_agent_depth: "10", mcp_tool_execution_timeout: "30", mcp_code_mode_binding_level: "server", mcp_tool_sync_interval: "10", + oauth2_auth_code_ttl: "600", + oauth2_access_token_ttl: "600", }); useEffect(() => { @@ -66,6 +70,10 @@ export default function MCPView() { config?.mcp_code_mode_binding_level || "server", mcp_tool_sync_interval: config?.mcp_tool_sync_interval?.toString() || "10", + oauth2_auth_code_ttl: + config?.oauth2_server_config?.auth_code_ttl?.toString() || "600", + oauth2_access_token_ttl: + config?.oauth2_server_config?.access_token_ttl?.toString() || "600", }); } }, [config, bifrostConfig]); @@ -76,6 +84,10 @@ export default function MCPView() { localConfig.mcp_external_client_url, config.mcp_external_client_url, ); + const issuerURLChanged = !envVarEquals( + localConfig.oauth2_server_config?.issuer_url, + config.oauth2_server_config?.issuer_url, + ); return ( localConfig.mcp_agent_depth !== config.mcp_agent_depth || localConfig.mcp_tool_execution_timeout !== @@ -88,7 +100,14 @@ export default function MCPView() { (config.mcp_disable_auto_tool_inject ?? false) || localConfig.mcp_enable_temp_token_auth !== (config.mcp_enable_temp_token_auth ?? false) || - clientURLChanged + clientURLChanged || + (localConfig.mcp_server_auth_mode ?? "headers") !== + (config.mcp_server_auth_mode ?? "headers") || + issuerURLChanged || + (localConfig.oauth2_server_config?.auth_code_ttl ?? 600) !== + (config.oauth2_server_config?.auth_code_ttl ?? 600) || + (localConfig.oauth2_server_config?.access_token_ttl ?? 600) !== + (config.oauth2_server_config?.access_token_ttl ?? 600) ); }, [config, localConfig]); @@ -147,6 +166,41 @@ export default function MCPView() { setLocalConfig((prev) => ({ ...prev, mcp_external_client_url: value })); }, []); + const handleAuthModeChange = useCallback((value: string) => { + if (value === "headers" || value === "both" || value === "oauth") { + setLocalConfig((prev) => ({ ...prev, mcp_server_auth_mode: value })); + } + }, []); + + const handleIssuerURLChange = useCallback((value: EnvVar) => { + setLocalConfig((prev) => ({ + ...prev, + oauth2_server_config: { ...prev.oauth2_server_config, issuer_url: value }, + })); + }, []); + + const handleAuthCodeTTLChange = useCallback((value: string) => { + setLocalValues((prev) => ({ ...prev, oauth2_auth_code_ttl: value })); + const num = Number.parseInt(value); + if (!isNaN(num) && num >= 60) { + setLocalConfig((prev) => ({ + ...prev, + oauth2_server_config: { ...prev.oauth2_server_config, auth_code_ttl: num }, + })); + } + }, []); + + const handleAccessTokenTTLChange = useCallback((value: string) => { + setLocalValues((prev) => ({ ...prev, oauth2_access_token_ttl: value })); + const num = Number.parseInt(value); + if (!isNaN(num) && num >= 60) { + setLocalConfig((prev) => ({ + ...prev, + oauth2_server_config: { ...prev.oauth2_server_config, access_token_ttl: num }, + })); + } + }, []); + const handleSave = useCallback(async () => { try { const agentDepth = Number.parseInt(localValues.mcp_agent_depth); @@ -164,6 +218,29 @@ export default function MCPView() { return; } + // The TTL fields are only shown (and only relevant) in OAuth modes; the + // backend likewise validates oauth2_server_config only then. Guard the + // checks so a stale value can't dead-end the save after switching back to + // headers mode, where the fields are hidden and unfixable. + const oauthModeActive = + localConfig.mcp_server_auth_mode === "both" || + localConfig.mcp_server_auth_mode === "oauth"; + + const authCodeTTL = Number.parseInt(localValues.oauth2_auth_code_ttl); + const accessTokenTTL = Number.parseInt( + localValues.oauth2_access_token_ttl, + ); + + if (oauthModeActive && (isNaN(authCodeTTL) || authCodeTTL < 60)) { + toast.error("Authorization code TTL must be at least 60 seconds."); + return; + } + + if (oauthModeActive && (isNaN(accessTokenTTL) || accessTokenTTL < 60)) { + toast.error("Access token TTL must be at least 60 seconds."); + return; + } + if (!bifrostConfig) { toast.error("Configuration not loaded. Please refresh and try again."); return; @@ -428,6 +505,168 @@ export default function MCPView() {

+ {/* MCP Server Auth Mode */} +
+ +

+ Controls how inbound MCP clients (e.g. Claude Code, Cursor) + authenticate to the /mcp{" "} + endpoint.{" "} + headers (default) - VK / api-key / session headers + only, OAuth discovery disabled.{" "} + both - accepts header credentials and Bifrost-issued + JWTs; existing integrations are unaffected.{" "} + oauth - JWTs only; VK and header access is disabled. +

+ + {/* oauth: VK/header access disabled */} + {localConfig.mcp_server_auth_mode === "oauth" && ( + + + VK / header MCP access will be disabled + + All existing MCP integrations that use a virtual key, + api-key, or session header will stop working immediately. + Clients must re-authenticate via the OAuth consent flow to + obtain a JWT before they can connect. + + + )} + + {/* headers: warn if downgrading from oauth-enabled mode */} + {localConfig.mcp_server_auth_mode === "headers" && + (config?.mcp_server_auth_mode === "both" || + config?.mcp_server_auth_mode === "oauth") && ( + + + OAuth discovery will be disabled + + All MCP clients that authenticated via the OAuth consent + flow will lose access — their JWTs will be rejected and + their refresh tokens will become unusable. They will need + to reconfigure using a virtual key or api-key header. + + + )} + + {/* both: informational note about additive nature */} + {localConfig.mcp_server_auth_mode === "both" && + (config?.mcp_server_auth_mode ?? "headers") !== "both" && ( + + + Existing VK / header integrations continue to work + unchanged. New MCP clients can connect via OAuth - they'll + be redirected to the consent page to pick an identity. + + + )} +
+ + {/* OAuth2 AS Settings — only shown when auth mode is not headers */} + {(localConfig.mcp_server_auth_mode === "both" || + localConfig.mcp_server_auth_mode === "oauth") && ( +
+

OAuth2 Server Settings

+ + {/* Issuer URL */} +
+ +

+ Stable public URL advertised in discovery documents and + embedded as the iss claim + in every JWT. Leave blank to derive it from the request{" "} + Host header (sufficient + for most deployments). Multi-host or reverse-proxy + deployments might need this. Supports env var syntax (e.g.{" "} + env.BIFROST_ISSUER_URL). +

+ +
+ + {/* Token TTLs */} +
+
+ +

+ How long the one-time code is valid after the consent + page redirects back to the MCP client (default: 600). +

+ handleAuthCodeTTLChange(e.target.value)} + disabled={!hasSettingsUpdateAccess} + /> +
+
+ +

+ Lifetime of issued JWT Bearer tokens. Clients silently + refresh when expired (default: 600 = 10 min). Also bounds + how long a revoked grant keeps working before it is cut off. +

+ handleAccessTokenTTLChange(e.target.value)} + disabled={!hasSettingsUpdateAccess} + /> +
+
+
+ )} diff --git a/ui/lib/store/apis/index.ts b/ui/lib/store/apis/index.ts index 65f45387f8..249efbd040 100644 --- a/ui/lib/store/apis/index.ts +++ b/ui/lib/store/apis/index.ts @@ -16,6 +16,7 @@ export * from "./mcpApi"; export * from "./mcpLogsApi"; export * from "./mcpPerUserHeadersApi"; export * from "./mcpSessionsApi"; +export * from "./oauth2ConsentApi"; export * from "./pluginsApi"; export * from "./providersApi"; export * from "./promptsApi"; diff --git a/ui/lib/store/apis/oauth2ConsentApi.ts b/ui/lib/store/apis/oauth2ConsentApi.ts new file mode 100644 index 0000000000..16761438fc --- /dev/null +++ b/ui/lib/store/apis/oauth2ConsentApi.ts @@ -0,0 +1,42 @@ +import { baseApi } from "./baseApi"; + +export interface OAuth2ConsentFlowDetail { + client_name: string; + available_modes: Array<"vk" | "session" | "user">; + logged_in_user?: { id: string; name?: string }; + expires_at: string; +} + +export interface OAuth2ConsentSubmitRequest { + mode: "vk" | "session" | "user"; + value?: string; // VK plaintext for mode=vk; absent for session/user +} + +export interface OAuth2ConsentSubmitResponse { + redirect_url: string; +} + +export const oauth2ConsentApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + getOAuth2ConsentFlow: builder.query({ + query: (flowId) => ({ + url: `/oauth2/consent/flows/${encodeURIComponent(flowId)}`, + }), + }), + submitOAuth2ConsentFlow: builder.mutation< + OAuth2ConsentSubmitResponse, + { flowId: string; body: OAuth2ConsentSubmitRequest } + >({ + query: ({ flowId, body }) => ({ + url: `/oauth2/consent/flows/${encodeURIComponent(flowId)}`, + method: "PUT", + body, + }), + }), + }), +}); + +export const { + useGetOAuth2ConsentFlowQuery, + useSubmitOAuth2ConsentFlowMutation, +} = oauth2ConsentApi; diff --git a/ui/lib/types/config.ts b/ui/lib/types/config.ts index 4ab6a408be..1e487fbd27 100644 --- a/ui/lib/types/config.ts +++ b/ui/lib/types/config.ts @@ -558,6 +558,12 @@ export interface CoreConfig { routing_chain_max_depth: number; header_filter_config?: GlobalHeaderFilterConfig; mcp_external_client_url?: EnvVar; + mcp_server_auth_mode?: "headers" | "both" | "oauth"; + oauth2_server_config?: { + issuer_url?: EnvVar; + auth_code_ttl?: number; + access_token_ttl?: number; + }; } export const DefaultCoreConfig: CoreConfig = { diff --git a/ui/lib/utils/loginGoto.ts b/ui/lib/utils/loginGoto.ts index 1884b87f30..d01d63bd08 100644 --- a/ui/lib/utils/loginGoto.ts +++ b/ui/lib/utils/loginGoto.ts @@ -25,6 +25,10 @@ function isWorkspaceRoute(value: string): boolean { value === DEFAULT_POST_LOGIN_PATH || value.startsWith(`${DEFAULT_POST_LOGIN_PATH}/`) || value.startsWith(`${DEFAULT_POST_LOGIN_PATH}?`) || - value.startsWith(`${DEFAULT_POST_LOGIN_PATH}#`) + value.startsWith(`${DEFAULT_POST_LOGIN_PATH}#`) || + value === "/oauth/consent" || + value.startsWith("/oauth/consent?") || + value.startsWith("/oauth/consent#") || + value.startsWith("/oauth/consent/") ); }