import { jwtVerify, SignJWT } from "jose"; import { NextResponse, type NextRequest } from "next/server"; import { getCachedSettings } from "../../lib/db/readCache"; import { isDraining } from "../../lib/gracefulShutdown"; import { checkBodySize, getBodySizeLimit } from "../../shared/middleware/bodySizeGuard"; import { generateRequestId } from "../../shared/utils/requestId"; import { applyCorsHeaders } from "../cors/origins"; import { classifyRoute } from "./classify"; import { classifyStampedPeerLocality } from "./peerStamp"; import { clientApiPolicy } from "./policies/clientApi"; import { managementPolicy } from "./policies/management"; import { publicPolicy } from "./policies/public"; import { AUTHZ_HEADER_AUTH_ID, AUTHZ_HEADER_AUTH_KIND, AUTHZ_HEADER_AUTH_LABEL, AUTHZ_HEADER_AUTH_SCOPES, AUTHZ_HEADER_PEER_LOCALITY, AUTHZ_HEADER_REQUEST_ID, AUTHZ_HEADER_ROUTE_CLASS, AUTHZ_TRUSTED_HEADERS, PEER_IP_HEADER, VIA_PROXY_HEADER, } from "./headers"; import type { AuthSubject, RouteClass, RouteClassification } from "./types"; import type { AuthOutcome, RoutePolicy } from "./context"; export interface AuthzPipelineOptions { enforce?: boolean; } const POLICIES: Record = { PUBLIC: publicPolicy, CLIENT_API: clientApiPolicy, MANAGEMENT: managementPolicy, }; function stampSubject(headers: Headers, subject: AuthSubject): void { headers.set(AUTHZ_HEADER_AUTH_KIND, subject.kind); headers.set(AUTHZ_HEADER_AUTH_ID, subject.id); if (subject.label) headers.set(AUTHZ_HEADER_AUTH_LABEL, subject.label); if (subject.scopes && subject.scopes.length > 0) { headers.set(AUTHZ_HEADER_AUTH_SCOPES, subject.scopes.join(",")); } } function rejectionResponse( outcome: Extract, classification: RouteClassification, requestId: string ): NextResponse { const response = NextResponse.json( { error: { code: outcome.code, message: outcome.message, correlation_id: requestId, }, }, { status: outcome.status } ); response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); response.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); return response; } function isDashboardPath(pathname: string): boolean { return ( pathname === "/dashboard" || pathname.startsWith("/dashboard/") || pathname === "/home" || pathname.startsWith("/home/") ); } function isManagementDashboardRoute( classification: RouteClassification, pathname: string ): boolean { return classification.routeClass === "MANAGEMENT" && isDashboardPath(pathname); } function getCookieValue(request: NextRequest, name: string): string | null { const fromCookies = request.cookies.get(name)?.value; if (fromCookies) return fromCookies; const cookieHeader = request.headers.get("cookie") || request.headers.get("Cookie"); if (!cookieHeader) return null; for (const segment of cookieHeader.split(";")) { const [rawKey, ...rawValue] = segment.split("="); if (!rawKey || rawValue.length === 0) continue; if (rawKey.trim() === name) return rawValue.join("=").trim() || null; } return null; } function getJwtSecret(): Uint8Array | null { const secret = process.env.JWT_SECRET?.trim(); return secret ? new TextEncoder().encode(secret) : null; } function shouldUseSecureCookie(request: NextRequest): boolean { if (process.env.AUTH_COOKIE_SECURE === "true") return true; const forwardedProto = (request.headers.get("x-forwarded-proto") || "") .split(",")[0] .trim() .toLowerCase(); return forwardedProto === "https" || request.nextUrl.protocol === "https:"; } async function refreshDashboardSessionIfNeeded( response: NextResponse, request: NextRequest ): Promise { const secret = getJwtSecret(); if (!secret) return; const token = getCookieValue(request, "auth_token"); if (!token) return; try { const { payload } = await jwtVerify(token, secret); const exp = typeof payload.exp === "number" ? payload.exp : null; if (!exp) return; const now = Math.floor(Date.now() / 1000); const refreshWindowSeconds = 7 * 24 * 60 * 60; if (exp - now >= refreshWindowSeconds) return; const freshToken = await new SignJWT({ authenticated: true }) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("30d") .sign(secret); response.cookies.set("auth_token", freshToken, { httpOnly: true, secure: shouldUseSecureCookie(request), sameSite: "lax", path: "/", }); } catch (error) { console.error("[Authz] JWT auto-refresh failed:", error); } } function dashboardLoginRedirect(request: NextRequest, requestId: string): NextResponse { const response = NextResponse.redirect(new URL("/login", request.url)); response.cookies.delete("auth_token"); stampRouteResponse(response, requestId, "MANAGEMENT"); applyCorsHeaders(response, request); return response; } function drainingResponse(requestId: string): NextResponse { const response = NextResponse.json( { error: { code: "SERVICE_UNAVAILABLE", message: "Server is shutting down", correlation_id: requestId, }, }, { status: 503 } ); response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); return response; } function stampRouteResponse( response: Response, requestId: string, routeClass: RouteClass ): Response { response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); response.headers.set(AUTHZ_HEADER_ROUTE_CLASS, routeClass); return response; } async function getBodySizeSettings(): Promise | undefined> { try { return await getCachedSettings(); } catch (error) { console.warn( "[Authz] Failed to load request body limit settings:", error instanceof Error ? error.message : error ); return undefined; } } export async function runAuthzPipeline( request: NextRequest, options: AuthzPipelineOptions = {} ): Promise { const { pathname } = request.nextUrl; const method = request.method; const requestId = generateRequestId(); if (pathname === "/") { const response = NextResponse.redirect(new URL("/dashboard", request.url)); return stampRouteResponse(response, requestId, "MANAGEMENT"); } const classification = classifyRoute(pathname, method); const guardedPathname = classification.normalizedPath; const managementDashboardRoute = isManagementDashboardRoute(classification, pathname); if (guardedPathname.startsWith("/api/") && isDraining()) { const response = drainingResponse(requestId); stampRouteResponse(response, requestId, classification.routeClass); applyCorsHeaders(response, request); return response; } if (guardedPathname.startsWith("/api/") && method !== "GET" && method !== "OPTIONS") { const bodySizeSettings = await getBodySizeSettings(); const bodySizeRejection = checkBodySize( request, getBodySizeLimit(guardedPathname, bodySizeSettings) ); if (bodySizeRejection) { stampRouteResponse(bodySizeRejection, requestId, classification.routeClass); applyCorsHeaders(bodySizeRejection, request); return bodySizeRejection; } } const requestHeaders = new Headers(request.headers); for (const trusted of AUTHZ_TRUSTED_HEADERS) { requestHeaders.delete(trusted); } // The trusted peer-IP + via-proxy stamps are read by the policy from the // ORIGINAL request (above); strip them from the forwarded headers so the // per-process token never reaches route handlers or upstream providers. requestHeaders.delete(PEER_IP_HEADER); requestHeaders.delete(VIA_PROXY_HEADER); requestHeaders.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); requestHeaders.set(AUTHZ_HEADER_REQUEST_ID, requestId); // Stamp a trusted, non-secret locality verdict derived from the real stamped // peer IP AND the via-proxy marker. Route handlers (e.g. cliTokenAuth) read // this instead of re-deriving locality from the spoofable Host header. The // client-supplied values (if any) were already removed by the // AUTHZ_TRUSTED_HEADERS strip above. When the via-proxy marker is set, a // loopback socket is the proxy hop, not the end-user — verdict is downgraded // to "remote" so the LOCAL_ONLY gate is not bypassed by a request arriving // through an external reverse proxy (nginx / Caddy / Cloudflare Tunnel). // See peerStamp.ts and the upstream da667836 reference for the full rationale. requestHeaders.set( AUTHZ_HEADER_PEER_LOCALITY, classifyStampedPeerLocality( request.headers.get(PEER_IP_HEADER), request.headers.get(VIA_PROXY_HEADER), process.env.OMNIROUTE_PEER_STAMP_TOKEN ) ); if (method === "OPTIONS") { const preflight = new NextResponse(null, { status: 204 }); preflight.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); preflight.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); applyCorsHeaders(preflight, request); return preflight; } if (!options.enforce) { const response = NextResponse.next({ request: { headers: requestHeaders } }); response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); response.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); applyCorsHeaders(response, request); return response; } const policy = POLICIES[classification.routeClass]; const outcome = await policy.evaluate({ request, classification, requestId }); if (!outcome.allow) { if (managementDashboardRoute) { return dashboardLoginRedirect(request, requestId); } const rejection = rejectionResponse(outcome, classification, requestId); applyCorsHeaders(rejection, request); return rejection; } stampSubject(requestHeaders, outcome.subject); const response = NextResponse.next({ request: { headers: requestHeaders } }); response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); response.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); applyCorsHeaders(response, request); if (managementDashboardRoute) { await refreshDashboardSessionIfNeeded(response, request); } return response; }