1bc2de655a
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
152 lines
8.9 KiB
TypeScript
152 lines
8.9 KiB
TypeScript
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 };
|
|
}
|