diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 2c155dce..5fe167c1 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -58,21 +58,40 @@ const handleParaglide: Handle = ({ event, resolve }) => }); const userGroup: Handle = async ({ event, resolve }) => { - const auth = event.cookies.get('auth_token'); + // One-off cleanup of the legacy Basic-credentials cookie from before the Spring Session migration (#523). + if (event.cookies.get('auth_token')) { + event.cookies.delete('auth_token', { path: '/' }); + } - if (auth) { - try { - const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; - const response = await fetch(`${apiUrl}/api/users/me`, { - headers: { Authorization: auth } - }); - if (response.ok) { - const user = await response.json(); - event.locals.user = user; + const sessionId = event.cookies.get('fa_session'); + if (!sessionId) { + return resolve(event); + } + + try { + const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; + const response = await fetch(`${apiUrl}/api/users/me`, { + headers: { Cookie: `fa_session=${sessionId}` } + }); + + if (response.ok) { + event.locals.user = await response.json(); + } else if (response.status === 401) { + // Backend rejected the session (expired or invalidated). Drop the stale + // cookie and surface the reason on the login page. PUBLIC_PATHS check + // avoids a redirect loop if the user is already on /login. + event.cookies.delete('fa_session', { path: '/' }); + const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p)); + if (!isPublic) { + throw redirect(302, '/login?reason=expired'); } - } catch (error) { - console.error('Error fetching user in hook:', error); } + } catch (error) { + // Don't swallow SvelteKit redirects — they're thrown as objects with a `status` field. + if (error instanceof Object && 'status' in error && 'location' in error) { + throw error; + } + console.error('Error fetching user in hook:', error); } return resolve(event); @@ -83,14 +102,11 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/'); if (isApi) { - // If the request already carries an explicit Authorization header (e.g. the - // login action sends Basic auth), pass it through unchanged. - if (request.headers.has('Authorization')) { - return fetch(request); - } - - // Password reset endpoints are public — no auth header needed. + // Auth endpoints that establish/check their own credentials manage cookies themselves; + // don't double-inject a stale fa_session. const PUBLIC_API_PATHS = [ + '/api/auth/login', + '/api/auth/logout', '/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/invite/', @@ -100,24 +116,20 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { return fetch(request); } - const token = event.cookies.get('auth_token'); - - if (!token) { + const sessionId = event.cookies.get('fa_session'); + if (!sessionId) { return new Response('Unauthorized', { status: 401 }); } - // Clone the request first to preserve the body - const clonedRequest = request.clone(); - - // Create new request with Authorization header and preserved body - const modifiedRequest = new Request(clonedRequest, { + // Clone first so the body stream is preserved on the new Request. + const cloned = request.clone(); + const modified = new Request(cloned, { headers: { - ...Object.fromEntries(clonedRequest.headers), - Authorization: token + ...Object.fromEntries(cloned.headers), + Cookie: `fa_session=${sessionId}` } }); - - return fetch(modifiedRequest); + return fetch(modified); } return fetch(request);