103 lines
3.6 KiB
TypeScript
103 lines
3.6 KiB
TypeScript
|
|
// 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<CryptoKey> {
|
||
|
|
return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
||
|
|
"sign",
|
||
|
|
"verify",
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Returns "<base64url payload>.<base64url signature>". */
|
||
|
|
export async function createSessionToken(secret: string, ttlSeconds = SESSION_TTL_SECONDS): Promise<string> {
|
||
|
|
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<boolean> {
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
}
|