aboutsummaryrefslogtreecommitdiff
path: root/src/lib/server/auth.ts
blob: 3db93f029874fde8e06e4a11ec22c497a67ca19f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

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: Temporal.Instant = Temporal.Now.instant(),
): string {
	const expiresAt = now.add({
		milliseconds: SESSION_MAX_AGE_MS,
	}).epochMilliseconds;
	const payload = String(expiresAt);
	return `${payload}.${sign(payload)}`;
}

export function isValidSession(
	value: string | undefined,
	now: Temporal.Instant = Temporal.Now.instant(),
): 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.epochMilliseconds)
		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: Temporal.Instant = Temporal.Now.instant(),
): 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: Temporal.Instant = Temporal.Now.instant(),
): void {
	cookies.set(AUTH_COOKIE_NAME, createSession(now), {
		path: '/',
		httpOnly: true,
		secure,
		sameSite: 'lax',
		maxAge: SESSION_MAX_AGE_SECONDS,
	});
}