From 7fd0dcb8eb971484aae27f9082ea32efeca37931 Mon Sep 17 00:00:00 2001 From: mrinal Date: Tue, 10 Nov 2026 05:31:41 +0000 Subject: [PATCH] Use a shared dashboard login token store Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/eslint-suppressions.json | 7 +- .../app/(dashboard)/hooks/useAuthorized.ts | 6 +- .../src/app/(dashboard)/layout.test.tsx | 119 ++++++++++++++- .../src/app/(dashboard)/layout.tsx | 11 +- .../src/app/login/LoginPage.test.tsx | 1 + .../components/AIHub/ModelHubTable.test.tsx | 6 +- .../src/components/AIHub/ModelHubTable.tsx | 6 +- .../src/components/liteadmin/useLiteAdmin.ts | 4 +- .../src/components/networking.test.ts | 2 +- .../src/components/networking.tsx | 4 +- .../publicModelHub/useRequirePublicHubAuth.ts | 7 +- .../src/contexts/AuthContext.tsx | 140 ++++-------------- .../src/utils/cookieUtils.test.ts | 35 ++++- ui/litellm-dashboard/src/utils/cookieUtils.ts | 27 +++- 14 files changed, 230 insertions(+), 145 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index e9bbeb94355..e998503a50c 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1740,7 +1740,7 @@ "count": 5 }, "no-restricted-syntax": { - "count": 132 + "count": 131 }, "prefer-const": { "count": 26 @@ -2079,11 +2079,6 @@ "count": 1 } }, - "src/contexts/AuthContext.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/contexts/ThemeContext.tsx": { "no-restricted-syntax": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts index f65c56e747e..2d2d0b32fc1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -1,6 +1,6 @@ "use client"; -import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; +import { clearTokenCookies, getCookie, useAuthToken } from "@/utils/cookieUtils"; import { checkTokenValidity, decodeToken } from "@/utils/jwtUtils"; import { useEffect, useMemo } from "react"; import { effectiveSessionRole, formatUserRole, isViewOnlySessionRole, userRoleDisplayLabel } from "@/utils/roles"; @@ -11,7 +11,7 @@ import { useUIConfig } from "./uiConfig/useUIConfig"; const useAuthorized = () => { const { data: uiConfig, isLoading: isUIConfigLoading } = useUIConfig(); - const token = typeof document !== "undefined" ? getCookie("token") : null; + const token = useAuthToken(); const decoded = useMemo(() => decodeToken(token), [token]); const isTokenValid = useMemo(() => checkTokenValidity(token), [token]); @@ -29,7 +29,7 @@ const useAuthorized = () => { if (isLoading) return; if (!isAuthorized) { - if (token) { + if (token || getCookie("token")) { clearTokenCookies(); } redirectToLogin(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index 19b70b1d42d..953617093cc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -21,6 +21,13 @@ const currentUserRequests = () => let searchParamsValue = new URLSearchParams(); +const sessionCookie = (claims: Record) => { + const encode = (part: Record) => + btoa(JSON.stringify(part)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + const exp = Math.floor(Date.now() / 1000) + 3600; + return `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ ...claims, exp })}.sig`; +}; + vi.mock("@/lib/telemetry/uiEvents", () => ({ recordUiEvent: vi.fn(() => Promise.resolve()), })); @@ -98,9 +105,12 @@ describe("(dashboard) Layout", () => { pathOf(input) === CURRENT_USER_PATH ? currentUserReply() : Promise.resolve(Response.json({}, { status: 404 })), ); vi.stubGlobal("fetch", fetchMock); + clearTokenCookies(); + storeLoginToken(sessionCookie({ user_id: "normal-user", key: "sk-normal-session" })); }); afterEach(() => { + clearTokenCookies(); vi.unstubAllGlobals(); }); @@ -234,13 +244,6 @@ describe("(dashboard) Layout", () => { }); describe("forced password reset routing", () => { - const sessionCookie = (claims: Record) => { - const encode = (part: Record) => - btoa(JSON.stringify(part)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); - const exp = Math.floor(Date.now() / 1000) + 3600; - return `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ ...claims, exp })}.sig`; - }; - afterEach(() => { clearTokenCookies(); document.cookie = "token=; Max-Age=0; Path=/"; @@ -253,6 +256,18 @@ describe("(dashboard) Layout", () => { return
{userEmail ? `${userEmail} (${userRole})` : "Signed out"}
; } + function SessionDetails() { + const { authLoading, token, userID, userEmail, userRole, accessToken, premiumUser, showSSOBanner } = useAuth(); + if (authLoading) return
Loading session
; + return ( +
+ {token ? "Token present" : "No token"} | {userID ?? "No user"} | {userEmail ?? "No email"} |{" "} + {userRole || "No role"} | {accessToken ?? "No key"} | {premiumUser ? "Premium" : "Standard"} |{" "} + {showSSOBanner ? "SSO banner" : "No SSO banner"} +
+ ); + } + it("updates the mounted session after login and when a different user signs in", async () => { clearTokenCookies(); render( @@ -270,6 +285,69 @@ describe("(dashboard) Layout", () => { expect(await screen.findByText("user@example.test (Internal User)")).toBeInTheDocument(); }); + it("clears the previous principal's missing email and premium status when switching users", async () => { + clearTokenCookies(); + const adminClaims = { + user_id: "admin", + user_email: "admin@example.test", + user_role: "proxy_admin", + key: "admin-key", + premium_user: true, + login_method: "sso", + }; + storeLoginToken(sessionCookie(adminClaims)); + render( + + + , + ); + pendingUiConfig.resolve(); + expect( + await screen.findByText( + "Token present | admin | admin@example.test | Admin | admin-key | Premium | No SSO banner", + ), + ).toBeInTheDocument(); + + act(() => + storeLoginToken(sessionCookie({ user_id: "internal", user_role: "internal_user", key: "internal-key" })), + ); + + expect( + await screen.findByText( + "Token present | internal | No email | Internal User | internal-key | Standard | SSO banner", + ), + ).toBeInTheDocument(); + }); + + it("clears the token and all identity fields for mounted consumers on sign out", async () => { + clearTokenCookies(); + const adminClaims = { + user_id: "admin", + user_email: "admin@example.test", + user_role: "proxy_admin", + key: "admin-key", + premium_user: true, + }; + storeLoginToken(sessionCookie(adminClaims)); + render( + + + , + ); + pendingUiConfig.resolve(); + expect( + await screen.findByText( + "Token present | admin | admin@example.test | Admin | admin-key | Premium | SSO banner", + ), + ).toBeInTheDocument(); + + act(() => clearTokenCookies()); + + expect( + await screen.findByText("No token | No user | No email | No role | No key | Standard | SSO banner"), + ).toBeInTheDocument(); + }); + it.each(["", " ", "invalid-token"])("does not authenticate a stored invalid token %j", async (token) => { clearTokenCookies(); render( @@ -390,6 +468,33 @@ describe("(dashboard) Layout", () => { } }); + it("redirects a signed-out session without mounting the shell", async () => { + clearTokenCookies(); + const originalLocation = window.location; + const locationReplace = vi.fn(); + Object.defineProperty(window, "location", { + value: { ...originalLocation, replace: locationReplace }, + writable: true, + }); + try { + render( + + +
+ + , + ); + pendingUiConfig.resolve(); + + await waitFor(() => expect(locationReplace).toHaveBeenCalledWith(expect.stringContaining("/login"))); + expect(screen.queryByTestId("sidebar")).not.toBeInTheDocument(); + expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + expect(currentUserRequests()).toHaveLength(0); + } finally { + Object.defineProperty(window, "location", { value: originalLocation, writable: true }); + } + }); + it("keeps the session of an invitation link whose current user cannot be loaded", async () => { const originalLocation = window.location; const locationReplace = vi.fn(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 5c93d214812..fe4230bbe13 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -240,13 +240,14 @@ function LayoutContent({ children }: { children: React.ReactNode }) { const searchParams = useSearchParams(); const pathname = usePathname(); const { accessToken, token, authLoading, passwordResetRequired } = useAuth(); + const redirectingToLogin = useRef(false); const session = decodeToken(token); const { isLoading: currentUserLoading, isError: currentUserFailed } = useCurrentUser( session?.key ?? null, session?.auth_header_name, ); const isInvitationFlow = Boolean(searchParams.get("invitation_id")); - const identityPending = currentUserLoading || currentUserFailed; + const identityPending = !token || currentUserLoading || currentUserFailed; // Legacy invitation links point at /ui/?invitation_id=; the onboarding form now lives at its own // /onboarding route. Redirect once ui-config has loaded so uiHref resolves the SERVER_ROOT_PATH base. @@ -265,10 +266,12 @@ function LayoutContent({ children }: { children: React.ReactNode }) { }, [authLoading, passwordResetRequired, pathname, router]); useEffect(() => { - if (!currentUserFailed || isInvitationFlow) return; - clearTokenCookies(); + if (authLoading || isInvitationFlow || redirectingToLogin.current) return; + if (token && !currentUserFailed) return; + redirectingToLogin.current = true; + if (currentUserFailed) clearTokenCookies(); redirectToLogin(); - }, [currentUserFailed, isInvitationFlow]); + }, [authLoading, currentUserFailed, isInvitationFlow, token]); if (authLoading || identityPending || isInvitationFlow) { return ; diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx index 6a61f5ed85f..9ec73fdc2a9 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx @@ -20,6 +20,7 @@ vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => ({ vi.mock("@/utils/cookieUtils", () => ({ clearTokenCookies: vi.fn(), getCookieFromDocument: vi.fn(), + getAuthToken: vi.fn(() => null), })); vi.mock("@/utils/jwtUtils", () => ({ diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx index 1b30b060ab0..b2769bbe84a 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx @@ -8,7 +8,7 @@ import { renderWithProviders, screen, testQueryClient, waitFor, within } from ". import ModelHubTable from "./ModelHubTable"; const mockUseUISettings = vi.hoisted(() => vi.fn()); -const mockGetCookie = vi.hoisted(() => vi.fn()); +const mockUseAuthToken = vi.hoisted(() => vi.fn()); const mockCheckTokenValidity = vi.hoisted(() => vi.fn()); const mockRouterReplace = vi.hoisted(() => vi.fn()); const mockLocationReplace = vi.hoisted(() => vi.fn()); @@ -73,7 +73,7 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ })); vi.mock("@/utils/cookieUtils", () => ({ - getCookie: mockGetCookie, + useAuthToken: mockUseAuthToken, })); vi.mock("@/utils/jwtUtils", () => ({ @@ -117,7 +117,7 @@ describe("ModelHubTable", () => { }, isLoading: false, }); - mockGetCookie.mockReturnValue(tokenValue); + mockUseAuthToken.mockReturnValue(tokenValue); mockCheckTokenValidity.mockReturnValue(isTokenValid); mockRouterReplace.mockClear(); mockLocationReplace.mockClear(); diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index ad27e56dc56..696d2e3f3b5 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -46,7 +46,7 @@ import { prism } from "react-syntax-highlighter/dist/esm/styles/prism"; import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { checkTokenValidity } from "@/utils/jwtUtils"; -import { getCookie } from "@/utils/cookieUtils"; +import { useAuthToken } from "@/utils/cookieUtils"; import { getLoginUrl } from "@/utils/returnUrlUtils"; import { uiHref } from "@/utils/uiHref"; import { @@ -154,6 +154,7 @@ const ModelHubTable: React.FC = ({ const [skillLoading, setSkillLoading] = useState(false); const [isMakeSkillPublicModalVisible, setIsMakeSkillPublicModalVisible] = useState(false); const { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings(); + const token = useAuthToken(); // Check authentication requirement for public AI Hub useEffect(() => { @@ -166,7 +167,6 @@ const ModelHubTable: React.FC = ({ // If require_auth_for_public_ai_hub is true, verify token if (requireAuth === true) { - const token = getCookie("token"); const isTokenValid = checkTokenValidity(token); // If token is invalid, redirect to login @@ -176,7 +176,7 @@ const ModelHubTable: React.FC = ({ } } // If require_auth_for_public_ai_hub is false, allow public access (no change) - }, [isUISettingsLoading, publicPage, uiSettings]); + }, [isUISettingsLoading, publicPage, uiSettings, token]); useEffect(() => { const fetchData = async (accessToken: string) => { diff --git a/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts b/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts index fdf3cf35a9c..9e0882a75fa 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts @@ -6,7 +6,7 @@ import { getProxyBaseUrl } from "@/components/networking"; import i18n from "@/i18n/i18n"; import { extractProxyErrorMessage } from "@/lib/http/client"; import { toast } from "@/lib/toast"; -import { getCookie } from "@/utils/cookieUtils"; +import { getAuthToken } from "@/utils/cookieUtils"; import { checkTokenValidity } from "@/utils/jwtUtils"; import { runLiteAdmin } from "./agent"; import type { ActionResult, AriaAccess, LiteAdminAction } from "./operations"; @@ -119,7 +119,7 @@ export function useLiteAdmin(session: LiteAdminSession) { setEntries((current) => [...current, { kind: "message", id: message.id, message }]); setPhase("thinking"); const sameSession = () => - getCookie("token") === session.token && + getAuthToken() === session.token && checkTokenValidity(session.token) && getProxyBaseUrl() === session.managementBaseUrl; const assertCurrent = () => { diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 47a1aef87c0..818c56f8fb1 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -5,7 +5,7 @@ import { uiHref } from "@/utils/uiHref"; vi.mock("@/utils/cookieUtils", () => ({ clearTokenCookies: vi.fn(), - getCookie: vi.fn(), + getAuthToken: vi.fn(), storeLoginToken: vi.fn(), })); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 7be345de09a..2d7455d0335 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -88,7 +88,7 @@ export const getAutoRouterAssembledPromptCall = async ( */ import i18n from "@/i18n/i18n"; import { toast } from "@/lib/toast"; -import { clearTokenCookies, getCookie, storeLoginToken } from "@/utils/cookieUtils"; +import { clearTokenCookies, getAuthToken, storeLoginToken } from "@/utils/cookieUtils"; import { decodeToken } from "@/utils/jwtUtils"; import { normalizeBackendPageUrlToUiOrigin } from "@/utils/returnUrlUtils"; import { TagNewRequest, TagUpdateRequest, TagListResponse, TagInfoResponse } from "./tag_management/types"; @@ -545,7 +545,7 @@ const gatewayApiClient = createApiClient({ registerBaseUrlGetter(getProxyBaseUrl); registerAuthHeaderNameGetter(getGlobalLitellmHeaderName); -registerAuthTokenGetter(() => decodeToken(getCookie("token"))?.key ?? null); +registerAuthTokenGetter(() => decodeToken(getAuthToken())?.key ?? null); registerErrorHandler(handleError); export const makeModelGroupPublic = async (accessToken: string, modelGroups: string[]) => { diff --git a/ui/litellm-dashboard/src/components/publicModelHub/useRequirePublicHubAuth.ts b/ui/litellm-dashboard/src/components/publicModelHub/useRequirePublicHubAuth.ts index ff8ddcd048d..88d815dc7f4 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/useRequirePublicHubAuth.ts +++ b/ui/litellm-dashboard/src/components/publicModelHub/useRequirePublicHubAuth.ts @@ -4,16 +4,17 @@ import { useEffect } from "react"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { getProxyBaseUrl, getUiPageUrl } from "@/components/networking"; -import { getCookie } from "@/utils/cookieUtils"; +import { useAuthToken } from "@/utils/cookieUtils"; import { checkTokenValidity } from "@/utils/jwtUtils"; import { getLoginUrl } from "@/utils/returnUrlUtils"; export function useRequirePublicHubAuth() { const { data: uiSettings, isLoading } = useUISettings(); + const token = useAuthToken(); const requireAuth = !isLoading && uiSettings?.values?.require_auth_for_public_ai_hub === true; useEffect(() => { - if (requireAuth && !checkTokenValidity(getCookie("token"))) { + if (requireAuth && !checkTokenValidity(token)) { window.location.replace(getUiPageUrl(getLoginUrl(getProxyBaseUrl()))); } - }, [requireAuth]); + }, [requireAuth, token]); } diff --git a/ui/litellm-dashboard/src/contexts/AuthContext.tsx b/ui/litellm-dashboard/src/contexts/AuthContext.tsx index 18dd45cb56a..fc97e7ac903 100644 --- a/ui/litellm-dashboard/src/contexts/AuthContext.tsx +++ b/ui/litellm-dashboard/src/contexts/AuthContext.tsx @@ -1,18 +1,22 @@ "use client"; -import React, { createContext, useContext, useEffect, useState } from "react"; +import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; import { jwtDecode } from "jwt-decode"; -import { clearTokenCookies, getCookie, LOGIN_TOKEN_CHANGED_EVENT } from "@/utils/cookieUtils"; -import { isJwtExpired } from "@/utils/jwtUtils"; +import { clearTokenCookies, getAuthToken, getCookie, useAuthToken } from "@/utils/cookieUtils"; import { effectiveSessionRole } 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 SessionClaims = { + key?: string; + user_id?: string; + user_role?: string; + user_email?: string; + premium_user?: boolean; + disabled_non_admin_personal_key_creation?: boolean; + login_method?: string; + password_reset_required?: boolean; + auth_header_name?: string; +}; type AuthContextValue = { authLoading: boolean; @@ -25,132 +29,52 @@ type AuthContextValue = { disabledPersonalKeyCreation: boolean; showSSOBanner: boolean; passwordResetRequired: 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); - const [passwordResetRequired, setPasswordResetRequired] = useState(false); + const token = useAuthToken(); + const decoded = useMemo(() => (token ? jwtDecode(token) : null), [token]); - // 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; - const refreshToken = () => { - const raw = getCookie("token"); - const valid = raw && !isJwtExpired(raw) ? raw : null; - if (raw && !valid) { - deleteCookie("token", "/"); - } - setToken(valid); - }; - - window.addEventListener(LOGIN_TOKEN_CHANGED_EVENT, refreshToken); - (async () => { try { await getUiConfig(); - } catch { - // proceed regardless; auth state must still be resolved + } finally { + if (!cancelled) setAuthLoading(false); } - - if (cancelled) return; - - refreshToken(); - setAuthLoading(false); - })(); + })().catch(() => {}); return () => { cancelled = true; - window.removeEventListener(LOGIN_TOKEN_CHANGED_EVENT, refreshToken); }; }, []); - // Decode JWT and populate derived auth state whenever the token changes. useEffect(() => { - if (!token) { - return; + if (getCookie("token") && !getAuthToken()) { + clearTokenCookies(); } - - 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(effectiveSessionRole(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); - } - setPasswordResetRequired(decoded.password_reset_required === true); }, [token]); + useEffect(() => { + setGlobalLitellmHeaderName(decoded?.auth_header_name ?? "Authorization"); + }, [decoded?.auth_header_name]); + const value: AuthContextValue = { authLoading, token, - userID, - userRole, - userEmail, - accessToken, - premiumUser, - disabledPersonalKeyCreation, - showSSOBanner, - passwordResetRequired, - setToken, - setUserID, - setUserRole, - setUserEmail, - setAccessToken, - setPremiumUser, - setShowSSOBanner, + userID: decoded?.user_id ?? null, + userRole: decoded?.user_role ? effectiveSessionRole(decoded.user_role) : "", + userEmail: decoded?.user_email ?? null, + accessToken: decoded?.key ?? null, + premiumUser: decoded?.premium_user ?? false, + disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? false, + showSSOBanner: decoded?.login_method ? decoded.login_method === "username_password" : true, + passwordResetRequired: decoded?.password_reset_required === true, }; return {children}; diff --git a/ui/litellm-dashboard/src/utils/cookieUtils.test.ts b/ui/litellm-dashboard/src/utils/cookieUtils.test.ts index a1bf827560b..d5306b142a3 100644 --- a/ui/litellm-dashboard/src/utils/cookieUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/cookieUtils.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect, beforeEach, vi } from "vitest"; -import { clearTokenCookies, getCookie, storeLoginToken } from "./cookieUtils"; +import { clearTokenCookies, getAuthToken, getCookie, storeLoginToken, subscribeAuthToken } from "./cookieUtils"; import { getToken, setToken } from "./mcpTokenStore"; describe("cookieUtils", () => { @@ -14,6 +14,39 @@ describe("cookieUtils", () => { vi.spyOn(console, "log").mockImplementation(() => {}); }); + describe("auth token snapshots", () => { + const jwt = (exp: number) => `eyJhbGciOiJub25lIn0.${btoa(JSON.stringify({ exp }))}.sig`; + + it("reads a stable valid snapshot from the session fallback", () => { + const token = jwt(Math.floor(Date.now() / 1000) + 3600); + sessionStorage.setItem("token", token); + + expect(getAuthToken()).toBe(token); + expect(getAuthToken()).toBe(getAuthToken()); + }); + + it.each(["invalid-token", jwt(0)])("returns null without mutating invalid storage for %s", (token) => { + sessionStorage.setItem("token", token); + + expect(getAuthToken()).toBeNull(); + expect(sessionStorage.getItem("token")).toBe(token); + }); + + it("notifies subscribers after writes and clears and stops after unsubscribe", () => { + const snapshots: (string | null)[] = []; + const token = jwt(Math.floor(Date.now() / 1000) + 3600); + const unsubscribe = subscribeAuthToken(() => snapshots.push(getAuthToken())); + + storeLoginToken(token); + clearTokenCookies(); + unsubscribe(); + storeLoginToken(token); + + expect(snapshots).toEqual([token, null]); + clearTokenCookies(); + }); + }); + describe("clearTokenCookies", () => { it("should clear token cookie from root path", () => { document.cookie = "token=test-token-value; path=/"; diff --git a/ui/litellm-dashboard/src/utils/cookieUtils.ts b/ui/litellm-dashboard/src/utils/cookieUtils.ts index a403775b20e..571747ff49d 100644 --- a/ui/litellm-dashboard/src/utils/cookieUtils.ts +++ b/ui/litellm-dashboard/src/utils/cookieUtils.ts @@ -2,9 +2,31 @@ * Utility functions for managing cookies */ +import { useSyncExternalStore } from "react"; +import { isJwtExpired } from "./jwtUtils"; import { clearAllMcpTokens } from "./mcpTokenStore"; -export const LOGIN_TOKEN_CHANGED_EVENT = "litellm-login-token-changed"; +const authTokenListeners = new Set<() => void>(); + +function notifyAuthToken() { + authTokenListeners.forEach((listener) => listener()); +} + +export function getAuthToken(): string | null { + const token = getCookie("token"); + return token && !isJwtExpired(token) ? token : null; +} + +export function subscribeAuthToken(listener: () => void): () => void { + authTokenListeners.add(listener); + return () => { + authTokenListeners.delete(listener); + }; +} + +export function useAuthToken(): string | null { + return useSyncExternalStore(subscribeAuthToken, getAuthToken, () => null); +} /** * Returns the cookie path for the UI. @@ -72,6 +94,7 @@ export function clearTokenCookies() { } clearAllMcpTokens(); + notifyAuthToken(); } /** @@ -106,7 +129,7 @@ export function storeLoginToken(token: string) { // sessionStorage may be unavailable (e.g. private browsing quota exceeded) } - window.dispatchEvent(new Event(LOGIN_TOKEN_CHANGED_EVENT)); + notifyAuthToken(); } /**