mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-29 03:47:18 +02:00
📍 feat: Preserve Deep Link Destinations Through the Auth Redirect Flow (#10275)
* added support for url query param persistance * refactor: authentication redirect handling - Introduced utility functions for managing login redirects, including `persistRedirectToSession`, `buildLoginRedirectUrl`, and `getPostLoginRedirect`. - Updated `Login` and `AuthContextProvider` components to utilize these utilities for improved redirect logic. - Refactored `useAuthRedirect` to streamline navigation to the login page while preserving intended destinations. - Cleaned up the `StartupLayout` to remove unnecessary redirect handling, ensuring a more straightforward navigation flow. - Added a new `redirect.ts` file to encapsulate redirect-related logic, enhancing code organization and maintainability. * fix: enhance safe redirect validation logic - Updated the `isSafeRedirect` function to improve validation of redirect URLs. - Ensured that only safe relative paths are accepted, specifically excluding paths that lead to the login page. - Refactored the logic to streamline the checks for valid redirect targets. * test: add unit tests for redirect utility functions - Introduced comprehensive tests for `isSafeRedirect`, `buildLoginRedirectUrl`, `getPostLoginRedirect`, and `persistRedirectToSession` functions. - Validated various scenarios including safe and unsafe redirects, URL encoding, and session storage behavior. - Enhanced test coverage to ensure robust handling of redirect logic and prevent potential security issues. * chore: streamline authentication and redirect handling - Removed unused `useLocation` import from `AuthContextProvider` and replaced its usage with `window.location` for better clarity. - Updated `StartupLayout` to check for pending redirects before navigating to the new chat page, ensuring users are directed appropriately based on their session state. - Enhanced unit tests for `useAuthRedirect` to verify correct handling of redirect parameters, including encoding of the current path and query parameters. * test: add unit tests for StartupLayout redirect behavior - Introduced a new test suite for the StartupLayout component to validate redirect logic based on authentication status and session storage. - Implemented tests to ensure correct navigation to the new conversation page when authenticated without pending redirects, and to prevent navigation when a redirect URL parameter or session storage redirect is present. - Enhanced coverage for scenarios where users are not authenticated, ensuring robust handling of redirect conditions. --------- Co-authored-by: Vamsi Konakanchi <vamsi.k@trackmind.com> Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
a0f9782e60
commit
e978a934fc
9 changed files with 529 additions and 44 deletions
197
client/src/utils/__tests__/redirect.test.ts
Normal file
197
client/src/utils/__tests__/redirect.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import {
|
||||
isSafeRedirect,
|
||||
buildLoginRedirectUrl,
|
||||
getPostLoginRedirect,
|
||||
persistRedirectToSession,
|
||||
SESSION_KEY,
|
||||
} from '../redirect';
|
||||
|
||||
describe('isSafeRedirect', () => {
|
||||
it('accepts a simple relative path', () => {
|
||||
expect(isSafeRedirect('/c/new')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a path with query params and hash', () => {
|
||||
expect(isSafeRedirect('/c/new?q=hello&submit=true#section')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a nested path', () => {
|
||||
expect(isSafeRedirect('/dashboard/settings/profile')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an absolute http URL', () => {
|
||||
expect(isSafeRedirect('https://evil.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an absolute http URL with path', () => {
|
||||
expect(isSafeRedirect('https://evil.com/phishing')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a protocol-relative URL', () => {
|
||||
expect(isSafeRedirect('//evil.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a bare domain', () => {
|
||||
expect(isSafeRedirect('evil.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty string', () => {
|
||||
expect(isSafeRedirect('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects /login to prevent redirect loops', () => {
|
||||
expect(isSafeRedirect('/login')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects /login with query params', () => {
|
||||
expect(isSafeRedirect('/login?redirect_to=/c/new')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects /login sub-paths', () => {
|
||||
expect(isSafeRedirect('/login/2fa')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects /login with hash', () => {
|
||||
expect(isSafeRedirect('/login#foo')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the root path', () => {
|
||||
expect(isSafeRedirect('/')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLoginRedirectUrl', () => {
|
||||
const originalLocation = window.location;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/c/abc123', search: '?model=gpt-4', hash: '#msg-5' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', { value: originalLocation, writable: true });
|
||||
});
|
||||
|
||||
it('builds a login URL from explicit args', () => {
|
||||
const result = buildLoginRedirectUrl('/c/new', '?q=hello', '');
|
||||
expect(result).toBe('/login?redirect_to=%2Fc%2Fnew%3Fq%3Dhello');
|
||||
});
|
||||
|
||||
it('encodes complex paths with query and hash', () => {
|
||||
const result = buildLoginRedirectUrl('/c/new', '?q=hello&submit=true', '#section');
|
||||
expect(result).toContain('redirect_to=');
|
||||
const encoded = result.split('redirect_to=')[1];
|
||||
expect(decodeURIComponent(encoded)).toBe('/c/new?q=hello&submit=true#section');
|
||||
});
|
||||
|
||||
it('falls back to window.location when no args provided', () => {
|
||||
const result = buildLoginRedirectUrl();
|
||||
const encoded = result.split('redirect_to=')[1];
|
||||
expect(decodeURIComponent(encoded)).toBe('/c/abc123?model=gpt-4#msg-5');
|
||||
});
|
||||
|
||||
it('falls back to "/" when all location parts are empty', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '', search: '', hash: '' },
|
||||
writable: true,
|
||||
});
|
||||
const result = buildLoginRedirectUrl();
|
||||
expect(result).toBe('/login?redirect_to=%2F');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPostLoginRedirect', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('returns the redirect_to param when valid', () => {
|
||||
const params = new URLSearchParams('redirect_to=%2Fc%2Fnew');
|
||||
expect(getPostLoginRedirect(params)).toBe('/c/new');
|
||||
});
|
||||
|
||||
it('falls back to sessionStorage when no URL param', () => {
|
||||
sessionStorage.setItem(SESSION_KEY, '/c/abc123');
|
||||
const params = new URLSearchParams();
|
||||
expect(getPostLoginRedirect(params)).toBe('/c/abc123');
|
||||
});
|
||||
|
||||
it('prefers URL param over sessionStorage', () => {
|
||||
sessionStorage.setItem(SESSION_KEY, '/c/old');
|
||||
const params = new URLSearchParams('redirect_to=%2Fc%2Fnew');
|
||||
expect(getPostLoginRedirect(params)).toBe('/c/new');
|
||||
});
|
||||
|
||||
it('clears sessionStorage after reading', () => {
|
||||
sessionStorage.setItem(SESSION_KEY, '/c/abc123');
|
||||
const params = new URLSearchParams();
|
||||
getPostLoginRedirect(params);
|
||||
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no redirect source exists', () => {
|
||||
const params = new URLSearchParams();
|
||||
expect(getPostLoginRedirect(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an absolute URL from params', () => {
|
||||
const params = new URLSearchParams('redirect_to=https%3A%2F%2Fevil.com');
|
||||
expect(getPostLoginRedirect(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a protocol-relative URL from params', () => {
|
||||
const params = new URLSearchParams('redirect_to=%2F%2Fevil.com');
|
||||
expect(getPostLoginRedirect(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an absolute URL from sessionStorage', () => {
|
||||
sessionStorage.setItem(SESSION_KEY, 'https://evil.com');
|
||||
const params = new URLSearchParams();
|
||||
expect(getPostLoginRedirect(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects /login redirect to prevent loops', () => {
|
||||
const params = new URLSearchParams('redirect_to=%2Flogin');
|
||||
expect(getPostLoginRedirect(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects /login sub-path redirect', () => {
|
||||
const params = new URLSearchParams('redirect_to=%2Flogin%2F2fa');
|
||||
expect(getPostLoginRedirect(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('still clears sessionStorage even when target is unsafe', () => {
|
||||
sessionStorage.setItem(SESSION_KEY, 'https://evil.com');
|
||||
const params = new URLSearchParams();
|
||||
getPostLoginRedirect(params);
|
||||
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistRedirectToSession', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('stores a valid relative path', () => {
|
||||
persistRedirectToSession('/c/new?q=hello');
|
||||
expect(sessionStorage.getItem(SESSION_KEY)).toBe('/c/new?q=hello');
|
||||
});
|
||||
|
||||
it('rejects an absolute URL', () => {
|
||||
persistRedirectToSession('https://evil.com');
|
||||
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a protocol-relative URL', () => {
|
||||
persistRedirectToSession('//evil.com');
|
||||
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects /login paths', () => {
|
||||
persistRedirectToSession('/login?redirect_to=/c/new');
|
||||
expect(sessionStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue