From 6193e28587a0c461ad039692d87d9a526240fdeb Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 20:54:42 +0200 Subject: [PATCH] feat(auth): hooks forward fa_session cookie instead of injecting Basic auth userGroup: GET /api/users/me with Cookie: fa_session=. On 401, drop the stale cookie and redirect to /login?reason=expired (unless already on a public path) so the user sees an explainer instead of a silent kick. handleFetch: forward fa_session as a Cookie header on every API call except the public auth endpoints. Drops the old auth_token injection. Also adds a one-off cleanup of any lingering auth_token cookie from pre-migration sessions. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.server.ts | 74 +++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 31 deletions(-) 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);