aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/server/auth.test.ts59
-rw-r--r--src/lib/server/auth.ts88
2 files changed, 147 insertions, 0 deletions
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,
+ });
+}