mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 06:17:21 +02:00
* fix: auth-aware config caching for fresh sessions - Add auth state to startup config query key via shared `startupConfigKey` builder so login (unauthenticated) and chat (authenticated) configs are cached independently - Disable queries during login onMutate to prevent premature unauthenticated refetches after cache clear - Re-enable queries in setUserContext only after setTokenHeader runs, with positive-only guard to avoid redundant disable on logout - Update all getQueryData call sites to use the shared key builder - Fall back to getConfigDefaults().interface in useEndpoints, hoisted to module-level constant to avoid per-render recomputation * fix: address review findings for auth-aware config caching - Move defaultInterface const after all imports in ModelSelector.tsx - Remove dead QueryKeys import, use import type for TStartupConfig in ImportConversations.tsx - Spread real exports in useQueryParams.spec.ts mock to preserve startupConfigKey, fixing TypeError in all 6 tests * chore: import order * fix: re-enable queries on login failure When login fails, onSuccess never fires so queriesEnabled stays false. Re-enable in onError so the login page can re-fetch config (needed for LDAP username validation and social login options).
290 lines
8.8 KiB
TypeScript
290 lines
8.8 KiB
TypeScript
import {
|
|
useRef,
|
|
useMemo,
|
|
useState,
|
|
useEffect,
|
|
useContext,
|
|
useCallback,
|
|
createContext,
|
|
} from 'react';
|
|
import { debounce } from 'lodash';
|
|
import { useRecoilState, useSetRecoilState } from 'recoil';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
apiBaseUrl,
|
|
SystemRoles,
|
|
setTokenHeader,
|
|
buildLoginRedirectUrl,
|
|
} from 'librechat-data-provider';
|
|
import type * as t from 'librechat-data-provider';
|
|
import type { ReactNode } from 'react';
|
|
import {
|
|
useGetRole,
|
|
useGetUserQuery,
|
|
useLoginUserMutation,
|
|
useLogoutUserMutation,
|
|
useRefreshTokenMutation,
|
|
} from '~/data-provider';
|
|
import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common';
|
|
import { SESSION_KEY, isSafeRedirect, getPostLoginRedirect } from '~/utils';
|
|
import useTimeout from './useTimeout';
|
|
import store from '~/store';
|
|
|
|
const AuthContext = createContext<TAuthContext | undefined>(undefined);
|
|
|
|
const AuthContextProvider = ({
|
|
authConfig,
|
|
children,
|
|
}: {
|
|
authConfig?: TAuthConfig;
|
|
children: ReactNode;
|
|
}) => {
|
|
const isExternalRedirectRef = useRef(false);
|
|
const [user, setUser] = useRecoilState(store.user);
|
|
const logoutRedirectRef = useRef<string | undefined>(undefined);
|
|
const [token, setToken] = useState<string | undefined>(undefined);
|
|
const [error, setError] = useState<string | undefined>(undefined);
|
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
|
const setQueriesEnabled = useSetRecoilState<boolean>(store.queriesEnabled);
|
|
|
|
const { data: userRole = null } = useGetRole(SystemRoles.USER, {
|
|
enabled: !!(isAuthenticated && (user?.role ?? '')),
|
|
});
|
|
const { data: adminRole = null } = useGetRole(SystemRoles.ADMIN, {
|
|
enabled: !!(isAuthenticated && user?.role === SystemRoles.ADMIN),
|
|
});
|
|
|
|
const navigate = useNavigate();
|
|
|
|
const setUserContext = useMemo(
|
|
() =>
|
|
debounce((userContext: TUserContext) => {
|
|
const { token, isAuthenticated, user, redirect } = userContext;
|
|
setUser(user);
|
|
setToken(token);
|
|
setTokenHeader(token);
|
|
setIsAuthenticated(isAuthenticated);
|
|
if (isAuthenticated) {
|
|
setQueriesEnabled(true);
|
|
}
|
|
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
const postLoginRedirect = getPostLoginRedirect(searchParams);
|
|
|
|
const logoutRedirect = logoutRedirectRef.current;
|
|
logoutRedirectRef.current = undefined;
|
|
|
|
const finalRedirect =
|
|
logoutRedirect ??
|
|
postLoginRedirect ??
|
|
(redirect && isSafeRedirect(redirect) ? redirect : null);
|
|
|
|
if (finalRedirect == null) {
|
|
return;
|
|
}
|
|
|
|
navigate(finalRedirect, { replace: true });
|
|
}, 50),
|
|
[navigate, setUser, setQueriesEnabled],
|
|
);
|
|
const doSetError = useTimeout({ callback: (error) => setError(error as string | undefined) });
|
|
|
|
const loginUser = useLoginUserMutation({
|
|
onSuccess: (data: t.TLoginResponse) => {
|
|
const { user, token, twoFAPending, tempToken } = data;
|
|
if (twoFAPending) {
|
|
navigate(`/login/2fa?tempToken=${tempToken}`, { replace: true });
|
|
return;
|
|
}
|
|
setError(undefined);
|
|
setUserContext({ token, isAuthenticated: true, user, redirect: '/c/new' });
|
|
},
|
|
onError: (error: TResError | unknown) => {
|
|
const resError = error as TResError;
|
|
doSetError(resError.message);
|
|
// Preserve a valid redirect_to across login failures so the deep link survives retries.
|
|
// Cannot use buildLoginRedirectUrl() here — it reads the current pathname (already /login)
|
|
// and would return plain /login, dropping the redirect_to destination.
|
|
const redirectTo = new URLSearchParams(window.location.search).get('redirect_to');
|
|
const loginPath =
|
|
redirectTo && isSafeRedirect(redirectTo)
|
|
? `/login?redirect_to=${encodeURIComponent(redirectTo)}`
|
|
: '/login';
|
|
navigate(loginPath, { replace: true });
|
|
},
|
|
});
|
|
const logoutUser = useLogoutUserMutation({
|
|
onSuccess: (data) => {
|
|
if (data.redirect) {
|
|
/** data.redirect is the IdP's end_session_endpoint URL — an absolute URL generated
|
|
* server-side from trusted IdP metadata (not user input), so isSafeRedirect is bypassed.
|
|
* setUserContext is debounced (50ms) and won't fire before page unload, so clear the
|
|
* axios Authorization header synchronously to prevent in-flight requests. */
|
|
isExternalRedirectRef.current = true;
|
|
setTokenHeader(undefined);
|
|
window.location.replace(data.redirect);
|
|
return;
|
|
}
|
|
setUserContext({
|
|
token: undefined,
|
|
isAuthenticated: false,
|
|
user: undefined,
|
|
redirect: '/login',
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
doSetError((error as Error).message);
|
|
setUserContext({
|
|
token: undefined,
|
|
isAuthenticated: false,
|
|
user: undefined,
|
|
redirect: '/login',
|
|
});
|
|
},
|
|
});
|
|
const refreshToken = useRefreshTokenMutation();
|
|
|
|
const logout = useCallback(
|
|
(redirect?: string) => {
|
|
if (redirect) {
|
|
logoutRedirectRef.current = redirect;
|
|
}
|
|
logoutUser.mutate(undefined);
|
|
},
|
|
[logoutUser],
|
|
);
|
|
|
|
const userQuery = useGetUserQuery({ enabled: !!(token ?? '') });
|
|
|
|
const login = (data: t.TLoginUser) => {
|
|
loginUser.mutate(data);
|
|
};
|
|
|
|
const silentRefresh = useCallback(() => {
|
|
if (authConfig?.test === true) {
|
|
console.log('Test mode. Skipping silent refresh.');
|
|
return;
|
|
}
|
|
if (isExternalRedirectRef.current) {
|
|
return;
|
|
}
|
|
refreshToken.mutate(undefined, {
|
|
onSuccess: (data: t.TRefreshTokenResponse | undefined) => {
|
|
if (isExternalRedirectRef.current) {
|
|
return;
|
|
}
|
|
const { user, token = '' } = data ?? {};
|
|
if (token) {
|
|
const storedRedirect = sessionStorage.getItem(SESSION_KEY);
|
|
sessionStorage.removeItem(SESSION_KEY);
|
|
const baseUrl = apiBaseUrl();
|
|
const rawPath = window.location.pathname;
|
|
const strippedPath =
|
|
baseUrl && (rawPath === baseUrl || rawPath.startsWith(baseUrl + '/'))
|
|
? rawPath.slice(baseUrl.length) || '/'
|
|
: rawPath;
|
|
const currentUrl = `${strippedPath}${window.location.search}`;
|
|
const fallbackRedirect = isSafeRedirect(currentUrl) ? currentUrl : '/c/new';
|
|
const redirect =
|
|
storedRedirect && isSafeRedirect(storedRedirect) ? storedRedirect : fallbackRedirect;
|
|
setUserContext({ user, token, isAuthenticated: true, redirect });
|
|
return;
|
|
}
|
|
console.log('Token is not present. User is not authenticated.');
|
|
if (authConfig?.test === true) {
|
|
return;
|
|
}
|
|
navigate(buildLoginRedirectUrl());
|
|
},
|
|
onError: (error) => {
|
|
if (isExternalRedirectRef.current) {
|
|
return;
|
|
}
|
|
console.log('refreshToken mutation error:', error);
|
|
if (authConfig?.test === true) {
|
|
return;
|
|
}
|
|
navigate(buildLoginRedirectUrl());
|
|
},
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- deps are stable at mount; adding refreshToken causes infinite re-fire
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isExternalRedirectRef.current) {
|
|
return;
|
|
}
|
|
if (userQuery.data) {
|
|
setUser(userQuery.data);
|
|
} else if (userQuery.isError) {
|
|
doSetError((userQuery.error as Error).message);
|
|
navigate(buildLoginRedirectUrl(), { replace: true });
|
|
}
|
|
if (error != null && error && isAuthenticated) {
|
|
doSetError(undefined);
|
|
}
|
|
if (token == null || !token || !isAuthenticated) {
|
|
silentRefresh();
|
|
}
|
|
}, [
|
|
token,
|
|
isAuthenticated,
|
|
userQuery.data,
|
|
userQuery.isError,
|
|
userQuery.error,
|
|
error,
|
|
setUser,
|
|
navigate,
|
|
silentRefresh,
|
|
setUserContext,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const handleTokenUpdate = (event: CustomEvent<string>) => {
|
|
console.log('tokenUpdated event received event');
|
|
setUserContext({
|
|
token: event.detail,
|
|
isAuthenticated: true,
|
|
user: user,
|
|
});
|
|
};
|
|
|
|
window.addEventListener('tokenUpdated', handleTokenUpdate as EventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener('tokenUpdated', handleTokenUpdate as EventListener);
|
|
};
|
|
}, [setUserContext, user]);
|
|
|
|
const memoedValue = useMemo(
|
|
() => ({
|
|
user,
|
|
token,
|
|
error,
|
|
login,
|
|
logout,
|
|
setError,
|
|
roles: {
|
|
[SystemRoles.USER]: userRole,
|
|
[SystemRoles.ADMIN]: adminRole,
|
|
},
|
|
isAuthenticated,
|
|
}),
|
|
|
|
[user, error, isAuthenticated, token, userRole, adminRole],
|
|
);
|
|
|
|
return <AuthContext.Provider value={memoedValue}>{children}</AuthContext.Provider>;
|
|
};
|
|
|
|
const useAuthContext = () => {
|
|
const context = useContext(AuthContext);
|
|
|
|
if (context === undefined) {
|
|
throw new Error('useAuthContext should be used inside AuthProvider');
|
|
}
|
|
|
|
return context;
|
|
};
|
|
|
|
export { AuthContextProvider, useAuthContext, AuthContext };
|