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
+116 -24
View File
@@ -253,12 +253,12 @@ logs, and error carry no relation name, column name, SQL, driver message, host,
## What is and is not operational
Email/password sign-in, principal resolution, sign-out, and the Dashboard's host-owned browser flow
are operational — see [API](api.md) and ADR 0034. Public registration remains a future product
policy, not a production capability: it is disabled by default, may be explicitly enabled only for
development/test, and production rejects it. No email verification, password recovery, invitation
flow, email delivery, social or OAuth provider, passkey, magic link, two-factor authentication, or
roles/groups/teams/tenants model exists.
Email/password sign-in, principal resolution, sign-out, the Dashboard's host-owned browser flow,
email verification, and password recovery are operational — see [API](api.md), ADR 0034, and
ADR 0038. Public registration remains a future product policy, not a production capability: it is
disabled by default, may be explicitly enabled only for development/test, and production rejects it.
No invitation flow, social or OAuth provider, passkey, magic link, two-factor authentication,
email-change verification, or roles/groups/teams/tenants model exists.
Core's authorization foundation (platform owners, explicit grants, and an immutable permission
registry) is covered separately in [Authorization](authorization.md). Its bootstrap command grants
@@ -279,31 +279,93 @@ wins, so a browser holding both remains a person.
`/api/v1/principal` reports a device as `anonymous` on purpose. It is the endpoint the Dashboard uses
to decide whether a person is signed in, and a device is not one.
## Outbound email and the verification seam
## Email verification and password recovery
Ekklesia has a host-provided SMTP transport
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md), see
[Outbound email](email.md)) — but **no identity flow uses it yet.**
A verified email address is a **precondition for holding a session**
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Better Auth
enforces this after the password is checked and before any session exists, so an unverified sign-in
returns `403 EMAIL_NOT_VERIFIED` and sets no cookie.
Better Auth exposes exactly two hooks for the flows that need mail, and this repository configures
neither:
That is why there is no "unverified principal": a correct password on an unverified address carries
exactly the same authority as no password at all, and `/api/v1/principal` still reports only
`anonymous`, an authenticated human, or a device.
| hook | flow |
### Who owns what
| concern | owner |
| --- | --- |
| `emailAndPassword.sendResetPassword({ user, url, token })` | Password reset |
| `emailVerification.sendVerificationEmail({ user, url, token })` | Email verification |
| token minting, expiry, consumption, password hashing, session revocation | Better Auth |
| recipient, subject, plaintext and HTML bodies, link construction | `core/src/identity/notifications.ts` |
| SMTP host, port, TLS, credentials, sender identity | the mail provider ([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)) |
| the browser screens a person actually sees | the shell (`surfaces/shell`) |
Phase 7C Pass 2 implements them over the mail transport. `startApi` already constructs the provider
and closes it on every shutdown path, so the remaining work is to inject it into
`createIdentityRuntime` and write the two message bodies.
Ekklesia implements no token table, no token hashing, and no expiry clock. A structural test asserts
that none appears.
**The `url` and `token` those hooks receive are credentials.** The entire security of both flows is
that only the recipient sees them, so they must never be logged, never appear in an error, and never
reach a diagnostic. The transport already logs nothing and refuses to echo a message body.
### Token semantics, stated precisely
Until that pass lands: email verification, password recovery, and invitations do not exist, and
`requireEmailVerification` stays off — turning it on without a working sender would lock every
account out.
| | verification | password reset |
| --- | --- | --- |
| representation | signed JWT (not stored) | random value stored in `ekklesia_core_verification` |
| lifetime | 1 hour | 1 hour |
| single-use | **no** — valid until it expires | **yes** — consumed on use |
| replay | idempotent no-op, mints no session | refused with `INVALID_TOKEN` |
Verification is deliberately idempotent because mail scanners and prefetchers dereference links.
Following one never creates a session (`autoSignInAfterVerification: false`), so a link fetched by
something that is not the recipient hands out nothing.
### Links
Both links are rebuilt from validated configuration, taking **only the token** from Better Auth —
never the `url` it prebuilds from a caller-supplied `callbackURL`/`redirectTo`. No request parameter
can influence where a recipient lands.
They point at `IdentityConfig.dashboardOrigin` (`EKKLESIA_DASHBOARD_ORIGIN`), which defaults to the
identity base URL, must be a canonical origin, must be https and non-loopback in production, and must
be a trusted origin — checked at startup, so a misconfiguration fails once for the operator instead of
for every recipient. It is the authenticated Dashboard host, not the public church website.
**The `url` and `token` are credentials.** Nothing logs them, nothing puts them in an error, nothing
puts them in a diagnostic, and the browser strips the reset token from the address bar with
`history.replaceState` before rendering the form.
### Recovery is enumeration-safe
Every user-facing answer is identical for an address with an account, an address without one, an
already-verified address, and one whose message could not be delivered. The notification adapter
therefore **never throws** — Better Auth's `/send-verification-email` rethrows what its callback
throws, which would turn a broken relay into an account oracle. Failures are reported out of band:
```text
[identity] email-verification email was not delivered: MAIL_NOT_CONFIGURED
```
### Password reset and sessions
`revokeSessionsOnPasswordReset` is enabled: completing a reset deletes **every** session for that
person, including the browser that performed it, so the completion screen returns to sign-in.
Recovery is what someone uses when they think their credential is compromised, and leaving the
attacker signed in would make it cosmetic.
Managed device sessions are untouched — a device holds a separate credential digest, no email, and no
password.
### Resending
A sign-in attempt against an unverified account resends the message (`sendOnSignIn`). That path costs
nothing to secure because it already required the correct password.
`POST /api/auth/send-verification-email` is also available and is enumeration-safe on its own.
Better Auth rate-limits both mail-triggering paths at **3 requests per 60 seconds per IP**, and
sign-in at 3 per 10 seconds. That limiter is **in-memory and per process** — a real control for a
single deployment, and deliberately not claimed to be a distributed one.
### Without a configured relay
The platform starts, serves, and signs its bootstrap owner in. It configures the identical
verification policy and simply cannot deliver. Absent mail does **not** relax
`requireEmailVerification`, which is exactly why the initial owner is created already verified.
## Creating the first platform owner
@@ -325,6 +387,36 @@ DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email owne
`--name` is optional and defaults to the email address; the identity model requires a display name,
and asking for one that nobody can edit yet would be a question without a purpose.
### This one account is created already verified
Every other human account must confirm its address by email before it may hold a session. This one
cannot: it is created on a freshly provisioned server, by an operator who already holds the database
credentials, at a moment when SMTP may not be configured, may not be reachable, and may not yet have
a domain to send from. Requiring an email round trip here would produce a working installation whose
only administrator can never sign in to configure the mail service the requirement depends on
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)).
**The command has no mail prerequisite of any kind.** It marks Better Auth's own `emailVerified`
column — so the row is indistinguishable from one that verified normally — before granting ownership;
if that write fails, the grant and the one-time sentinel roll back together and the command remains
available. An adopted identity is marked verified too, because the alternative is bootstrap
succeeding and producing an owner who cannot sign in.
The exception is narrow: exactly one address, exactly once per installation. Its authority is
possession of the database, not possession of a mailbox. An account created any other way is still
subject to the ordinary policy.
Existing installations are covered by migration `0007_verified_bootstrap_owners`, which marks
currently-granted platform owners verified so enabling this policy cannot lock an operator out of a
platform they already run. It touches no other account.
### How future human accounts will become verified
There is no user administration and no invitation flow yet, and this documentation does not claim
either exists. When one is built, the path is already in place: an account created by any trusted
server-side means starts unverified, its first sign-in attempt sends a verification message, and it
becomes ordinary once the address is confirmed. The bootstrap owner remains the only exception.
### The password never becomes an argument or a variable
It is prompted for twice, invisibly, when a terminal is attached. `--password` is **refused with an