diff --git a/.github/workflows/test_server_root_path.yml b/.github/workflows/test_server_root_path.yml
index 155445acdf..57ff746c9c 100644
--- a/.github/workflows/test_server_root_path.yml
+++ b/.github/workflows/test_server_root_path.yml
@@ -101,6 +101,31 @@ jobs:
docker logs litellm-test
exit 1
+ - name: Setup Node for Playwright
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ with:
+ node-version: "20"
+
+ - name: Install UI deps and Chromium
+ working-directory: ui/litellm-dashboard
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium
+
+ - name: Run SERVER_ROOT_PATH redirect e2e
+ working-directory: ui/litellm-dashboard
+ env:
+ SERVER_ROOT_PATH: ${{ matrix.root_path }}
+ run: npx playwright test --config=e2e_tests/serverRootPath.config.ts
+
+ - name: Upload Playwright artifacts on failure
+ if: failure()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: playwright-trace-${{ strategy.job-index }}
+ path: ui/litellm-dashboard/test-results/
+ retention-days: 7
+
- name: Cleanup
if: always()
run: |
diff --git a/ui/litellm-dashboard/e2e_tests/serverRootPath.config.ts b/ui/litellm-dashboard/e2e_tests/serverRootPath.config.ts
new file mode 100644
index 0000000000..83831f82da
--- /dev/null
+++ b/ui/litellm-dashboard/e2e_tests/serverRootPath.config.ts
@@ -0,0 +1,32 @@
+import { defineConfig, devices } from "@playwright/test";
+
+// Minimal config for the SERVER_ROOT_PATH redirect spec. Deliberately does NOT
+// reuse the main e2e config because:
+// - globalSetup logs in via http://localhost:4000/ui/login, which 404s when
+// the proxy is mounted under a non-root path.
+// - The redirect spec must run against a clean, unauthenticated session, so
+// no storage state should be loaded.
+export default defineConfig({
+ testDir: "./tests/login",
+ testMatch: ["serverRootPathRedirect.spec.ts"],
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: 1,
+ reporter: "list",
+ use: {
+ trace: "on-first-retry",
+ actionTimeout: 15 * 1000,
+ navigationTimeout: 30 * 1000,
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ ],
+ timeout: 60 * 1000,
+ expect: {
+ timeout: 10 * 1000,
+ },
+});
diff --git a/ui/litellm-dashboard/e2e_tests/tests/login/serverRootPathRedirect.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/login/serverRootPathRedirect.spec.ts
new file mode 100644
index 0000000000..62a3e91318
--- /dev/null
+++ b/ui/litellm-dashboard/e2e_tests/tests/login/serverRootPathRedirect.spec.ts
@@ -0,0 +1,32 @@
+import { expect, test } from "@playwright/test";
+
+// Driven by the SERVER_ROOT_PATH env var injected by the workflow; the container
+// is booted with the same value, so the asset paths and the runtime config it
+// serves at /litellm/.well-known/litellm-ui-config will both reflect it.
+const ROOT_PATH = process.env.SERVER_ROOT_PATH ?? "";
+
+test.skip(!ROOT_PATH, "Requires SERVER_ROOT_PATH env var");
+
+// Contract: an unauthenticated visit must redirect to a login URL that preserves
+// the SERVER_ROOT_PATH prefix. The redirect URL is built client-side from
+// `proxyBaseUrl`, which is populated by an async fetch of the runtime UI config.
+// If the redirect fires before that fetch resolves, the URL is missing the
+// prefix and the user lands on a 404. To make the race deterministic across
+// runners, the config endpoint is intentionally delayed.
+test("unauth redirect preserves SERVER_ROOT_PATH prefix", async ({ page }) => {
+ // Matches both `/litellm/.well-known/litellm-ui-config` and
+ // `${SERVER_ROOT_PATH}/.well-known/litellm-ui-config` (the proxy rewrites the
+ // bundle at boot when a root path is set).
+ await page.route("**/.well-known/litellm-ui-config", async (route) => {
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ await route.continue();
+ });
+
+ await page.context().clearCookies();
+
+ await page.goto(`http://localhost:4000${ROOT_PATH}/ui/?page=virtual-keys`);
+
+ await page.waitForURL((url) => url.pathname.endsWith("/ui/login"), { timeout: 15_000 });
+
+ expect(page.url()).toContain(`${ROOT_PATH}/ui/login`);
+});
diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx
index a4ed17cde3..f79b7eb702 100644
--- a/ui/litellm-dashboard/src/app/layout.tsx
+++ b/ui/litellm-dashboard/src/app/layout.tsx
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import AntdGlobalProvider from "@/contexts/AntdGlobalProvider";
+import { AuthProvider } from "@/contexts/AuthContext";
import ReactQueryProvider from "@/contexts/ReactQueryProvider";
const inter = Inter({ subsets: ["latin"] });
@@ -22,7 +23,9 @@ export default function RootLayout({
- {children}
+
+ {children}
+
diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx
index 5761818596..ce12967c91 100644
--- a/ui/litellm-dashboard/src/app/page.tsx
+++ b/ui/litellm-dashboard/src/app/page.tsx
@@ -20,7 +20,7 @@ import { Team } from "@/components/key_team_helpers/key_list";
import { MCPServers } from "@/components/mcp_tools";
import ModelHubTable from "@/components/AIHub/ModelHubTable";
import Navbar from "@/components/navbar";
-import { getUiConfig, Organization, proxyBaseUrl, setGlobalLitellmHeaderName, getInProductNudgesCall } from "@/components/networking";
+import { Organization, proxyBaseUrl, getInProductNudgesCall } from "@/components/networking";
import NewUsagePage from "@/components/UsagePage/components/UsagePageView";
import OldTeams from "@/components/OldTeams";
import { fetchUserModels, CreateKeyPrefillData } from "@/components/organisms/create_key_button";
@@ -45,24 +45,14 @@ import WorkflowRuns from "@/components/workflow_runs";
import SpendLogsTable from "@/components/view_logs";
import ViewUserDashboard from "@/components/view_users";
import { ThemeProvider } from "@/contexts/ThemeContext";
-import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
-import { isJwtExpired } from "@/utils/jwtUtils";
+import { useAuth } from "@/contexts/AuthContext";
import { buildLoginUrlWithReturn, consumeReturnUrl, isValidReturnUrl, normalizeUrlForCompare, storeReturnUrl } from "@/utils/returnUrlUtils";
-import { formatUserRole, isAdminRole } from "@/utils/roles";
+import { isAdminRole } from "@/utils/roles";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { jwtDecode } from "jwt-decode";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { ConfigProvider, theme } from "antd";
-function deleteCookie(name: string, path = "/") {
- // Best-effort client-side clear (works for non-HttpOnly cookies without Domain)
- document.cookie = `${name}=; Max-Age=0; Path=${path}`;
- if (name === "token") {
- clearTokenCookies();
- }
-}
-
interface ProxySettings {
PROXY_BASE_URL: string;
PROXY_LOGOUT_URL: string;
@@ -77,10 +67,18 @@ interface ProxySettings {
const LEGACY_REDIRECTS: Record = {};
function CreateKeyPageContent() {
- const [userRole, setUserRole] = useState("");
- const [premiumUser, setPremiumUser] = useState(false);
- const [disabledPersonalKeyCreation, setDisabledPersonalKeyCreation] = useState(false);
- const [userEmail, setUserEmail] = useState(null);
+ const {
+ authLoading,
+ token,
+ userID,
+ userRole,
+ userEmail,
+ accessToken,
+ premiumUser,
+ setUserRole,
+ setUserEmail,
+ } = useAuth();
+
const [teams, setTeams] = useState(null);
const [keys, setKeys] = useState([]);
const [organizations, setOrganizations] = useState([]);
@@ -90,14 +88,10 @@ function CreateKeyPageContent() {
PROXY_LOGOUT_URL: "",
});
- const [showSSOBanner, setShowSSOBanner] = useState(true);
const router = useRouter();
const searchParams = useSearchParams()!;
const [modelData, setModelData] = useState({ data: [] });
- const [token, setToken] = useState(null);
const [createClicked, setCreateClicked] = useState(false);
- const [authLoading, setAuthLoading] = useState(true);
- const [userID, setUserID] = useState(null);
// Survey state - always show by default
const [showSurveyPrompt, setShowSurveyPrompt] = useState(true);
@@ -185,7 +179,6 @@ function CreateKeyPageContent() {
setPage(newPage);
};
- const [accessToken, setAccessToken] = useState(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Track if we've already attempted a return URL redirect to prevent race conditions
@@ -201,38 +194,6 @@ function CreateKeyPageContent() {
};
const redirectToLogin = authLoading === false && token === null && invitation_id === null;
- useEffect(() => {
- let cancelled = false;
-
- (async () => {
- try {
- await getUiConfig(); // ensures proxyBaseUrl etc. are ready
- } catch {
- // proceed regardless; we still need to decide auth state
- }
-
- if (cancelled) return;
-
- const raw = getCookie("token");
- const valid = raw && !isJwtExpired(raw) ? raw : null;
-
- // If token exists but is invalid/expired, clear it so downstream code
- // doesn't keep trying to use it and cause redirect spasms.
- if (raw && !valid) {
- deleteCookie("token", "/");
- }
-
- if (!cancelled) {
- setToken(valid);
- setAuthLoading(false);
- }
- })();
-
- return () => {
- cancelled = true;
- };
- }, []);
-
useEffect(() => {
if (redirectToLogin) {
// Store the current URL so we can redirect back after login
@@ -291,62 +252,6 @@ function CreateKeyPageContent() {
}
}, [token]);
- useEffect(() => {
- if (!token) {
- return;
- }
-
- // Defensive: re-check expiry in case cookie changed after mount
- if (isJwtExpired(token)) {
- deleteCookie("token", "/");
- setToken(null);
- return;
- }
-
- let decoded: any = null;
- try {
- decoded = jwtDecode(token);
- } catch {
- // Malformed token → treat as unauthenticated
- deleteCookie("token", "/");
- setToken(null);
- return;
- }
-
- if (decoded) {
- // set accessToken
- setAccessToken(decoded.key);
-
- setDisabledPersonalKeyCreation(decoded.disabled_non_admin_personal_key_creation);
-
- // check if userRole is defined
- if (decoded.user_role) {
- const formattedUserRole = formatUserRole(decoded.user_role);
- setUserRole(formattedUserRole);
- }
-
- if (decoded.user_email) {
- setUserEmail(decoded.user_email);
- }
-
- if (decoded.login_method) {
- setShowSSOBanner(decoded.login_method == "username_password" ? true : false);
- }
-
- if (decoded.premium_user) {
- setPremiumUser(decoded.premium_user);
- }
-
- if (decoded.auth_header_name) {
- setGlobalLitellmHeaderName(decoded.auth_header_name);
- }
-
- if (decoded.user_id) {
- setUserID(decoded.user_id);
- }
- }
- }, [token]);
-
useEffect(() => {
if (accessToken && userID && userRole) {
fetchUserModels(userID, userRole, accessToken, setUserModels);
diff --git a/ui/litellm-dashboard/src/contexts/AuthContext.tsx b/ui/litellm-dashboard/src/contexts/AuthContext.tsx
new file mode 100644
index 0000000000..3693d85895
--- /dev/null
+++ b/ui/litellm-dashboard/src/contexts/AuthContext.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import React, { createContext, useContext, useEffect, useState } from "react";
+import { jwtDecode } from "jwt-decode";
+import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
+import { isJwtExpired } from "@/utils/jwtUtils";
+import { formatUserRole } from "@/utils/roles";
+import { getUiConfig, setGlobalLitellmHeaderName } from "@/components/networking";
+
+function deleteCookie(name: string, path = "/") {
+ document.cookie = `${name}=; Max-Age=0; Path=${path}`;
+ if (name === "token") {
+ clearTokenCookies();
+ }
+}
+
+type AuthContextValue = {
+ authLoading: boolean;
+ token: string | null;
+ userID: string | null;
+ userRole: string;
+ userEmail: string | null;
+ accessToken: string | null;
+ premiumUser: boolean;
+ disabledPersonalKeyCreation: boolean;
+ showSSOBanner: boolean;
+
+ setToken: React.Dispatch>;
+ setUserID: React.Dispatch>;
+ setUserRole: React.Dispatch>;
+ setUserEmail: React.Dispatch>;
+ setAccessToken: React.Dispatch>;
+ setPremiumUser: React.Dispatch>;
+ setShowSSOBanner: React.Dispatch>;
+};
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [authLoading, setAuthLoading] = useState(true);
+ const [token, setToken] = useState(null);
+ const [userID, setUserID] = useState(null);
+ const [userRole, setUserRole] = useState("");
+ const [userEmail, setUserEmail] = useState(null);
+ const [accessToken, setAccessToken] = useState(null);
+ const [premiumUser, setPremiumUser] = useState(false);
+ const [disabledPersonalKeyCreation, setDisabledPersonalKeyCreation] = useState(false);
+ const [showSSOBanner, setShowSSOBanner] = useState(true);
+
+ // Load runtime UI config (populates proxyBaseUrl etc.) before clearing
+ // authLoading, so any consumer that builds proxy-rooted URLs from authLoading=false
+ // (e.g. the unauthenticated login redirect) sees the resolved value rather than
+ // the module-init default. Then read the cookie and validate JWT expiry.
+ useEffect(() => {
+ let cancelled = false;
+
+ (async () => {
+ try {
+ await getUiConfig();
+ } catch {
+ // proceed regardless; auth state must still be resolved
+ }
+
+ if (cancelled) return;
+
+ const raw = getCookie("token");
+ const valid = raw && !isJwtExpired(raw) ? raw : null;
+
+ // Clear expired/invalid token so downstream code doesn't keep trying to use it.
+ if (raw && !valid) {
+ deleteCookie("token", "/");
+ }
+
+ setToken(valid);
+ setAuthLoading(false);
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ // Decode JWT and populate derived auth state whenever the token changes.
+ useEffect(() => {
+ if (!token) {
+ return;
+ }
+
+ if (isJwtExpired(token)) {
+ deleteCookie("token", "/");
+ setToken(null);
+ return;
+ }
+
+ let decoded: { [k: string]: any } | null = null;
+ try {
+ decoded = jwtDecode(token);
+ } catch {
+ deleteCookie("token", "/");
+ setToken(null);
+ return;
+ }
+
+ if (!decoded) return;
+
+ setAccessToken(decoded.key);
+ setDisabledPersonalKeyCreation(decoded.disabled_non_admin_personal_key_creation);
+
+ if (decoded.user_role) {
+ setUserRole(formatUserRole(decoded.user_role));
+ }
+ if (decoded.user_email) {
+ setUserEmail(decoded.user_email);
+ }
+ if (decoded.login_method) {
+ setShowSSOBanner(decoded.login_method === "username_password");
+ }
+ if (decoded.premium_user) {
+ setPremiumUser(decoded.premium_user);
+ }
+ if (decoded.auth_header_name) {
+ setGlobalLitellmHeaderName(decoded.auth_header_name);
+ }
+ if (decoded.user_id) {
+ setUserID(decoded.user_id);
+ }
+ }, [token]);
+
+ const value: AuthContextValue = {
+ authLoading,
+ token,
+ userID,
+ userRole,
+ userEmail,
+ accessToken,
+ premiumUser,
+ disabledPersonalKeyCreation,
+ showSSOBanner,
+ setToken,
+ setUserID,
+ setUserRole,
+ setUserEmail,
+ setAccessToken,
+ setPremiumUser,
+ setShowSSOBanner,
+ };
+
+ return {children};
+}
+
+export function useAuth(): AuthContextValue {
+ const ctx = useContext(AuthContext);
+ if (!ctx) {
+ throw new Error("useAuth must be used within an AuthProvider");
+ }
+ return ctx;
+}
diff --git a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx
index 1d725572a3..3b9232861b 100644
--- a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx
+++ b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx
@@ -147,6 +147,18 @@ vi.mock("@/lib/cva.config", () => ({
}));
import CreateKeyPage from "@/app/page";
+import { AuthProvider } from "@/contexts/AuthContext";
+
+// The page consumes auth state via useAuth(). Wrap it so the hook resolves
+// against a real provider — the provider's effects (cookie read, JWT decode,
+// redirect-on-expired) are what these tests exercise.
+function PageUnderTest() {
+ return (
+
+
+
+ );
+}
/** ----------------------------
* Helpers
@@ -207,7 +219,7 @@ describe("CreateKeyPage auth behavior", () => {
const cookieSetSpy = vi.spyOn(document, "cookie", "set");
// Act
- render();
+ render();
// Assert: we eventually redirect to SSO login with return URL (single replace, not assign/href)
await waitFor(() => {
@@ -243,7 +255,7 @@ describe("CreateKeyPage auth behavior", () => {
});
// Act
- render();
+ render();
// Assert: no redirect
await waitFor(() => {
@@ -286,7 +298,7 @@ describe("CreateKeyPage auth behavior", () => {
// Return URL has the same params in a different order
consumeReturnUrlMock.mockReturnValue("http://localhost/ui?a=1&b=2");
- render();
+ render();
await waitFor(() => {
expect(window.location.replace).not.toHaveBeenCalled();