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.
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 noenforce 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.
+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
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.
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.
lib/auth.ts (the HMAC session scaffold used by /api/auth/* and the portal) is
unrelated to AUTH_ENFORCE and is not touched.
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).
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 main2026-08-04 13:24:17 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
(a) What the default was, and what it is now
The auth seam is
src/auth/context.ts, mounted bymiddleware.ts. It read: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-Idheader..env.exampleshippedAUTH_ENFORCE=false, teaching exactlythat setting.
Now:
Only the literal string
"false"(trimmed, case-insensitive) opts out. Unset,"","0","no","flase"— all enforce.Plus a startup gate,
assertAuthConfigSafe(), which throwsAuthMisconfigurationErrorwhenNODE_ENV=productionand enforcement resolved tooff, naming
AUTH_ENFORCEin the message. It runsmiddleware.ts(per instance boot), andresolveAuthContext()(belt and braces: even ifthe 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 buildstill compiles(Next bundles
middleware.ts, it does not execute it at build time), so this does notbreak 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
0ptis deployed on Vercel at 0pt.app (README.md→ Deploy;SECURITY.mdscope).Probed today, anonymously, before the fix:
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/healthreports"db":"not_configured"— so no records leaked today. The authorisation decision wasnevertheless open on every request.)
Deploy note for the operator: after merge, verify that
AUTH_ENFORCEin the Vercelproject is either unset or
true. If it is currently set tofalsethere, thedeployed 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: falseoption, so they test the two modes, not the default.The three
enforce: falsecases remain valid: they now describe the explicitlocal-development escape hatch, and the
describeblock 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 firstdescribe), including theone the task asks for:
calls
resolveAuthContextwith noenforceoption, so the decision comes fromthe environment, exactly as in a fresh container.
authEnforced()is true for absent /""/"flase"/"0"/"no"/"true"."false"/"FALSE"/" false "opt out.assertAuthConfigSafethrows in production when off, and namesAUTH_ENFORCE.assertAuthConfigSafeis silent for production+enforced and for development+off.AUTH_ENFORCE=falsecannot 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.tsreports4 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):After (this branch):
+7= the seven new cases. The one skip isiceberg-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 andafter, byte-identical; all of it pre-existing noise in the v0-generated tree.
eslint src/auth/context.ts tests/auth/context.test.ts middleware.tsis clean.Scope — what this PR deliberately does NOT do
/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.
/api/0pt/*handlers. Themiddleware forwards
x-0pt-verified-sub/x-0pt-verified-tenant, but no routehandler reads them (
grep -rn 'x-0pt-verified' app/api/0pt→ no hits). E.g.app/api/0pt/sources/rate-policies/route.tstakestenant_idstraight from thequery 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.
lib/auth.ts(the HMAC session scaffold used by/api/auth/*and the portal) isunrelated to
AUTH_ENFORCEand is not touched.Files
src/auth/context.ts— inverted predicate,assertAuthConfigSafe(),AuthMisconfigurationError,AUTH_ENFORCE_VAR, injectableAuthEnvView, rewrittenheader docs.
middleware.ts— module-scopeassertAuthConfigSafe()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-closedcontract and the production refusal spelled out.
README.md— Deploy section states the fail-closed posture (this repo has noDEPLOY.md).