cameras/src/lib/admin-auth.ts
Michael Mainguy 462141aa35 Add admin login foundations: scrypt admin file, CLI, sessions
Groundwork for securing the web front end (vrek gol-wqf95dq). The app
does not enforce login yet.

- src/lib/admin-file.ts: the admin file at ADMIN_AUTH_FILE (default
  .data/admin.json). scrypt hashing (N=2^16, random salt, bounded
  parameters, constant-time compare), zod-validated reads where a
  malformed file is an error, and atomic 0600 writes that won't
  replace an existing admin without overwrite. Plain Node, so the
  CLI can share it (iss-mffqscg).
- src/lib/admin-auth.ts: server-only app layer; failed logins always
  cost one hash.
- scripts/create-admin.mts + `npm run admin:create`: create or reset
  the admin outside the app, interactive (hidden, confirmed) or piped
  (iss-7xmka20). The README documents it, a no-npm Node one-liner,
  the file format, and password reset.
- src/lib/session-token.ts and session.ts: stateless HMAC-signed
  session cookie, keyed from the password hash so a password change
  ends every session, with a 12 h sliding window (iss-e27nb70,
  dec-f0xar8r).

281 tests, 99.8% line coverage. Refreshes the vrek export.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 09:46:40 -05:00

46 lines
1.5 KiB
TypeScript

import "server-only";
import {
adminFilePath,
createAdminRecord,
hashPassword,
readAdminFile,
verifyPassword,
writeAdminFile,
type AdminFile,
} from "./admin-file";
/**
* The app's view of the admin login. The file is re-read on each call (it's tiny), so an
* admin created or reset with the CLI takes effect without restarting the server.
*/
export function getAdmin(): Promise<AdminFile | null> {
return readAdminFile(adminFilePath());
}
export async function hasAdmin(): Promise<boolean> {
return (await getAdmin()) !== null;
}
// Hashed once, lazily: lets a failed login cost the same whether or not the username exists.
let dummyHash: Promise<string> | null = null;
/**
* True only for the admin's exact username and password. Always spends one full scrypt
* hash, so timing doesn't reveal whether an admin exists or the username was right.
*/
export async function checkLogin(username: string, password: string): Promise<boolean> {
const admin = await getAdmin();
const userMatches = admin !== null && admin.username === username;
const hash = userMatches ? admin.passwordHash : await (dummyHash ??= hashPassword("not the password"));
const passwordMatches = await verifyPassword(password, hash);
return userMatches && passwordMatches;
}
/** Creates the first admin. Fails with EEXIST if one already exists. */
export async function createAdmin(username: string, password: string): Promise<AdminFile> {
const admin = await createAdminRecord(username, password);
await writeAdminFile(adminFilePath(), admin);
return admin;
}