fix(auth): make the AUTH_ENFORCE default fail-closed -- 0pt.app was answering anonymously #4
+7
-5
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user