// lib/auth.ts // // Session helpers for the app password gate. // // Design notes: // • Uses only Web Crypto (crypto.subtle), never node:crypto, so the same // code runs in the Edge middleware and in Node route handlers. // • The session cookie carries an expiry plus an HMAC-SHA256 signature. // It is NOT a PocketBase token: the app keeps using its server-side // PB_TOKEN, this only proves "a human who knows APP_PASSWORD got here". // • Nothing secret is baked into the client bundle — the password is only // ever read inside a route handler on the server. export const SESSION_COOKIE = "wf_session"; /** 12 hours. Long enough for a working day, short enough to expire. */ export const SESSION_TTL_SECONDS = 60 * 60 * 12; const enc = new TextEncoder(); const dec = new TextDecoder(); function b64u(bytes: Uint8Array): string { let bin = ""; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function unb64u(s: string): Uint8Array { const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4)); const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } async function hmacKey(secret: string): Promise { return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [ "sign", "verify", ]); } /** Returns ".". */ export async function createSessionToken(secret: string, ttlSeconds = SESSION_TTL_SECONDS): Promise { if (!secret) throw new Error("AUTH_SECRET is not set"); const payload = b64u( enc.encode(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + ttlSeconds })) ); const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(payload))); return `${payload}.${b64u(sig)}`; } export async function verifySessionToken( secret: string | undefined, token: string | undefined | null ): Promise { if (!secret || !token) return false; const dot = token.lastIndexOf("."); if (dot <= 0) return false; const payload = token.slice(0, dot); let sig: Uint8Array; try { sig = unb64u(token.slice(dot + 1)); } catch { return false; } try { // crypto.subtle.verify is a constant-time comparison for us. const ok = await crypto.subtle.verify("HMAC", await hmacKey(secret), sig, enc.encode(payload)); if (!ok) return false; const { exp } = JSON.parse(dec.decode(unb64u(payload))); return typeof exp === "number" && exp > Math.floor(Date.now() / 1000); } catch { return false; } } /** * Constant-time-ish string compare. Prevents an attacker from learning the * password one character at a time by timing the response. */ export function secretsMatch(a: string, b: string): boolean { const ab = enc.encode(a); const bb = enc.encode(b); let diff = ab.length ^ bb.length; const max = Math.max(ab.length, bb.length); for (let i = 0; i < max; i++) diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0); return diff === 0; } /** Cookie options shared by the login and logout routes. */ export function sessionCookieOptions(req: { headers: Headers; nextUrl: URL }, maxAge: number) { // Behind Traefik the browser talks https to the proxy, which forwards http // to the container — so trust x-forwarded-proto rather than nextUrl.protocol. const proto = req.headers.get("x-forwarded-proto") ?? req.nextUrl.protocol; return { httpOnly: true, sameSite: "lax" as const, secure: proto === "https:", path: "/", maxAge, }; }