mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
9cd7ad2634
commit
6eb78e46c9
6 changed files with 46 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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=/";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue