fix: distinguish identity session failures
Principal resolution could not tell a broken session store from a genuinely anonymous request, and did unnecessary database work for callers that never presented a session at all. Both are corrected here. Any cookie previously triggered the ambiguity check, so `Cookie: x=y` on an otherwise anonymous request caused an identity database round trip an unauthenticated caller could drive with a header they chose themselves. `hasConfiguredSessionCookie` now parses the Cookie header per RFC 6265 (split on `;`, name is the text before the first `=`, trimmed) and compares the whole name exactly — never `includes`, a substring test, or an unanchored regular expression — so `better-auth.session_token_x`, `x_better-auth.session_token`, a differently-cased spelling, and a percent-encoded lookalike are all correctly not the session cookie. A duplicated name still reports true: ambiguity fails safe towards checking, and reporting true never grants access on its own. The cookie name is not a second hard-coded copy. One `betterAuthOptions` object now feeds both `betterAuth()` and Better Auth's own supported `getCookies` helper, so the detected name — including the `__Secure-` prefix an https baseURL implies — is derived from the exact configuration Better Auth itself uses, and drift is structurally impossible rather than merely tested. The readiness check itself proved almost nothing: `select 1` still succeeded against a dropped session table, a drifted or removed column, or a revoked SELECT privilege, so Better Auth's ambiguous null was reported as a genuinely anonymous request. `probeSessionStore` now plans a real read of the session relation and the exact columns the adapter depends on, under `where false`: PostgreSQL resolves the relation, every column, and the caller's privilege at plan time while matching zero rows, so no session row, token, or user id can be read, logged, or enumerated by the check. It uses the injected pool, never a second connection, and stays bounded by the existing timeout. A missing relation, dropped column, revoked privilege, unreachable connection, and query timeout now all produce the same sanitized 503 with no relation name, column name, SQL, driver text, host, or stack — proven against real PostgreSQL, since that is the only place a broken store looks exactly like an anonymous request. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3d6c85b0f8
commit
7a0f624fc3
@@ -87,7 +87,7 @@ Authenticated:
|
||||
{ "kind": "authenticated", "userId": "2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1" }
|
||||
```
|
||||
|
||||
Infrastructure failure (the identity database could not be reached while resolving the principal —
|
||||
Infrastructure failure (the identity session store was not usable while resolving the principal —
|
||||
never silently reported as anonymous):
|
||||
|
||||
```json
|
||||
@@ -95,6 +95,13 @@ never silently reported as anonymous):
|
||||
```
|
||||
returned with HTTP `503`.
|
||||
|
||||
A request carrying no cookies, or only unrelated cookies, never reaches that path and never performs
|
||||
identity database work at all: only a request presenting the *configured session cookie* that Better
|
||||
Auth resolved to `null` is ambiguous enough to check. When it is checked, a missing session table, a
|
||||
dropped column, a revoked `SELECT` privilege, an unreachable database, and a query timeout all
|
||||
produce this same sanitized `503` — carrying no relation name, column name, SQL, driver message,
|
||||
host, or stack. See [Identity § How a principal is resolved](identity.md#how-a-principal-is-resolved).
|
||||
|
||||
This is deliberately not the same thing as Better Auth's own `GET /api/auth/get-session` response,
|
||||
which returns Better Auth's full `{session, user}` shape (session id, expiry, IP address, user
|
||||
agent, email, name, image, email-verification status, and more). `/api/v1/principal` is the
|
||||
|
||||
@@ -144,7 +144,8 @@ ADR 0010.
|
||||
`handleAuthRequest(request): Promise<Response>` (delegates directly to Better Auth's handler; its
|
||||
normal responses, including its own error responses, pass through unmodified) and
|
||||
`resolvePrincipal(headers): Promise<EkklesiaPrincipal>` (never silently downgrades a database
|
||||
outage to an anonymous result — see ADR 0011 for exactly how). Neither method, nor anything else
|
||||
outage to an anonymous result — see ADR 0011 and [session resolution](#how-a-principal-is-resolved)
|
||||
below). Neither method, nor anything else
|
||||
exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's session/user types, the
|
||||
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).
|
||||
@@ -166,6 +167,35 @@ hung or slow query, which `connectionTimeoutMs` alone cannot do since it only bo
|
||||
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.
|
||||
|
||||
## How a principal is resolved
|
||||
|
||||
`resolvePrincipal(headers)` returns the anonymous principal for a missing, expired, revoked, or
|
||||
malformed session, and throws a sanitized `PRINCIPAL_RESOLUTION_FAILED` for an infrastructure
|
||||
failure. Telling those apart takes explicit work, because Better Auth's `getSession` swallows
|
||||
internal failures and returns `null` for them exactly as it does for a genuinely absent session.
|
||||
|
||||
1. **No cookies, or only unrelated cookies → no identity database work at all.** `Cookie: x=y` is
|
||||
not evidence that Better Auth ever attempted to resolve an Ekklesia session, so its `null` is
|
||||
unambiguous and the anonymous principal is returned immediately. An unauthenticated caller
|
||||
cannot drive identity database load with a cookie header they chose themselves.
|
||||
2. **The configured session cookie is present, and Better Auth returned `null` → the ambiguous
|
||||
case, and only then is the session store checked.** Detection is an exact, whole-name cookie
|
||||
comparison (RFC 6265 parsing; names are case-sensitive and never percent-decoded), so
|
||||
`better-auth.session_token_x` and `x_better-auth.session_token` are correctly *not* the session
|
||||
cookie. The name itself is derived from the same options object passed to Better Auth, via its
|
||||
own exported `getCookies` helper — including the `__Secure-` prefix an `https` base URL implies
|
||||
— so there is no second copy of it to drift.
|
||||
3. **The check reads the real session relation**, not `select 1`: it plans a read of the exact
|
||||
columns the Better Auth adapter depends on under a `where false` predicate. PostgreSQL resolves
|
||||
the relation, every named column, and the caller's `SELECT` privilege while matching zero rows,
|
||||
so no session row, token, or user id can be read, logged, or enumerated by the check. It runs on
|
||||
the injected pool — never a second connection — under a bounded 3-second timeout.
|
||||
|
||||
A missing session table, a dropped or drifted column, a revoked `SELECT` privilege, an unreachable
|
||||
database, and a query timeout therefore all produce a sanitized
|
||||
`503 {"code":"PRINCIPAL_RESOLUTION_FAILED"}` — never a silent anonymous result. The response body,
|
||||
logs, and error carry no relation name, column name, SQL, driver message, host, or stack.
|
||||
|
||||
## What is not operational yet
|
||||
|
||||
No email verification, password recovery, invitation flow, email delivery, social or OAuth
|
||||
|
||||
@@ -19,8 +19,10 @@ connection and operation (`query_timeout`/`statement_timeout`) database timeouts
|
||||
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
|
||||
they contain, pinned 8–128 password-length bounds, sanitized failures, exact whole-name
|
||||
session-cookie detection that never fires on a prefixed, suffixed, or differently-cased lookalike,
|
||||
and that a broken session store during principal resolution is never silently reported as anonymous
|
||||
while an unrelated cookie performs no identity database work at all), 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, module
|
||||
route mounting/enforcement/CORS/response sanitization, CORS allow/deny/preflight, deterministic 404
|
||||
@@ -83,6 +85,15 @@ outage during principal resolution producing a sanitized `503` rather than a fal
|
||||
starting the API against an unmigrated database creating no tables at all, and a clean listener/pool
|
||||
shutdown.
|
||||
|
||||
A dedicated group within that suite proves session-store readiness against the real database rather
|
||||
than a mock, since that is the only place a broken store looks exactly like an anonymous request: a
|
||||
request carrying no cookies and one carrying only an unrelated cookie both resolve as anonymous
|
||||
while issuing **no** identity database work at all; a request presenting the configured session
|
||||
cookie against a dropped session table, a dropped column, and a revoked `SELECT` privilege each
|
||||
produce a sanitized `503 PRINCIPAL_RESOLUTION_FAILED` with no relation name, column name, SQL, or
|
||||
driver text in the body or the logs; and a genuinely expired or unknown session against a healthy
|
||||
store still resolves as anonymous.
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user