344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
140 lines
6.3 KiB
TypeScript
140 lines
6.3 KiB
TypeScript
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<OperatorSession | null> {
|
|
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<OperatorRole, number> = { SUPPORT: 1, OWNER: 2 };
|
|
|
|
/** Throws rather than returns null so a route cannot forget to check. */
|
|
export async function requireOperator(minRole: OperatorRole = "SUPPORT"): Promise<OperatorSession> {
|
|
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 */ });
|
|
}
|