Merge pull request 'fix(auth): make the AUTH_ENFORCE default fail-closed -- 0pt.app was answering anonymously' (#4) from fix/fail-closed-auth-default into main
CI (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / quality (push) Successful in 39s

This commit is contained in:
2026-08-04 13:24:16 +00:00
5 changed files with 211 additions and 50 deletions
+7 -5
View File
@@ -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.
+4
View File
@@ -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
+17 -10
View File
@@ -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.
+92 -26
View File
@@ -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<AuthDecision> {
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)`,
);
}
}
+91 -9
View File
@@ -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<string, string>): 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');
}
});