Skip to content

Commit 5c6fb5e

Browse files
Merge pull request #15309 from BerriAI/litellm_cookie_spasm_mitigation
potentially fixes a UI spasm issue with an expired cookie
2 parents 1c56a0d + fe2d4ad commit 5c6fb5e

File tree

2 files changed

+271
-10
lines changed

2 files changed

+271
-10
lines changed

ui/litellm-dashboard/src/app/page.tsx

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,33 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
4141
import { cx } from "@/lib/cva.config";
4242

4343
function getCookie(name: string) {
44-
const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "="));
45-
return cookieValue ? cookieValue.split("=")[1] : null;
44+
// Safer cookie read + decoding; handles '=' inside values
45+
const match = document.cookie.split("; ").find((row) => row.startsWith(name + "="));
46+
if (!match) return null;
47+
const value = match.slice(name.length + 1);
48+
try {
49+
return decodeURIComponent(value);
50+
} catch {
51+
return value;
52+
}
53+
}
54+
55+
function deleteCookie(name: string, path = "/") {
56+
// Best-effort client-side clear (works for non-HttpOnly cookies without Domain)
57+
document.cookie = `${name}=; Max-Age=0; Path=${path}`;
58+
}
59+
60+
function isJwtExpired(token: string): boolean {
61+
try {
62+
const decoded: any = jwtDecode(token);
63+
if (decoded && typeof decoded.exp === "number") {
64+
return decoded.exp * 1000 <= Date.now();
65+
}
66+
return false;
67+
} catch {
68+
// If we can't decode, treat as invalid/expired
69+
return true;
70+
}
4671
}
4772

4873
function formatUserRole(userRole: string) {
@@ -149,17 +174,42 @@ export default function CreateKeyPage() {
149174
const redirectToLogin = authLoading === false && token === null && invitation_id === null;
150175

151176
useEffect(() => {
152-
const token = getCookie("token");
153-
getUiConfig().then((data) => {
154-
// get the information for constructing the proxy base url, and then set the token and auth loading
155-
setToken(token);
156-
setAuthLoading(false);
157-
});
177+
let cancelled = false;
178+
179+
(async () => {
180+
try {
181+
await getUiConfig(); // ensures proxyBaseUrl etc. are ready
182+
} catch {
183+
// proceed regardless; we still need to decide auth state
184+
}
185+
186+
if (cancelled) return;
187+
188+
const raw = getCookie("token");
189+
const valid = raw && !isJwtExpired(raw) ? raw : null;
190+
191+
// If token exists but is invalid/expired, clear it so downstream code
192+
// doesn't keep trying to use it and cause redirect spasms.
193+
if (raw && !valid) {
194+
deleteCookie("token", "/");
195+
}
196+
197+
if (!cancelled) {
198+
setToken(valid);
199+
setAuthLoading(false);
200+
}
201+
})();
202+
203+
return () => {
204+
cancelled = true;
205+
};
158206
}, []);
159207

160208
useEffect(() => {
161209
if (redirectToLogin) {
162-
window.location.href = (proxyBaseUrl || "") + "/sso/key/generate";
210+
// Replace instead of assigning to avoid back-button loops
211+
const dest = (proxyBaseUrl || "") + "/sso/key/generate";
212+
window.location.replace(dest);
163213
}
164214
}, [redirectToLogin]);
165215

@@ -168,7 +218,23 @@ export default function CreateKeyPage() {
168218
return;
169219
}
170220

171-
const decoded = jwtDecode(token) as { [key: string]: any };
221+
// Defensive: re-check expiry in case cookie changed after mount
222+
if (isJwtExpired(token)) {
223+
deleteCookie("token", "/");
224+
setToken(null);
225+
return;
226+
}
227+
228+
let decoded: any = null;
229+
try {
230+
decoded = jwtDecode(token);
231+
} catch {
232+
// Malformed token → treat as unauthenticated
233+
deleteCookie("token", "/");
234+
setToken(null);
235+
return;
236+
}
237+
172238
if (decoded) {
173239
// set accessToken
174240
setAccessToken(decoded.key);
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import React from "react";
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import { vi, describe, it, beforeEach, afterEach, expect } from "vitest";
4+
5+
/** ----------------------------
6+
* Hoisted helpers for mocks (required by Vitest)
7+
* --------------------------- */
8+
const { stub, jwtDecodeMock } = vi.hoisted(() => {
9+
const React = require("react");
10+
const stub = (name: string) => () => React.createElement("div", { "data-testid": name });
11+
return {
12+
stub,
13+
jwtDecodeMock: vi.fn(),
14+
};
15+
});
16+
17+
/** ----------------------------
18+
* Mocks
19+
* --------------------------- */
20+
21+
// next/navigation: just return empty URLSearchParams (no invitation/page)
22+
vi.mock("next/navigation", () => ({
23+
useSearchParams: () => new URLSearchParams(""),
24+
}));
25+
26+
// Networking layer
27+
vi.mock("@/components/networking", () => {
28+
return {
29+
// Called on mount; we don't care about its contents, only that it resolves
30+
getUiConfig: vi.fn().mockResolvedValue({}),
31+
// Used to build the redirect URL
32+
proxyBaseUrl: "https://example.com",
33+
// Called when decoding a valid token
34+
setGlobalLitellmHeaderName: vi.fn(),
35+
Organization: {},
36+
};
37+
});
38+
39+
// jwt-decode: we’ll swap implementation per test via mockImplementation
40+
vi.mock("jwt-decode", () => ({
41+
jwtDecode: (token: string) => jwtDecodeMock(token),
42+
}));
43+
44+
// Super-light stubs for all heavy components so rendering doesn't explode
45+
vi.mock("@/components/navbar", () => ({ default: stub("navbar") }));
46+
vi.mock("@/components/user_dashboard", () => ({ default: stub("user-dashboard") }));
47+
vi.mock("@/components/templates/model_dashboard", () => ({ default: stub("model-dashboard") }));
48+
vi.mock("@/components/view_users", () => ({ default: stub("view-users") }));
49+
vi.mock("@/components/teams", () => ({ default: stub("teams") }));
50+
vi.mock("@/components/organizations", () => ({
51+
default: stub("organizations"),
52+
fetchOrganizations: vi.fn(), // consumed in effects
53+
}));
54+
vi.mock("@/components/admins", () => ({ default: stub("admin-panel") }));
55+
vi.mock("@/components/settings", () => ({ default: stub("settings") }));
56+
vi.mock("@/components/general_settings", () => ({ default: stub("general-settings") }));
57+
vi.mock("@/components/pass_through_settings", () => ({ default: stub("pass-through-settings") }));
58+
vi.mock("@/components/budgets/budget_panel", () => ({ default: stub("budget-panel") }));
59+
vi.mock("@/components/view_logs", () => ({ default: stub("spend-logs") }));
60+
vi.mock("@/components/model_hub_table", () => ({ default: stub("model-hub-table") }));
61+
vi.mock("@/components/new_usage", () => ({ default: stub("new-usage") }));
62+
vi.mock("@/components/api_ref", () => ({ default: stub("api-ref") }));
63+
vi.mock("@/components/chat_ui/ChatUI", () => ({ default: stub("chat-ui") }));
64+
vi.mock("@/components/leftnav", () => ({ default: stub("sidebar") }));
65+
vi.mock("@/components/usage", () => ({ default: stub("usage") }));
66+
vi.mock("@/components/cache_dashboard", () => ({ default: stub("cache-dashboard") }));
67+
vi.mock("@/components/guardrails", () => ({ default: stub("guardrails") }));
68+
vi.mock("@/components/prompts", () => ({ default: stub("prompts") }));
69+
vi.mock("@/components/transform_request", () => ({ default: stub("transform-request") }));
70+
vi.mock("@/components/mcp_tools", () => ({ MCPServers: stub("mcp-servers") }));
71+
vi.mock("@/components/tag_management", () => ({ default: stub("tag-management") }));
72+
vi.mock("@/components/vector_store_management", () => ({ default: stub("vector-stores") }));
73+
vi.mock("@/components/ui_theme_settings", () => ({ default: stub("ui-theme-settings") }));
74+
vi.mock("@/components/organisms/create_key_button", () => ({ fetchUserModels: vi.fn() }));
75+
vi.mock("@/components/common_components/fetch_teams", () => ({ fetchTeams: vi.fn() }));
76+
vi.mock("@/components/ui/ui-loading-spinner", () => ({
77+
UiLoadingSpinner: stub("spinner"),
78+
}));
79+
vi.mock("@/contexts/ThemeContext", () => {
80+
const React = require("react");
81+
return {
82+
ThemeProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
83+
};
84+
});
85+
vi.mock("@/lib/cva.config", () => ({
86+
cx: (...args: string[]) => args.join(" "),
87+
}));
88+
89+
import CreateKeyPage from "@/app/page";
90+
91+
/** ----------------------------
92+
* Helpers
93+
* --------------------------- */
94+
95+
function setCookie(raw: string) {
96+
// JSDOM allows simple string assignment to document.cookie
97+
document.cookie = raw;
98+
}
99+
100+
function clearAllCookies() {
101+
// JSDOM doesn't give an API to clear; overwrite with empty string
102+
// plus ensure we wipe known names used by this app.
103+
document.cookie = "token=; Max-Age=0; Path=/";
104+
}
105+
106+
const originalLocation = window.location;
107+
108+
beforeEach(() => {
109+
// Fresh module state & DOM
110+
vi.clearAllMocks();
111+
clearAllCookies();
112+
113+
// Make location.replace spy-able to validate redirect
114+
delete (window as any).location;
115+
// minimal location object with replace and assign stubs
116+
(window as any).location = {
117+
...originalLocation,
118+
href: "http://localhost/",
119+
assign: vi.fn(),
120+
replace: vi.fn(),
121+
};
122+
});
123+
124+
afterEach(() => {
125+
// Restore location to avoid leaking across test envs
126+
delete (window as any).location;
127+
(window as any).location = originalLocation;
128+
});
129+
130+
/** ----------------------------
131+
* Tests
132+
* --------------------------- */
133+
134+
describe("CreateKeyPage auth behavior", () => {
135+
it("redirects to SSO when cookie token is expired and clears it (no spasms)", async () => {
136+
// Arrange: expired token in cookie
137+
setCookie("token=expiredtoken");
138+
139+
// jwtDecode returns past exp → expired
140+
jwtDecodeMock.mockImplementation((tok: string) => {
141+
expect(tok).toBe("expiredtoken");
142+
return { exp: Math.floor(Date.now() / 1000) - 60 }; // expired 60s ago
143+
});
144+
145+
// Spy on cookie writes to ensure we clear with Max-Age=0
146+
const cookieSetSpy = vi.spyOn(document, "cookie", "set");
147+
148+
// Act
149+
render(<CreateKeyPage />);
150+
151+
// Assert: we eventually redirect to SSO login (single replace, not assign/href)
152+
await waitFor(() => {
153+
expect(window.location.replace).toHaveBeenCalledWith("https://example.com/sso/key/generate");
154+
});
155+
156+
// And we attempted to clear the cookie (defensive deletion)
157+
const wroteDeletion = cookieSetSpy.mock.calls.some(
158+
(args) => typeof args[0] === "string" && args[0].includes("Max-Age=0") && args[0].startsWith("token="),
159+
);
160+
expect(wroteDeletion).toBe(true);
161+
});
162+
163+
it("does NOT redirect when token is valid and renders the app chrome", async () => {
164+
// Arrange: valid token in cookie
165+
setCookie("token=validtoken");
166+
167+
// jwtDecode returns future exp and expected shape
168+
jwtDecodeMock.mockImplementation((tok: string) => {
169+
expect(tok).toBe("validtoken");
170+
return {
171+
exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1h in the future
172+
key: "accessKey-123",
173+
user_role: "app_user",
174+
user_email: "[email protected]",
175+
login_method: "username_password",
176+
premium_user: false,
177+
auth_header_name: "x-litellm-auth",
178+
user_id: "u_123",
179+
};
180+
});
181+
182+
// Act
183+
render(<CreateKeyPage />);
184+
185+
// Assert: no redirect
186+
await waitFor(() => {
187+
expect(window.location.replace).not.toHaveBeenCalled();
188+
});
189+
190+
// And some top-level UI appears (Navbar stub)
191+
await waitFor(() => {
192+
expect(screen.getByTestId("navbar")).toBeInTheDocument();
193+
});
194+
});
195+
});

0 commit comments

Comments
 (0)