feat: verify email addresses and recover forgotten passwords
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:
co-authored by
Claude Opus 5
parent
3581ad7c8e
commit
ea28f8aa2e
@@ -0,0 +1,181 @@
|
||||
# 0038: Email verification and password recovery, with a trusted-bootstrap exception
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Extends: [0010](0010-core-identity-and-better-auth-adoption.md),
|
||||
[0011](0011-identity-http-transport-and-public-enrollment.md),
|
||||
[0034](0034-host-owned-browser-authentication.md),
|
||||
[0035](0035-initial-platform-owner-bootstrap.md),
|
||||
[0036](0036-managed-device-identity-and-pairing.md),
|
||||
[0037](0037-host-provided-outbound-email.md)
|
||||
|
||||
## Context
|
||||
|
||||
Ekklesia can authenticate a person, hold a session, pair a device, and — since
|
||||
[ADR 0037](0037-host-provided-outbound-email.md) — send a transactional email. What it cannot do is
|
||||
either half of the ordinary human identity lifecycle: it has no way to establish that an address
|
||||
belongs to the person who claimed it, and no way for someone who has forgotten their password to
|
||||
get back in. The Better Auth hooks for both have existed and been deliberately unimplemented since
|
||||
[ADR 0011](0011-identity-http-transport-and-public-enrollment.md); Pass 1 built the transport they
|
||||
were waiting for.
|
||||
|
||||
Adding them is not simply "turn the feature on", because two things about this platform are unusual.
|
||||
|
||||
The **initial platform owner** is created by an operator-run command
|
||||
([ADR 0035](0035-initial-platform-owner-bootstrap.md)) on a freshly provisioned server, at a moment
|
||||
when SMTP may not be configured, may not be reachable, and may not yet have a domain to send from.
|
||||
A verification requirement applied naively would produce a correctly installed platform whose only
|
||||
administrator can never sign in to configure the very mail service the requirement depends on.
|
||||
|
||||
And **password recovery is an anonymous, unauthenticated mutation** that sends mail to an address
|
||||
the caller chose. That is the classic account-enumeration surface, and it is also the classic way to
|
||||
turn a platform into someone else's mail cannon.
|
||||
|
||||
## Decision
|
||||
|
||||
### Better Auth owns the mechanics; Ekklesia owns the experience
|
||||
|
||||
```text
|
||||
host-owned browser screens ← Ekklesia
|
||||
↓
|
||||
Ekklesia auth boundary ← Ekklesia
|
||||
↓
|
||||
Better Auth ← tokens, hashing, expiry, consumption, session policy
|
||||
↓ "deliver this"
|
||||
identity notification adapter ← Ekklesia: recipient, subject, bodies
|
||||
↓
|
||||
MailProvider seam → SMTP ← ADR 0037
|
||||
```
|
||||
|
||||
Ekklesia implements **no** token table, token hashing, token expiry, or token consumption. Those
|
||||
exist and are correct in the pinned release, and a second implementation would be a second thing to
|
||||
get wrong. A structural test asserts that no Ekklesia schema gains a reset or verification token
|
||||
column and that identity code never hashes or compares one.
|
||||
|
||||
### A verified address is a precondition for holding a session
|
||||
|
||||
`emailAndPassword.requireEmailVerification` is enabled. In the pinned release this is enforced
|
||||
*after* the password is validated and *before* any session is created: an unverified sign-in fails
|
||||
with `403 EMAIL_NOT_VERIFIED` and **no cookie is set**.
|
||||
|
||||
That single fact is what keeps the principal model unchanged. There is no "unverified principal",
|
||||
because there is no session to attach one to — a correct password on an unverified address produces
|
||||
exactly the same authority as no password at all. `/api/v1/principal` continues to describe only
|
||||
`anonymous`, an authenticated human, and a managed device, and the Dashboard shell continues to
|
||||
render authenticated content only after the principal endpoint says so. Nothing was added to the
|
||||
public principal union, and nothing needed to be.
|
||||
|
||||
### The initial platform owner is created already verified
|
||||
|
||||
`createInitialPlatformOwner` marks the address verified — writing Better Auth's own `emailVerified`
|
||||
column, so the resulting row is indistinguishable from one that verified normally — before it grants
|
||||
ownership. If that write fails, the grant and the one-time sentinel roll back together and the
|
||||
command remains available.
|
||||
|
||||
The exception's authority is the same authority that already makes the command safe: **possession of
|
||||
the database, not possession of the mailbox.** It is not a weaker policy, it is a different and
|
||||
stronger proof, available exactly once per installation. `pnpm identity:create-initial-owner` gains
|
||||
no mail prerequisite of any kind.
|
||||
|
||||
An adopted identity is marked verified too. The alternative — bootstrap succeeding and producing an
|
||||
owner who cannot sign in — is the failure this whole exception exists to prevent.
|
||||
|
||||
Migration `0007_verified_bootstrap_owners` marks currently-granted platform owners of *existing*
|
||||
installations verified, so enabling this policy cannot lock an operator out of a platform they
|
||||
already run. It is deliberately narrow: it touches only identities the authorization tables already
|
||||
record as owners, and no other account.
|
||||
|
||||
### Links are built from configuration, never from a request
|
||||
|
||||
Better Auth hands its callbacks a prebuilt URL embedding whatever `callbackURL` or `redirectTo` the
|
||||
requesting client supplied, and validates those against its trusted origins. That validation is
|
||||
sound. It is nonetheless not what Ekklesia relies on: the notification adapter takes **only the
|
||||
token** and rebuilds the link from validated configuration.
|
||||
|
||||
The result is that no request parameter can influence where a recipient lands, independently of
|
||||
whether any origin check behaves as expected. An emailed link is the one artifact a person clicks
|
||||
without looking.
|
||||
|
||||
The destination is a new validated `IdentityConfig.dashboardOrigin` — the **Dashboard** browser
|
||||
origin, which defaults to the identity base URL (the ordinary same-origin shape) and may be
|
||||
configured separately (`EKKLESIA_DASHBOARD_ORIGIN`) for a deployment that splits its API from its
|
||||
Dashboard. It is validated to be a canonical origin, https and non-loopback in production, and a
|
||||
Better Auth trusted origin — so a misconfiguration fails at startup instead of producing a link
|
||||
every recipient is refused at. It is explicitly **not** the public church website origin: this is
|
||||
the authenticated Dashboard host and nothing else.
|
||||
|
||||
### Following a link never mints a session
|
||||
|
||||
`emailVerification.autoSignInAfterVerification` is `false`. Verification links travel through mail
|
||||
infrastructure and are routinely dereferenced by things that are not the recipient — link scanners,
|
||||
prefetchers, corporate mail gateways. Turning that GET into a logged-in session would hand a session
|
||||
to whatever fetched the URL. Verification flips the flag and the person signs in afterwards.
|
||||
|
||||
This also makes replay harmless, which matters: the verification token is a signed JWT rather than a
|
||||
stored row, so it is **not single-use** — it is valid until it expires (one hour). Replaying it on an
|
||||
already-verified address is a no-op that mints nothing. The reset token *is* a stored row and *is*
|
||||
consumed, so a captured reset link cannot be replayed.
|
||||
|
||||
### Password reset revokes that human's sessions
|
||||
|
||||
`emailAndPassword.revokeSessionsOnPasswordReset` is enabled. Recovery is what someone uses when they
|
||||
believe their credential is compromised; leaving the attacker's session alive would make it
|
||||
cosmetic. This is Better Auth's own supported option, not a Core reach into the session table.
|
||||
|
||||
It deletes *all* of that person's sessions, including the browser performing the reset — so the
|
||||
completion screen returns to sign-in rather than pretending to be authenticated. **Managed device
|
||||
sessions are unaffected**: a device is a separate credential digest keyed by device
|
||||
([ADR 0036](0036-managed-device-identity-and-pairing.md)), holds no email and no password, and has
|
||||
no row in the human session table.
|
||||
|
||||
### Recovery is enumeration-safe, including when mail is broken
|
||||
|
||||
The user-facing answer is identical for an address with an account, an address without one, an
|
||||
already-verified address, and an address whose message could not be delivered.
|
||||
|
||||
Making that true required one thing beyond the library's own behaviour. Better Auth's
|
||||
`/send-verification-email` rethrows whatever the delivery callback throws, which would make "this
|
||||
address has an unverified account" observable the moment SMTP broke. **The notification adapter
|
||||
therefore never throws.** A failure is reported out of band with a sanitized code and no address,
|
||||
token, or link, where no HTTP response shape depends on it.
|
||||
|
||||
### Sending is still direct, non-retrying, and rate-limited per process
|
||||
|
||||
[ADR 0037](0037-host-provided-outbound-email.md)'s discipline is unchanged: one bounded attempt, no
|
||||
application-level retry. A timeout after SMTP DATA is genuinely ambiguous, and retrying it sends the
|
||||
same reset link twice.
|
||||
|
||||
Better Auth's own limiter applies **3 requests per 60 seconds per IP** to password-reset requests and
|
||||
verification resends, and 3 per 10 seconds to sign-in. That limiter is in-memory and per process.
|
||||
It is a real abuse control for a single deployment and is deliberately **not** claimed to be a
|
||||
distributed one; a multi-process deployment gets that budget per process. Resending is also reachable
|
||||
by simply attempting to sign in (`emailVerification.sendOnSignIn`), which costs nothing to secure
|
||||
because it requires the correct password.
|
||||
|
||||
### Mail-less installations still run
|
||||
|
||||
An installation with no SMTP configured starts, serves, and signs its bootstrap owner in. It
|
||||
configures the identical verification policy and simply cannot deliver; every attempt reports
|
||||
`MAIL_NOT_CONFIGURED` through the reporter. Absent mail does **not** relax
|
||||
`requireEmailVerification` — which is precisely why the bootstrap exception exists.
|
||||
|
||||
The operator requirement is therefore: **normal production use of verification and recovery requires
|
||||
configured outbound email; initial platform-owner bootstrap does not.**
|
||||
|
||||
### Public signup remains disabled
|
||||
|
||||
Email verification is not a licence to self-register. `POST /api/auth/sign-up/email` stays refused,
|
||||
production still cannot enable it at all, and the browser shell offers no registration control. A
|
||||
regression test asserts each. Future human accounts will arrive through deliberate administration or
|
||||
invitation, which this decision does not design.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A human account's lifecycle is complete: it exists, verifies, signs in, recovers, and signs out.
|
||||
- The principal union, the device model, and the module-facing surface are all unchanged.
|
||||
- Ekklesia Lite is unaffected: it has no human authentication, so it acquires no SMTP requirement.
|
||||
- Verification and recovery are now the second and third things that will break if outbound email is
|
||||
misconfigured. `pnpm doctor`'s `outbound-email.configuration` check is the place an operator finds
|
||||
that out.
|
||||
- Deferred deliberately: invitations, user administration, email-change verification, multiple
|
||||
addresses, MFA, OAuth/SSO, a durable mail outbox, and distributed rate limiting.
|
||||
@@ -46,3 +46,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
||||
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||
|
||||
Reference in New Issue
Block a user