fix(auth): make the AUTH_ENFORCE default fail-closed -- 0pt.app was answering anonymously #4

Merged
admin merged 1 commits from fix/fail-closed-auth-default into main 2026-08-04 13:24:17 +00:00

(a) What the default was, and what it is now

The auth seam is src/auth/context.ts, mounted by middleware.ts. It read:

export function authEnforced(): boolean {
  return process.env.AUTH_ENFORCE === 'true';
}

Absence of the variable meant no authentication. Every fresh container, local
run, DR restore, or redeploy that dropped the env var took the non-enforce branch:
never rejects, no token required, tenant taken from the caller-supplied
X-Tenant-Id header. .env.example shipped AUTH_ENFORCE=false, teaching exactly
that setting.

Now:

export function authEnforced(env = currentEnv()): boolean {
  return env.AUTH_ENFORCE?.trim().toLowerCase() !== 'false';  // absent => ENFORCED
}

Only the literal string "false" (trimmed, case-insensitive) opts out. Unset, "",
"0", "no", "flase" — all enforce.

Plus a startup gate, assertAuthConfigSafe(), which throws
AuthMisconfigurationError when NODE_ENV=production and enforcement resolved to
off, naming AUTH_ENFORCE in the message. It runs

  • at module load in middleware.ts (per instance boot), and
  • on the env-driven request path in resolveAuthContext() (belt and braces: even if
    the module-scope check were bypassed, no single request can be served openly in
    production).

A warning was not considered sufficient — n0d already warns and is still open.

Measured behaviour of the gate: AUTH_ENFORCE=false pnpm run build still compiles
(Next bundles middleware.ts, it does not execute it at build time), so this does not
break the Vercel build. The throw fires when the middleware module is first evaluated
at runtime, i.e. a misconfigured production deploy fails requests closed (500)
instead of answering anonymously.

(b) Deployed? Yes — the hole was LIVE, not latent

0pt is deployed on Vercel at 0pt.app (README.md → Deploy; SECURITY.md scope).
Probed today, anonymously, before the fix:

GET https://0pt.app/api/0pt                          -> 200
GET https://0pt.app/api/0pt/sources/rate-policies    -> 200  []
GET https://0pt.app/api/0pt/sources/rate-policies \
    -H 'Authorization: Bearer aaa.bbb.ccc' \
    -H 'X-Tenant-Id: t_evil'                         -> 200  []

A garbage Bearer token plus an arbitrary tenant header is accepted. The guard exists
and is wired; it was simply switched off by default. (The responses are empty because
the production database is not configured — /api/health reports
"db":"not_configured" — so no records leaked today. The authorisation decision was
nevertheless open on every request.)

Deploy note for the operator: after merge, verify that AUTH_ENFORCE in the Vercel
project is either unset or true. If it is currently set to false there, the
deployed middleware will throw on /api/0pt/* (fail-closed, 500) until it is fixed —
and callers of /api/0pt/* will need a real Zitadel Bearer token from that moment on.
That is the intended behaviour change; it is not silent.

(c) Tests changed or deleted

None inverted, none deleted. No existing case asserted that an absent or invalid
token is accepted by default — all eight existing cases pass an explicit
enforce: true / enforce: false option, so they test the two modes, not the default.
The three enforce: false cases remain valid: they now describe the explicit
local-development escape hatch, and the describe block was retitled from
"non-enforce (default) mode" to "explicit AUTH_ENFORCE=false escape hatch (local dev
only)" so nothing in the suite still calls the open mode "the default".

Seven cases added (tests/auth/context.test.ts, new first describe), including the
one the task asks for:

  • REGRESSION: with the variable unset, an unauthenticated request is REJECTED (401) —
    calls resolveAuthContext with no enforce option, so the decision comes from
    the environment, exactly as in a fresh container.
  • with the variable unset, an invalid token is rejected (401) rather than ignored.
  • authEnforced() is true for absent / "" / "flase" / "0" / "no" / "true".
  • only "false" / "FALSE" / " false " opt out.
  • assertAuthConfigSafe throws in production when off, and names AUTH_ENFORCE.
  • assertAuthConfigSafe is silent for production+enforced and for development+off.
  • a production process with AUTH_ENFORCE=false cannot serve a request at all.

Negative control (the new tests actually catch the defect): with only the predicate
reverted to the old === 'true', vitest run tests/auth/context.test.ts reports
4 failed | 11 passed (15), the regression case among them. Restored afterwards.

(d) Measured test results

Same machine, same command (pnpm run test = vitest run), Node 22, pnpm 9.15.9.

Before (clean clone of main, 0eb47aa):

 Test Files  17 passed (17)
      Tests  121 passed | 1 skipped (122)

After (this branch):

 Test Files  17 passed (17)
      Tests  128 passed | 1 skipped (129)

+7 = the seven new cases. The one skip is iceberg-write-service.realinfra.test.ts,
which self-skips when the live ST0RE backend is unreachable — skipped identically
before and after.

Other CI gates, run locally:

  • pnpm run typecheck (tsc -p tsconfig.check.json) — clean before and after.
  • pnpm run build (next build) — succeeds.
  • pnpm run lint (advisory in CI) — 16 problems (12 errors, 4 warnings) before and
    after, byte-identical; all of it pre-existing noise in the v0-generated tree.
    eslint src/auth/context.ts tests/auth/context.test.ts middleware.ts is clean.

Scope — what this PR deliberately does NOT do

  1. The middleware matcher still only covers /api/0pt/:path*. Not widened here,
    because widening it cannot be proven safe without route-by-route work. Everything
    else on 0pt.app is outside any middleware auth today, and probed anonymously
    right now: /api/admin/metrics → 200 (order counts and deposit totals),
    /api/orders → 200, /api/sources → 200, /api/catalog → 200. Also outside:
    /api/estimates, /api/uploads, /api/connections/db-grant, /api/screen,
    /api/vehicles, /api/subscriptions, /api/ai/parse. That is a separate,
    larger piece of work and needs its own PR.
  2. Tenancy is still caller-asserted inside the /api/0pt/* handlers. The
    middleware forwards x-0pt-verified-sub / x-0pt-verified-tenant, but no route
    handler reads them
    (grep -rn 'x-0pt-verified' app/api/0pt → no hits). E.g.
    app/api/0pt/sources/rate-policies/route.ts takes tenant_id straight from the
    query string, and the POST takes it from the body. So once a caller is
    authenticated, it can still address another tenant's rows by asking for them. This
    PR closes the authentication default only; the tenant-binding fix is described here
    deliberately and left untouched.
  3. lib/auth.ts (the HMAC session scaffold used by /api/auth/* and the portal) is
    unrelated to AUTH_ENFORCE and is not touched.

Files

  • src/auth/context.ts — inverted predicate, assertAuthConfigSafe(),
    AuthMisconfigurationError, AUTH_ENFORCE_VAR, injectable AuthEnvView, rewritten
    header docs.
  • middleware.ts — module-scope assertAuthConfigSafe() call; docs updated.
  • tests/auth/context.test.ts — 7 new cases; escape-hatch block retitled.
  • .env.example — AUTH_ENFORCE=false → AUTH_ENFORCE=true, with the fail-closed
    contract and the production refusal spelled out.
  • README.md — Deploy section states the fail-closed posture (this repo has no
    DEPLOY.md).
## (a) What the default was, and what it is now The auth seam is `src/auth/context.ts`, mounted by `middleware.ts`. It read: ```ts export function authEnforced(): boolean { return process.env.AUTH_ENFORCE === 'true'; } ``` **Absence of the variable meant no authentication.** Every fresh container, local run, DR restore, or redeploy that dropped the env var took the non-enforce branch: never rejects, no token required, tenant taken from the caller-supplied `X-Tenant-Id` header. `.env.example` shipped `AUTH_ENFORCE=false`, teaching exactly that setting. Now: ```ts export function authEnforced(env = currentEnv()): boolean { return env.AUTH_ENFORCE?.trim().toLowerCase() !== 'false'; // absent => ENFORCED } ``` Only the literal string `"false"` (trimmed, case-insensitive) opts out. Unset, `""`, `"0"`, `"no"`, `"flase"` — all enforce. Plus a startup gate, `assertAuthConfigSafe()`, which **throws** `AuthMisconfigurationError` when `NODE_ENV=production` and enforcement resolved to off, naming `AUTH_ENFORCE` in the message. It runs * at module load in `middleware.ts` (per instance boot), and * on the env-driven request path in `resolveAuthContext()` (belt and braces: even if the module-scope check were bypassed, no single request can be served openly in production). A warning was not considered sufficient — n0d already warns and is still open. Measured behaviour of the gate: `AUTH_ENFORCE=false pnpm run build` still **compiles** (Next bundles `middleware.ts`, it does not execute it at build time), so this does not break the Vercel build. The throw fires when the middleware module is first evaluated at runtime, i.e. a misconfigured production deploy fails requests **closed** (500) instead of answering anonymously. ## (b) Deployed? Yes — the hole was LIVE, not latent `0pt` is deployed on Vercel at **0pt.app** (`README.md` → Deploy; `SECURITY.md` scope). Probed today, anonymously, before the fix: ``` GET https://0pt.app/api/0pt -> 200 GET https://0pt.app/api/0pt/sources/rate-policies -> 200 [] GET https://0pt.app/api/0pt/sources/rate-policies \ -H 'Authorization: Bearer aaa.bbb.ccc' \ -H 'X-Tenant-Id: t_evil' -> 200 [] ``` A garbage Bearer token plus an arbitrary tenant header is accepted. The guard exists and is wired; it was simply switched off by default. (The responses are empty because the production database is not configured — `/api/health` reports `"db":"not_configured"` — so no records leaked today. The authorisation decision was nevertheless open on every request.) **Deploy note for the operator:** after merge, verify that `AUTH_ENFORCE` in the Vercel project is either unset or `true`. If it is currently set to `false` there, the deployed middleware will throw on `/api/0pt/*` (fail-closed, 500) until it is fixed — and callers of `/api/0pt/*` will need a real Zitadel Bearer token from that moment on. That is the intended behaviour change; it is not silent. ## (c) Tests changed or deleted **None inverted, none deleted.** No existing case asserted that an absent or invalid token is accepted *by default* — all eight existing cases pass an explicit `enforce: true` / `enforce: false` option, so they test the two modes, not the default. The three `enforce: false` cases remain valid: they now describe the explicit local-development escape hatch, and the `describe` block was retitled from "non-enforce (default) mode" to "explicit AUTH_ENFORCE=false escape hatch (local dev only)" so nothing in the suite still calls the open mode "the default". **Seven cases added** (`tests/auth/context.test.ts`, new first `describe`), including the one the task asks for: * *REGRESSION: with the variable unset, an unauthenticated request is REJECTED (401)* — calls `resolveAuthContext` with **no** `enforce` option, so the decision comes from the environment, exactly as in a fresh container. * with the variable unset, an invalid token is rejected (401) rather than ignored. * `authEnforced()` is true for absent / `""` / `"flase"` / `"0"` / `"no"` / `"true"`. * only `"false"` / `"FALSE"` / `" false "` opt out. * `assertAuthConfigSafe` throws in production when off, and names `AUTH_ENFORCE`. * `assertAuthConfigSafe` is silent for production+enforced and for development+off. * a production process with `AUTH_ENFORCE=false` cannot serve a request at all. **Negative control** (the new tests actually catch the defect): with only the predicate reverted to the old `=== 'true'`, `vitest run tests/auth/context.test.ts` reports `4 failed | 11 passed (15)`, the regression case among them. Restored afterwards. ## (d) Measured test results Same machine, same command (`pnpm run test` = `vitest run`), Node 22, pnpm 9.15.9. **Before** (clean clone of `main`, `0eb47aa`): ``` Test Files 17 passed (17) Tests 121 passed | 1 skipped (122) ``` **After** (this branch): ``` Test Files 17 passed (17) Tests 128 passed | 1 skipped (129) ``` `+7` = the seven new cases. The one skip is `iceberg-write-service.realinfra.test.ts`, which self-skips when the live ST0RE backend is unreachable — skipped identically before and after. Other CI gates, run locally: * `pnpm run typecheck` (`tsc -p tsconfig.check.json`) — clean before and after. * `pnpm run build` (`next build`) — succeeds. * `pnpm run lint` (advisory in CI) — `16 problems (12 errors, 4 warnings)` before **and** after, byte-identical; all of it pre-existing noise in the v0-generated tree. `eslint src/auth/context.ts tests/auth/context.test.ts middleware.ts` is clean. ## Scope — what this PR deliberately does NOT do 1. **The middleware matcher still only covers `/api/0pt/:path*`.** Not widened here, because widening it cannot be proven safe without route-by-route work. Everything else on 0pt.app is outside any middleware auth today, and probed anonymously right now: `/api/admin/metrics` → 200 (order counts and deposit totals), `/api/orders` → 200, `/api/sources` → 200, `/api/catalog` → 200. Also outside: `/api/estimates`, `/api/uploads`, `/api/connections/db-grant`, `/api/screen`, `/api/vehicles`, `/api/subscriptions`, `/api/ai/parse`. That is a separate, larger piece of work and needs its own PR. 2. **Tenancy is still caller-asserted inside the `/api/0pt/*` handlers.** The middleware forwards `x-0pt-verified-sub` / `x-0pt-verified-tenant`, but **no route handler reads them** (`grep -rn 'x-0pt-verified' app/api/0pt` → no hits). E.g. `app/api/0pt/sources/rate-policies/route.ts` takes `tenant_id` straight from the query string, and the POST takes it from the body. So once a caller is authenticated, it can still address another tenant's rows by asking for them. This PR closes the authentication default only; the tenant-binding fix is described here deliberately and left untouched. 3. `lib/auth.ts` (the HMAC session scaffold used by `/api/auth/*` and the portal) is unrelated to `AUTH_ENFORCE` and is not touched. ## Files * `src/auth/context.ts` — inverted predicate, `assertAuthConfigSafe()`, `AuthMisconfigurationError`, `AUTH_ENFORCE_VAR`, injectable `AuthEnvView`, rewritten header docs. * `middleware.ts` — module-scope `assertAuthConfigSafe()` call; docs updated. * `tests/auth/context.test.ts` — 7 new cases; escape-hatch block retitled. * `.env.example` — `AUTH_ENFORCE=false` → `AUTH_ENFORCE=true`, with the fail-closed contract and the production refusal spelled out. * `README.md` — Deploy section states the fail-closed posture (this repo has no DEPLOY.md).
sax3l added 1 commit 2026-07-27 11:28:37 +00:00
fix(auth): make the AUTH_ENFORCE default fail-closed on /api/0pt/*
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
CI (SIAX Cloud) / security (push) Successful in 23s
1c1e2cb649
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.
admin merged commit into main 2026-08-04 13:24:17 +00:00
admin deleted branch fix/fail-closed-auth-default 2026-08-04 13:24:17 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/0pt#4