mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-12 21:04:10 +00:00
fix(ui): break logout redirect loop across origins (#29360)
When the user has visited both the dev UI (e.g. localhost:3000) and the
proxy UI (e.g. localhost:4000) in the same tab, logging out from the dev
origin produced an infinite logout/login redirect.
The proxy-side LoginPage's "is the user still authenticated?" check
was reading getCookie("token"), which falls back to sessionStorage when
document.cookie has no token. The cross-origin clearTokenCookies() call
from the dev origin can clear cookies on the shared hostname, but cannot
reach sessionStorage on the proxy origin (sessionStorage is per-origin),
so the fallback returned a stale token and LoginPage interpreted the
user as logged in, redirecting back to the dev origin. Dev origin then
saw no cookie and redirected to LoginPage, repeating ~20x per second.
This change introduces getCookieFromDocument(), a cookie-only read with
no sessionStorage fallback, and uses it in LoginPage's already-logged-in
check. The HttpOnly-reverse-proxy defense from PR #23532 is unaffected:
storeLoginToken still writes both the JS cookie at /ui and the
sessionStorage backup, and getCookie still falls back for callers that
want the full read path.
This commit is contained in:
@@ -18,7 +18,8 @@ vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/cookieUtils", () => ({
|
||||
getCookie: vi.fn(),
|
||||
clearTokenCookies: vi.fn(),
|
||||
getCookieFromDocument: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/jwtUtils", () => ({
|
||||
@@ -53,7 +54,7 @@ vi.mock("@/hooks/useWorker", () => ({
|
||||
}));
|
||||
|
||||
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
|
||||
import { getCookie } from "@/utils/cookieUtils";
|
||||
import { getCookieFromDocument } from "@/utils/cookieUtils";
|
||||
import { isJwtExpired } from "@/utils/jwtUtils";
|
||||
|
||||
const createQueryClient = () =>
|
||||
@@ -83,7 +84,7 @@ describe("LoginPage", () => {
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(getCookieFromDocument as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
render(
|
||||
@@ -108,7 +109,7 @@ describe("LoginPage", () => {
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(validToken);
|
||||
(getCookieFromDocument as ReturnType<typeof vi.fn>).mockReturnValue(validToken);
|
||||
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
@@ -134,7 +135,7 @@ describe("LoginPage", () => {
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(invalidToken);
|
||||
(getCookieFromDocument as ReturnType<typeof vi.fn>).mockReturnValue(invalidToken);
|
||||
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
@@ -160,7 +161,7 @@ describe("LoginPage", () => {
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(invalidToken);
|
||||
(getCookieFromDocument as ReturnType<typeof vi.fn>).mockReturnValue(invalidToken);
|
||||
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
@@ -189,7 +190,7 @@ describe("LoginPage", () => {
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(validToken);
|
||||
(getCookieFromDocument as ReturnType<typeof vi.fn>).mockReturnValue(validToken);
|
||||
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
@@ -216,7 +217,7 @@ describe("LoginPage", () => {
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(getCookieFromDocument as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
render(
|
||||
@@ -244,7 +245,7 @@ describe("LoginPage", () => {
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(getCookieFromDocument as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
@@ -271,7 +272,7 @@ describe("LoginPage", () => {
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(getCookieFromDocument as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
@@ -324,7 +325,7 @@ describe("LoginPage", () => {
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(getCookieFromDocument as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
@@ -352,7 +353,7 @@ describe("LoginPage", () => {
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue("legitimate-session-jwt");
|
||||
(getCookieFromDocument as ReturnType<typeof vi.fn>).mockReturnValue("legitimate-session-jwt");
|
||||
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLogin } from "@/app/(dashboard)/hooks/login/useLogin";
|
||||
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
|
||||
import LoadingScreen from "@/components/common_components/LoadingScreen";
|
||||
import { exchangeLoginCode, getProxyBaseUrl, switchToWorkerUrl } from "@/components/networking";
|
||||
import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
|
||||
import { clearTokenCookies, getCookieFromDocument } from "@/utils/cookieUtils";
|
||||
import { isJwtExpired } from "@/utils/jwtUtils";
|
||||
import { consumeReturnUrl, getReturnUrl, isValidReturnUrl } from "@/utils/returnUrlUtils";
|
||||
import { InfoCircleOutlined, CloudServerOutlined } from "@ant-design/icons";
|
||||
@@ -74,7 +74,7 @@ function LoginPageContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawToken = getCookie("token");
|
||||
const rawToken = getCookieFromDocument("token");
|
||||
if (rawToken && !isJwtExpired(rawToken)) {
|
||||
// User already logged in - redirect to return URL or default
|
||||
const returnUrl = consumeReturnUrl();
|
||||
|
||||
@@ -105,22 +105,35 @@ export function storeLoginToken(token: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a cookie value directly from document.cookie with no fallback.
|
||||
*
|
||||
* Use this in flows that decide whether to redirect on the basis of "is the user
|
||||
* still authenticated?". sessionStorage is per-origin and survives a logout
|
||||
* triggered from a different origin (e.g. dev UI on :3000 cannot reach
|
||||
* sessionStorage on the proxy origin :4000), which produces an infinite
|
||||
* logout/login redirect.
|
||||
*/
|
||||
export function getCookieFromDocument(name: string) {
|
||||
if (typeof document === "undefined") return null;
|
||||
const row = document.cookie.split("; ").find((r) => r.startsWith(name + "="));
|
||||
if (!row) return null;
|
||||
const raw = row.split("=").slice(1).join("=");
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a cookie value by name
|
||||
* @param name The name of the cookie to retrieve
|
||||
* @returns The cookie value or null if not found
|
||||
*/
|
||||
export function getCookie(name: string) {
|
||||
if (typeof document === "undefined") return null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
const fromCookie = getCookieFromDocument(name);
|
||||
if (fromCookie !== null) return fromCookie;
|
||||
// Fallback to sessionStorage — covers the case where a reverse proxy
|
||||
// added HttpOnly to the server-set cookie, making it invisible to JS.
|
||||
if (name === "token" && typeof window !== "undefined") {
|
||||
|
||||
Reference in New Issue
Block a user