LibreChat/client/src/hooks/__tests__/AuthContext.spec.tsx
Danny Avila 0568f1c1eb
🪃 fix: Prevent Recursive Login Redirect Loop (#11964)
* fix: Prevent recursive login redirect loop

    buildLoginRedirectUrl() would blindly encode the current URL into a
    redirect_to param even when already on /login, causing infinite nesting
    (/login?redirect_to=%2Flogin%3Fredirect_to%3D...). Guard at source so
    it returns plain /login when pathname starts with /login.

    Also validates redirect_to in the login error handler with isSafeRedirect
    to close an open-redirect vector, and removes a redundant /login guard
    from useAuthRedirect now handled by the centralized check.

* 🔀 fix: Handle basename-prefixed login paths and remove double URL decoding

    buildLoginRedirectUrl now uses isLoginPath() which matches /login,
    /librechat/login, and /login/2fa — covering subdirectory deployments
    where window.location.pathname includes the basename prefix.

    Remove redundant decodeURIComponent calls on URLSearchParams.get()
    results (which already returns decoded values) in getPostLoginRedirect,
    Login.tsx, and AuthContext login error handler. The extra decode could
    throw URIError on inputs containing literal percent signs.

* 🔀 fix: Tighten login path matching and add onError redirect tests

    Replace overbroad `endsWith('/login')` with a single regex
    `/(^|\/)login(\/|$)/` that matches `/login` only as a full path
    segment. Unifies `isSafeRedirect` and `buildLoginRedirectUrl` to use
    the same `LOGIN_PATH_RE` constant — no more divergent definitions.

    Add tests for the AuthContext onError redirect_to preservation logic
    (valid path preserved, open-redirect blocked, /login loop blocked),
    and a false-positive guard proving `/c/loginhistory` is not matched.

    Update JSDoc on `buildLoginRedirectUrl` to document the plain `/login`
    early-return, and add explanatory comment in AuthContext `onError`
    for why `buildLoginRedirectUrl()` cannot be used there.

* test: Add unit tests for AuthContextProvider login error handling

    Introduced a new test suite for AuthContextProvider to validate the handling of login errors and the preservation of redirect parameters. The tests cover various scenarios including valid redirect preservation, open-redirect prevention, and recursive redirect prevention. This enhances the robustness of the authentication flow and ensures proper navigation behavior during login failures.
2026-02-26 16:10:14 -05:00

174 lines
4.9 KiB
TypeScript

import React from 'react';
import { render, act } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { TAuthConfig } from '~/common';
import { AuthContextProvider, useAuthContext } from '../AuthContext';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
jest.mock('librechat-data-provider', () => ({
...jest.requireActual('librechat-data-provider'),
setTokenHeader: jest.fn(),
}));
let mockCapturedLoginOptions: {
onSuccess: (...args: unknown[]) => void;
onError: (...args: unknown[]) => void;
};
jest.mock('~/data-provider', () => ({
useLoginUserMutation: jest.fn(
(options: {
onSuccess: (...args: unknown[]) => void;
onError: (...args: unknown[]) => void;
}) => {
mockCapturedLoginOptions = options;
return { mutate: jest.fn() };
},
),
useLogoutUserMutation: jest.fn(() => ({ mutate: jest.fn() })),
useRefreshTokenMutation: jest.fn(() => ({ mutate: jest.fn() })),
useGetUserQuery: jest.fn(() => ({
data: undefined,
isError: false,
error: null,
})),
useGetRole: jest.fn(() => ({ data: null })),
}));
const authConfig: TAuthConfig = { loginRedirect: '/login', test: true };
function TestConsumer() {
const ctx = useAuthContext();
return <div data-testid="consumer" data-authenticated={ctx.isAuthenticated} />;
}
function renderProvider() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<MemoryRouter>
<AuthContextProvider authConfig={authConfig}>
<TestConsumer />
</AuthContextProvider>
</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>,
);
}
describe('AuthContextProvider — login onError redirect handling', () => {
const originalLocation = window.location;
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(window, 'location', {
value: { ...originalLocation, pathname: '/login', search: '', hash: '' },
writable: true,
configurable: true,
});
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
configurable: true,
});
});
it('preserves a valid redirect_to param across login failure', () => {
Object.defineProperty(window, 'location', {
value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc123', hash: '' },
writable: true,
configurable: true,
});
renderProvider();
act(() => {
mockCapturedLoginOptions.onError({ message: 'Invalid credentials' });
});
expect(mockNavigate).toHaveBeenCalledWith('/login?redirect_to=%2Fc%2Fabc123', {
replace: true,
});
});
it('drops redirect_to when it contains an absolute URL (open-redirect prevention)', () => {
Object.defineProperty(window, 'location', {
value: { pathname: '/login', search: '?redirect_to=https%3A%2F%2Fevil.com', hash: '' },
writable: true,
configurable: true,
});
renderProvider();
act(() => {
mockCapturedLoginOptions.onError({ message: 'Invalid credentials' });
});
expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
});
it('drops redirect_to when it points to /login (recursive redirect prevention)', () => {
Object.defineProperty(window, 'location', {
value: { pathname: '/login', search: '?redirect_to=%2Flogin', hash: '' },
writable: true,
configurable: true,
});
renderProvider();
act(() => {
mockCapturedLoginOptions.onError({ message: 'Invalid credentials' });
});
expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
});
it('navigates to plain /login when no redirect_to param exists', () => {
renderProvider();
act(() => {
mockCapturedLoginOptions.onError({ message: 'Server error' });
});
expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
});
it('preserves redirect_to with query params and hash', () => {
const target = '/c/abc123?model=gpt-4#section';
Object.defineProperty(window, 'location', {
value: {
pathname: '/login',
search: `?redirect_to=${encodeURIComponent(target)}`,
hash: '',
},
writable: true,
configurable: true,
});
renderProvider();
act(() => {
mockCapturedLoginOptions.onError({ message: 'Invalid credentials' });
});
const navigatedUrl = mockNavigate.mock.calls[0][0] as string;
const params = new URLSearchParams(navigatedUrl.split('?')[1]);
expect(decodeURIComponent(params.get('redirect_to')!)).toBe(target);
});
});