diff --git a/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx b/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx index b53fc37..84b3d23 100644 --- a/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx +++ b/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx @@ -8,6 +8,7 @@ import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; import { LoaderCircle, ArrowRight, Key } from "lucide-react"; import { useAuthContext } from "../AuthLayoutClient"; +import { setApiKeyCookieAction } from "@/app/actions"; const containerVariants = { hidden: { opacity: 0 }, @@ -67,8 +68,9 @@ export function ApiKeyLoginForm() { const data = await response.json(); if (data.valid) { - // Store API key in cookie via the existing connection mechanism - document.cookie = `lg_apiKey=${encodeURIComponent(apiKey.trim())}; path=/; max-age=${365 * 24 * 3600}; samesite=lax`; + // Store the API key as an httpOnly cookie via a server action so it is + // not readable from JS (mitigates XSS exfiltration). + await setApiKeyCookieAction(apiKey.trim()); router.push("/"); router.refresh(); } else { diff --git a/frontend/src/app/actions.ts b/frontend/src/app/actions.ts index 25d44e5..d6607e7 100644 --- a/frontend/src/app/actions.ts +++ b/frontend/src/app/actions.ts @@ -54,6 +54,35 @@ export async function updateConnectionAction(connection: { return { success: true }; } +/** + * Server action to set only the API key as an httpOnly cookie. + * + * Used by the API-key login form, which collects only an apiKey (no apiUrl). + * Replaces direct `document.cookie =` writes that left the key JS-readable. + */ +export async function setApiKeyCookieAction(apiKey: string) { + await requireAuth(); + const trimmed = apiKey.trim(); + + const cookieStore = await cookies(); + + if (!trimmed) { + cookieStore.delete(CONNECTION_COOKIE_NAMES.apiKey); + return { success: true }; + } + + const isProduction = process.env.NODE_ENV === "production"; + cookieStore.set(CONNECTION_COOKIE_NAMES.apiKey, trimmed, { + path: "/", + maxAge: COOKIE_MAX_AGE, + sameSite: "lax" as const, + httpOnly: isProduction, + secure: isProduction, + }); + + return { success: true }; +} + /** * Server action to update only the assistantId */ diff --git a/frontend/src/lib/api-key.tsx b/frontend/src/lib/api-key.tsx deleted file mode 100644 index f50f627..0000000 --- a/frontend/src/lib/api-key.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export function getApiKey(): string | null { - try { - if (typeof window === "undefined") return null; - return window.localStorage.getItem("lg:chat:apiKey") ?? null; - } catch { - // no-op - } - - return null; -} diff --git a/frontend/src/providers/Thread.tsx b/frontend/src/providers/Thread.tsx index c8187d9..ebfcb86 100644 --- a/frontend/src/providers/Thread.tsx +++ b/frontend/src/providers/Thread.tsx @@ -1,3 +1,5 @@ +"use client"; + import { validate } from "uuid"; import { Thread, Client } from "@langchain/langgraph-sdk"; import { @@ -10,7 +12,6 @@ import { SetStateAction, } from "react"; import { createClient } from "./client"; -import { getApiKey } from "@/lib/api-key"; import type { ConnectionConfig } from "./Stream"; export interface ThreadContextType { @@ -45,7 +46,7 @@ export function ThreadProvider({ children, connection }: ThreadProviderProps) { const [threads, setThreads] = useState([]); const [threadsLoading, setThreadsLoading] = useState(false); const finalAssistantId = connection.assistantId?.trim() || undefined; - const apiKey = connection.apiKey || getApiKey() || undefined; + const apiKey = connection.apiKey || undefined; // Create client once and memoize const client = useMemo(() => { diff --git a/frontend/src/shared/components/settings/ConnectionList.tsx b/frontend/src/shared/components/settings/ConnectionList.tsx index 188c76d..35c36f1 100644 --- a/frontend/src/shared/components/settings/ConnectionList.tsx +++ b/frontend/src/shared/components/settings/ConnectionList.tsx @@ -108,14 +108,10 @@ export function ConnectionList({ onConnectionChange }: ConnectionListProps) { await switchConnection(connection.id); - // Save API key to localStorage if provided - if (connection.apiKey) { - localStorage.setItem("lg:chat:apiKey", connection.apiKey); - } - toast.success(`Switching to ${connection.name}...`); - // Reload page to apply connection (cookies are already set by switchConnection) + // Reload page to apply connection (cookies are already set by switchConnection + // via updateConnectionAction, which uses httpOnly cookies in production). setTimeout(() => { window.location.href = window.location.pathname; }, 500);