import type { Handle } from '@sveltejs/kit'; import { randomBytes, createHmac } from 'node:crypto'; import { env } from '$env/dynamic/private'; import { log } from '$lib/server/logger'; import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase'; const SESSION_COOKIE = 'libnovel_session'; const AUTH_COOKIE = 'libnovel_auth'; const ONE_YEAR = 60 * 60 * 24 * 365; const AUTH_SECRET = env.AUTH_SECRET ?? 'dev_secret_change_in_production'; // ─── Token helpers ──────────────────────────────────────────────────────────── /** * Sign a payload string with HMAC-SHA256 using AUTH_SECRET. * Returns ".". */ export function signToken(payload: string): string { const sig = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex'); return `${payload}.${sig}`; } /** * Verify a signed token. Returns the payload string on success, null on failure. */ export function verifyToken(token: string): string | null { const lastDot = token.lastIndexOf('.'); if (lastDot < 0) return null; const payload = token.slice(0, lastDot); const expected = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex'); const actual = token.slice(lastDot + 1); // constant-time comparison if (expected.length !== actual.length) return null; let diff = 0; for (let i = 0; i < expected.length; i++) { diff |= expected.charCodeAt(i) ^ actual.charCodeAt(i); } return diff === 0 ? payload : null; } /** * Create a signed auth token for a user. * Payload format: ":::" * authSessionId uniquely identifies this login session (for revocation). */ export function createAuthToken(userId: string, username: string, role: string, authSessionId: string): string { return signToken(`${userId}:${username}:${role}:${authSessionId}`); } /** * Parse a verified auth token into user data. Returns null if invalid. * Supports both old format (3 segments) and new format (4 segments). */ export function parseAuthToken(token: string): { id: string; username: string; role: string; authSessionId: string } | null { const payload = verifyToken(token); if (!payload) return null; const parts = payload.split(':'); // New format: userId:username:role:authSessionId (4 parts) // Old format: userId:username:role (3 parts — legacy tokens before session tracking) if (parts.length < 3) return null; const id = parts[0]; const username = parts[1]; const role = parts[2]; const authSessionId = parts[3] ?? ''; // empty string for legacy tokens if (!id || !username) return null; return { id, username, role, authSessionId }; } // ─── Hook ───────────────────────────────────────────────────────────────────── export const handle: Handle = async ({ event, resolve }) => { // Anonymous session cookie (for reading progress) let sessionId = event.cookies.get(SESSION_COOKIE) ?? ''; if (!sessionId) { sessionId = randomBytes(16).toString('hex'); event.cookies.set(SESSION_COOKIE, sessionId, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: ONE_YEAR }); } event.locals.sessionId = sessionId; // Auth cookie → resolve logged-in user const authToken = event.cookies.get(AUTH_COOKIE); if (authToken) { const user = parseAuthToken(authToken); if (!user) { log.warn('auth', 'auth cookie present but failed to parse (malformed or tampered)'); event.locals.user = null; } else { // Validate session against DB (only for new-format tokens with authSessionId) let sessionValid = true; if (user.authSessionId) { try { const revoked = await isSessionRevoked(user.authSessionId); if (revoked) { log.info('auth', 'auth cookie references revoked session', { userId: user.id, authSessionId: user.authSessionId }); sessionValid = false; // Clear the invalid cookie event.cookies.delete(AUTH_COOKIE, { path: '/' }); } else { // Best-effort: update last_seen in the background touchUserSession(user.authSessionId).catch(() => {}); } } catch (err) { // DB error — fail open to avoid locking everyone out log.warn('auth', 'session check failed (fail open)', { err: String(err) }); } } event.locals.user = sessionValid ? user : null; } } else { event.locals.user = null; } return resolve(event); };