WFDASH-WEBAPP/src/lib/server/tv-session.ts

135 lines
4.4 KiB
TypeScript
Raw Normal View History

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<string> {
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<boolean> {
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<boolean> {
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<boolean> {
const jar = await cookies();
const raw = jar.get(COOKIE_NAME)?.value;
return verifyTvToken(raw);
}
export const TV_SESSION_COOKIE_NAME = COOKIE_NAME;