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