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