mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
refactor(ui): single auth engine; useAuthorized becomes a policy layer over AuthContext
The dashboard had two parallel auth systems: AuthContext (provided at the
root, used by the legacy ?page= shell) and useAuthorized (used by ~85 files
in the migrated tree), each reading the cookie, decoding the JWT, and
deciding when to redirect on its own. Only AuthContext applied the decoded
auth_header_name via setGlobalLitellmHeaderName, so the two trees could
disagree on custom auth headers, and each ran its own login-redirect effect
AuthContext stays the engine: it still resolves uiConfig before clearing
authLoading (so proxy-rooted URLs are correct) and performs the single
decode. useAuthorized now consumes the context and only layers on policy:
the admin_ui_disabled check and the redirect-to-login side effect. Its
return shape is unchanged, so none of the ~85 consumers are touched
Context semantics are aligned to what the hook's consumers already
expected: userRole defaults to formatUserRole("") instead of "", and
showSSOBanner is computed strictly from login_method (default false)
instead of defaulting to true. The context's unused public setters
(setToken, setUserID, setAccessToken, setPremiumUser, setShowSSOBanner)
are dropped from the exported surface; only setUserRole/setUserEmail have
consumers. Decoded-JWT fields keep their legacy `any` typing at the hook
boundary since ~25 call sites rely on it; tightening is a follow-up
The hook's tests now exercise the real provider + hook together with real
JWTs instead of mocking the decode away, which also covers the provider's
expiry and undecodable-token paths
This commit is contained in:
parent
6ae8a509f0
commit
3ece94bb7a
4 changed files with 89 additions and 147 deletions
|
|
@ -3,28 +3,20 @@ import React from "react";
|
|||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import useAuthorized from "./useAuthorized";
|
||||
|
||||
// Unmock useAuthorized to test the actual implementation
|
||||
vi.unmock("@/app/(dashboard)/hooks/useAuthorized");
|
||||
|
||||
const {
|
||||
replaceMock,
|
||||
clearTokenCookiesMock,
|
||||
getProxyBaseUrlMock,
|
||||
getUiConfigMock,
|
||||
decodeTokenMock,
|
||||
checkTokenValidityMock,
|
||||
buildLoginUrlWithReturnMock,
|
||||
} = vi.hoisted(() => ({
|
||||
replaceMock: vi.fn(),
|
||||
clearTokenCookiesMock: vi.fn(),
|
||||
getProxyBaseUrlMock: vi.fn(() => "http://proxy.example"),
|
||||
getUiConfigMock: vi.fn(),
|
||||
decodeTokenMock: vi.fn(),
|
||||
checkTokenValidityMock: vi.fn(),
|
||||
buildLoginUrlWithReturnMock: vi.fn((baseUrl: string) => baseUrl),
|
||||
}));
|
||||
const { replaceMock, clearTokenCookiesMock, getProxyBaseUrlMock, getUiConfigMock, buildLoginUrlWithReturnMock } =
|
||||
vi.hoisted(() => ({
|
||||
replaceMock: vi.fn(),
|
||||
clearTokenCookiesMock: vi.fn(),
|
||||
getProxyBaseUrlMock: vi.fn(() => "http://proxy.example"),
|
||||
getUiConfigMock: vi.fn(),
|
||||
buildLoginUrlWithReturnMock: vi.fn((baseUrl: string) => baseUrl),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
|
|
@ -49,15 +41,6 @@ vi.mock("@/utils/cookieUtils", async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("@/utils/jwtUtils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/utils/jwtUtils")>();
|
||||
return {
|
||||
...actual,
|
||||
decodeToken: decodeTokenMock,
|
||||
checkTokenValidity: checkTokenValidityMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/utils/returnUrlUtils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/utils/returnUrlUtils")>();
|
||||
return {
|
||||
|
|
@ -66,6 +49,7 @@ vi.mock("@/utils/returnUrlUtils", async (importOriginal) => {
|
|||
storeReturnUrl: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -78,7 +62,11 @@ const createQueryClient = () =>
|
|||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const queryClient = createQueryClient();
|
||||
return React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
return React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(AuthProvider, null, children),
|
||||
);
|
||||
};
|
||||
|
||||
const createJwt = (payload: Record<string, unknown>) => {
|
||||
|
|
@ -90,39 +78,37 @@ const clearCookie = () => {
|
|||
document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
};
|
||||
|
||||
const uiConfig = (overrides: Record<string, unknown> = {}) => ({
|
||||
server_root_path: "/",
|
||||
proxy_base_url: null,
|
||||
auto_redirect_to_sso: false,
|
||||
admin_ui_disabled: false,
|
||||
sso_configured: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const decodedPayload = {
|
||||
key: "api-key-123",
|
||||
user_id: "user-1",
|
||||
user_email: "user@example.com",
|
||||
user_role: "app_admin",
|
||||
premium_user: true,
|
||||
disabled_non_admin_personal_key_creation: false,
|
||||
login_method: "username_password",
|
||||
};
|
||||
|
||||
describe("useAuthorized", () => {
|
||||
afterEach(() => {
|
||||
replaceMock.mockReset();
|
||||
clearTokenCookiesMock.mockReset();
|
||||
getProxyBaseUrlMock.mockClear();
|
||||
getUiConfigMock.mockReset();
|
||||
decodeTokenMock.mockReset();
|
||||
checkTokenValidityMock.mockReset();
|
||||
buildLoginUrlWithReturnMock.mockClear();
|
||||
clearCookie();
|
||||
});
|
||||
|
||||
it("should decode the token and expose user details", async () => {
|
||||
getUiConfigMock.mockResolvedValue({
|
||||
server_root_path: "/",
|
||||
proxy_base_url: null,
|
||||
auto_redirect_to_sso: false,
|
||||
admin_ui_disabled: false,
|
||||
sso_configured: false,
|
||||
});
|
||||
|
||||
const decodedPayload = {
|
||||
key: "api-key-123",
|
||||
user_id: "user-1",
|
||||
user_email: "user@example.com",
|
||||
user_role: "app_admin",
|
||||
premium_user: true,
|
||||
disabled_non_admin_personal_key_creation: false,
|
||||
login_method: "username_password",
|
||||
};
|
||||
|
||||
decodeTokenMock.mockReturnValue(decodedPayload);
|
||||
checkTokenValidityMock.mockReturnValue(true);
|
||||
getUiConfigMock.mockResolvedValue(uiConfig());
|
||||
|
||||
const token = createJwt(decodedPayload);
|
||||
document.cookie = `token=${token}; path=/;`;
|
||||
|
|
@ -133,6 +119,7 @@ describe("useAuthorized", () => {
|
|||
expect(result.current.token).toBe(token);
|
||||
});
|
||||
|
||||
expect(result.current.isAuthorized).toBe(true);
|
||||
expect(result.current.accessToken).toBe("api-key-123");
|
||||
expect(result.current.userId).toBe("user-1");
|
||||
expect(result.current.userEmail).toBe("user@example.com");
|
||||
|
|
@ -144,52 +131,25 @@ describe("useAuthorized", () => {
|
|||
expect(clearTokenCookiesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should clear cookies and redirect on an invalid token", async () => {
|
||||
getUiConfigMock.mockResolvedValue({
|
||||
server_root_path: "/",
|
||||
proxy_base_url: null,
|
||||
auto_redirect_to_sso: false,
|
||||
admin_ui_disabled: false,
|
||||
sso_configured: false,
|
||||
});
|
||||
|
||||
decodeTokenMock.mockReturnValue(null);
|
||||
checkTokenValidityMock.mockReturnValue(false);
|
||||
it("should clear cookies and redirect on an undecodable token", async () => {
|
||||
getUiConfigMock.mockResolvedValue(uiConfig());
|
||||
|
||||
document.cookie = "token=invalid-token; path=/;";
|
||||
|
||||
const { result } = renderHook(() => useAuthorized(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clearTokenCookiesMock).toHaveBeenCalled();
|
||||
expect(replaceMock).toHaveBeenCalledWith("http://proxy.example/ui/login");
|
||||
});
|
||||
|
||||
expect(replaceMock).toHaveBeenCalledWith("http://proxy.example/ui/login");
|
||||
expect(clearTokenCookiesMock).toHaveBeenCalled();
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.accessToken).toBeNull();
|
||||
expect(result.current.userRole).toBe("Undefined Role");
|
||||
});
|
||||
|
||||
it("should redirect even with valid token if admin_ui_disabled is true", async () => {
|
||||
getUiConfigMock.mockResolvedValue({
|
||||
server_root_path: "/",
|
||||
proxy_base_url: null,
|
||||
auto_redirect_to_sso: false,
|
||||
admin_ui_disabled: true,
|
||||
sso_configured: false,
|
||||
});
|
||||
|
||||
const decodedPayload = {
|
||||
key: "api-key-123",
|
||||
user_id: "user-1",
|
||||
user_email: "user@example.com",
|
||||
user_role: "app_admin",
|
||||
premium_user: true,
|
||||
disabled_non_admin_personal_key_creation: false,
|
||||
login_method: "username_password",
|
||||
};
|
||||
|
||||
decodeTokenMock.mockReturnValue(decodedPayload);
|
||||
checkTokenValidityMock.mockReturnValue(true);
|
||||
getUiConfigMock.mockResolvedValue(uiConfig({ admin_ui_disabled: true }));
|
||||
|
||||
const token = createJwt(decodedPayload);
|
||||
document.cookie = `token=${token}; path=/;`;
|
||||
|
|
@ -200,22 +160,16 @@ describe("useAuthorized", () => {
|
|||
expect(replaceMock).toHaveBeenCalledWith("http://proxy.example/ui/login");
|
||||
});
|
||||
|
||||
expect(clearTokenCookiesMock).toHaveBeenCalled();
|
||||
expect(result.current.isAuthorized).toBe(false);
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.accessToken).toBe("api-key-123");
|
||||
expect(result.current.userId).toBe("user-1");
|
||||
expect(result.current.userEmail).toBe("user@example.com");
|
||||
});
|
||||
|
||||
it("should redirect when token is missing", async () => {
|
||||
getUiConfigMock.mockResolvedValue({
|
||||
server_root_path: "/",
|
||||
proxy_base_url: null,
|
||||
auto_redirect_to_sso: false,
|
||||
admin_ui_disabled: false,
|
||||
sso_configured: false,
|
||||
});
|
||||
|
||||
decodeTokenMock.mockReturnValue(null);
|
||||
checkTokenValidityMock.mockReturnValue(false);
|
||||
getUiConfigMock.mockResolvedValue(uiConfig());
|
||||
|
||||
// No token cookie set
|
||||
const { result } = renderHook(() => useAuthorized(), { wrapper });
|
||||
|
|
@ -229,25 +183,9 @@ describe("useAuthorized", () => {
|
|||
});
|
||||
|
||||
it("should clear cookies and redirect when token is expired", async () => {
|
||||
getUiConfigMock.mockResolvedValue({
|
||||
server_root_path: "/",
|
||||
proxy_base_url: null,
|
||||
auto_redirect_to_sso: false,
|
||||
admin_ui_disabled: false,
|
||||
sso_configured: false,
|
||||
});
|
||||
getUiConfigMock.mockResolvedValue(uiConfig());
|
||||
|
||||
const decodedPayload = {
|
||||
key: "api-key-123",
|
||||
user_id: "user-1",
|
||||
user_email: "user@example.com",
|
||||
user_role: "app_admin",
|
||||
};
|
||||
|
||||
decodeTokenMock.mockReturnValue(decodedPayload);
|
||||
checkTokenValidityMock.mockReturnValue(false);
|
||||
|
||||
const token = createJwt(decodedPayload);
|
||||
const token = createJwt({ ...decodedPayload, exp: Math.floor(Date.now() / 1000) - 60 });
|
||||
document.cookie = `token=${token}; path=/;`;
|
||||
|
||||
const { result } = renderHook(() => useAuthorized(), { wrapper });
|
||||
|
|
@ -257,6 +195,7 @@ describe("useAuthorized", () => {
|
|||
});
|
||||
|
||||
expect(replaceMock).toHaveBeenCalledWith("http://proxy.example/ui/login");
|
||||
expect(checkTokenValidityMock).toHaveBeenCalledWith(token);
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.accessToken).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,34 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
|
||||
import { checkTokenValidity, decodeToken } from "@/utils/jwtUtils";
|
||||
import { clearTokenCookies } from "@/utils/cookieUtils";
|
||||
import { buildLoginUrlWithReturn, storeReturnUrl } from "@/utils/returnUrlUtils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { formatUserRole } from "@/utils/roles";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUIConfig } from "./uiConfig/useUIConfig";
|
||||
|
||||
// Decoded-JWT fields keep the pre-consolidation `any` typing: the old hook returned
|
||||
// `any` here and ~25 call sites pass these where `string` is expected. Tightening to
|
||||
// the context's `string | null` is a follow-up that has to fix those call sites.
|
||||
type LegacyDecodedField = any;
|
||||
|
||||
/**
|
||||
* Policy hook for pages that require an authenticated user. Auth state itself
|
||||
* lives in AuthContext (single decode at the root); this hook layers on the
|
||||
* admin_ui_disabled check and the redirect-to-login side effect.
|
||||
*/
|
||||
const useAuthorized = () => {
|
||||
const router = useRouter();
|
||||
const { data: uiConfig, isLoading: isUIConfigLoading } = useUIConfig();
|
||||
const {
|
||||
authLoading,
|
||||
token,
|
||||
userID,
|
||||
userRole,
|
||||
userEmail,
|
||||
accessToken,
|
||||
premiumUser,
|
||||
disabledPersonalKeyCreation,
|
||||
showSSOBanner,
|
||||
} = useAuth();
|
||||
|
||||
const token = typeof document !== "undefined" ? getCookie("token") : null;
|
||||
const isLoading = authLoading || isUIConfigLoading;
|
||||
const isAuthorized = token !== null && !uiConfig?.admin_ui_disabled;
|
||||
|
||||
const decoded = useMemo(() => decodeToken(token), [token]);
|
||||
const isTokenValid = useMemo(() => checkTokenValidity(token), [token]);
|
||||
const isLoading = isUIConfigLoading;
|
||||
const isAuthorized = isTokenValid && !uiConfig?.admin_ui_disabled;
|
||||
|
||||
// Helper function to redirect to login while preserving the current URL
|
||||
const redirectToLogin = useCallback(() => {
|
||||
storeReturnUrl();
|
||||
const baseLoginUrl = `${getProxyBaseUrl()}/ui/login`;
|
||||
const loginUrlWithReturn = buildLoginUrlWithReturn(baseLoginUrl);
|
||||
router.replace(loginUrlWithReturn);
|
||||
router.replace(buildLoginUrlWithReturn(baseLoginUrl));
|
||||
}, [router]);
|
||||
|
||||
// Single useEffect for all redirect logic
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
|
|
@ -44,13 +57,13 @@ const useAuthorized = () => {
|
|||
isLoading,
|
||||
isAuthorized,
|
||||
token: isAuthorized ? token : null,
|
||||
accessToken: decoded?.key ?? null,
|
||||
userId: decoded?.user_id ?? null,
|
||||
userEmail: decoded?.user_email ?? null,
|
||||
userRole: formatUserRole(decoded?.user_role),
|
||||
premiumUser: decoded?.premium_user ?? null,
|
||||
disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null,
|
||||
showSSOBanner: decoded?.login_method === "username_password",
|
||||
accessToken: accessToken as LegacyDecodedField,
|
||||
userId: userID as LegacyDecodedField,
|
||||
userEmail: userEmail as LegacyDecodedField,
|
||||
userRole,
|
||||
premiumUser: premiumUser as LegacyDecodedField,
|
||||
disabledPersonalKeyCreation: disabledPersonalKeyCreation as LegacyDecodedField,
|
||||
showSSOBanner,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -25,13 +25,8 @@ type AuthContextValue = {
|
|||
disabledPersonalKeyCreation: boolean;
|
||||
showSSOBanner: 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);
|
||||
|
|
@ -40,12 +35,12 @@ 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 [userRole, setUserRole] = useState(formatUserRole(""));
|
||||
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 [showSSOBanner, setShowSSOBanner] = useState(false);
|
||||
|
||||
// Load runtime UI config (populates proxyBaseUrl etc.) before clearing
|
||||
// authLoading, so any consumer that builds proxy-rooted URLs from authLoading=false
|
||||
|
|
@ -105,6 +100,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
|
||||
setAccessToken(decoded.key);
|
||||
setDisabledPersonalKeyCreation(decoded.disabled_non_admin_personal_key_creation);
|
||||
setShowSSOBanner(decoded.login_method === "username_password");
|
||||
|
||||
if (decoded.user_role) {
|
||||
setUserRole(formatUserRole(decoded.user_role));
|
||||
|
|
@ -112,9 +108,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
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);
|
||||
}
|
||||
|
|
@ -136,13 +129,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
premiumUser,
|
||||
disabledPersonalKeyCreation,
|
||||
showSSOBanner,
|
||||
setToken,
|
||||
setUserID,
|
||||
setUserRole,
|
||||
setUserEmail,
|
||||
setAccessToken,
|
||||
setPremiumUser,
|
||||
setShowSSOBanner,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -138,6 +138,8 @@ vi.mock("@tremor/react", async (importOriginal) => {
|
|||
// Global mock for useAuthorized hook to avoid repeating the same mock in every test file
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => ({
|
||||
isLoading: false,
|
||||
isAuthorized: true,
|
||||
token: "123",
|
||||
accessToken: "123",
|
||||
userId: "user-1",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue