ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { recordAudit } from "@/lib/audit";
|
||||
import { bumpRev } from "@/lib/ops";
|
||||
import { createOrUpdateConnection, deleteConnection, domainTakenBy, getConnection, normaliseDomain, ssoConfigured, SsoError } from "@/lib/sso";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* An admin's single sign-on settings for their own facility.
|
||||
*
|
||||
* GET the switches, the registered domains, and whether the broker holds a connection;
|
||||
* POST connect: hand the IdP metadata to the broker, then — and only then — switch SSO on;
|
||||
* PATCH the switches and domains, with SSO already connected;
|
||||
* DELETE disconnect: remove the connection from the broker and switch everything off.
|
||||
*
|
||||
* The IdP metadata never touches this database; the broker keeps it. The switches live on the
|
||||
* facility row, so the sign-in routes can read them without asking the broker. Admin only, never
|
||||
* the demo, and 404 throughout when no broker is configured — the feature then does not exist. */
|
||||
function notHere() { return NextResponse.json({ error: "Single sign-on is not available on this server." }, { status: 404 }); }
|
||||
|
||||
async function gate(req: NextRequest, json: boolean) {
|
||||
if (!ssoConfigured()) return { res: notHere() } as const;
|
||||
const csrf = sameOriginJson(req, json);
|
||||
if (csrf) return { res: NextResponse.json({ error: csrf }, { status: 403 }) } as const;
|
||||
const user = await currentUser();
|
||||
if (!user) return { res: NextResponse.json({ error: "Not signed in" }, { status: 401 }) } as const;
|
||||
if (user.role !== "ADMIN") return { res: NextResponse.json({ error: "Admins only" }, { status: 403 }) } as const;
|
||||
if (user.isDemo) return { res: NextResponse.json({ error: "Not available in the demo." }, { status: 403 }) } as const;
|
||||
if (!allow("sso-admin:" + user.id, 30, 15 * 60 * 1000)) return { res: NextResponse.json({ error: "Too many changes — try again in a few minutes." }, { status: 429 }) } as const;
|
||||
return { user } as const;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!ssoConfigured()) return notHere();
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admins only" }, { status: 403 });
|
||||
const f = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { ssoEnabled: true, ssoRequired: true, ssoStaff: true, ssoDomains: true } });
|
||||
let connected: boolean | null = null, idp: string | null = null;
|
||||
try {
|
||||
const c = await getConnection(user.facilityId);
|
||||
connected = !!c;
|
||||
idp = c?.idpMetadata?.provider || c?.idpMetadata?.entityID || null;
|
||||
} catch {
|
||||
connected = null; // the broker could not be reached; the switches still say what they say
|
||||
}
|
||||
return NextResponse.json({ enabled: f.ssoEnabled, required: f.ssoRequired, staff: f.ssoStaff, domains: f.ssoDomains, connected, idp });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await gate(req, true);
|
||||
if ("res" in g) return g.res;
|
||||
const { user } = g;
|
||||
let body: { metadataUrl?: unknown; metadataXml?: unknown; domains?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const metadataUrl = String(body.metadataUrl ?? "").trim().slice(0, 2000);
|
||||
const metadataXml = String(body.metadataXml ?? "").trim().slice(0, 200_000);
|
||||
if (!metadataUrl && !metadataXml) return NextResponse.json({ error: "Paste your identity provider's metadata URL or its XML." }, { status: 400 });
|
||||
if (metadataUrl) {
|
||||
let u: URL;
|
||||
try { u = new URL(metadataUrl); } catch { return NextResponse.json({ error: "That metadata URL isn't a valid URL." }, { status: 400 }); }
|
||||
// Fetched by the broker server-side: only https, or a document could be swapped in transit.
|
||||
if (u.protocol !== "https:") return NextResponse.json({ error: "The metadata URL must start with https://." }, { status: 400 });
|
||||
}
|
||||
const domains = await checkDomains(body.domains, user.facilityId);
|
||||
if ("error" in domains) return NextResponse.json({ error: domains.error }, { status: 400 });
|
||||
if (domains.list.length === 0) return NextResponse.json({ error: "Add at least one email domain — it is how your people reach your sign-in." }, { status: 400 });
|
||||
|
||||
const f = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { name: true } });
|
||||
try {
|
||||
await createOrUpdateConnection({ facilityId: user.facilityId, facilityName: f.name, metadataUrl: metadataUrl || undefined, metadataXml: metadataXml || undefined });
|
||||
} catch (e) {
|
||||
if (e instanceof SsoError) return NextResponse.json({ error: e.message }, { status: 502 });
|
||||
throw e;
|
||||
}
|
||||
// Only once the broker holds a real connection does the switch go on.
|
||||
await prisma.facility.update({ where: { id: user.facilityId }, data: { ssoEnabled: true, ssoDomains: domains.list } });
|
||||
recordAudit(user, "settings.sso.connect", { domains: domains.list, via: metadataUrl ? "url" : "xml" }, clientIp(req.headers));
|
||||
await bumpRev(user.facilityId);
|
||||
return NextResponse.json({ ok: true, enabled: true, domains: domains.list });
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const g = await gate(req, true);
|
||||
if ("res" in g) return g.res;
|
||||
const { user } = g;
|
||||
let body: { required?: unknown; staff?: unknown; domains?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const f = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { ssoEnabled: true } });
|
||||
if (!f.ssoEnabled) return NextResponse.json({ error: "Connect your identity provider first." }, { status: 400 });
|
||||
const data: { ssoRequired?: boolean; ssoStaff?: boolean; ssoDomains?: string[] } = {};
|
||||
if (body.required !== undefined) {
|
||||
data.ssoRequired = body.required === true;
|
||||
if (data.ssoRequired) {
|
||||
// Requiring SSO with nobody left holding a password is a facility nobody can enter the day
|
||||
// the identity provider is down. Somebody — an admin — keeps a key.
|
||||
const keys = await prisma.user.count({ where: { facilityId: user.facilityId, role: "ADMIN", inactive: false, ssoBreakGlass: true } });
|
||||
if (keys === 0) return NextResponse.json({ error: "Mark at least one admin as break-glass first — they keep a working password for the day the identity provider is down." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (body.staff !== undefined) data.ssoStaff = body.staff === true;
|
||||
if (body.domains !== undefined) {
|
||||
const domains = await checkDomains(body.domains, user.facilityId);
|
||||
if ("error" in domains) return NextResponse.json({ error: domains.error }, { status: 400 });
|
||||
if (domains.list.length === 0) return NextResponse.json({ error: "Keep at least one email domain." }, { status: 400 });
|
||||
data.ssoDomains = domains.list;
|
||||
}
|
||||
await prisma.facility.update({ where: { id: user.facilityId }, data });
|
||||
recordAudit(user, "settings.sso.update", data, clientIp(req.headers));
|
||||
await bumpRev(user.facilityId);
|
||||
return NextResponse.json({ ok: true, ...data });
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const g = await gate(req, false);
|
||||
if ("res" in g) return g.res;
|
||||
const { user } = g;
|
||||
try {
|
||||
await deleteConnection(user.facilityId);
|
||||
} catch (e) {
|
||||
if (e instanceof SsoError) return NextResponse.json({ error: e.message }, { status: 502 });
|
||||
throw e;
|
||||
}
|
||||
// Everything off, whatever the broker said: the button's job is to end SSO here.
|
||||
await prisma.facility.update({ where: { id: user.facilityId }, data: { ssoEnabled: false, ssoRequired: false, ssoStaff: false } });
|
||||
recordAudit(user, "settings.sso.disconnect", {}, clientIp(req.headers));
|
||||
await bumpRev(user.facilityId);
|
||||
return NextResponse.json({ ok: true, enabled: false });
|
||||
}
|
||||
|
||||
/** Up to ten well-formed domains, each owned by no other facility. */
|
||||
async function checkDomains(raw: unknown, facilityId: string): Promise<{ list: string[] } | { error: string }> {
|
||||
const arr = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\s,]+/) : [];
|
||||
const list: string[] = [];
|
||||
for (const r of arr) {
|
||||
if (typeof r !== "string" || !r.trim()) continue;
|
||||
const d = normaliseDomain(r);
|
||||
if (!d) return { error: `“${String(r).slice(0, 60)}” isn't a domain. Use the part after the @ in your work addresses, like health.example.` };
|
||||
if (["gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com"].includes(d)) return { error: `${d} is a public mail service, not your facility's — anyone could sign up there.` };
|
||||
if (!list.includes(d)) list.push(d);
|
||||
}
|
||||
if (list.length > 10) return { error: "Ten domains at most." };
|
||||
for (const d of list) {
|
||||
const owner = await domainTakenBy(d, facilityId);
|
||||
if (owner) return { error: `${d} is already registered by another facility.` };
|
||||
}
|
||||
return { list };
|
||||
}
|
||||
Reference in New Issue
Block a user