LibreChat/client/src/hooks/AuthContext.tsx
Danny Avila 7b368916d5
🔑 fix: Auth-Aware Startup Config Caching for Fresh Sessions (#12505)
* 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).
2026-04-01 17:20:39 -04:00

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 };