fix(ui): resolve login redirect loop when reverse proxy adds HttpOnly to cookies

When LiteLLM is behind nginx-ingress or similar with security-hardened
configs, the reverse proxy adds HttpOnly to all Set-Cookie headers. This
makes the JWT token unreadable by JavaScript, causing an infinite login
redirect loop. Fix by returning the JWT token in the /v2/login response
body so the frontend can set a JS-accessible cookie directly.

Fixes #19663

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hendrik Jaks 2026-03-13 09:49:55 +02:00
parent 9cd7ad2634
commit 6eb78e46c9
6 changed files with 46 additions and 7 deletions

View file

@ -11030,7 +11030,7 @@ async def login_v2(request: Request): # noqa: PLR0915
litellm_dashboard_ui += "?login=success"
json_response = JSONResponse(
content={"redirect_url": litellm_dashboard_ui},
content={"redirect_url": litellm_dashboard_ui, "token": jwt_token},
status_code=status.HTTP_200_OK,
)
json_response.set_cookie(key="token", value=jwt_token)

View file

@ -104,10 +104,10 @@ def test_login_v2_returns_redirect_url_and_sets_cookie(monkeypatch):
)
assert response.status_code == 200
assert (
response.json()
== {"redirect_url": "http://testserver/ui/?login=success"}
)
assert response.json() == {
"redirect_url": "http://testserver/ui/?login=success",
"token": "signed-token",
}
assert response.cookies.get("token") == "signed-token"
mock_authenticate_user.assert_awaited_once_with(

View file

@ -5,6 +5,7 @@ import * as Networking from "./networking";
vi.mock("@/utils/cookieUtils", () => ({
clearTokenCookies: vi.fn(),
getCookie: vi.fn(),
setTokenCookie: vi.fn(),
}));
vi.mock("./molecules/notifications_manager", () => ({

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 } from "@/utils/cookieUtils";
import { clearTokenCookies, setTokenCookie } 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";
@ -8976,6 +8976,7 @@ export interface LoginRequest {
interface LoginResponse {
redirect_url: string;
token?: string;
}
export const loginCall = async (username: string, password: string): Promise<LoginResponse> => {
@ -9003,6 +9004,9 @@ export const loginCall = async (username: string, password: string): Promise<Log
}
const data = await response.json();
if (data.token) {
setTokenCookie(data.token);
}
return data;
};

View file

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { clearTokenCookies, getCookie } from "./cookieUtils";
import { clearTokenCookies, getCookie, setTokenCookie } from "./cookieUtils";
describe("cookieUtils", () => {
beforeEach(() => {
@ -118,6 +118,30 @@ describe("cookieUtils", () => {
});
});
describe("setTokenCookie", () => {
it("should set a token cookie readable by getCookie", () => {
setTokenCookie("my-jwt-token");
expect(getCookie("token")).toBe("my-jwt-token");
});
it("should overwrite an existing token cookie", () => {
setTokenCookie("old-token");
expect(getCookie("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;
});
});
describe("getCookie", () => {
it("should return cookie value when it exists", () => {
document.cookie = "token=my-test-token; path=/";

View file

@ -46,6 +46,16 @@ export function clearTokenCookies() {
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.
*/
export function setTokenCookie(token: string) {
if (typeof document === "undefined") return;
const isSecure = window.location.protocol === "https:";
document.cookie = `token=${token}; Path=/; SameSite=Lax${isSecure ? "; Secure" : ""}`;
}
/**
* Gets a cookie value by name
* @param name The name of the cookie to retrieve