mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
22409da932
commit
461cf00408
4 changed files with 78 additions and 47 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Log
|
|||
|
||||
const data = await response.json();
|
||||
if (data.token) {
|
||||
setTokenCookie(data.token);
|
||||
storeLoginToken(data.token);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { clearTokenCookies, getCookie, setTokenCookie } from "./cookieUtils";
|
||||
import { clearTokenCookies, getCookie, storeLoginToken } from "./cookieUtils";
|
||||
|
||||
describe("cookieUtils", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue