135 lines
4.4 KiB
TypeScript
135 lines
4.4 KiB
TypeScript
|
|
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;
|