/** * Next.js auth seam for /api/0pt. Browser/user calls use verified bearer/session. * Explicit machine routes accept: (1) legacy X-Internal-Token + server-configured * single-tenant scope, or (2) verified CL0UD workload JWT (tenant/capabilities * from claims, never client headers). Deny by default. */ 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_CAPABILITIES_HEADER, SERVICE_TENANT_SCOPE_HEADER, resolveInternalServiceTenants, } from '@/src/auth/internal-service-tenant'; import { isCl0udWorkloadConfigured, looksLikeCl0udWorkloadToken, verifyCl0udWorkloadToken } from '@/src/auth/cl0ud-workload'; assertAuthConfigSafe(); export const config = { matcher: ['/api/0pt', '/api/0pt/:path*'] }; const EXEMPT_PATTERNS: RegExp[] = [/^\/api\/health(\/|$)/, /^\/api\/ready(\/|$)/, /^\/api\/admin\/metrics(\/|$)/]; const INTERNAL_SERVICE_ROUTES: RegExp[] = [ /^\/api\/0pt\/quality\/bridge-run$/, /^\/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, SERVICE_CAPABILITIES_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; } function bearerToken(req: NextRequest): string | null { const h = req.headers.get('authorization'); if (!h?.toLowerCase().startsWith('bearer ')) return null; return h.slice(7).trim() || null; } 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))) { // (1) Legacy shared internal token (engine/n8n). 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. if (verifyInternalServiceToken(req.headers)) { 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 } }); } // (2) CL0UD workload JWT (dual-accept): tenant and capabilities come from // verified claims only. Attempted only when the issuer is configured or the // presented token is issued by it; an invalid one is a 401, never a // fall-through to an unauthenticated dev path. const bt = bearerToken(req); if (bt && (isCl0udWorkloadConfigured() || looksLikeCl0udWorkloadToken(bt))) { try { const principal = await verifyCl0udWorkloadToken(bt); const forwarded = stripVerifiedHeaders(req); forwarded.set('x-0pt-verified-sub', principal.sub); forwarded.set('x-0pt-verified-tenant', principal.tenantId); forwarded.set(SERVICE_CAPABILITIES_HEADER, principal.capabilities.join(',')); return NextResponse.next({ request: { headers: forwarded } }); } catch (err) { const message = err instanceof Error ? err.message : 'workload token verification failed'; return NextResponse.json({ error: 'OPT_UNAUTHENTICATED', message }, { status: 401 }); } } } if (_authDiagCount < 5) { _authDiagCount += 1; console.log(`[0pt-auth] middleware path=${path} auth_header=${!!req.headers.get('authorization')} auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} request#${_authDiagCount}`); } 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) } }); }