From 0d8a25e73fd1b82dbfebff012eebbb2089445855 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sun, 20 Sep 2026 07:16:38 +0200 Subject: [PATCH] fix: redirect expired sessions to login Invalidate authentication on protected 401 responses and JWT expiration, redirect to the login route, and explain that the session is no longer active. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontend/README.md | 5 +++- frontend/src/api/client.test.ts | 27 ++++++++++++++++++- frontend/src/api/client.ts | 28 +++++++++++++++++--- frontend/src/app/App.test.tsx | 46 +++++++++++++++++++++++++++++++++ frontend/src/app/App.tsx | 39 +++++++++++++++++++++++++--- frontend/src/styles/global.css | 10 +++++++ 6 files changed, 147 insertions(+), 8 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index d9456b9..685ed2e 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -37,7 +37,10 @@ application shell. Vite proxies `/api` to `http://localhost:8080`. Login stores the returned JWT in `sessionStorage`; protected requests send it as `Authorization: Bearer `. API failures are -shown in the UI and are never replaced with demo data. +shown in the UI and are never replaced with demo data. A `401` from a protected +JSON, file, or event-stream request clears authentication and returns the user to +`/login`. The app also schedules logout from the JWT expiration claim so an idle +expired session cannot remain on a protected page. Supported routes: diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 03f4780..78f80f4 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { api, authStorage } from './client' +import { api, authStorage, SESSION_EXPIRED_EVENT } from './client' describe('API client', () => { beforeEach(() => { @@ -11,6 +11,7 @@ describe('API client', () => { token: 'signed-jwt', user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' }, }) + const fetchMock = vi.fn().mockResolvedValue(new Response('[]', { status: 200 })) vi.stubGlobal('fetch', fetchMock) @@ -24,6 +25,30 @@ describe('API client', () => { ) }) + it('invalidates the session when a protected request is unauthorized', async () => { + authStorage.save({ + token: 'expired-jwt', + user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' }, + }) + const expired = vi.fn() + window.addEventListener(SESSION_EXPIRED_EVENT, expired, { once: true }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: 'session expired' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + await expect(api.jobs.list()).rejects.toMatchObject({ status: 401 }) + + expect(authStorage.token()).toBeNull() + expect(authStorage.user()).toBeNull() + expect(expired).toHaveBeenCalledOnce() + }) + it('uses the backend participant multipart field names', async () => { authStorage.save({ token: 'signed-jwt', diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 51b07fa..fbbfe2b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -20,6 +20,7 @@ import type { const TOKEN_KEY = 'go-guess-token' const USER_KEY = 'go-guess-user' +export const SESSION_EXPIRED_EVENT = 'go-guess:session-expired' export class ApiError extends Error { constructor( @@ -45,6 +46,23 @@ export const authStorage = { sessionStorage.removeItem(TOKEN_KEY) sessionStorage.removeItem(USER_KEY) }, + expiresAt: () => { + try { + const payload = sessionStorage.getItem(TOKEN_KEY)?.split('.')[1] + if (!payload) return null + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') + const claims = JSON.parse(atob(padded)) as { exp?: unknown } + return typeof claims.exp === 'number' ? claims.exp * 1000 : null + } catch { + return null + } + }, +} + +export function invalidateSession() { + authStorage.clear() + window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT)) } async function request( @@ -78,7 +96,7 @@ async function request( } catch { details = undefined } - if (response.status === 401 && protectedRequest) authStorage.clear() + if (response.status === 401 && protectedRequest) invalidateSession() throw new ApiError(message, response.status, details) } if (response.status === 204) return undefined as T @@ -93,8 +111,10 @@ async function requestBlob(path: string): Promise { ...(token ? { Authorization: `Bearer ${token}` } : {}), }, }) - if (!response.ok) + if (!response.ok) { + if (response.status === 401) invalidateSession() throw new ApiError(`Request failed with status ${response.status}`, response.status) + } return response.blob() } @@ -115,8 +135,10 @@ async function subscribeToInterviewEvents( }, signal, }) - if (!response.ok || !response.body) + if (!response.ok || !response.body) { + if (response.status === 401 && protectedRequest) invalidateSession() throw new ApiError(`Live updates failed with status ${response.status}`, response.status) + } const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx index 041f3d7..9cda5ca 100644 --- a/frontend/src/app/App.test.tsx +++ b/frontend/src/app/App.test.tsx @@ -164,6 +164,52 @@ describe('Go Guess frontend', () => { expect(window.location.pathname).toBe('/jobs') }) + it('returns to login when the active session expires', async () => { + authenticate() + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: 'session expired' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + renderApp('/jobs') + + expect( + await screen.findByRole('heading', { name: 'Sign in to your workspace' }), + ).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Your session is no longer active') + expect(sessionStorage.getItem('go-guess-token')).toBeNull() + }) + + it('rejects an already expired stored token without waiting for an API response', async () => { + const payload = btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 60 })).replace( + /=+$/, + '', + ) + sessionStorage.setItem('go-guess-token', `header.${payload}.signature`) + sessionStorage.setItem( + 'go-guess-user', + JSON.stringify({ + id: 1, + email: 'admin@example.com', + displayName: 'Admin', + role: 'admin', + }), + ) + + renderApp('/calendar') + + expect( + await screen.findByRole('heading', { name: 'Sign in to your workspace' }), + ).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Your session is no longer active') + expect(sessionStorage.getItem('go-guess-token')).toBeNull() + }) + it('filters jobs by title', async () => { authenticate() const user = userEvent.setup() diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 4b5cd4e..5680b6a 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -43,7 +43,7 @@ import { useNavigate, useParams, } from 'react-router-dom' -import { api, ApiError, authStorage } from '../api/client' +import { api, ApiError, authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../api/client' import { CalendarPage, InboxPage, @@ -168,7 +168,7 @@ function Logo() { ) } -function LoginPage({ onLogin }: { onLogin: () => void }) { +function LoginPage({ onLogin, sessionExpired }: { onLogin: () => void; sessionExpired: boolean }) { const [loading, setLoading] = useState(false) const [error, setError] = useState('') @@ -210,6 +210,11 @@ function LoginPage({ onLogin }: { onLogin: () => void }) {
Welcome back

Sign in to your workspace

+ {sessionExpired && ( +
+ Your session is no longer active. Sign in again to continue. +
+ )}

Use your work account to continue.

@@ -2391,17 +2396,43 @@ function NotFound() { export default function App() { const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token())) + const [sessionExpired, setSessionExpired] = useState(false) const navigate = useNavigate() const location = useLocation() + useEffect(() => { + function sessionExpired() { + setAuthenticated(false) + setSessionExpired(true) + navigate('/login', { replace: true }) + } + window.addEventListener(SESSION_EXPIRED_EVENT, sessionExpired) + return () => window.removeEventListener(SESSION_EXPIRED_EVENT, sessionExpired) + }, [navigate]) + + useEffect(() => { + if (!authenticated) return + const expiresAt = authStorage.expiresAt() + if (expiresAt === null) return + const remaining = expiresAt - Date.now() + if (remaining <= 0) { + invalidateSession() + return + } + const timeout = window.setTimeout(invalidateSession, remaining) + return () => window.clearTimeout(timeout) + }, [authenticated]) + function completeLogin() { setAuthenticated(true) + setSessionExpired(false) navigate('/jobs', { replace: true }) } function logout() { authStorage.clear() setAuthenticated(false) + setSessionExpired(false) } if (location.pathname.startsWith('/participant/')) { return ( @@ -2412,7 +2443,9 @@ export default function App() { ) } - if (!authenticated) return + if (!authenticated) { + return + } return ( diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index aeaf912..92ea345 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -338,6 +338,16 @@ button { font-size: 1.8rem; margin-bottom: 0.5rem; } +.session-notice { + margin-top: 1rem; + padding: 0.8rem 0.9rem; + color: #6f5513; + border: 1px solid #e6ce83; + border-radius: 9px; + background: #fff8dc; + font-size: 0.86rem; + line-height: 1.45; +} .stack-form { display: flex; flex-direction: column;