import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type EkklesiaPrincipal, } from "@ekklesia/contracts"; import { betterAuth, type BetterAuthOptions } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { getCookies } from "better-auth/cookies"; import { sql } from "drizzle-orm"; import type { IdentityConfig } from "./config.js"; import type { IdentityDrizzleDatabase } from "./database.js"; import type { IdentityNotifications } from "./notifications.js"; import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js"; export type IdentityRuntimeErrorCode = | "AUTH_REQUEST_FAILED" | "IDENTITY_CONSTRUCTION_FAILED" | "PRINCIPAL_RESOLUTION_FAILED"; const runtimeErrorMessages: Readonly> = Object.freeze({ AUTH_REQUEST_FAILED: "The identity authentication request could not be handled.", IDENTITY_CONSTRUCTION_FAILED: "The identity runtime could not be constructed.", PRINCIPAL_RESOLUTION_FAILED: "The request principal could not be resolved.", }); const identityDatabaseProbeTimeoutMs = 3_000; /** * The one header name Better Auth's own client-address resolution (`advanced.ipAddress. * ipAddressHeaders`) is configured to trust — never `X-Forwarded-For`, `Forwarded`, * `CF-Connecting-IP`, `X-Real-IP`, or any other caller-reachable header. Core never reinterprets an * untrusted proxy header itself: `apps/api`'s transport boundary (`apps/api/src/client-address.ts`) * is the only writer, resolving the real socket peer (direct mode) or a chain walked against an * explicitly configured trusted-proxy set (proxy mode) before overwriting any externally supplied * copy of this exact header with its own server-derived result. Better Auth is configured to trust * only this one synthesized value, never a second, competing header name. */ export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address"; /** * A fixed, generic replacement for whatever Better Auth actually logged. Better Auth's own log * call sites are not a stable contract Core can audit line by line, and several of them pass a * raw internal exception or interpolate user-controlled input directly into the message (for * example the origin-check middleware logs `Invalid origin: ${originHeader}`). Rather than trying * to sanitize Better Auth's message/argument payload case by case, the payload is dropped * entirely and only this fixed string is ever written. */ const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error."; /** * One hour for both, matching the pinned release's own defaults but stated rather than inherited. * * Short enough that a link sitting in a forwarded or archived mailbox stops working, long enough * that someone who reads their mail after lunch is not sent back to the start. The message bodies * state the same hour, so the two can be checked against each other. */ const EMAIL_VERIFICATION_TOKEN_TTL_SECONDS = 3600; const RESET_PASSWORD_TOKEN_TTL_SECONDS = 3600; /** * Explicitly replaces Better Auth's default logger so its own message text and arguments — which * may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a * session token, a password, or a user-controlled origin/referer header — are never written * anywhere. Only the fixed, generic message above is ever emitted, and only for error-level * events; this is deliberately not just "quiet under NODE_ENV=test": the same replacement applies * in every environment, so production logging is exactly as sanitized as test logging. */ function sanitizedBetterAuthLogger(level: string): void { if (level === "error") { console.error(sanitizedBetterAuthLogMessage); } } /** Never carries the underlying Better Auth error, database error, cause, stack, secret, or URL. */ export class IdentityRuntimeError extends Error { readonly code: IdentityRuntimeErrorCode; constructor(code: IdentityRuntimeErrorCode) { super(runtimeErrorMessages[code]); this.name = "IdentityRuntimeError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } export interface IdentityRateLimitOverride { readonly max?: number; readonly window?: number; } export interface CreateIdentityRuntimeOptions { readonly config: IdentityConfig; readonly db: IdentityDrizzleDatabase; /** * How verification and password-reset messages are delivered. * * Injected rather than constructed here, for the same reason the database is: this file composes * Better Auth and must not decide what a message says or how it travels. Omitting it is a * complete, valid deployment — email verification and password recovery are then configured * exactly as they otherwise are, and each attempt reports `MAIL_NOT_CONFIGURED` out of band while * every HTTP response keeps its usual shape (ADR 0038). * * Note what omitting it does **not** do: it does not relax `requireEmailVerification`. An * installation with no mail cannot verify a new account, which is why the initial platform owner * is created already verified by a trusted operator command rather than by an email round trip. */ readonly notifications?: IdentityNotifications; /** * A narrow, explicit override for the rate-limit windows on the four paths Better Auth treats * more strictly than its global default, used only by test composition that issues many such * requests quickly. Rate limiting itself is always enabled; production composition must leave this * unset so Better Auth's own built-in defaults apply unmodified — 3 requests / 10 seconds on * sign-in and sign-up, and 3 requests / 60 seconds on password-reset requests and verification * resends, which are the two paths an attacker could otherwise use to send mail repeatedly. * * That limiter is per-process and in-memory (ADR 0038): it is a real abuse control for a single * deployment and is deliberately **not** claimed to be a distributed one. */ readonly rateLimitOverride?: IdentityRateLimitOverride; } /** * Deliberately narrow: exposes exactly two Fetch-standard capabilities and nothing else. It never * returns the Better Auth instance, `auth.api`, inferred user/session types, the Drizzle adapter, * the database, a session token, cookie contents, a password hash, or Better Auth configuration. */ export interface EkklesiaIdentityRuntime { /** Delegates directly to Better Auth's Fetch handler. Normal Better Auth HTTP responses * (including its own 4xx error responses) pass through unmodified; only a genuinely unexpected * failure is converted to a sanitized `IdentityRuntimeError`. */ handleAuthRequest(request: Request): Promise; /** Resolves the caller's Ekklesia principal from request headers. Returns the anonymous * principal for a missing, expired, revoked, or malformed session — never for an infrastructure * failure, which instead throws a sanitized `IdentityRuntimeError`. */ resolvePrincipal(headers: Headers): Promise; } async function withTimeout(operation: Promise, timeoutMs: number): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ operation, new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error("timed out")), timeoutMs); }), ]); } finally { clearTimeout(timer); } } /** * Whether the caller actually presented *the* configured Better Auth session cookie — not merely * some cookie. An arbitrary anonymous cookie (`Cookie: x=y`) is not evidence that Better Auth ever * attempted to resolve an Ekklesia session, so it must never trigger session-store work. * * Parsing deliberately mirrors RFC 6265's `cookie-string` grammar the same way Better Auth's own * parser does: split on `;`, take each pair's name as the text before its first `=`, and trim * surrounding whitespace. Matching is an exact, whole-name string comparison — 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 (cookie names are case-sensitive per RFC 6265 §4.1.1, and * cookie names are never percent-decoded). * * Ambiguity fails safe *towards* checking rather than away from it: a duplicated session-cookie * name still reports `true`, because reporting `false` is what would let a genuinely broken * session store be reported as an anonymous request. Returning `true` never grants access on its * own — it only means the readiness check runs and Better Auth's own result still governs. */ export function hasConfiguredSessionCookie(headers: Headers, cookieName: string): boolean { const cookieHeader = headers.get("cookie"); if (cookieHeader === null) { return false; } for (const pair of cookieHeader.split(";")) { const separatorIndex = pair.indexOf("="); const name = (separatorIndex === -1 ? pair : pair.slice(0, separatorIndex)).trim(); if (name === cookieName) { return true; } } return false; } /** * Whether the actual Better Auth session store is usable — not merely whether PostgreSQL can * execute a trivial statement. * * The previous `select 1` probe proved only that *a* connection could run *a* query, so a missing * session relation, a drifted/absent column, or a revoked `SELECT` privilege all still looked * "reachable", and Better Auth's ambiguous `null` was then reported as a genuinely anonymous * request. This instead plans a real read of the exact session relation and the exact columns the * adapter depends on, under a `where false` predicate: PostgreSQL still resolves the relation, * every named column, and the caller's `SELECT` privilege at plan time, while guaranteeing zero * rows are matched, read, or returned — so no session row, token, or user id can ever be * enumerated, logged, or exposed by the check itself. * * Uses the injected Identity Drizzle view (the API's one shared pool); it never constructs another * pool, client, connection string, or Drizzle instance. The bounded `withTimeout` guarantees the * public response is never blocked by a hung query, and `clearTimeout` in its `finally` leaves no * timer behind. */ async function probeSessionStore(db: IdentityDrizzleDatabase): Promise { try { await withTimeout( db .select({ expiresAt: ekklesiaCoreSession.expiresAt, id: ekklesiaCoreSession.id, token: ekklesiaCoreSession.token, userId: ekklesiaCoreSession.userId, }) .from(ekklesiaCoreSession) .where(sql`false`), identityDatabaseProbeTimeoutMs, ); return true; } catch { // Deliberately swallowed: the caught value may carry raw SQL, a relation/column name, a // driver detail, a host/port, or a stack. The caller converts `false` into the fixed, // sanitized `PRINCIPAL_RESOLUTION_FAILED` contract instead. return false; } } function constructBetterAuth(build: () => T): T { try { return build(); } catch { throw new IdentityRuntimeError("IDENTITY_CONSTRUCTION_FAILED"); } } /** * Core's Better Auth composition boundary and Fetch-compatible identity transport. Constructs * Better Auth explicitly from injected, already-validated configuration and an injected Drizzle * database — it never reads environment variables and enables no provider or plugin beyond the * four core models and email/password sign-up/sign-in required for this slice. */ export function createIdentityRuntime( options: CreateIdentityRuntimeOptions, ): EkklesiaIdentityRuntime { const rateLimitOverride = options.rateLimitOverride; // One options object feeds both `betterAuth()` and `getCookies()` below, so the session-cookie // name this runtime detects is derived from the exact same configuration Better Auth itself // uses. That makes drift structurally impossible rather than merely tested: there is no second // hard-coded copy of the cookie name (or of the `__Secure-` prefix rule the configured HTTPS // `baseURL` implies) anywhere in this repository. const betterAuthOptions = { advanced: { // Better Auth's own default is environment-sensitive: `skipOriginCheck` falls back to // `true` whenever `isTest()` is true (NODE_ENV=test, which Vitest sets, or TEST=1) unless // `disableOriginCheck` is explicitly set — and origin skip in turn also silently skips // CSRF validation as long as `disableCSRFCheck` is left unset (Better Auth's documented // backward-compatibility coupling). Left implicit, this means origin/CSRF protection is // silently *off* under the test runner and *on* in production: the exact same code path // behaves differently in the two places it matters most to verify. Setting both flags to // `false` explicitly removes the `isTest()` branch entirely, so the identical protection // is enforced in every environment, including under Vitest. disableCSRFCheck: false, disableOriginCheck: false, // The pinned release's own client-address resolution (`getIp`, `@better-auth/core/utils/ // ip`) only ever reads request headers — it has no way to see the actual TCP socket peer. // Restricting `ipAddressHeaders` to exactly one platform-owned header name, rather than // leaving it on Better Auth's own default (`x-forwarded-for`, trusted directly with no // proxy policy), is what stops a caller from choosing its own rate-limit bucket: the named // header is populated only by `apps/api`'s transport boundary, after it has already // resolved and verified the caller's address against the actual socket peer (and, in // explicit proxy mode, a configured trusted-proxy chain) — see // `apps/api/src/client-address.ts`. `trustedProxies` is deliberately left unset here: that // option only inspects header *content* for proxy-shaped addresses and can never verify who // actually sent the request, so the equivalent trust decision is made once, correctly, at // the transport boundary instead of asked of Better Auth a second time. ipAddress: { ipAddressHeaders: [TRUSTED_CLIENT_ADDRESS_HEADER], }, }, baseURL: options.config.baseUrl, database: drizzleAdapter(options.db, { provider: "pg", schema: identityAdapterSchema, }), emailAndPassword: { autoSignIn: false, // Public registration is disabled unless explicitly enabled (`IdentityConfig. // publicSignupEnabled`, `core/src/identity/config.ts`) — never a side effect of an absent, // misspelled, or loosely parsed configuration value. Better Auth's own `disableSignUp` gate // rejects every `/sign-up/email` call with a fixed `400 EMAIL_PASSWORD_SIGN_UP_DISABLED` // before ever looking up the email, so a disabled deployment never reveals whether an // address is already registered; sign-in and existing sessions are entirely unaffected. disableSignUp: !options.config.publicSignupEnabled, enabled: true, // Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128` // in the pinned release): an implicit default can change silently on a future Better Auth // upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. The reset endpoint // enforces the identical pair from the same configuration, so there is deliberately no // separate reset-time password policy. maxPasswordLength: 128, minPasswordLength: 8, /** * A person may not hold a session until their address is verified (ADR 0038). * * In the pinned release this is enforced *after* the password is checked and *before* any * session is created: the attempt fails with `403 EMAIL_NOT_VERIFIED` and no cookie is set. * That is the whole reason no "unverified principal" exists anywhere in this platform — a * correct password on an unverified account produces no session at all, so there is no * half-authenticated state for the Dashboard to misread. `/api/v1/principal` continues to * describe only anonymous, authenticated human, and device. */ requireEmailVerification: true, /** Pinned rather than left on the release's `|| 3600` fallback, for the same reason the * password bounds are pinned. */ resetPasswordTokenExpiresIn: RESET_PASSWORD_TOKEN_TTL_SECONDS, /** * Every existing session for that account is deleted when its password is reset. * * Password reset is the recovery path someone uses precisely when they suspect their * credential is compromised, and leaving the attacker's session alive would make the recovery * cosmetic. This is Better Auth's own supported option (`deleteUserSessions` in the pinned * release), not a Core reach into the session table. * * It deletes *all* of that human's sessions, including the browser that performed the reset — * so the completion state returns to the sign-in form rather than pretending to be signed in. * Managed device sessions are a separate Ekklesia store keyed by device, hold no email and no * password, and are entirely unaffected (ADR 0036). */ revokeSessionsOnPasswordReset: true, ...(options.notifications ? { sendResetPassword: options.notifications.sendResetPassword } : {}), }, /** * Verification is configured identically whether or not mail is deliverable, so the policy an * installation enforces never depends on the state of its relay. */ emailVerification: { /** * No session is minted from following an emailed link. * * A verification URL travels through mail infrastructure and is routinely fetched by things * that are not the recipient — link scanners, prefetchers, corporate mail gateways. Turning * that GET into a logged-in browser session would hand a session to whoever, or whatever, * dereferenced the link. Verification therefore only flips the flag, and the person signs in * afterwards through the ordinary form. */ autoSignInAfterVerification: false, /** Pinned rather than left on the release's `|| 3600` fallback. */ expiresIn: EMAIL_VERIFICATION_TOKEN_TTL_SECONDS, /** * A sign-in attempt against an unverified account re-sends the verification message. * * This is the resend path that costs nothing to secure: reaching it requires the correct * password, so it reveals nothing an attacker did not already know, and it means the common * case — the first link expired — is handled by the thing the person was already trying to do. * The standalone `/send-verification-email` endpoint remains available for the case where they * would rather not retype a password, and is enumeration-safe on its own. */ sendOnSignIn: true, /** * Pinned rather than left on the release's "follow `requireEmailVerification`" fallback. * * It reaches nothing in production, where signup is refused outright — but stating it means a * development or test deployment that does enable signup behaves the way this platform * intends, rather than the way an inherited default happens to. */ sendOnSignUp: true, ...(options.notifications ? { sendVerificationEmail: options.notifications.sendVerificationEmail } : {}), }, logger: { disabled: false, level: "error", log: (level) => sanitizedBetterAuthLogger(level), }, // A second, independent layer alongside `logger` above: the pinned release's own 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 `APIError`s (wrong password, invalid origin, disabled sign-up, ...) still resolve to // their normal HTTP response completely unaffected, but a genuinely unexpected failure (for // example a database outage) now propagates out of `auth.handler()` instead of being caught // and printed raw internally, so it reaches `handleAuthRequest`'s own try/catch below and // becomes a sanitized `IdentityRuntimeError` like any other unexpected failure. onAPIError: { throw: true, }, rateLimit: { enabled: true, max: 100, window: 10, ...(rateLimitOverride ? { customRules: { // The four paths Better Auth applies a stricter-than-global default to. Production // composition never sets this override, so those defaults — 3 requests / 10 seconds // on sign-in and sign-up, 3 requests / 60 seconds on the two paths that trigger an // email — remain in force there, unmodified. "/request-password-reset": { max: rateLimitOverride.max ?? 100, window: rateLimitOverride.window ?? 10, }, "/send-verification-email": { max: rateLimitOverride.max ?? 100, window: rateLimitOverride.window ?? 10, }, "/sign-in/email": { max: rateLimitOverride.max ?? 100, window: rateLimitOverride.window ?? 10, }, "/sign-up/email": { max: rateLimitOverride.max ?? 100, window: rateLimitOverride.window ?? 10, }, }, } : {}), }, secret: options.config.secret, trustedOrigins: [...options.config.trustedOrigins], } satisfies BetterAuthOptions; const auth = constructBetterAuth(() => betterAuth(betterAuthOptions)); // `getCookies` is Better Auth's own supported, exported helper (`better-auth/cookies`) for // resolving the configured cookie names from an options object — not a dependency internal. It // is given the identical object above, so the returned `sessionToken.name` is exactly the name // the constructed runtime will read, including the `__Secure-` prefix an https `baseURL` implies. const sessionCookieName = constructBetterAuth( () => getCookies(betterAuthOptions).sessionToken.name, ); return Object.freeze({ handleAuthRequest: async (request: Request): Promise => { try { return await auth.handler(request); } catch { throw new IdentityRuntimeError("AUTH_REQUEST_FAILED"); } }, resolvePrincipal: async (headers: Headers): Promise => { let session: Awaited>; try { session = await auth.api.getSession({ headers }); } catch { // Defense in depth: the pinned Better Auth release already catches internal failures and // resolves to null instead of throwing here, but a future release is not guaranteed to. throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED"); } const userId = session?.user?.id; if (typeof userId === "string") { return createAuthenticatedPrincipal(userId); } // Better Auth's getSession swallows infrastructure failures internally and returns null for // them exactly like a genuinely missing, expired, revoked, or malformed session — it never // throws. Only a request that actually presented *the configured session cookie* is // ambiguous enough to be worth disambiguating: with no cookie at all, or with only unrelated // cookies, Better Auth never resolved an Ekklesia session in the first place, so `null` is // unambiguous and no session-store work is performed. This is what keeps an arbitrary // anonymous cookie from causing identity database work on every request. if (hasConfiguredSessionCookie(headers, sessionCookieName)) { const sessionStoreUsable = await probeSessionStore(options.db); if (!sessionStoreUsable) { throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED"); } } return ANONYMOUS_PRINCIPAL; }, }); }