mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-03 23:00:18 +01:00
🧭 fix: Restore Post-Auth Navigation After Silent Token Refresh (#12025)
* chore: Update import path for StartupLayout in tests * 🔒 fix: Enhance AuthContext to handle stored redirects during user authentication - Added SESSION_KEY import and logic to retrieve and clear stored redirect URLs from sessionStorage. - Updated user context state to include redirect URL, defaulting to '/c/new' if none is found. * 🧪 test: Add tests for silentRefresh post-login redirect handling in AuthContext - Introduced new test suite to validate navigation behavior after successful token refresh. - Implemented tests for stored sessionStorage redirects, default navigation, and prevention of unsafe redirects. - Enhanced logout error handling tests to ensure proper state clearing without external redirects. * 🔒 fix: Update AuthContext to handle unsafe stored redirects during authentication - Removed conditional check for stored redirect in sessionStorage, ensuring it is always cleared. - Enhanced logic to validate stored redirects, defaulting to '/c/new' for unsafe URLs. - Updated tests to verify navigation behavior for both safe and unsafe redirects after token refresh.
This commit is contained in:
parent
9b3152807b
commit
7c71875da3
3 changed files with 148 additions and 3 deletions
|
|
@ -20,7 +20,7 @@ import {
|
|||
useLogoutUserMutation,
|
||||
useRefreshTokenMutation,
|
||||
} from '~/data-provider';
|
||||
import { isSafeRedirect, buildLoginRedirectUrl, getPostLoginRedirect } from '~/utils';
|
||||
import { SESSION_KEY, isSafeRedirect, buildLoginRedirectUrl, getPostLoginRedirect } from '~/utils';
|
||||
import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common';
|
||||
import useTimeout from './useTimeout';
|
||||
import store from '~/store';
|
||||
|
|
@ -166,7 +166,14 @@ const AuthContextProvider = ({
|
|||
}
|
||||
const { user, token = '' } = data ?? {};
|
||||
if (token) {
|
||||
setUserContext({ token, isAuthenticated: true, user });
|
||||
const storedRedirect = sessionStorage.getItem(SESSION_KEY);
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
setUserContext({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
redirect: storedRedirect && isSafeRedirect(storedRedirect) ? storedRedirect : '/c/new',
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.log('Token is not present. User is not authenticated.');
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { MemoryRouter } from 'react-router-dom';
|
|||
import type { TAuthConfig } from '~/common';
|
||||
|
||||
import { AuthContextProvider, useAuthContext } from '../AuthContext';
|
||||
import { SESSION_KEY } from '~/utils';
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
|
|
@ -274,6 +275,143 @@ describe('AuthContextProvider — logout onSuccess/onError handling', () => {
|
|||
expect(window.location.replace).toHaveBeenCalled();
|
||||
expect(mockRefreshMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthContextProvider — silentRefresh post-login redirect', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('navigates to stored sessionStorage redirect after successful token refresh', () => {
|
||||
jest.useFakeTimers();
|
||||
sessionStorage.setItem(SESSION_KEY, '/c/new?endpoint=bedrock&model=claude-sonnet-4-6');
|
||||
|
||||
renderProviderLive();
|
||||
|
||||
expect(mockRefreshMutate).toHaveBeenCalledTimes(1);
|
||||
const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [
|
||||
unknown,
|
||||
{ onSuccess: (data: unknown) => void },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
refreshOptions.onSuccess({ user: { id: '1', role: 'USER' }, token: 'new-token' });
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/c/new?endpoint=bedrock&model=claude-sonnet-4-6', {
|
||||
replace: true,
|
||||
});
|
||||
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('navigates to /c/new when no stored redirect exists', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
renderProviderLive();
|
||||
|
||||
expect(mockRefreshMutate).toHaveBeenCalledTimes(1);
|
||||
const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [
|
||||
unknown,
|
||||
{ onSuccess: (data: unknown) => void },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
refreshOptions.onSuccess({ user: { id: '1', role: 'USER' }, token: 'new-token' });
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/c/new', { replace: true });
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not re-trigger silentRefresh after successful redirect', () => {
|
||||
jest.useFakeTimers();
|
||||
sessionStorage.setItem(SESSION_KEY, '/c/abc?endpoint=bedrock');
|
||||
|
||||
renderProviderLive();
|
||||
|
||||
expect(mockRefreshMutate).toHaveBeenCalledTimes(1);
|
||||
const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [
|
||||
unknown,
|
||||
{ onSuccess: (data: unknown) => void },
|
||||
];
|
||||
mockRefreshMutate.mockClear();
|
||||
|
||||
act(() => {
|
||||
refreshOptions.onSuccess({ user: { id: '1', role: 'USER' }, token: 'new-token' });
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/c/abc?endpoint=bedrock', { replace: true });
|
||||
expect(mockRefreshMutate).not.toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('falls back to /c/new for unsafe stored redirect', () => {
|
||||
jest.useFakeTimers();
|
||||
sessionStorage.setItem(SESSION_KEY, 'https://evil.com/steal');
|
||||
|
||||
renderProviderLive();
|
||||
|
||||
expect(mockRefreshMutate).toHaveBeenCalledTimes(1);
|
||||
const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [
|
||||
unknown,
|
||||
{ onSuccess: (data: unknown) => void },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
refreshOptions.onSuccess({ user: { id: '1', role: 'USER' }, token: 'new-token' });
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/c/new', { replace: true });
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('https://evil.com/steal', expect.anything());
|
||||
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthContextProvider — logout error handling', () => {
|
||||
const originalLocation = window.location;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
...originalLocation,
|
||||
pathname: '/c/some-chat',
|
||||
search: '',
|
||||
hash: '',
|
||||
replace: jest.fn(),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears auth state on logout error without external redirect', () => {
|
||||
jest.useFakeTimers();
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import StartupLayout from '~/routes/Layouts/Startup';
|
||||
import { SESSION_KEY } from '~/utils';
|
||||
import StartupLayout from '../Layouts/Startup';
|
||||
|
||||
if (typeof Request === 'undefined') {
|
||||
global.Request = class Request {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue