From 461cf00408aac241fc294de407cd6e37a45e0b59 Mon Sep 17 00:00:00 2001 From: Hendrik Jaks Date: Fri, 13 Mar 2026 10:58:06 +0200 Subject: [PATCH] fix(ui): use sessionStorage instead of cookie for login token storage Replace setTokenCookie (which is a no-op when reverse proxy adds HttpOnly) with storeLoginToken using sessionStorage. Add sessionStorage fallback to getCookie so the token is found even when the cookie is HttpOnly. Also handle '=' in cookie values with .slice(1).join("=") and clear sessionStorage on logout. Co-Authored-By: Claude Opus 4.6 --- .../src/components/networking.test.ts | 16 +++--- .../src/components/networking.tsx | 4 +- .../src/utils/cookieUtils.test.ts | 57 +++++++++++-------- ui/litellm-dashboard/src/utils/cookieUtils.ts | 48 +++++++++++----- 4 files changed, 78 insertions(+), 47 deletions(-) diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 2f4d41f8bb3..3c107fa586f 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -5,7 +5,7 @@ import * as Networking from "./networking"; vi.mock("@/utils/cookieUtils", () => ({ clearTokenCookies: vi.fn(), getCookie: vi.fn(), - setTokenCookie: vi.fn(), + storeLoginToken: vi.fn(), })); vi.mock("./molecules/notifications_manager", () => ({ @@ -80,7 +80,7 @@ describe("networking - expired session handling", () => { }); }); -describe("loginCall - setTokenCookie integration", () => { +describe("loginCall - storeLoginToken integration", () => { const originalFetch = global.fetch; beforeEach(() => { @@ -91,24 +91,24 @@ describe("loginCall - setTokenCookie integration", () => { global.fetch = originalFetch; }); - it("calls setTokenCookie when response includes token", async () => { + it("calls storeLoginToken when response includes token", async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ redirect_url: "/ui/?login=success", token: "my-jwt" }), }) as any; - const { setTokenCookie } = await import("@/utils/cookieUtils"); + const { storeLoginToken } = await import("@/utils/cookieUtils"); await Networking.loginCall("admin", "pass"); - expect(setTokenCookie).toHaveBeenCalledWith("my-jwt"); + expect(storeLoginToken).toHaveBeenCalledWith("my-jwt"); }); - it("does not call setTokenCookie when response has no token", async () => { + it("does not call storeLoginToken when response has no token", async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ redirect_url: "/ui/?login=success" }), }) as any; - const { setTokenCookie } = await import("@/utils/cookieUtils"); + const { storeLoginToken } = await import("@/utils/cookieUtils"); await Networking.loginCall("admin", "pass"); - expect(setTokenCookie).not.toHaveBeenCalled(); + expect(storeLoginToken).not.toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index c8dd00cbb9a..35d9576a8c5 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -69,7 +69,7 @@ export const getInProductNudgesCall = async (accessToken: string) => { * Helper file for calls being made to proxy */ import { message } from "antd"; -import { clearTokenCookies, setTokenCookie } from "@/utils/cookieUtils"; +import { clearTokenCookies, storeLoginToken } from "@/utils/cookieUtils"; import { TagNewRequest, TagUpdateRequest, TagListResponse, TagInfoResponse } from "./tag_management/types"; import { Team } from "./key_team_helpers/key_list"; import { UserInfo } from "./view_users/types"; @@ -9005,7 +9005,7 @@ export const loginCall = async (username: string, password: string): Promise { beforeEach(() => { document.cookie.split(";").forEach((c) => { document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/"); }); + sessionStorage.clear(); vi.spyOn(console, "log").mockImplementation(() => {}); }); @@ -116,41 +117,35 @@ describe("cookieUtils", () => { vi.restoreAllMocks(); }); + + it("should clear sessionStorage token", () => { + sessionStorage.setItem("token", "stored-token"); + clearTokenCookies(); + expect(sessionStorage.getItem("token")).toBeNull(); + }); }); - describe("setTokenCookie", () => { - it("should set a token cookie readable by getCookie", () => { - setTokenCookie("my-jwt-token"); - expect(getCookie("token")).toBe("my-jwt-token"); + describe("storeLoginToken", () => { + it("should store the token in sessionStorage", () => { + storeLoginToken("my-jwt-token"); + expect(sessionStorage.getItem("token")).toBe("my-jwt-token"); }); - it("should overwrite an existing token cookie", () => { - setTokenCookie("old-token"); - expect(getCookie("token")).toBe("old-token"); + it("should overwrite an existing token in sessionStorage", () => { + storeLoginToken("old-token"); + expect(sessionStorage.getItem("token")).toBe("old-token"); - setTokenCookie("new-token"); - expect(getCookie("token")).toBe("new-token"); - }); - - it("should not throw when document is undefined (server-side rendering)", () => { - const originalDocument = global.document; - delete (global as any).document; - - expect(() => setTokenCookie("token")).not.toThrow(); - - global.document = originalDocument; + storeLoginToken("new-token"); + expect(sessionStorage.getItem("token")).toBe("new-token"); }); it("should not throw when window is undefined (server-side rendering)", () => { const originalWindow = global.window; - const originalDocument = global.document; delete (global as any).window; - delete (global as any).document; - expect(() => setTokenCookie("token")).not.toThrow(); + expect(() => storeLoginToken("token")).not.toThrow(); global.window = originalWindow; - global.document = originalDocument; }); }); @@ -177,5 +172,21 @@ describe("cookieUtils", () => { expect(getCookie("token")).toBe("token-value"); expect(getCookie("other")).toBe("other-value"); }); + + it("should handle values containing '=' characters", () => { + document.cookie = "token=abc=def=ghi; path=/"; + expect(getCookie("token")).toBe("abc=def=ghi"); + }); + + it("should fall back to sessionStorage when cookie is not found", () => { + sessionStorage.setItem("token", "session-stored-jwt"); + expect(getCookie("token")).toBe("session-stored-jwt"); + }); + + it("should prefer cookie over sessionStorage", () => { + document.cookie = "token=cookie-value; path=/"; + sessionStorage.setItem("token", "session-value"); + expect(getCookie("token")).toBe("cookie-value"); + }); }); }); diff --git a/ui/litellm-dashboard/src/utils/cookieUtils.ts b/ui/litellm-dashboard/src/utils/cookieUtils.ts index 6fe43f30400..229a49a6a03 100644 --- a/ui/litellm-dashboard/src/utils/cookieUtils.ts +++ b/ui/litellm-dashboard/src/utils/cookieUtils.ts @@ -43,17 +43,27 @@ export function clearTokenCookies() { }); }); + try { + sessionStorage.removeItem("token"); + } catch { + // sessionStorage may be unavailable + } + console.log("After clearing cookies:", document.cookie); } /** - * Sets the token cookie from a login response body. - * This ensures the token is JS-accessible even when a reverse proxy adds HttpOnly to server-set cookies. + * Stores the login token in sessionStorage. + * This ensures the token is available even when a reverse proxy adds HttpOnly + * to server-set cookies, making them invisible to JavaScript. */ -export function setTokenCookie(token: string) { - if (typeof window === "undefined" || typeof document === "undefined") return; - const isSecure = window.location.protocol === "https:"; - document.cookie = `token=${encodeURIComponent(token)}; Path=/; SameSite=Lax${isSecure ? "; Secure" : ""}`; +export function storeLoginToken(token: string) { + if (typeof window === "undefined") return; + try { + sessionStorage.setItem("token", token); + } catch { + // sessionStorage may be unavailable (e.g. private browsing quota exceeded) + } } /** @@ -63,13 +73,23 @@ export function setTokenCookie(token: string) { */ export function getCookie(name: string) { if (typeof document === "undefined") return null; - const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); - if (!cookieValue) return null; - const rawValue = cookieValue.split("=")[1]; - try { - return decodeURIComponent(rawValue); - } catch { - return rawValue; + const row = document.cookie.split("; ").find((r) => r.startsWith(name + "=")); + if (row) { + const raw = row.split("=").slice(1).join("="); + try { + return decodeURIComponent(raw); + } catch { + return raw; + } } -} + // Fallback to sessionStorage — covers the case where a reverse proxy + // added HttpOnly to the server-set cookie, making it invisible to JS. + if (typeof window !== "undefined") { + try { + return sessionStorage.getItem(name); + } catch { + return null; + } + } + return null; }