/** * Next.js auth seam for the complete /api/0pt capability surface. * * Customer calls use verified Zitadel bearer identity and receive immutable * x-0pt-verified-* headers. Explicit machine routes may instead use the * shared X-Internal-Token convention; mutation handlers verify it again for * defence in depth. The canonical-fact write is one such explicit machine route. */ import { NextResponse, type NextRequest } from 'next/server'; import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context'; import { SESSION_COOKIE, verifySession } from '@/lib/auth'; import { verifyInternalServiceToken } from '@/src/auth/internal-token'; import { SERVICE_TENANT_SCOPE_HEADER, resolveInternalServiceTenants, } from '@/src/auth/internal-service-tenant'; assertAuthConfigSafe(); export const config = { matcher: ['/api/0pt', '/api/0pt/:path*'], }; const EXEMPT_PATTERNS: RegExp[] = [ /^\/api\/health(\/|$)/, /^\/api\/ready(\/|$)/, /^\/api\/admin\/metrics(\/|$)/, ]; /** Routes that may be reached with a service token instead of end-user bearer auth. */ const INTERNAL_SERVICE_ROUTES: RegExp[] = [ /^\/api\/0pt\/quality\/bridge-run$/, // Acquisition engine -> 0pt canonical observation write (machine-to-machine, // same X-Internal-Token class as the quality bridge; not a blanket bypass). /^\/api\/0pt\/canonical\/facts$/, /^\/api\/0pt\/internal\/evidence-outbox\/dispatch$/, /^\/api\/0pt\/internal\/er\/candidates$/, /^\/api\/0pt\/search\/definitions$/, ]; let _authDiagCount = 0; let internalScopeWarned = false; /** * Headers that only middleware may set. Anything a client sends under these * names is a forgery attempt and is removed before forwarding — including on * the internal-token path, which previously passed the original header map * through unchanged. */ const VERIFIED_HEADERS = [ 'x-0pt-verified-tenant', 'x-0pt-verified-sub', SERVICE_TENANT_SCOPE_HEADER, ] as const; function stripVerifiedHeaders(req: NextRequest): Headers { const forwarded = new Headers(req.headers); for (const name of VERIFIED_HEADERS) forwarded.delete(name); return forwarded; } export async function middleware(req: NextRequest): Promise { const path = req.nextUrl.pathname; if (EXEMPT_PATTERNS.some((re) => re.test(path))) return NextResponse.next(); if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path)) && verifyInternalServiceToken(req.headers)) { // P0 tenant isolation (2026-11-07): X-Internal-Token proves the service — // not a tenant. Tenant authority is SERVER CONFIG (INTERNAL_SERVICE_TENANTS), // never a client header: an inbound `X-0pt-Internal-Tenant` is ignored and // stripped. With no configured tenants the principal has no tenant // authority and tenant-scoped writes fail closed with 403. const forwarded = stripVerifiedHeaders(req); const tenants = resolveInternalServiceTenants(); if (tenants.length > 1) { // The shared X-Internal-Token identifies ONE service process, not a set // of independently authenticated principals. A multi-tenant allowlist // would let any holder of that shared token choose between all listed // tenants, which is exactly the client-controlled authority this guard // exists to remove. Multi-tenant service access requires a verified // workload-identity binding (CL0UD delegation contract); until that // exists, an ambiguous configuration fails closed. console.error( '[0pt-auth] INTERNAL_SERVICE_TENANTS lists more than one tenant: refusing the internal request until workload-identity binding exists', ); return NextResponse.json( { error: 'OPT_UNAUTHENTICATED', message: 'internal service tenant scope is ambiguous: configure exactly one tenant until a verified workload-identity binding exists', }, { status: 401 }, ); } forwarded.set(SERVICE_TENANT_SCOPE_HEADER, tenants.join(',')); if (tenants.length === 1) { forwarded.set('x-0pt-verified-tenant', tenants[0]); } else if (tenants.length === 0 && !internalScopeWarned) { internalScopeWarned = true; console.warn( '[0pt-auth] INTERNAL_SERVICE_TENANTS is unset: the internal service principal has no tenant authority; tenant-scoped writes will be denied', ); } return NextResponse.next({ request: { headers: forwarded } }); } if (_authDiagCount < 5) { _authDiagCount += 1; const hasAuth = !!req.headers.get('authorization'); console.log( `[0pt-auth] middleware path=${path} auth_header=${hasAuth} ` + `auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} ` + `node_env=${process.env.NODE_ENV ?? 'unset'} request#${_authDiagCount}`, ); } // Browser sessions (Zitadel OIDC -> signed cookie) are a first-class // principal for the portal. A Bearer token always wins when present. if (!req.headers.get('authorization')) { const session = await verifySession(req.cookies.get(SESSION_COOKIE)?.value); if ( session && typeof session.sub === 'string' && typeof session.tenantId === 'string' && typeof session.exp === 'number' && session.exp > Date.now() ) { const forwarded = stripVerifiedHeaders(req); forwarded.set('x-0pt-verified-sub', session.sub); forwarded.set('x-0pt-verified-tenant', session.tenantId); return NextResponse.next({ request: { headers: forwarded } }); } } const decision = await resolveAuthContext(req.headers); if (!decision.ok) { return NextResponse.json( { error: decision.code, message: decision.message, ...(decision.details ? { details: decision.details } : {}), }, { status: decision.status }, ); } const principal = decision.ctx.principal; if (principal) { const forwarded = stripVerifiedHeaders(req); forwarded.set('x-0pt-verified-sub', principal.sub); forwarded.set('x-0pt-verified-tenant', principal.tenantId); return NextResponse.next({ request: { headers: forwarded } }); } // No verified principal (explicit AUTH_ENFORCE=false development mode). // Never forward a client-named verified header, even in dev. return NextResponse.next({ request: { headers: stripVerifiedHeaders(req) } }); }