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
+202
View File
@@ -0,0 +1,202 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
import { prisma } from "./db";
/* Single sign-on for a facility's own people, through their own identity provider.
*
* The heavy lifting — SAML or OIDC against a hospital's Entra, Okta or Google — is done by a
* self-hosted BoxyHQ Jackson broker at JACKSON_URL (sso.threadcount.tech). Jackson holds each
* facility's IdP metadata, keyed (tenant = facility id, product = "threadcount"), and exposes the
* finished login as a plain OAuth 2.0 code flow. This module is the whole conversation with it:
*
* management POST/GET/DELETE {JACKSON_URL}/api/v1/sso with `Authorization: Api-Key …`
* (admin settings only; the key never leaves the server);
* front door /api/oauth/authorize → /api/oauth/token → /api/oauth/userinfo (the login routes).
*
* OPTIONAL: with JACKSON_URL or JACKSON_API_KEY unset, ssoConfigured() is false, no button renders
* and every route answers 404 — the product without SSO is exactly the product as it was.
*
* ThreadCount has no per-facility hostname, so a person is routed to their facility's IdP by the
* domain of the email they type: a facility registers its domains, and a domain belongs to one
* facility. The callback URL is one fixed address on the product host, never derived from a
* request header, and Jackson only redirects to what was registered. */
const PRODUCT = "threadcount";
// BoxyHQ's documented convention for a single per-tenant connection: fixed client credentials,
// the real routing in `tenant` and `product`.
const OAUTH_CLIENT_ID = "dummy";
const OAUTH_CLIENT_SECRET = "dummy";
export function ssoConfigured(): boolean {
return !!process.env.JACKSON_URL && !!process.env.JACKSON_API_KEY;
}
function jacksonUrl(): string {
const base = process.env.JACKSON_URL;
if (!base) throw new Error("JACKSON_URL is not set");
return base.replace(/\/+$/, "");
}
function apiKeyHeader(): string {
const key = process.env.JACKSON_API_KEY;
if (!key) throw new Error("JACKSON_API_KEY is not set");
return `Api-Key ${key}`;
}
/** The one redirect target Jackson may use: the product's own host, https, fixed path. */
export function ssoCallbackUrl(): string {
const base = (process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech").replace(/\/+$/, "");
return `${base}/api/auth/sso/callback`;
}
export class SsoError extends Error {}
/* ---------- domains ---------- */
/** The registrable part of an address, lower-cased; null when it is not an address. */
export function domainOf(email: string): string | null {
const m = /^[^\s@]+@([^\s@]+\.[^\s@]+)$/.exec(email.trim().toLowerCase());
return m ? m[1] : null;
}
export function normaliseDomain(raw: string): string | null {
const d = raw.trim().toLowerCase().replace(/^@/, "");
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(d) && d.length <= 253 ? d : null;
}
export type SsoFacility = { id: string; name: string; ssoEnabled: boolean; ssoRequired: boolean; ssoStaff: boolean; isDemo: boolean };
/** The facility whose registered domain matches this address and has SSO switched on, if any. */
export async function facilityForEmail(email: string): Promise<SsoFacility | null> {
const d = domainOf(email);
if (!d) return null;
const f = await prisma.facility.findFirst({
where: { ssoEnabled: true, ssoDomains: { has: d } },
select: { id: true, name: true, ssoEnabled: true, ssoRequired: true, ssoStaff: true, isDemo: true },
});
return f && !f.isDemo ? f : null;
}
/** Is this domain already claimed by another facility? Domains route sign-ins, so one owner each. */
export async function domainTakenBy(domain: string, exceptFacilityId: string): Promise<string | null> {
const f = await prisma.facility.findFirst({ where: { ssoDomains: { has: domain }, id: { not: exceptFacilityId } }, select: { name: true } });
return f?.name ?? null;
}
/* ---------- the state cookie: CSRF for the redirect dance ---------- */
export const STATE_COOKIE = "tc_sso";
const STATE_TTL_MS = 10 * 60 * 1000;
export type SsoAudience = "user" | "staff";
function stateKey() {
const s = process.env.SESSION_SECRET;
if (!s) throw new Error("SESSION_SECRET not set");
return createHash("sha256").update("threadcount:sso:v1:" + s).digest();
}
function b64url(buf: Buffer) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** A fresh nonce, and the signed cookie value that binds it to one facility and one audience. */
export function mintState(facilityId: string, aud: SsoAudience): { nonce: string; cookie: string } {
const nonce = randomBytes(32).toString("base64url");
const payload = b64url(Buffer.from(JSON.stringify({ n: nonce, f: facilityId, a: aud, exp: Date.now() + STATE_TTL_MS })));
const sig = b64url(createHmac("sha256", stateKey()).update(payload).digest());
return { nonce, cookie: `${payload}.${sig}` };
}
/** The facility and audience the cookie was minted for, only if `state` is its nonce. */
export function readState(cookie: string | undefined, state: string): { facilityId: string; aud: SsoAudience } | null {
if (!cookie || !state) return null;
const [payload, sig] = cookie.split(".");
if (!payload || !sig) return null;
const expect = b64url(createHmac("sha256", stateKey()).update(payload).digest());
const a = Buffer.from(sig), b = Buffer.from(expect);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const d = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString());
if (!d.n || !d.f || !d.exp || d.exp < Date.now()) return null;
const n = Buffer.from(String(d.n)), s = Buffer.from(state);
if (n.length !== s.length || !timingSafeEqual(n, s)) return null;
return { facilityId: String(d.f), aud: d.a === "staff" ? "staff" : "user" };
} catch {
return null;
}
}
/* ---------- management API ---------- */
export type SsoConnection = { tenant: string; product: string; name?: string; idpMetadata?: { entityID?: string; provider?: string }; clientID?: string };
export async function createOrUpdateConnection(args: { facilityId: string; facilityName: string; metadataUrl?: string; metadataXml?: string }): Promise<SsoConnection> {
const callback = ssoCallbackUrl();
const form = new URLSearchParams();
form.set("tenant", args.facilityId);
form.set("product", PRODUCT);
form.set("name", `ThreadCount — ${args.facilityName}`.slice(0, 120));
form.set("redirectUrl", JSON.stringify([callback]));
form.set("defaultRedirectUrl", callback);
if (args.metadataUrl) form.set("metadataUrl", args.metadataUrl);
else if (args.metadataXml) form.set("encodedRawMetadata", Buffer.from(args.metadataXml, "utf8").toString("base64"));
else throw new SsoError("Provide the identity provider's metadata URL or XML.");
const res = await fetch(`${jacksonUrl()}/api/v1/sso`, {
method: "POST",
headers: { Authorization: apiKeyHeader(), "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(),
});
if (!res.ok) throw new SsoError(`The SSO service rejected that metadata (${res.status}). ${summarise(await res.text().catch(() => ""))}`);
return (await res.json().catch(() => ({}))) as SsoConnection;
}
export async function getConnection(facilityId: string): Promise<SsoConnection | null> {
const res = await fetch(`${jacksonUrl()}/api/v1/sso?tenant=${encodeURIComponent(facilityId)}&product=${PRODUCT}`, { headers: { Authorization: apiKeyHeader() }, cache: "no-store" });
if (!res.ok) throw new SsoError(`Could not read the SSO connection (${res.status}). ${summarise(await res.text().catch(() => ""))}`);
const data = (await res.json().catch(() => [])) as SsoConnection[] | SsoConnection;
const list = Array.isArray(data) ? data : [data];
return list[0] && list[0].tenant ? list[0] : null;
}
export async function deleteConnection(facilityId: string): Promise<void> {
const res = await fetch(`${jacksonUrl()}/api/v1/sso?tenant=${encodeURIComponent(facilityId)}&product=${PRODUCT}`, { method: "DELETE", headers: { Authorization: apiKeyHeader() } });
if (!res.ok && res.status !== 404) throw new SsoError(`Could not remove the SSO connection (${res.status}). ${summarise(await res.text().catch(() => ""))}`);
}
/* ---------- front door ---------- */
export function buildAuthorizeUrl(facilityId: string, state: string): string {
const q = new URLSearchParams({ client_id: OAUTH_CLIENT_ID, redirect_uri: ssoCallbackUrl(), response_type: "code", scope: "openid", state, tenant: facilityId, product: PRODUCT });
return `${jacksonUrl()}/api/oauth/authorize?${q.toString()}`;
}
export async function exchangeCode(code: string): Promise<string> {
const body = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: ssoCallbackUrl(), client_id: OAUTH_CLIENT_ID, client_secret: OAUTH_CLIENT_SECRET });
const res = await fetch(`${jacksonUrl()}/api/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString() });
if (!res.ok) throw new SsoError(`SSO token exchange failed (${res.status}). ${summarise(await res.text().catch(() => ""))}`);
const data = (await res.json().catch(() => ({}))) as { access_token?: string };
if (!data.access_token) throw new SsoError("SSO token exchange returned no access token.");
return data.access_token;
}
export type SsoProfile = { email: string; name?: string };
export async function fetchProfile(accessToken: string): Promise<SsoProfile> {
const res = await fetch(`${jacksonUrl()}/api/oauth/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store" });
if (!res.ok) throw new SsoError(`Could not read the SSO profile (${res.status}). ${summarise(await res.text().catch(() => ""))}`);
const p = (await res.json().catch(() => ({}))) as { email?: string; firstName?: string; lastName?: string; name?: string };
const email = typeof p.email === "string" ? p.email.trim().toLowerCase() : "";
if (!email) throw new SsoError("The identity provider did not return an email address.");
const name = (p.name?.trim() || [p.firstName, p.lastName].filter(Boolean).join(" ").trim()) || undefined;
return { email, name };
}
// Jackson error bodies are sometimes JSON ({error:{message}}) and sometimes text; keep a short
// safe snippet for the server log and the admin, never a whole SAML payload.
function summarise(detail: string): string {
if (!detail) return "";
try {
const j = JSON.parse(detail) as { error?: { message?: string } | string };
const msg = typeof j.error === "string" ? j.error : j.error?.message;
if (msg) return msg.slice(0, 200);
} catch { /* not JSON */ }
return detail.slice(0, 200);
}