344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
203 lines
11 KiB
TypeScript
203 lines
11 KiB
TypeScript
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);
|
|
}
|