ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user