Files
0pt/middleware.ts
T
Claude (SIAX platform session) 1c1e2cb649
CI (SIAX Cloud) / security (push) Successful in 23s
CI (SIAX Cloud) / quality (push) Successful in 42s
CI (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / quality (pull_request) Successful in 42s
fix(auth): make the AUTH_ENFORCE default fail-closed on /api/0pt/*
The auth seam read `process.env.AUTH_ENFORCE === 'true'`, so an absent env
var meant NO authentication: any fresh container, local run, DR restore or
redeploy that dropped the variable served /api/0pt/* anonymously, taking the
tenant from the caller-supplied X-Tenant-Id header. 0pt.app is deployed and
was answering anonymously (verified: an invalid Bearer token plus an
arbitrary X-Tenant-Id returned 200), so this was a live hole, not latent.

* authEnforced() now returns true unless AUTH_ENFORCE is explicitly "false"
  (trimmed, case-insensitive). Unset/empty/misspelled => ENFORCED.
* assertAuthConfigSafe() throws AuthMisconfigurationError when
  NODE_ENV=production and enforcement resolved off, naming AUTH_ENFORCE. It
  runs at middleware module load (startup) and on the env-driven request
  path, so a production instance can neither boot nor serve a single
  unauthenticated request with auth switched off.
* .env.example no longer teaches AUTH_ENFORCE=false; README's Deploy section
  states the fail-closed posture (no DEPLOY.md in this repo).
* 7 new tests, incl. the regression case: with AUTH_ENFORCE unset and no
  `enforce` option, an unauthenticated request is rejected with 401. No test
  was inverted or deleted -- none asserted the open default; the three
  enforce:false cases now describe the explicit local-dev escape hatch.

Tests: 121 passed / 1 skipped before, 128 passed / 1 skipped after.
Typecheck and build clean; advisory lint unchanged (16 pre-existing problems).

Scope: auth default only. The matcher still covers /api/0pt/* alone, and the
handlers still take tenant_id from the query/body rather than the verified
principal -- both described in the PR body, neither fixed here.
2026-07-27 13:28:24 +02:00

63 lines
2.5 KiB
TypeScript

/**
* `middleware.ts` -- Next.js request middleware = 0pt's framework-level auth
* seam (the Next analog of a Fastify `onRequest` hook).
*
* This runs the Zitadel verification (src/auth/context.ts ->
* src/auth/zitadel.ts) in front of every 0pt capability route (/api/0pt/*).
* It does NOT touch the v0-generated UI routes or the unrelated
* payment/order APIs (see the matcher below and the note in the PR that
* introduced the fail-closed default).
*
* AUTH_ENFORCE unset, or anything except "false" (DEFAULT, enforced):
* a valid verified token is required; 401 on missing/invalid, 403 when
* X-Tenant-Id disagrees with the token tenant. On success the verified
* principal is forwarded to the handler as `x-0pt-verified-sub` /
* `x-0pt-verified-tenant` request headers.
* AUTH_ENFORCE == "false" (explicit local-dev escape hatch): never rejects.
* REFUSED in production -- `assertAuthConfigSafe()` below throws at module
* load, so a production instance cannot boot with auth switched off.
*
* Health/metrics are exempt (they are also outside the matcher below, but the
* exemption is kept explicit for robustness).
*/
import { NextResponse, type NextRequest } from 'next/server';
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
// STARTUP GATE: fail to boot rather than serve /api/0pt/* openly in production.
assertAuthConfigSafe();
// Only guard the 0pt capability surface. The matcher keeps this middleware off
// the UI, auth, and payment routes entirely.
export const config = {
matcher: ['/api/0pt/:path*'],
};
const EXEMPT_PATTERNS: RegExp[] = [/^\/api\/health(\/|$)/, /^\/api\/admin\/metrics(\/|$)/];
export async function middleware(req: NextRequest): Promise<NextResponse> {
const path = req.nextUrl.pathname;
if (EXEMPT_PATTERNS.some((re) => re.test(path))) {
return NextResponse.next();
}
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 = new Headers(req.headers);
forwarded.set('x-0pt-verified-sub', principal.sub);
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
return NextResponse.next({ request: { headers: forwarded } });
}
return NextResponse.next();
}