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 { 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."; /** * 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; /** * A narrow, explicit override for the sign-in/sign-up rate-limit window 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 strict built-in default * (3 requests / 10 seconds on sign-in and sign-up) applies unmodified. */ 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. maxPasswordLength: 128, minPasswordLength: 8, }, 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: { "/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; }, }); }