From 6eb78e46c966f5f663abf9c00b28dbefbd094bba Mon Sep 17 00:00:00 2001 From: Hendrik Jaks Date: Fri, 13 Mar 2026 09:49:55 +0200 Subject: [PATCH] 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 --- litellm/proxy/proxy_server.py | 2 +- tests/test_litellm/proxy/test_proxy_server.py | 8 +++--- .../src/components/networking.test.ts | 1 + .../src/components/networking.tsx | 6 ++++- .../src/utils/cookieUtils.test.ts | 26 ++++++++++++++++++- ui/litellm-dashboard/src/utils/cookieUtils.ts | 10 +++++++ 6 files changed, 46 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 77e2a88796e..ddfba140ff2 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 112a06b1731..de3ffb9f839 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -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( diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index c57dcb97eb9..459492f9edd 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -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", () => ({ diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 6b5ec5139e6..c8dd00cbb9a 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 } 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 => { @@ -9003,6 +9004,9 @@ export const loginCall = async (username: string, password: string): Promise { 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=/"; diff --git a/ui/litellm-dashboard/src/utils/cookieUtils.ts b/ui/litellm-dashboard/src/utils/cookieUtils.ts index 01add36542c..3b7b3551ebb 100644 --- a/ui/litellm-dashboard/src/utils/cookieUtils.ts +++ b/ui/litellm-dashboard/src/utils/cookieUtils.ts @@ -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