pingql/apps/status/src/auth.ts

82 lines
3.6 KiB
TypeScript

// Password gate for protected status pages. We sign a short-lived cookie with
// the page id + a tag derived from the current password hash + an expiry, so
// a successful password unlock survives across page loads without us having
// to hit Postgres on every request - and so changing the page password
// invalidates every cookie issued under the old password.
//
// Cookie format: <pageId>.<pwTag>.<exp>.<sig>
// pwTag = HMAC(SECRET, password_hash).slice(0, 16)
// sig = HMAC(SECRET, "<pageId>.<pwTag>.<exp>")
//
// Why include pwTag: without it, the signed payload would be tied only to
// the page id, so a stolen / old cookie would remain valid for the full TTL
// even after the operator rotated the password. With pwTag, the verifier
// derives the expected tag from the *current* password_hash; old cookies
// no longer match and are silently rejected.
import { createHmac, timingSafeEqual } from "crypto";
const SECRET = process.env.STATUS_COOKIE_SECRET ?? process.env.MONITOR_TOKEN ?? "dev-secret-change-me";
const COOKIE = "pingql_status_auth";
const TTL_MS = 12 * 60 * 60 * 1000; // 12 hours
function sign(payload: string): string {
return createHmac("sha256", SECRET).update(payload).digest("hex");
}
// Deterministic 16-hex-char tag derived from the current password hash.
// Stable for as long as the password_hash row doesn't change. When the
// operator changes the password, password_hash changes, pwTag changes, old
// cookies' embedded pwTag stops matching → verification fails.
function passwordTag(passwordHash: string): string {
return createHmac("sha256", SECRET).update(`pw:${passwordHash}`).digest("hex").slice(0, 16);
}
export function makeAuthCookie(pageId: string, passwordHash: string): string {
const exp = Date.now() + TTL_MS;
const pwTag = passwordTag(passwordHash);
const payload = `${pageId}.${pwTag}.${exp}`;
const sig = sign(payload);
const value = `${payload}.${sig}`;
return `${COOKIE}=${value}; Path=/; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax${process.env.NODE_ENV !== "development" ? "; Secure" : ""}`;
}
export function verifyAuthCookie(cookieHeader: string | null | undefined, pageId: string, passwordHash: string): boolean {
if (!cookieHeader) return false;
const match = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`));
if (!match) return false;
const value = match.slice(COOKIE.length + 1);
const lastDot = value.lastIndexOf(".");
if (lastDot < 0) return false;
const payload = value.slice(0, lastDot);
const sig = value.slice(lastDot + 1);
const parts = payload.split(".");
if (parts.length !== 3) return false;
const [id, pwTag, expStr] = parts;
if (id !== pageId) return false;
// Compare the cookie's pwTag against the tag derived from the *current*
// password hash. Mismatch = the password was rotated since this cookie was
// issued, so the cookie is dead. Constant-time compare to avoid leaking
// partial-match timing.
const expectedPwTag = passwordTag(passwordHash);
if (pwTag.length !== expectedPwTag.length) return false;
try {
if (!timingSafeEqual(Buffer.from(pwTag), Buffer.from(expectedPwTag))) return false;
} catch {
return false;
}
const exp = Number(expStr);
if (!Number.isFinite(exp) || Date.now() > exp) return false;
const expected = sign(payload);
if (expected.length !== sig.length) return false;
try {
return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
} catch {
return false;
}
}
export async function checkPassword(plain: string, hash: string): Promise<boolean> {
return await Bun.password.verify(plain, hash);
}