diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 50aabec5..3f00a76e 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -1,12 +1,29 @@ import { fail, redirect, type Actions } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; -import { getErrorMessage } from '$lib/shared/errors'; +import { getErrorMessage, type ErrorCode } from '$lib/shared/errors'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = ({ url }) => { - return { registered: url.searchParams.get('registered') === '1' }; + return { + registered: url.searchParams.get('registered') === '1', + reason: url.searchParams.get('reason') + }; }; +/** + * Extracts the fa_session cookie value from a Set-Cookie response header. + * The backend may emit attributes like `Path`, `HttpOnly`, `SameSite=Strict`, `Max-Age`, `Secure`; + * we only forward the opaque session id — the SvelteKit cookies API will rewrite + * the attributes itself. + */ +function extractFaSessionId(setCookieHeaders: string[]): string | null { + for (const header of setCookieHeaders) { + const match = header.match(/^fa_session=([^;]+)/); + if (match) return match[1]; + } + return null; +} + export const actions = { login: async ({ request, cookies, fetch, url }) => { const data = await request.formData(); @@ -17,44 +34,60 @@ export const actions = { return fail(400, { error: getErrorMessage('MISSING_CREDENTIALS') }); } - const credentials = btoa(`${email}:${password}`); - const authHeader = `Basic ${credentials}`; - - // Raw fetch is intentional here: we need to pass an explicit Authorization - // header built from the form data, not the cookie-based auth used elsewhere. + const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; + let response: Response; try { - const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; - const response = await fetch(`${baseUrl}/api/users/me`, { - method: 'GET', - headers: { Authorization: authHeader } - }); - - if (response.status === 401 || response.status === 403) { - return fail(401, { error: getErrorMessage('UNAUTHORIZED') }); - } - - if (!response.ok) { - return fail(500, { error: getErrorMessage('INTERNAL_ERROR') }); - } - - // The cookie IS the API credential — promoted to `Authorization: Basic …` - // on every browser → backend request by AuthTokenCookieFilter on the - // Spring side (see #520). It must be Secure on HTTPS or it leaks - // a 24h Basic token on plaintext networks. Dev runs over HTTP and - // would silently lose the cookie if we hardcoded secure=true. - const isHttps = url.protocol === 'https:'; - cookies.set('auth_token', authHeader, { - path: '/', - httpOnly: true, - sameSite: 'strict', - secure: isHttps, - maxAge: 60 * 60 * 24 + response = await fetch(`${baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) }); } catch (e) { - console.error(e); + console.error('Login request failed', e); return fail(500, { error: getErrorMessage('INTERNAL_ERROR') }); } + if (response.status === 401) { + let code: ErrorCode = 'INVALID_CREDENTIALS'; + try { + const body = (await response.json()) as { code?: string }; + if (body?.code) code = body.code as ErrorCode; + } catch { + // Body not JSON — fall through to INVALID_CREDENTIALS + } + return fail(401, { error: getErrorMessage(code) }); + } + + if (!response.ok) { + return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') }); + } + + // Extract fa_session id from the Set-Cookie header and re-emit to the browser. + // Modern Node/Undici exposes getSetCookie(); fall back to a single header for older runtimes. + const setCookieHeaders = + typeof response.headers.getSetCookie === 'function' + ? response.headers.getSetCookie() + : response.headers.get('set-cookie') + ? [response.headers.get('set-cookie')!] + : []; + const sessionId = extractFaSessionId(setCookieHeaders); + if (!sessionId) { + console.error('Backend returned 200 OK on login but no fa_session cookie'); + return fail(500, { error: getErrorMessage('INTERNAL_ERROR') }); + } + + const isHttps = url.protocol === 'https:'; + cookies.set('fa_session', sessionId, { + path: '/', + httpOnly: true, + sameSite: 'strict', + secure: isHttps, + maxAge: 60 * 60 * 8 // 8h — must match backend spring.session.timeout + }); + + // Best-effort cleanup of the legacy Basic-auth cookie from older deployments. + cookies.delete('auth_token', { path: '/' }); + return redirect(303, '/'); } } satisfies Actions;