ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:45:19 +10:00
commit 1bc2de655a
505 changed files with 56223 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { setSessionCookie } from "@/lib/session";
import { setStaffCookie } from "@/lib/staffsession";
import { allow, clientIp, fail, over } from "@/lib/ratelimit";
import { recordAuthEvent } from "@/lib/audit";
import { exchangeCode, fetchProfile, readState, ssoConfigured, SsoError, STATE_COOKIE } from "@/lib/sso";
export const dynamic = "force-dynamic";
/* The broker sends the browser back here with ?code&state once the identity provider has spoken.
* This route is the whole trust boundary, so, in order:
*
* 1. state must be the nonce in our signed cookie, which also says WHICH facility this login
* was started for and whether a coordinator or a wearer is expected;
* 2. the code is exchanged server-side and the profile read from the broker — the IdP's tokens
* never touch the browser;
* 3. the profile's email must match an existing, active account IN THAT FACILITY: a coordinator
* (User) or, if the facility allows wearers, a staff account. Nothing is ever created here —
* an address the identity provider vouches for but the facility never added is not a person
* the facility asked to let in;
* 4. only then is the ordinary session cookie minted, marked sso. A passed assertion is a whole
* authentication (the identity provider owns the second factor), so no TOTP step follows.
*
* No Turnstile and no same-origin check: this is a top-level navigation from the broker's origin,
* and the state cookie is the CSRF proof. Every failure lands on /auth?error=…, never a bypass. */
const back = (path: string) => {
const res = new NextResponse(null, { status: 303, headers: { Location: path } });
res.cookies.set(STATE_COOKIE, "", { httpOnly: true, sameSite: "lax", path: "/api/auth/sso", maxAge: 0 });
return res;
};
export async function GET(req: NextRequest) {
if (!ssoConfigured()) return back("/auth?error=sso_unavailable");
const ip = clientIp(req.headers);
// Failures only, so a whole site signing in behind one address is never locked out.
if (over("sso-callback:" + ip, 20, 15 * 60 * 1000)) return back("/auth?error=sso_failed");
const bad = (path: string) => { fail("sso-callback:" + ip, 15 * 60 * 1000); return back(path); };
if (!allow("sso-callback-all:" + ip, 120, 15 * 60 * 1000)) return back("/auth?error=sso_failed");
const code = req.nextUrl.searchParams.get("code") || "";
const state = req.nextUrl.searchParams.get("state") || "";
if (req.nextUrl.searchParams.get("error") || !code || !state) return bad("/auth?error=sso_failed");
const st = readState(req.cookies.get(STATE_COOKIE)?.value, state);
if (!st) return bad("/auth?error=sso_state");
const f = await prisma.facility.findUnique({ where: { id: st.facilityId }, select: { id: true, isDemo: true, ssoEnabled: true, ssoStaff: true } });
if (!f || f.isDemo || !f.ssoEnabled) return bad("/auth?error=sso_unavailable");
let email: string;
try {
email = (await fetchProfile(await exchangeCode(code))).email;
} catch (e) {
if (!(e instanceof SsoError)) console.error("[sso] callback exchange failed", e);
return bad("/auth?error=sso_failed");
}
if (st.aud === "staff") {
if (!f.ssoStaff) return bad("/auth?error=sso_unavailable");
const acc = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true, facilityId: true, passwordHash: true, staff: { select: { id: true, first: true, last: true, inactive: true } } } });
if (!acc || acc.facilityId !== f.id) return bad("/auth?error=sso_no_account");
if (acc.staff.inactive) return bad("/auth?error=sso_inactive");
await prisma.staffAccount.update({ where: { id: acc.id }, data: { lastSeenAt: new Date() } });
await setStaffCookie(acc.id, acc.passwordHash, true);
recordAuthEvent({ facilityId: acc.facilityId, userId: acc.staff.id, userName: `${acc.staff.first} ${acc.staff.last}` }, "staff:signin", ip, "sso");
return back("/my");
}
const u = await prisma.user.findUnique({ where: { email }, select: { id: true, facilityId: true, passwordHash: true, first: true, last: true, inactive: true } });
if (!u || u.facilityId !== f.id) {
// A wearer typing at the shared box reaches here with aud "user"; if the facility lets its
// wearers use SSO, look them up too rather than sending them away.
if (f.ssoStaff) {
const acc = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true, facilityId: true, passwordHash: true, staff: { select: { id: true, first: true, last: true, inactive: true } } } });
if (acc && acc.facilityId === f.id) {
if (acc.staff.inactive) return bad("/auth?error=sso_inactive");
await prisma.staffAccount.update({ where: { id: acc.id }, data: { lastSeenAt: new Date() } });
await setStaffCookie(acc.id, acc.passwordHash, true);
recordAuthEvent({ facilityId: acc.facilityId, userId: acc.staff.id, userName: `${acc.staff.first} ${acc.staff.last}` }, "staff:signin", ip, "sso");
return back("/my");
}
}
return bad("/auth?error=sso_no_account");
}
if (u.inactive) return bad("/auth?error=sso_inactive");
await setSessionCookie(u.id, u.passwordHash, true);
recordAuthEvent({ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}` }, "auth:signin", ip, "sso");
return back("/app");
}