feat: verify email addresses and recover forgotten passwords
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s

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]>
This commit is contained in:
ekklesia
2026-08-25 07:21:17 -05:00
co-authored by Claude Opus 5
parent 3581ad7c8e
commit ea28f8aa2e
52 changed files with 5675 additions and 197 deletions
+119 -5
View File
@@ -9,6 +9,7 @@ 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 =
@@ -46,6 +47,16 @@ export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address";
*/
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
@@ -82,10 +93,29 @@ 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.
* 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;
}
@@ -264,9 +294,81 @@ export function createIdentityRuntime(
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.
// 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,
@@ -293,6 +395,18 @@ export function createIdentityRuntime(
...(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,