Ekklesia could authenticate a person and, since Pass 1, send a transactional email — but it had no way to establish that an address belonged to whoever claimed it, and no way back in for someone who forgot their password. A verified address is now a precondition for holding a session. Better Auth enforces that after the password check and before any session exists, so an unverified sign-in returns 403 with no cookie. That is why no "unverified principal" was needed: a correct password on an unverified address carries exactly the authority of no password, and /api/v1/principal is unchanged. Better Auth keeps every token. Ekklesia adds no token table, no hashing, and no expiry clock. It supplies a recipient, a subject, and two bodies, and the mail provider supplies everything about SMTP. Verification and reset links are rebuilt from a validated Dashboard origin, taking only the token — never the URL Better Auth prebuilds from a caller-supplied callbackURL — so no request can steer where a recipient lands. Following a verification link mints no session, because mail infrastructure dereferences links. Reset revokes that person's other sessions and leaves managed devices alone. Recovery answers identically for an address with an account, without one, already verified, or undeliverable — including when SMTP is broken, which is why the delivery adapter never throws. The initial platform owner is the one exception: created already verified by an operator command with no mail prerequisite, so a fresh server can be signed in to before a relay exists. Migration 0007 extends that to owners of installations that already exist. Public signup stays disabled; verification is not a licence to self-register. Co-Authored-By: Claude Opus 5 <[email protected]>
475 lines
24 KiB
TypeScript
475 lines
24 KiB
TypeScript
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<Record<IdentityRuntimeErrorCode, string>> = 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<Response>;
|
||
/** 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<EkklesiaPrincipal>;
|
||
}
|
||
|
||
async function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
try {
|
||
return await Promise.race([
|
||
operation,
|
||
new Promise<never>((_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<boolean> {
|
||
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<T>(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<Response> => {
|
||
try {
|
||
return await auth.handler(request);
|
||
} catch {
|
||
throw new IdentityRuntimeError("AUTH_REQUEST_FAILED");
|
||
}
|
||
},
|
||
resolvePrincipal: async (headers: Headers): Promise<EkklesiaPrincipal> => {
|
||
let session: Awaited<ReturnType<typeof auth.api.getSession>>;
|
||
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;
|
||
},
|
||
});
|
||
}
|