import { createHmac, timingSafeEqual } from "node:crypto"; import { cookies } from "next/headers"; import { NextRequest } from "next/server"; /* ============================================================================ * TV dashboard session — signed-cookie auth. * * The TV is more visible than the 3 counter phones (anyone walking past * can glance at it), so the operator gets a PIN-gated shell instead of * the fully-open kiosk model the counter PWA uses. * * Storage: a single httpOnly cookie `tv_session` holding a base64url * payload of `{ exp: number }` HMAC-signed with TV_SESSION_SECRET. The * whole cookie is `payload.signature`; the cookie helpers below * assemble and verify it. * * Rotation: change TV_SESSION_SECRET + restart → all existing cookies * fail verify and the TV goes back to the PIN screen. * * `assertTvSession` returns true when the cookie is present AND valid; * `false` when it's missing OR expired OR tampered. The caller decides * whether to bounce to /pin (redirect 307) or just render an "unauth" * placeholder. * * NOTE: this is intentionally not a JWT library — we don't need the * header/payload/3-segment shape, alg/version negotiation, or the rest * of the spec. A minimal HMAC over a single number is enough. * ========================================================================= */ const COOKIE_NAME = "tv_session"; const DEFAULT_TTL_HOURS = 12; function secret(): string { const s = process.env.TV_SESSION_SECRET; if (!s) { throw new Error( "TV_SESSION_SECRET is not configured. Set it in .env.local — generate with: node -e \"console.log(require('crypto').randomBytes(48).toString('base64'))\"", ); } return s; } function ttlHours(): number { const raw = process.env.TV_SESSION_HOURS; const parsed = raw ? Number.parseInt(raw, 10) : DEFAULT_TTL_HOURS; return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_HOURS; } function b64urlEncode(buf: Buffer): string { return buf.toString("base64url"); } function b64urlDecode(input: string): Buffer { return Buffer.from(input, "base64url"); } function sign(payload: string): string { return b64urlEncode( createHmac("sha256", secret()).update(payload).digest(), ); } function timingSafeStringEqual(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); if (ab.length !== bb.length) return false; return timingSafeEqual(ab, bb); } export async function issueTvSession(): Promise { const exp = Date.now() + ttlHours() * 60 * 60 * 1000; const payload = b64urlEncode(Buffer.from(JSON.stringify({ exp }))); const signature = sign(payload); return `${payload}.${signature}`; } /** * Cookie options for the auth cookie. httpOnly so JS can't read it, * sameSite=lax so the TV's same-origin fetch (auto-refresh) carries * it normally, secure only when we know we're on https (Coolify in * prod is HTTPS; dev on http://localhost stays non-secure). * * `secure` is left as a default that the runtime infers from the * request protocol when available. */ export function tvCookieOptions() { return { name: COOKIE_NAME, httpOnly: true, sameSite: "lax" as const, path: "/", maxAge: ttlHours() * 60 * 60, }; } export async function verifyTvToken(token: string | undefined): Promise { if (!token) return false; const dot = token.indexOf("."); if (dot < 0) return false; const payload = token.slice(0, dot); const providedSig = token.slice(dot + 1); const expectedSig = sign(payload); if (!timingSafeStringEqual(providedSig, expectedSig)) return false; let parsed: { exp?: unknown }; try { parsed = JSON.parse(b64urlDecode(payload).toString("utf8")); } catch { return false; } if (typeof parsed.exp !== "number" || !Number.isFinite(parsed.exp)) { return false; } return parsed.exp > Date.now(); } /** * Read the cookie from a route-handler `NextRequest` (API routes). * Returns true when the cookie is present AND valid. */ export async function assertTvSession(req: NextRequest): Promise { const raw = req.cookies.get(COOKIE_NAME)?.value; return verifyTvToken(raw); } /** * Read the cookie from a Server Component via `next/headers`. Same * return semantics as `assertTvSession`. */ export async function isTvAuthed(): Promise { const jar = await cookies(); const raw = jar.get(COOKIE_NAME)?.value; return verifyTvToken(raw); } export const TV_SESSION_COOKIE_NAME = COOKIE_NAME;