// Organizer authentication. Two options, both simple: // 1. ADMIN_PASSWORD — a login form sets a signed cookie for 12 hours. // 2. Cloudflare Access — if TRUST_CF_ACCESS=true, a request that carries the // Cf-Access-Authenticated-User-Email header (set by Cloudflare after the user passes // the Access policy on /admin) is treated as an organizer. Only enable this when the // app is reachable exclusively through the tunnel, otherwise the header can be forged. import { timingSafeEqual, randomBytes } from 'node:crypto'; import { parseCookies, sign, verify } from './http.js'; const COOKIE = 'courtside_admin'; const TTL_MS = 12 * 3600 * 1000; export class Auth { constructor({ password, secret, trustCfAccess = false }) { this.password = password || null; this.secret = secret || randomBytes(32).toString('hex'); // random secret = sessions reset on restart; fine for a fallback this.trustCfAccess = trustCfAccess; if (!this.password && !this.trustCfAccess) console.warn('[auth] ADMIN_PASSWORD is not set and TRUST_CF_ACCESS is off: the organizer desk is unreachable.'); } /** Returns the organizer identity or null. */ identify(req) { if (this.trustCfAccess && req.headers['cf-access-authenticated-user-email']) return String(req.headers['cf-access-authenticated-user-email']); const c = parseCookies(req)[COOKIE]; const v = verify(c, this.secret); if (!v) return null; const [exp, who] = v.split('|'); if (Number(exp) < Date.now()) return null; return who || 'organizer'; } checkPassword(candidate) { if (!this.password) return false; const a = Buffer.from(String(candidate)), b = Buffer.from(this.password); return a.length === b.length && timingSafeEqual(a, b); } cookie(who = 'organizer', secure = false) { const v = sign(`${Date.now() + TTL_MS}|${who}`, this.secret); return `${COOKIE}=${encodeURIComponent(v)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${TTL_MS / 1000}${secure ? '; Secure' : ''}`; } clearCookie() { return `${COOKIE}=; Path=/; HttpOnly; Max-Age=0`; } }