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();