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:
yuneng-jiang
2026-05-30 19:02:29 -07:00
committed by GitHub
parent a9cc6ed68c
commit f0ebfb2a1b
3 changed files with 38 additions and 24 deletions
@@ -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();
+23 -10
View File
@@ -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") {