import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { clientIp } from "@/lib/ratelimit"; import { safeOpsNext, verifyAccessJwt } from "@/lib/ops/cfAccess"; import { logOperatorEvent, setOpsCookie } from "@/lib/ops/session"; export const dynamic = "force-dynamic"; /* Single sign-on entry. Reached when a browser arrives with a Cloudflare Access assertion but no * operator session (the sign-in page hands off here). The assertion is verified fail-closed * (lib/ops/cfAccess.ts), the verified email is mapped to an EXISTING, ACTIVE operator, and the * ordinary tc_ops session is minted, marked `sso`. Never creates an operator: an email Access * admits but the console does not know falls back to the password door. Every failure lands on * /ops/login?sso=failed — never a bypass, never a loop (the sign-in page does not re-trigger SSO * when ?sso is present). * * Relative Location on purpose: an absolute URL built from the request would carry the origin * nginx sees (127.0.0.1:3000), not the public host. */ function seeOther(location: string): NextResponse { return new NextResponse(null, { status: 303, headers: { Location: location } }); } export async function GET(req: NextRequest) { const failed = seeOther("/ops/login?sso=failed"); const next = safeOpsNext(req.nextUrl.searchParams.get("next")); const ip = clientIp(req.headers); const email = await verifyAccessJwt(req.headers.get("cf-access-jwt-assertion")); if (!email) return failed; const op = await prisma.operator.findUnique({ where: { email }, select: { id: true, inactive: true, passwordHash: true }, }); if (!op || op.inactive) { // The trail cannot name an operator it does not have; the address goes in the detail, since // "who Access let in that the console refused" is the fact worth keeping. console.warn("[ops sso] no active operator for", email, "from", ip); return failed; } await setOpsCookie(op.id, op.passwordHash, true); logOperatorEvent({ operatorId: op.id, action: "ops:signin.sso", ip }); prisma.operator.update({ where: { id: op.id }, data: { lastSeenAt: new Date() } }).catch(() => {}); return seeOther(next); }