mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fix: preserve URL hash and OIDC redirect target through login flow
- Include location.hash in redirect param at all three producer sites (ProtectedRoute, axios 401 interceptor, OAuthAuthorizePage) so hash fragments survive the login bounce - Stash redirectTarget in sessionStorage before any OIDC provider redirect and restore it after the code exchange, since the IdP strips the original ?redirect= param during the roundtrip - Clear sessionStorage on OIDC error to avoid stale state - Add tests covering sessionStorage stash on mount, navigate to saved redirect after OIDC exchange, fallback to /dashboard, and cleanup on error
This commit is contained in:
+1
-1
@@ -58,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ apiClient.interceptors.response.use(
|
|||||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||||
const { pathname } = window.location
|
const { pathname } = window.location
|
||||||
if (!isAuthPublicPath(pathname)) {
|
if (!isAuthPublicPath(pathname)) {
|
||||||
const currentPath = pathname + window.location.search
|
const currentPath = pathname + window.location.search + window.location.hash
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '../../tests/helpers/render';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
|
import { resetAllStores } from '../../tests/helpers/store';
|
||||||
|
import LoginPage from './LoginPage';
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return { ...actual, useNavigate: () => mockNavigate };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoginPage — OIDC redirect preservation', () => {
|
||||||
|
let savedLocation: Location;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
mockNavigate.mockClear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
savedLocation = window.location;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: savedLocation,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setSearch(search: string) {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: { ...window.location, search },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
|
||||||
|
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
|
||||||
|
setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo');
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write to sessionStorage when no redirect param is present', async () => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByPlaceholderText('your@email.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auth/oidc/exchange', () =>
|
||||||
|
HttpResponse.json({ token: 'mock-oidc-token' })
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
|
||||||
|
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz');
|
||||||
|
setSearch('?oidc_code=testcode123');
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith(
|
||||||
|
'/oauth/authorize?client_id=foo&state=xyz',
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to /dashboard when no sessionStorage redirect is set', async () => {
|
||||||
|
setSearch('?oidc_code=testcode123');
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
|
||||||
|
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
|
||||||
|
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo');
|
||||||
|
setSearch('?oidc_error=token_failed');
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,12 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
return '/dashboard'
|
return '/dashboard'
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (redirectTarget !== '/dashboard') {
|
||||||
|
sessionStorage.setItem('oidc_redirect', redirectTarget)
|
||||||
|
}
|
||||||
|
}, [redirectTarget])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
@@ -83,7 +89,9 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
window.history.replaceState({}, '', '/login')
|
window.history.replaceState({}, '', '/login')
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
await loadUser()
|
await loadUser()
|
||||||
navigate('/dashboard', { replace: true })
|
const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard'
|
||||||
|
sessionStorage.removeItem('oidc_redirect')
|
||||||
|
navigate(savedRedirect, { replace: true })
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || t('login.oidcFailed'))
|
setError(data.error || t('login.oidcFailed'))
|
||||||
}
|
}
|
||||||
@@ -104,6 +112,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
invalid_state: t('login.oidc.invalidState'),
|
invalid_state: t('login.oidc.invalidState'),
|
||||||
}
|
}
|
||||||
setError(errorMessages[oidcError] || oidcError)
|
setError(errorMessages[oidcError] || oidcError)
|
||||||
|
sessionStorage.removeItem('oidc_redirect')
|
||||||
window.history.replaceState({}, '', '/login')
|
window.history.replaceState({}, '', '/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLoginRedirect() {
|
function handleLoginRedirect() {
|
||||||
const next = '/oauth/authorize?' + params.toString()
|
const next = '/oauth/authorize?' + params.toString() + window.location.hash
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user