feat(auth): rewrite login action to POST /api/auth/login and forward fa_session

Replaces the Basic-credentials-in-cookie flow with the Spring Session model:
1. POST {email, password} as JSON to /api/auth/login
2. Map 401 → INVALID_CREDENTIALS (or SESSION_EXPIRED if the backend returns it)
3. Parse Set-Cookie for fa_session=<opaque> and re-emit to the browser
4. Drop the legacy auth_token cookie

load() now also exposes ?reason= so the page can show the
session-expired banner (Task 21 wires it into the .svelte file).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-17 20:52:30 +02:00
parent cfff594732
commit ea800e5e2a

View File

@@ -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;