diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-13 09:38:16 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-13 09:38:16 +0200 |
| commit | 007384be67559e1848ee0a8a1726c6b0fa39a221 (patch) | |
| tree | 6b11cc43bd00967564c5bc74aed60168331f225b /src/lib/server/auth.ts | |
| parent | b30150a8feb960c0667eaff6cb105dcc55b65744 (diff) | |
feat(auth): add persistent family login
Diffstat (limited to 'src/lib/server/auth.ts')
| -rw-r--r-- | src/lib/server/auth.ts | 88 |
1 files changed, 88 insertions, 0 deletions
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, + }); +} |
