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");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { facilityForEmail, ssoConfigured } from "@/lib/sso";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Does this address belong to a facility that signs in with single sign-on?
|
||||
*
|
||||
* Asked by the Log in box once an address is typed, so the box can offer "Continue with single
|
||||
* sign-on" before anyone reaches for a password. It answers about a DOMAIN, never a person: a
|
||||
* facility that registered its domain is a fact about the facility, and the reply carries nothing
|
||||
* about whether the address itself has an account. Throttled per address, since it is a lookup
|
||||
* anyone may make. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
if (!allow("sso-lookup:" + clientIp(req.headers), 60, 15 * 60 * 1000)) return NextResponse.json({ sso: false });
|
||||
if (!ssoConfigured()) return NextResponse.json({ sso: false });
|
||||
let body: { email?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
|
||||
const f = await facilityForEmail(email);
|
||||
if (!f) return NextResponse.json({ sso: false });
|
||||
return NextResponse.json({ sso: true, required: f.ssoRequired, facility: f.name });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { buildAuthorizeUrl, facilityForEmail, mintState, ssoConfigured, STATE_COOKIE } from "@/lib/sso";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Begin single sign-on for the facility that owns this address's domain.
|
||||
*
|
||||
* Mints a nonce, keeps it in a signed, httpOnly cookie bound to that facility (and to whether a
|
||||
* wearer or a coordinator is expected back), echoes it as the OAuth `state`, and sends the browser
|
||||
* to the broker. The callback requires the returned state to be the cookie's nonce, so a forged
|
||||
* or replayed callback has nothing to match. The redirect target handed to the broker is the
|
||||
* product's one fixed callback address — never a request header.
|
||||
*
|
||||
* A cross-site link may not start this (login CSRF: a stranger's page must not be able to sign
|
||||
* you into an account of its choosing), and a signed-in coordinator is sent to the app instead. */
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!ssoConfigured()) return NextResponse.json({ error: "Single sign-on is not available." }, { status: 404 });
|
||||
if (req.headers.get("sec-fetch-site") === "cross-site") return new NextResponse(null, { status: 303, headers: { Location: "/auth" } });
|
||||
if (!allow("sso-start:" + clientIp(req.headers), 60, 15 * 60 * 1000)) return new NextResponse(null, { status: 303, headers: { Location: "/auth?error=sso_failed" } });
|
||||
const cur = await currentUser();
|
||||
if (cur && !cur.isDemo) return new NextResponse(null, { status: 303, headers: { Location: "/app" } });
|
||||
|
||||
const email = (req.nextUrl.searchParams.get("email") || "").trim().toLowerCase().slice(0, 160);
|
||||
const f = await facilityForEmail(email);
|
||||
if (!f) return new NextResponse(null, { status: 303, headers: { Location: "/auth?error=sso_unavailable" } });
|
||||
|
||||
const aud = req.nextUrl.searchParams.get("as") === "staff" ? "staff" : "user";
|
||||
if (aud === "staff" && !f.ssoStaff) return new NextResponse(null, { status: 303, headers: { Location: "/auth?error=sso_unavailable" } });
|
||||
const { nonce, cookie } = mintState(f.id, aud);
|
||||
const res = new NextResponse(null, { status: 302, headers: { Location: buildAuthorizeUrl(f.id, nonce) } });
|
||||
res.cookies.set(STATE_COOKIE, cookie, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax", // the broker returns by a top-level navigation, which lax still sends
|
||||
path: "/api/auth/sso",
|
||||
maxAge: 10 * 60,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
Reference in New Issue
Block a user