ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
import crypto from "node:crypto";
/* Cloudflare Access application-JWT verification for the operations console.
*
* ops.threadcount.tech sits behind a Cloudflare Access application whose login methods are the
* ThreadCount brand of Authentik (OIDC) and a one-time-PIN break-glass. Access puts a signed
* assertion on every request it lets through (`Cf-Access-Jwt-Assertion`). Trusting that assertion
* is what makes the console single-sign-on: the app mints an operator session from the verified
* email instead of asking for a second password.
*
* Hand-rolled on node:crypto, the way lib/session.ts and lib/totp.ts are, and RS256-ONLY: the
* only operation ever performed is an RSA verify, so the "alg: none" / HS256-with-a-public-key
* confusion cannot apply. Every check fails CLOSED — a malformed token, wrong issuer or audience,
* expired claim or bad signature returns null and the caller falls back to the password door. It
* never throws. Active only when both variables are set; without them the console simply has no
* SSO, and the fire escape is the whole door. */
const TEAM = process.env.CF_ACCESS_TEAM_DOMAIN; // e.g. example.cloudflareaccess.com
const AUD = process.env.CF_ACCESS_AUD; // the Access application's AUD tag
const ISSUER = TEAM ? `https://${TEAM}` : null;
const CERTS_URL = TEAM ? `https://${TEAM}/cdn-cgi/access/certs` : null;
export function accessSsoConfigured(): boolean {
return Boolean(TEAM && AUD);
}
/** Where to land after single sign-on: only a path under /ops, never the sign-in page (a loop),
* never a scheme or a protocol-relative host (an open redirect). Anything else → /ops. */
export function safeOpsNext(raw: string | null | undefined): string {
if (!raw || (raw !== "/ops" && !raw.startsWith("/ops/")) || raw.startsWith("/ops/login")) return "/ops";
if (/[\\\s]/.test(raw) || raw.includes("://")) return "/ops";
return raw;
}
type Jwk = { kid?: string; kty?: string; n?: string; e?: string };
let jwksCache: { keys: Jwk[]; fetchedAt: number } | null = null;
const JWKS_TTL_MS = 10 * 60 * 1000;
async function getKeys(force = false): Promise<Jwk[]> {
if (!force && jwksCache && Date.now() - jwksCache.fetchedAt < JWKS_TTL_MS) return jwksCache.keys;
const r = await fetch(CERTS_URL!, { cache: "no-store" });
if (!r.ok) throw new Error("access certs fetch failed");
const j = (await r.json()) as { keys?: Jwk[] };
const keys = Array.isArray(j.keys) ? j.keys : [];
jwksCache = { keys, fetchedAt: Date.now() };
return keys;
}
function decodeSeg(seg: string): Record<string, unknown> | null {
try {
return JSON.parse(Buffer.from(seg, "base64url").toString("utf8")) as Record<string, unknown>;
} catch {
return null;
}
}
/** The verified, lower-cased email, or null on any failure whatsoever. */
export async function verifyAccessJwt(token: string | null | undefined): Promise<string | null> {
if (!token || !TEAM || !AUD || !ISSUER || !CERTS_URL) return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
const [h, p, s] = parts;
const header = decodeSeg(h);
const payload = decodeSeg(p);
if (!header || !payload) return null;
if (header.alg !== "RS256" || typeof header.kid !== "string") return null;
const now = Math.floor(Date.now() / 1000);
const exp = payload.exp;
const iat = payload.iat;
if (typeof exp !== "number" || exp < now - 5) return null; // expired (small skew)
if (typeof iat === "number" && iat > now + 60) return null; // issued in the future
if (payload.iss !== ISSUER) return null;
const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!auds.includes(AUD)) return null;
const rawEmail = payload.email;
const email = typeof rawEmail === "string" ? rawEmail.trim().toLowerCase() : null;
if (!email) return null;
let keys: Jwk[];
try {
keys = await getKeys();
} catch {
return null;
}
let jwk = keys.find((k) => k.kid === header.kid);
if (!jwk) {
// Unknown kid — Access rotates keys; refresh once before giving up.
try {
keys = await getKeys(true);
} catch {
return null;
}
jwk = keys.find((k) => k.kid === header.kid);
}
if (!jwk || jwk.kty !== "RSA" || !jwk.n || !jwk.e) return null;
let pub: crypto.KeyObject;
try {
pub = crypto.createPublicKey({ key: jwk as crypto.JsonWebKey, format: "jwk" });
} catch {
return null;
}
let ok = false;
try {
ok = crypto.verify("RSA-SHA256", Buffer.from(`${h}.${p}`), pub, Buffer.from(s, "base64url"));
} catch {
return null;
}
return ok ? email : null;
}