fix: harden identity HTTP transport
This commit is contained in:
+85
-12
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user