diff --git a/client/src/App.tsx b/client/src/App.tsx index 5e0f5ed2..f5d96b51 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -58,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR } if (!isAuthenticated) { - const redirectParam = encodeURIComponent(location.pathname + location.search) + const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash) return } diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 378ddeab..e39c6a47 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -75,7 +75,7 @@ apiClient.interceptors.response.use( if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { const { pathname } = window.location 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) } } diff --git a/client/src/pages/LoginPage.oidc-redirect.test.tsx b/client/src/pages/LoginPage.oidc-redirect.test.tsx new file mode 100644 index 00000000..c14e0d96 --- /dev/null +++ b/client/src/pages/LoginPage.oidc-redirect.test.tsx @@ -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(); + + 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(); + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(sessionStorage.getItem('oidc_redirect')).toBeNull(); + }); + }); + }); +}); diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index d4646635..6fa2c192 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -55,6 +55,12 @@ export default function LoginPage(): React.ReactElement { return '/dashboard' }, []) + useEffect(() => { + if (redirectTarget !== '/dashboard') { + sessionStorage.setItem('oidc_redirect', redirectTarget) + } + }, [redirectTarget]) + useEffect(() => { const params = new URLSearchParams(window.location.search) @@ -83,7 +89,9 @@ export default function LoginPage(): React.ReactElement { window.history.replaceState({}, '', '/login') if (data.token) { await loadUser() - navigate('/dashboard', { replace: true }) + const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard' + sessionStorage.removeItem('oidc_redirect') + navigate(savedRedirect, { replace: true }) } else { setError(data.error || t('login.oidcFailed')) } @@ -104,6 +112,7 @@ export default function LoginPage(): React.ReactElement { invalid_state: t('login.oidc.invalidState'), } setError(errorMessages[oidcError] || oidcError) + sessionStorage.removeItem('oidc_redirect') window.history.replaceState({}, '', '/login') return } diff --git a/client/src/pages/OAuthAuthorizePage.tsx b/client/src/pages/OAuthAuthorizePage.tsx index 457d96ae..681326f2 100644 --- a/client/src/pages/OAuthAuthorizePage.tsx +++ b/client/src/pages/OAuthAuthorizePage.tsx @@ -124,7 +124,7 @@ export default function OAuthAuthorizePage(): React.ReactElement { } function handleLoginRedirect() { - const next = '/oauth/authorize?' + params.toString() + const next = '/oauth/authorize?' + params.toString() + window.location.hash window.location.href = '/login?redirect=' + encodeURIComponent(next) }