SPB Git
2.5 KB · 49 lines typescript
1import "server-only";2import { headers } from "next/headers";3import { redirect } from "next/navigation";4import { getSession, verifyCsrf, type SessionUser } from "./session";56export class HttpError extends Error { constructor(public status: number, message: string) { super(message); } }78/** Route handlers: 401 without session, 403 if MFA pending; mutating requests need same-origin + CSRF header. */9export async function requireUser(req?: Request): Promise<SessionUser> {10  const s = await getSession(); if (!s) throw new HttpError(401, "Unauthenticated");11  if (s.totpEnabled && !s.mfaPassed) throw new HttpError(403, "MFA required");12  if (req && !["GET", "HEAD", "OPTIONS"].includes(req.method)) {13    await assertSameOrigin();14    if (!verifyCsrf(s, req.headers.get("x-csrf-token"))) throw new HttpError(403, "CSRF token invalid");15  }16  return s;17}18/** Server actions: session + same-origin + CSRF field from the form. */19export async function requireAction(form: FormData): Promise<SessionUser> {20  const s = await getSession(); if (!s) throw new HttpError(401, "Unauthenticated");21  if (s.totpEnabled && !s.mfaPassed) throw new HttpError(403, "MFA required");22  await assertSameOrigin();23  if (!verifyCsrf(s, String(form.get("_csrf") ?? ""))) throw new HttpError(403, "CSRF token invalid");24  return s;25}26export async function requireUserPage(): Promise<SessionUser> {27  const s = await getSession(); if (!s) redirect("/login");28  if (s.totpEnabled && !s.mfaPassed) redirect("/login?step=totp");29  if (s.mustChangePassword && !((await headers()).get("x-pathname") ?? "").startsWith("/admin/account")) redirect("/admin/account?must_change=1");30  return s;31}32export async function assertSameOrigin() {33  const h = await headers();34  const sfs = h.get("sec-fetch-site");35  if (sfs && sfs !== "same-origin" && sfs !== "none") throw new HttpError(403, "Cross-site request blocked");36  const origin = h.get("origin");37  if (origin) {38    const host = h.get("x-forwarded-host") ?? h.get("host");39    const allowed = new Set([process.env.APP_URL?.replace(/\/$/, ""), `https://${host}`, `http://${host}`].filter(Boolean));40    if (!allowed.has(origin)) throw new HttpError(403, "Origin mismatch");41  }42}43export function jsonError(e: unknown) {44  if (e instanceof HttpError) return Response.json({ error: e.message }, { status: e.status });45  if (e && typeof e === "object" && "issues" in e) return Response.json({ error: "Validation failed", issues: (e as { issues: unknown }).issues }, { status: 400 });46  console.error(e);47  return Response.json({ error: "Internal error" }, { status: 500 });48}49
Files · main
Menu

Theme