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 { 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 { 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 { 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 { 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 { 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 { 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 { 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); }