mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Use a shared dashboard login token store
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
5b6ebe8918
commit
7fd0dcb8eb
14 changed files with 230 additions and 145 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ const currentUserRequests = () =>
|
|||
|
||||
let searchParamsValue = new URLSearchParams();
|
||||
|
||||
const sessionCookie = (claims: Record<string, unknown>) => {
|
||||
const encode = (part: Record<string, unknown>) =>
|
||||
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<string, unknown>) => {
|
||||
const encode = (part: Record<string, unknown>) =>
|
||||
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 <div>{userEmail ? `${userEmail} (${userRole})` : "Signed out"}</div>;
|
||||
}
|
||||
|
||||
function SessionDetails() {
|
||||
const { authLoading, token, userID, userEmail, userRole, accessToken, premiumUser, showSSOBanner } = useAuth();
|
||||
if (authLoading) return <div>Loading session</div>;
|
||||
return (
|
||||
<div>
|
||||
{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"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<AuthProvider>
|
||||
<SessionDetails />
|
||||
</AuthProvider>,
|
||||
);
|
||||
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(
|
||||
<AuthProvider>
|
||||
<SessionDetails />
|
||||
</AuthProvider>,
|
||||
);
|
||||
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(
|
||||
<AuthProvider>
|
||||
<Layout>
|
||||
<div data-testid="page-content" />
|
||||
</Layout>
|
||||
</AuthProvider>,
|
||||
);
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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 <LoadingScreen />;
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<ModelHubTableProps> = ({
|
|||
const [skillLoading, setSkillLoading] = useState<boolean>(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<ModelHubTableProps> = ({
|
|||
|
||||
// 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<ModelHubTableProps> = ({
|
|||
}
|
||||
}
|
||||
// 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) => {
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]) => {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<React.SetStateAction<string | null>>;
|
||||
setUserID: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setUserRole: React.Dispatch<React.SetStateAction<string>>;
|
||||
setUserEmail: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setAccessToken: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setPremiumUser: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setShowSSOBanner: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [userID, setUserID] = useState<string | null>(null);
|
||||
const [userRole, setUserRole] = useState("");
|
||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(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<SessionClaims>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -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=/";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue