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
+45 -3
View File
@@ -189,9 +189,12 @@ while the module retains the original response. Only anonymous unmounts the modu
authenticated preserves the session and mounted module. A stale response from an already-unmounted
frame is ignored, and network/server failures do not fabricate logout.
The flow remains at the current Dashboard URL and accepts no callback/return target, so this pass
adds no open-redirect input. It exposes no signup or recovery link: production signup is prohibited
and no email delivery/recovery system exists. The existing cookie stays host-only; exact trusted
The flow remains at the current Dashboard URL and accepts no callback/return target, so it adds no
open-redirect input. It exposes **no signup** link, because production signup is prohibited. It does
expose a recovery link, and the screens it leads to still accept no destination: the emailed link's
landing is parsed by a pure function that recognizes exactly two Ekklesia-owned values and names no
URL at all, and the link itself was built by the server from validated configuration rather than
from anything a request supplied (ADR 0038). The existing cookie stays host-only; exact trusted
origins, credentialed CORS, and explicitly enabled Better Auth origin/CSRF checks remain the
boundary. Broadening the cookie to a parent domain or inventing a Dashboard hostname would be a
separate deployment and threat-model decision.
@@ -620,6 +623,40 @@ Deliverability — SPF, DKIM, DMARC, and the relay's own authorization policy
responsibility this repository cannot verify, and Doctor says so rather than implying a successful
connection means mail arrives.
### Email verification and password recovery
Both flows are Better Auth's, delivered over that transport
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)).
- **A verified address is a precondition for a session.** An unverified sign-in fails after the
password check and before any session exists, so a correct password on an unverified address is
worth exactly as much as no password. There is no "partly authenticated" principal to misread.
- **The initial platform owner is the one exception**, created already verified by an operator
command whose authority is possession of the database. It is exactly one address, exactly once per
installation, with no mail prerequisite — the alternative is an installation nobody can ever sign
in to.
- **Ekklesia stores no token.** No token table, no token hashing, no expiry clock, no consumption
logic. Verification tokens are signed JWTs valid for one hour and replayable but idempotent (mail
scanners dereference links); reset tokens are stored rows, valid for one hour, and consumed.
- **Following a link never creates a session.** `autoSignInAfterVerification` is off, so a URL
fetched by a link scanner, a prefetcher, or a mail gateway hands out nothing.
- **Links are built from configuration.** Only the token is taken from the identity system; the
destination comes from a validated Dashboard origin that must be canonical, https and non-loopback
in production, and a trusted origin — checked at startup. No request parameter can steer where a
recipient lands.
- **Tokens never reach a log, an error, or a diagnostic**, and the browser strips the reset token
from the address bar with `history.replaceState` before rendering the form. It is never written to
`localStorage`, `sessionStorage`, or a cookie.
- **Recovery is enumeration-safe, including when mail is broken.** Every user-facing answer is
identical for an address with an account, without one, already verified, or undeliverable. The
delivery adapter never throws, because the identity system rethrows what its callback throws and
that would turn a failed relay into an account oracle. Failures are reported out of band with a
sanitized code and no address, token, or link.
- **Password reset revokes that person's sessions**, including the one performing it. Managed device
sessions are untouched: a device holds a separate credential digest, no email, and no password.
- **Verification does not imply registration.** Public signup remains disabled and the browser offers
no way to ask for an account.
### Rate limiting is per-process today
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
@@ -628,6 +665,11 @@ only while **both** of the following hold, which is the current state:
- production public signup is disabled (and cannot be enabled — see above); and
- exactly one API instance serves traffic.
The same limiter covers the two paths that trigger an email — password-reset requests and
verification resends — at **3 requests per 60 seconds per IP**. That is a real control against
turning the platform into someone's mail cannon from one source, and it is deliberately **not**
claimed to be distributed: a multi-process deployment gets that budget per process.
If either changes — public signup is enabled, or a second instance is added — a durable shared or
edge rate limiter becomes mandatory, and it must be proven that its counters are shared across
instances, survive process restart, and cannot be re-bucketed by a spoofed client-IP header.