From 007384be67559e1848ee0a8a1726c6b0fa39a221 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 13 Aug 2026 09:38:16 +0200 Subject: feat(auth): add persistent family login --- src/lib/server/auth.test.ts | 59 ++++++++++++++++++++++++++++++ src/lib/server/auth.ts | 88 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 src/lib/server/auth.test.ts create mode 100644 src/lib/server/auth.ts (limited to 'src/lib/server') diff --git a/src/lib/server/auth.test.ts b/src/lib/server/auth.test.ts new file mode 100644 index 0000000..59ddabc --- /dev/null +++ b/src/lib/server/auth.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Cookies } from '@sveltejs/kit'; +import { + AUTH_COOKIE_NAME, + SESSION_MAX_AGE_SECONDS, + createSession, + isValidSession, + renewSession, + verifyPassword, +} from './auth'; + +const TEST_PASSWORD = vi.hoisted(() => { + return 'family-password-5f2c1d7e'; +}); +const NOW = 1_800_000_000_000; + +vi.mock('$env/dynamic/private', () => ({ + env: { AUTH_PASSWORD: TEST_PASSWORD }, +})); + +describe('authentication', () => { + it('verifies the configured shared password', () => { + expect(verifyPassword(TEST_PASSWORD)).toBe(true); + expect(verifyPassword('wrong-password')).toBe(false); + }); + + it('accepts a signed session until its expiry', () => { + const session = createSession(NOW); + + expect( + isValidSession(session, NOW + SESSION_MAX_AGE_SECONDS * 1000 - 1), + ).toBe(true); + expect(isValidSession(session, NOW + SESSION_MAX_AGE_SECONDS * 1000)).toBe( + false, + ); + expect(isValidSession(`${session}x`, NOW)).toBe(false); + }); + + it('renews a valid session with the requested cookie settings', () => { + const session = createSession(NOW); + const cookies = { + get: vi.fn(() => session), + set: vi.fn(), + } as unknown as Cookies; + + expect(renewSession(cookies, true, NOW + 1_000)).toBe(true); + expect(cookies.set).toHaveBeenCalledWith( + AUTH_COOKIE_NAME, + expect.any(String), + expect.objectContaining({ + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: SESSION_MAX_AGE_SECONDS, + }), + ); + }); +}); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..f80eda0 --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,88 @@ +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; +import type { Cookies } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; + +export const AUTH_COOKIE_NAME = 'home_auth'; +export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; + +const SESSION_MAX_AGE_MS = SESSION_MAX_AGE_SECONDS * 1000; + +function configuredPassword(): string { + if (!env.AUTH_PASSWORD) { + throw new Error('AUTH_PASSWORD is not configured'); + } + return env.AUTH_PASSWORD; +} + +function digest(value: string): Buffer { + return createHash('sha256').update(value).digest(); +} + +function sign(value: string): string { + return createHmac('sha256', configuredPassword()) + .update(value) + .digest('base64url'); +} + +export function verifyPassword(candidate: string): boolean { + const expected = digest(configuredPassword()); + const actual = digest(candidate); + return timingSafeEqual(actual, expected); +} + +export function createSession(now = Date.now()): string { + const expiresAt = now + SESSION_MAX_AGE_MS; + const payload = String(expiresAt); + return `${payload}.${sign(payload)}`; +} + +export function isValidSession( + value: string | undefined, + now = Date.now(), +): boolean { + if (!value) return false; + + const [expiresAtText, signature, extra] = value.split('.'); + if (!expiresAtText || !signature || extra) return false; + + const expiresAt = Number(expiresAtText); + if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false; + + const expected = Buffer.from(sign(expiresAtText), 'base64url'); + const actual = Buffer.from(signature, 'base64url'); + if (actual.length !== expected.length) return false; + + return timingSafeEqual(actual, expected); +} + +export function renewSession( + cookies: Cookies, + secure: boolean, + now = Date.now(), +): boolean { + const current = cookies.get(AUTH_COOKIE_NAME); + if (!isValidSession(current, now)) return false; + + cookies.set(AUTH_COOKIE_NAME, createSession(now), { + path: '/', + httpOnly: true, + secure, + sameSite: 'lax', + maxAge: SESSION_MAX_AGE_SECONDS, + }); + return true; +} + +export function setSession( + cookies: Cookies, + secure: boolean, + now = Date.now(), +): void { + cookies.set(AUTH_COOKIE_NAME, createSession(now), { + path: '/', + httpOnly: true, + secure, + sameSite: 'lax', + maxAge: SESSION_MAX_AGE_SECONDS, + }); +} -- cgit v1.3