ai_api / src /server /authz /pipeline.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
10.2 kB
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<RouteClass, RoutePolicy> = {
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<AuthOutcome, { allow: false }>,
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<void> {
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<Record<string, unknown> | 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<Response> {
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;
}