From 1c1e2cb649344856f5447013c9d51fce4e1cac13 Mon Sep 17 00:00:00 2001 From: "Claude (SIAX platform session)" Date: Mon, 27 Jul 2026 13:28:24 +0200 Subject: [PATCH] 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. --- .env.example | 12 ++-- README.md | 4 ++ middleware.ts | 27 +++++---- src/auth/context.ts | 118 +++++++++++++++++++++++++++++-------- tests/auth/context.test.ts | 100 ++++++++++++++++++++++++++++--- 5 files changed, 211 insertions(+), 50 deletions(-) diff --git a/.env.example b/.env.example index e4ee706..61e712b 100644 --- a/.env.example +++ b/.env.example @@ -39,11 +39,13 @@ INTERNAL_TOKEN=change-me-internal-token # # Fleet Zitadel issuer (OIDC). Default when unset: https://id-customers.siax.io ZITADEL_ISSUER=https://id-customers.siax.io -# Flag-gated enforcement. When != "true" (DEFAULT) auth is non-breaking: -# a present Bearer token is best-effort verified and attached, but never -# rejected. Set to "true" to REQUIRE a valid token on /api/0pt/* (401 on -# missing/invalid, 403 when X-Tenant-Id disagrees with the token tenant). -AUTH_ENFORCE=false +# Enforcement is FAIL-CLOSED. Leave this UNSET (or set it to "true") and a +# valid, verified Bearer token is REQUIRED on /api/0pt/* -- 401 on +# missing/invalid, 403 when X-Tenant-Id disagrees with the token tenant. +# ONLY the literal value "false" disables authentication, and that value is +# REFUSED when NODE_ENV=production: the app throws at startup instead of +# serving openly. Use "false" for local development only. +AUTH_ENFORCE=true # Optional override for the claim key that carries the SIAX tenant id. When # unset, tenant derivation uses the documented precedence: # urn:zitadel:iam:org:id -> urn:zitadel:iam:user:resourceowner:id -> tenant_id -> tenant. diff --git a/README.md b/README.md index 9ccf42a..5bc2675 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ cd services/api && pip install -r requirements.txt && uvicorn orchestrator.main: - **Frontend** → Vercel (project `0pt`, scope *Simon Axelsson's projects*), domain `0pt.app`. - **Engine + DB + embeds** → one VPS via [`infra/docker-compose.yml`](infra/docker-compose.yml) (Postgres+pgvector, Redis, api, worker, NocoDB, Metabase, Caddy). - Env inventory: [`.env.example`](.env.example). +- **Auth on `/api/0pt/*` is fail-closed**: with `AUTH_ENFORCE` unset the middleware + REQUIRES a verified Zitadel Bearer token (401 without one). Only the literal + `AUTH_ENFORCE=false` disables it, and that is refused when `NODE_ENV=production` + (the process throws at startup rather than serving anonymously). ## Status Frontend, full order lifecycle, customer + admin portals, edge API, payment/auth/embed diff --git a/middleware.ts b/middleware.ts index d551401..acecf6a 100644 --- a/middleware.ts +++ b/middleware.ts @@ -2,23 +2,30 @@ * `middleware.ts` -- Next.js request middleware = 0pt's framework-level auth * seam (the Next analog of a Fastify `onRequest` hook). * - * Wave 2 hardening: this runs the flag-gated 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. + * 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 != "true" (default): never rejects; if a verified token is - * present the verified principal is forwarded to the handler as - * `x-0pt-verified-sub` / `x-0pt-verified-tenant` request headers. - * AUTH_ENFORCE == "true": a valid verified token is required; 401 on - * missing/invalid, 403 when X-Tenant-Id disagrees with the token tenant. + * 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 { resolveAuthContext } from '@/src/auth/context'; +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. diff --git a/src/auth/context.ts b/src/auth/context.ts index 1a19091..bd32eb0 100644 --- a/src/auth/context.ts +++ b/src/auth/context.ts @@ -3,26 +3,31 @@ * seam the framework layer (Next.js middleware.ts) calls. * * ========================================================================== - * WAVE 2 HARDENING STATUS + * AUTH DEFAULT: FAIL-CLOSED * - * 0pt now performs REAL, cryptographic Zitadel (OIDC) Bearer-token - * verification (src/auth/zitadel.ts), gated behind the AUTH_ENFORCE env flag - * so it can ship to the deployed console WITHOUT breaking existing callers: + * 0pt performs REAL, cryptographic Zitadel (OIDC) Bearer-token verification + * (src/auth/zitadel.ts). Enforcement is controlled by AUTH_ENFORCE, and the + * DEFAULT IS ENFORCED -- an absent, empty, or misspelled value authenticates: * - * AUTH_ENFORCE != "true" (DEFAULT, non-breaking): - * Preserves the historical behavior (no token was ever required; tenant - * came from the X-Tenant-Id header / request body). ADDITIONALLY, if a - * Bearer token is present and JWT-shaped, we ATTEMPT `verifyBearer` and - * attach the verified principal to the resolved context -- but we NEVER - * reject on failure in this mode (a warning is logged). This lets tokens - * start flowing and being verified with zero caller migration. - * - * AUTH_ENFORCE == "true" (enforced): + * AUTH_ENFORCE unset, or any value except "false" (DEFAULT, enforced): * REQUIRES a valid, verified token on every non-exempt route. The tenant * is derived from the TOKEN (not the X-Tenant-Id header). Missing/invalid * token -> 401 (OPT_UNAUTHENTICATED). If an X-Tenant-Id header is present * and disagrees with the token's tenant -> 403 (OPT_ACCESS_DENIED). * + * AUTH_ENFORCE == "false" (explicit local-development escape hatch): + * No token is required; the tenant comes from the X-Tenant-Id header / + * request body. A present, JWT-shaped Bearer token is still best-effort + * verified and attached, but never rejected. This mode is REFUSED in + * production: {@link assertAuthConfigSafe} throws when NODE_ENV=production, + * so an instance that loses (or mis-sets) the variable cannot serve + * openly -- it fails to start, and fails the request, instead. + * + * WHY THE INVERSION: the predicate previously read `AUTH_ENFORCE === 'true'`, + * so every fresh container, local run, DR restore, or redeploy that dropped + * the variable ran with NO authentication at all, and `.env.example` shipped + * `AUTH_ENFORCE=false`. Absence now means enforced. + * * This module is framework-neutral: it takes a minimal header-lookup (the Web * `Headers` interface satisfies it, as does Next's `NextRequest.headers`) and * returns a decision object. It does NOT import next/*, so it is fully @@ -38,7 +43,7 @@ export interface HeaderLookup { } export interface OptRequestContext { - /** Tenant id: from the token in enforce mode, else from X-Tenant-Id header. */ + /** Tenant id: from the token when enforced, else from the X-Tenant-Id header. */ tenantId: string; /** Actor id: from X-Actor-Id header, falling back to the token subject. */ actorId: string; @@ -61,9 +66,57 @@ export type AuthDecision = ctx: OptRequestContext; }; -/** True when hard auth enforcement is switched on (AUTH_ENFORCE=="true"). */ -export function authEnforced(): boolean { - return process.env.AUTH_ENFORCE === 'true'; +/** Name of the environment variable that can switch enforcement off. */ +export const AUTH_ENFORCE_VAR = 'AUTH_ENFORCE'; + +/** The only two environment values this module reads; injectable for tests. */ +export interface AuthEnvView { + AUTH_ENFORCE?: string; + NODE_ENV?: string; +} + +/** + * Snapshot the auth-relevant environment. `process.env.NODE_ENV` is written + * literally so bundlers (Next's edge compiler) inline it. + */ +function currentEnv(): AuthEnvView { + return { AUTH_ENFORCE: process.env.AUTH_ENFORCE, NODE_ENV: process.env.NODE_ENV }; +} + +/** + * True unless enforcement was EXPLICITLY disabled with AUTH_ENFORCE="false". + * + * FAIL-CLOSED: unset, empty, "flase", "0", or any other value => ENFORCED. + */ +export function authEnforced(env: AuthEnvView = currentEnv()): boolean { + return env.AUTH_ENFORCE?.trim().toLowerCase() !== 'false'; +} + +/** Thrown when the process is configured to run without auth in production. */ +export class AuthMisconfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthMisconfigurationError'; + } +} + +/** + * Refuse to run open in production. Throws {@link AuthMisconfigurationError} + * when NODE_ENV=production and enforcement resolved to OFF. + * + * Called at module scope by middleware.ts (startup) AND on the env-driven + * request path below, so a misconfigured production instance can neither boot + * nor serve a single unauthenticated request. + */ +export function assertAuthConfigSafe(env: AuthEnvView = currentEnv()): void { + if (env.NODE_ENV === 'production' && !authEnforced(env)) { + throw new AuthMisconfigurationError( + `0pt refuses to run with authentication disabled in production: ` + + `${AUTH_ENFORCE_VAR} is explicitly set to "${env.AUTH_ENFORCE}" while NODE_ENV=production. ` + + `Unset ${AUTH_ENFORCE_VAR} (absent = enforced) or set ${AUTH_ENFORCE_VAR}=true. ` + + `${AUTH_ENFORCE_VAR}=false is a local-development escape hatch only.`, + ); + } } function firstHeader(headers: HeaderLookup, name: string): string | undefined { @@ -111,23 +164,34 @@ export interface ResolveAuthOptions { } /** - * Resolve and (in enforce mode) enforce the request auth context. Returns an - * {@link AuthDecision}; the caller (middleware) turns a `!ok` decision into an - * HTTP response and an `ok` decision into forwarded principal headers. + * Resolve and (when enforced -- the default) enforce the request auth context. + * Returns an {@link AuthDecision}; the caller (middleware) turns a `!ok` + * decision into an HTTP response and an `ok` decision into forwarded principal + * headers. + * + * Throws {@link AuthMisconfigurationError} when the enforcement decision comes + * from the environment and that environment is "production with auth off". */ export async function resolveAuthContext( headers: HeaderLookup, opts: ResolveAuthOptions = {}, ): Promise { const verify = opts.verify ?? verifyBearer; - const enforce = opts.enforce ?? authEnforced(); + let enforce: boolean; + if (opts.enforce !== undefined) { + enforce = opts.enforce; + } else { + // Never serve openly in production, even if the startup check was bypassed. + assertAuthConfigSafe(); + enforce = authEnforced(); + } const logger: AuthLogger = opts.logger ?? { warn: (o, m) => console.warn(m ?? 'auth warning', o), }; const ctx = readRequestContext(headers); if (enforce) { - // --- ENFORCED: a valid verified token is mandatory ---------------------- + // --- ENFORCED (DEFAULT): a valid verified token is mandatory ------------ if (!ctx.bearerToken) { return { ok: false, @@ -168,19 +232,21 @@ export async function resolveAuthContext( return { ok: true, ctx }; } - // --- NON-ENFORCE (default): never reject, best-effort verify -------------- + // --- EXPLICIT OPT-OUT (AUTH_ENFORCE=false, non-production only) ----------- + // Never rejects; a present token is best-effort verified so tokens can start + // flowing in local development. if (ctx.bearerToken && looksLikeJwt(ctx.bearerToken)) { try { const principal = await verify(ctx.bearerToken); ctx.principal = principal; - // Do NOT overwrite the header/body tenant in non-enforce mode; only - // fill it in when the caller supplied none, to stay non-breaking. + // Do NOT overwrite the header/body tenant in this mode; only fill it in + // when the caller supplied none. if (!ctx.tenantId) ctx.tenantId = principal.tenantId; if (!ctx.actorId) ctx.actorId = principal.sub; } catch (err) { logger.warn( { err: err instanceof Error ? err.message : String(err) }, - '0pt: bearer verification failed (AUTH_ENFORCE off, ignoring)', + `0pt: bearer verification failed (${AUTH_ENFORCE_VAR}=false, ignoring)`, ); } } diff --git a/tests/auth/context.test.ts b/tests/auth/context.test.ts index 2ee0c6e..3026b39 100644 --- a/tests/auth/context.test.ts +++ b/tests/auth/context.test.ts @@ -1,12 +1,24 @@ /** - * Unit tests for src/auth/context.ts `resolveAuthContext` -- the flag-gated - * auth seam. Verification is injected (opts.verify) so these tests are pure - * and network-free; they exercise the decision logic in both AUTH_ENFORCE - * modes. + * Unit tests for src/auth/context.ts -- the auth seam. Verification is + * injected (opts.verify) so these tests are pure and network-free. + * + * Three groups: + * 1. the ENV DEFAULT (the fail-closed contract: absent AUTH_ENFORCE means + * an unauthenticated request is REJECTED, and production refuses to run + * with auth switched off), + * 2. enforced-mode decision logic, + * 3. the explicit `AUTH_ENFORCE=false` local-development escape hatch. */ -import { describe, it, expect, vi } from 'vitest'; -import { resolveAuthContext, type OptRequestContext } from '@/src/auth/context'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + resolveAuthContext, + authEnforced, + assertAuthConfigSafe, + AuthMisconfigurationError, + AUTH_ENFORCE_VAR, + type OptRequestContext, +} from '@/src/auth/context'; import type { VerifiedPrincipal } from '@/src/auth/zitadel'; const principal: VerifiedPrincipal = { @@ -24,7 +36,77 @@ function h(init: Record): Headers { return new Headers(init); } -describe('resolveAuthContext -- enforce mode', () => { +describe('auth default -- fail-closed (no AUTH_ENFORCE in the environment)', () => { + const saved = { auth: process.env.AUTH_ENFORCE, node: process.env.NODE_ENV }; + + beforeEach(() => { + delete process.env.AUTH_ENFORCE; + }); + + afterEach(() => { + if (saved.auth === undefined) delete process.env.AUTH_ENFORCE; + else process.env.AUTH_ENFORCE = saved.auth; + if (saved.node === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = saved.node; + }); + + it('REGRESSION: with the variable unset, an unauthenticated request is REJECTED (401)', async () => { + // No `enforce` option -> the decision comes from the environment, which is + // exactly the fresh-container / DR-restore / dropped-env-var case. + const d = await resolveAuthContext(h({ 'x-tenant-id': 'tenant_attacker' }), { verify: okVerify }); + expect(d.ok).toBe(false); + if (!d.ok) { + expect(d.status).toBe(401); + expect(d.code).toBe('OPT_UNAUTHENTICATED'); + } + }); + + it('with the variable unset, an invalid token is REJECTED (401) rather than ignored', async () => { + const d = await resolveAuthContext(h({ authorization: 'Bearer bad.tok.en' }), { verify: failVerify }); + expect(d.ok).toBe(false); + if (!d.ok) expect(d.status).toBe(401); + }); + + it('authEnforced() is true when the variable is absent, empty, or misspelled', () => { + expect(authEnforced({})).toBe(true); + expect(authEnforced({ AUTH_ENFORCE: '' })).toBe(true); + expect(authEnforced({ AUTH_ENFORCE: 'flase' })).toBe(true); + expect(authEnforced({ AUTH_ENFORCE: '0' })).toBe(true); + expect(authEnforced({ AUTH_ENFORCE: 'no' })).toBe(true); + expect(authEnforced({ AUTH_ENFORCE: 'true' })).toBe(true); + }); + + it('only the literal string "false" opts out', () => { + expect(authEnforced({ AUTH_ENFORCE: 'false' })).toBe(false); + expect(authEnforced({ AUTH_ENFORCE: 'FALSE' })).toBe(false); + expect(authEnforced({ AUTH_ENFORCE: ' false ' })).toBe(false); + }); + + it('assertAuthConfigSafe throws in production when enforcement is off, naming the variable', () => { + expect(() => assertAuthConfigSafe({ NODE_ENV: 'production', AUTH_ENFORCE: 'false' })).toThrow( + AuthMisconfigurationError, + ); + expect(() => assertAuthConfigSafe({ NODE_ENV: 'production', AUTH_ENFORCE: 'false' })).toThrow( + new RegExp(AUTH_ENFORCE_VAR), + ); + }); + + it('assertAuthConfigSafe is silent when production enforces, or when off outside production', () => { + expect(() => assertAuthConfigSafe({ NODE_ENV: 'production' })).not.toThrow(); + expect(() => assertAuthConfigSafe({ NODE_ENV: 'production', AUTH_ENFORCE: 'true' })).not.toThrow(); + expect(() => assertAuthConfigSafe({ NODE_ENV: 'development', AUTH_ENFORCE: 'false' })).not.toThrow(); + }); + + it('a production process with AUTH_ENFORCE=false cannot serve a request at all (throws)', async () => { + process.env.NODE_ENV = 'production'; + process.env.AUTH_ENFORCE = 'false'; + await expect( + resolveAuthContext(h({ 'x-tenant-id': 'tenant_attacker' }), { verify: okVerify }), + ).rejects.toBeInstanceOf(AuthMisconfigurationError); + }); +}); + +describe('resolveAuthContext -- enforced mode (the default)', () => { it('401s when no bearer token is present', async () => { const d = await resolveAuthContext(h({ 'x-tenant-id': 'tenant_x' }), { enforce: true, verify: okVerify }); expect(d.ok).toBe(false); @@ -78,7 +160,7 @@ describe('resolveAuthContext -- enforce mode', () => { }); }); -describe('resolveAuthContext -- non-enforce (default) mode', () => { +describe('resolveAuthContext -- explicit AUTH_ENFORCE=false escape hatch (local dev only)', () => { it('never rejects when no token is present', async () => { const d = await resolveAuthContext(h({ 'x-tenant-id': 'tenant_header' }), { enforce: false, @@ -111,7 +193,7 @@ describe('resolveAuthContext -- non-enforce (default) mode', () => { expect(d.ok).toBe(true); if (d.ok) { expect(d.ctx.principal).toEqual(principal); - // non-breaking: caller-supplied header tenant is preserved + // the caller-supplied header tenant is preserved in this mode expect(d.ctx.tenantId).toBe('tenant_header'); } }); -- 2.54.0