fix: harden identity HTTP transport
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 25s

This commit is contained in:
ekklesia
2026-07-21 23:50:41 -05:00
parent c91f0c8c6d
commit d9b73fd81a
15 changed files with 967 additions and 72 deletions
+85 -12
View File
@@ -5,6 +5,10 @@ one Ekklesia-owned route to resolve the current principal. It does not add a Das
Mobile Surface, and it does not add roles, permissions, or a platform-owner concept — see
[ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for the
full architectural decision and [Identity](identity.md) for the identity foundation this builds on.
A follow-up corrective slice hardened this transport (sanitized Better Auth logging,
production-equivalent origin/CSRF protection under the test runner, bounded identity database
timeouts, idempotent shutdown, and pinned password-length boundaries) without changing any of the
above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md).
## What this is, and is not
@@ -109,8 +113,12 @@ is `null`. The caller must call `/api/auth/sign-in/email` explicitly afterward.
per the pinned Better Auth release, `autoSignIn: false` activates its built-in email-enumeration
protection, so a duplicate sign-up returns the same `200` shape (with a fresh, unrelated random id)
whether or not the email was already registered, rather than revealing which. No custom
cryptography replaces Better Auth's own password hashing, and its default password-length policy
(8–128 characters) is kept unmodified.
cryptography replaces Better Auth's own password hashing. Password length is pinned explicitly to
8–128 characters (`minPasswordLength`/`maxPasswordLength` in `core/src/identity/runtime.ts`) rather
than left on Better Auth's fallback default — the pinned release's default happens to be the same
8–128 range, but an implicit default can change silently on a future Better Auth upgrade, while an
explicit value cannot. `apps/api/tests/api-http.integration.ts` proves the exact boundary against a
real server: 7 characters rejected, 8 accepted, 128 accepted, 129 rejected.
## Origin, CORS, and cookies
@@ -121,19 +129,67 @@ since `Access-Control-Allow-Origin: *` cannot be combined with `Access-Control-A
true`); a disallowed origin never receives the header at all, though the request itself may still
be processed — CORS is a browser-enforced, response-header mechanism, not a server-side gate. A
request with no `Origin` header at all (native or server-to-server clients) is unaffected by CORS.
Note this is narrower than it sounds for `/api/auth/sign-in/email` and `/api/auth/sign-up/email`
specifically: any client built on the Fetch API — every browser, and also Node's own `fetch()` —
automatically sends a `Sec-Fetch-Mode: cors` header on every request, and Better Auth's first-login
CSRF protection treats that as "this came from a Fetch client" and requires a trusted `Origin` (or
`Referer`) on those two routes regardless of whether a cookie is present. A genuinely non-Fetch
caller (curl, or most backend HTTP client libraries, which send none of the `Sec-Fetch-*` headers)
is unaffected and needs no `Origin` header at all — see the dedicated raw-socket test in
`apps/api/tests/api-http.integration.ts` for the exact distinction proven against a real server.
Better Auth's own CSRF/origin protections are never disabled
(`advanced.disableCSRFCheck`/`advanced.disableOriginCheck` are never set). Verified directly
against the running compiled server: any non-`GET`/`HEAD`/`OPTIONS` request carrying a session
cookie must also carry a trusted `Origin` (or `Referer`), or Better Auth itself rejects it with
`403`. See ADR 0011 for the exact verification method and an important caveat about reproducing
part of this guarantee inside the automated test suite.
Better Auth's own CSRF/origin protections are explicitly kept on:
`core/src/identity/runtime.ts` sets `advanced: { disableCSRFCheck: false, disableOriginCheck:
false }`. This is deliberately explicit rather than left unset — the pinned release's own default
is environment-sensitive (`skipOriginCheck` falls back to `true`, silently disabling both origin
and CSRF checking, whenever `isTest()` is true, which Vitest's default `NODE_ENV=test` triggers),
so an unset config would mean origin/CSRF protection is silently off under the test runner and on
in production. Setting both flags explicitly removes that environment-sensitive branch entirely,
so `pnpm test:migrations:integration` now automatically proves the exact production behavior: any
non-`GET`/`HEAD`/`OPTIONS` request carrying a session cookie must also carry a trusted `Origin` (or
`Referer`), or Better Auth rejects it with `403` (and the presented session remains valid
afterward); a no-cookie first authentication attempt from an untrusted origin is rejected the same
way; a trusted origin is accepted in both cases; and a denied origin never receives a permissive
CORS header. See [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md)
for the root-cause investigation and the full account of what changed.
Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `Secure` when the
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
attribute is set, so every cookie is host-only. Trusted proxy headers are not configured in this
slice — a deployment behind a reverse proxy is future work.
## Logging
Two independent, complementary layers, both configured explicitly in `core/src/identity/runtime.ts`
rather than left on any environment-sensitive default:
1. **`logger`.** Better Auth's own default (`level: "warn"`, plain `console.*` output) is replaced.
The pinned release's log call sites are not a stable, fully-audited contract — several pass a
raw internal exception, and at least one (the origin-check middleware) interpolates a
user-controlled `Origin` header directly into the message text. Rather than sanitize each call
site's message and arguments individually, Core's logger configuration drops the message and
every argument entirely and emits only a fixed, generic string
(`"[identity] Better Auth reported an error."`) via `console.error`, and only for error-level
events — nothing is ever emitted for warn/info/debug.
2. **`onAPIError: { throw: true }`.** Discovered while testing (1) above: the pinned release's own
HTTP transport dependency, `better-call`'s router, has an *unconditional*
`console.error("# SERVER_ERROR: ", error)` fallback for any thrown value it does not recognize
as one of Better Auth's own `APIError`s — this fires before Better Auth's `logger` option is
ever consulted, so no `logger` configuration can reach it. `onAPIError.throw: true` closes that
gap: Better Auth's own deliberate `APIError`s (wrong password, invalid origin, disabled sign-up,
password out of bounds, ...) still resolve to their normal, correct HTTP response completely
unaffected, but a genuinely unexpected failure (for example a database outage mid-request) now
propagates out of `auth.handler()` instead of being caught and printed raw internally by
`better-call`, reaching `handleAuthRequest`'s own try/catch and becoming a sanitized
`IdentityRuntimeError` like any other unexpected failure — and nothing is logged at all.
Both apply in every environment, including under Vitest: neither is a `NODE_ENV=test` special case.
A focused unit test (`core/tests/identity-runtime.test.ts`) simulates a hostile log call (a raw
exception carrying a password, a host, a port, a filesystem path, and a rejected origin) and asserts
none of it reaches `console.error`, and separately asserts `onAPIError` is configured; a live
integration test forces a real identity-database failure through Better Auth's own sign-in handler
and asserts nothing at all reaches `console.error`/`warn`/`log` in the real process.
## Rate limiting
Better Auth's built-in rate limiting is always enabled, including by the executable entry point —
@@ -146,13 +202,30 @@ trusted-proxy IP policy is configured, Better Auth may fall back to a single sha
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade
abuse-control design.
## Database connection limits
`apps/api/src/server.ts` configures the identity PostgreSQL pool (`createIdentityDrizzleDatabase`,
`core/src/identity/database.ts`) with both a connection timeout and an operation timeout (10
seconds each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts`
already uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does
nothing once a query is already running on one. The operation timeout additionally sets `pg`'s
`query_timeout` and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than
holding a pool connection (and its caller) indefinitely. An invalid timeout (non-integer, zero,
negative, or larger than PostgreSQL's own maximum) is rejected as configuration error before any
pool is constructed, sanitized the same way an invalid connection string already is.
## Graceful shutdown
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` is idempotent: it
stops the HTTP listener from accepting new connections and lets in-flight requests finish, then
closes the PostgreSQL pool. The executable entry point wires `SIGINT`/`SIGTERM` to call it and
exits afterward; a startup failure after the database pool was already created (identity
executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` memoizes one
shared promise the first time it is called: every caller, concurrent or sequential, awaits the
exact same listener-drain-then-pool-close operation, and that operation runs exactly once no matter
how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and
lets in-flight requests finish, then closes the PostgreSQL pool. The executable entry point's
`SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the
same function production composition uses, but independently testable with a fake `running`/`exit`
so a test can trigger "a signal arrived" without sending a real process signal or invoking the real
`process.exit`. A startup failure after the database pool was already created (identity
construction failure, or the server failing to bind its listen address) always closes that pool
before the failure propagates.
+17
View File
@@ -149,6 +149,23 @@ exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's sessi
Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the
only current consumer of this boundary; see [API](api.md).
Construction also explicitly configures four things the pinned release would otherwise leave on an
environment-sensitive or implicit default, all covered by [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md)
and [API](api.md#logging):
`advanced.disableCSRFCheck`/`disableOriginCheck` are both pinned to `false` (the default silently
skips both under a test runner), Better Auth's `logger` is replaced with one that drops its message
and arguments and emits only a fixed, generic string for error-level events, `onAPIError: { throw:
true }` routes a genuinely unexpected failure out to `handleAuthRequest`'s own sanitized boundary
instead of letting Better Auth's `better-call` transport dependency log it raw through a path no
`logger` configuration can reach, and `emailAndPassword.minPasswordLength`/`maxPasswordLength` are
pinned to `8`/`128` instead of left on Better Auth's fallback default.
`createIdentityDrizzleDatabase` (`core/src/identity/database.ts`) also accepts an optional
`operationTimeoutMs`, applied as both `pg`'s `query_timeout` and `statement_timeout` — bounding a
hung or slow query, which `connectionTimeoutMs` alone cannot do since it only bounds establishing
the connection. `apps/api` sets both timeouts explicitly (see [API](api.md)); an invalid value is
rejected the same sanitized way an invalid connection string already is.
## What is not operational yet
No email verification, password recovery, invitation flow, email delivery, social or OAuth
+32 -13
View File
@@ -14,15 +14,21 @@ module migration discovery, identity schema drift detection, formatting, linting
Migration-runner unit tests use a narrow database/session port to prove deterministic planning,
approval, transaction orchestration, cleanup, and sanitized errors. They do not claim to prove
PostgreSQL behavior. Identity unit tests cover branded opaque-ID contracts, principal shapes,
configuration validation (secret, base URL, and trusted-origin rules), the Core identity runtime
(explicit construction, exact schema mapping, only email/password and rate limiting enabled,
sanitized failures, and that a database outage during principal resolution is never silently
reported as anonymous), and Core migration discovery (deterministic ordering, the reserved `core`
identity, and collision rejection) — all without a live database. `apps/api`'s own unit tests cover
its pure Hono app factory (route shapes, CORS allow/deny/preflight, deterministic 404 and sanitized
error responses), its runtime configuration, and that importing any of its modules — including the
executable server module without calling its exported `main` — never reads the environment,
connects to PostgreSQL, or starts a server.
configuration validation (secret, base URL, and trusted-origin rules), bounded/validated
connection and operation (`query_timeout`/`statement_timeout`) database timeouts, the Core identity
runtime (explicit construction, exact schema mapping, only email/password and rate limiting
enabled, an explicit `advanced.disableCSRFCheck`/`disableOriginCheck: false` pair, an explicit
error-only sanitized logger that strips Better Auth's own message and arguments regardless of what
they contain, pinned 8–128 password-length bounds, sanitized failures, and that a database outage
during principal resolution is never silently reported as anonymous), and Core migration discovery
(deterministic ordering, the reserved `core` identity, and collision rejection) — all without a
live database. `apps/api`'s own unit tests cover its pure Hono app factory (route shapes, CORS
allow/deny/preflight, deterministic 404 and sanitized error responses), its runtime configuration,
its idempotent shutdown lifecycle (concurrent callers share one server-close-then-pool-close
promise; a startup failure after the pool was already created still closes it; `SIGINT`/`SIGTERM`
wiring is exercised through an injectable exit function rather than the real `process.exit`), and
that importing any of its modules — including the executable server module without calling its
exported `main` — never reads the environment, connects to PostgreSQL, or starts a server.
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
database.
@@ -65,13 +71,26 @@ automatic session, per `autoSignIn: false`), explicit sign-in producing a host-o
anonymous shapes, sign-out actually invalidating the session, a malformed or missing cookie
resolving as anonymous, duplicate sign-up creating no second identity row, two distinct accounts
producing identical principal shapes with no elevation and no owner/role/permission data at the
database level, no plaintext password ever stored, CORS never returning an allow-origin header for
database level, no plaintext password ever stored, the exact 8–128 password-length boundary (7
rejected, 8 accepted, 128 accepted, 129 rejected), CORS never returning an allow-origin header for
an untrusted origin, Better Auth's handler being unreachable outside `/api/auth/*`, a database
outage during principal resolution producing a sanitized `503` rather than a false anonymous `200`,
starting the API against an unmigrated database creating no tables at all, and a clean listener/pool
shutdown. See [ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md)
for one CSRF/origin guarantee that was verified manually against the real server but could not be
reproduced reliably inside this test runner, and is documented rather than silently asserted.
shutdown.
The same suite also proves Better Auth's own origin/CSRF protection is production-equivalent under
the test runner, not just in a plain Node script: a no-cookie first authentication attempt succeeds
from a trusted browser origin and is rejected (`403`, no permissive CORS header, no leaked detail)
from an untrusted one; a cookie-bearing state-changing request (sign-out) is rejected the same way
from an untrusted origin, and the presented session remains valid afterward, proving the rejected
request never reached sign-out logic. See
[ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md) for why
this previously could not be reproduced reliably under Vitest (Better Auth's own default silently
skips these checks whenever `isTest()` is true, unless `advanced.disableOriginCheck` is set
explicitly) and how the fix removes that gap entirely. A further scenario forces a real identity
database failure and asserts, against actual `console.error`/`warn`/`log` output, that Better
Auth's own internal error logging — configured explicitly in `core/src/identity/runtime.ts` — never
emits a raw exception, SQL, connection detail, or the rejected request's origin.
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
the following pull-request status context to `main` branch protection: