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