aboutsummaryrefslogtreecommitdiff
path: root/src/lib/server/auth.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/server/auth.ts')
-rw-r--r--src/lib/server/auth.ts88
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,
+ });
+}