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:
Hendrik Jaks 2026-03-13 10:58:06 +02:00
parent 22409da932
commit 461cf00408
4 changed files with 78 additions and 47 deletions

View file

@ -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();
});
});

View file

@ -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;
};

View file

@ -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");
});
});
});

View file

@ -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;
}