import { cookies } from "next/headers"; import { createHash, createHmac, timingSafeEqual } from "crypto"; import { prisma } from "../db"; /* Sessions for the operations console. * * A third kind of session, kept apart from the other two the same way lib/staffsession.ts keeps * the wearer's apart from the coordinator's: its own cookie, a signing key derived from a * separate secret with a separate domain string, and a payload claim named `oid` where the others * carry `uid` and `sid`. A coordinator or staff cookie pasted into tc_ops fails signature * verification, and even if it somehow didn't, readOpsToken() rejects a payload with no `oid`. * There is no arrangement of the other two tokens that becomes this one — not because a condition * says no, but because the three are not the same shape. * * ⛔ The secret is OPS_SESSION_SECRET, never SESSION_SECRET. SESSION_SECRET also derives the key * that encrypts every customer's TOTP secret, so if this console shared it, a compromised operator * credential would force a rotation that destroys every facility's second factor. The console * must be revocable on its own. * * ⛔ The cookie is host-only — no Domain attribute — so it is sent to ops.threadcount.tech and * nowhere else. Domain=.threadcount.tech would hand it to analytics. and errors.threadcount.tech, * both real applications this product's CSP already trusts. * * ⛔ Nothing in here, and nothing in the console, may mint a coordinator or staff session. The * demo route shows how few lines that takes; an operator signing in as a customer is the data * plane by another door, and the product's privacy page says it never happens. */ export const OPS_COOKIE = "tc_ops"; // Eight hours: an owner console is tuned the opposite way from the two convenience sessions. const MAX_AGE = 60 * 60 * 8; function secret() { const s = process.env.OPS_SESSION_SECRET; if (!s) throw new Error("OPS_SESSION_SECRET not set"); // Domain separation, and a different master secret underneath it. return createHash("sha256").update("threadcount:ops:v1:" + s).digest(); } function b64url(buf: Buffer) { return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } /** Short fingerprint of the password hash: a password change invalidates every older token. */ export function opsPwVersion(passwordHash: string) { return createHash("sha256").update(passwordHash).digest("base64url").slice(0, 12); } /** `sso` marks a session that Cloudflare Access already put a second factor behind. */ export function signOpsSession(oid: string, passwordHash: string, sso = false, maxAge = MAX_AGE) { const payload = b64url(Buffer.from(JSON.stringify({ oid, pv: opsPwVersion(passwordHash), sso, exp: Date.now() + maxAge * 1000 }))); const sig = b64url(createHmac("sha256", secret()).update(payload).digest()); return `${payload}.${sig}`; } export function readOpsToken(raw: string | undefined): { oid: string; pv: string; sso: boolean } | null { if (!raw) return null; const [payload, sig] = raw.split("."); if (!payload || !sig) return null; const expect = b64url(createHmac("sha256", secret()).update(payload).digest()); const a = Buffer.from(sig), b = Buffer.from(expect); if (a.length !== b.length || !timingSafeEqual(a, b)) return null; try { const data = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString()); if (!data.oid || !data.exp || data.exp < Date.now()) return null; return { oid: data.oid, pv: String(data.pv || ""), sso: data.sso === true }; } catch { return null; } } export async function setOpsCookie(oid: string, passwordHash: string, sso = false) { const jar = await cookies(); jar.set(OPS_COOKIE, signOpsSession(oid, passwordHash, sso), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: MAX_AGE, }); } export async function clearOpsCookie() { const jar = await cookies(); jar.set(OPS_COOKIE, "", { httpOnly: true, sameSite: "lax", path: "/", maxAge: 0 }); } export type OperatorRole = "OWNER" | "SUPPORT"; export type OperatorSession = { id: string; email: string; name: string; role: OperatorRole; /** The second factor was proven at the identity provider, not here. */ viaSso: boolean; totpEnabled: boolean; }; /** Re-read per request rather than trusting the token beyond an id, like currentUser(). */ export async function currentOperator(): Promise { const jar = await cookies(); const tok = readOpsToken(jar.get(OPS_COOKIE)?.value); if (!tok) return null; const o = await prisma.operator.findUnique({ where: { id: tok.oid }, select: { id: true, email: true, name: true, role: true, inactive: true, passwordHash: true, totpEnabledAt: true }, }); if (!o || o.inactive) return null; if (tok.pv !== opsPwVersion(o.passwordHash)) return null; // password changed since this token was minted return { id: o.id, email: o.email, name: o.name, role: o.role, viaSso: tok.sso, totpEnabled: !!o.totpEnabledAt }; } const RANK: Record = { SUPPORT: 1, OWNER: 2 }; /** Throws rather than returns null so a route cannot forget to check. */ export async function requireOperator(minRole: OperatorRole = "SUPPORT"): Promise { const o = await currentOperator(); if (!o) throw new Error("UNAUTHENTICATED"); if (RANK[o.role] < RANK[minRole]) throw new Error("FORBIDDEN"); return o; } /* The trail. Every write the console makes goes through here, and so does every reveal and every * sign-in. Never awaited by callers and never throws — a trail that could fail the action it * records would be a reason to skip recording it. `facilityId` is a plain string on purpose (see * the schema): a facility that leaves cannot take the record of what was looked at with it. */ export function logOperatorEvent(e: { operatorId: string; action: string; facilityId?: string; subject?: string; detail?: string; ip?: string }): void { prisma.operatorEvent.create({ data: { operatorId: e.operatorId, action: e.action.slice(0, 60), facilityId: (e.facilityId || "").slice(0, 40), subject: (e.subject || "").slice(0, 200), detail: (e.detail || "").slice(0, 400), ip: (e.ip || "").slice(0, 60), }, }).catch(() => { /* the trail must not fail the action */ }); }