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
+25 -2
View File
@@ -153,8 +153,9 @@ status, `Set-Cookie`, and other headers but discards the native token/user respo
The shell neither parses nor stores a token. It resolves `/api/v1/principal` again, and only an
authenticated result permits the navigation request and module mount. Credential denial and
service failure use distinct fixed messages, but no Better Auth response body, exception, email,
password, URL, or stack reaches browser output or logging. The form deliberately offers no signup
or password-reset link because neither is a supported production flow.
password, URL, or stack reaches browser output or logging. The form offers **no signup** control,
because public registration is not a supported production flow. It does offer **Forgot password?**,
which is now a real backend flow (ADR 0038).
Sign-out posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
resolves the principal again. The host does not equate hiding the shell with invalidating a session.
@@ -182,6 +183,28 @@ redirect target and performs no post-login navigation, so external and protocol-
have no redirect sink in this pass. See
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
### The four anonymous screens
The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
forgot-password, verification (notice or result), and choose-a-new-password
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). None of
them is authenticated, and none can become authenticated — `/api/v1/principal` still decides.
An emailed link returns to the Dashboard root carrying `?ekklesia_auth=verify-email` or
`?ekklesia_auth=reset-password`. Query parameters rather than paths, deliberately: the shell is a
static bundle, and a deep path would oblige every deployment's static host to rewrite unknown paths
to `index.html` before an emailed link worked at all.
The entry point parses that query once, with a pure function that recognizes only those two values
and **names no destination of any kind**, then removes it from the address bar with
`history.replaceState` — so a reset token never enters browser history, a shareable URL, or a
`Referer`. The token lives in React state for the life of one form and is never written to
`localStorage`, `sessionStorage`, or a cookie.
A correct password on an unverified address is reported as its own state (`403 EMAIL_NOT_VERIFIED`,
no cookie) so the shell can offer a resend rather than claim the password was wrong. It is never
confused with an authenticated principal.
## Public registration policy
**Public sign-up is disabled by default** (`EKKLESIA_PUBLIC_SIGNUP_ENABLED` unset or anything other
+40 -7
View File
@@ -3,9 +3,14 @@
Ekklesia can send transactional email through a host-configured SMTP transport
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
**Nothing in the platform sends email yet.** This pass added the transport; email verification,
password reset, and invitations are Phase 7C Pass 2. An installation with no SMTP configured is a
complete, valid deployment — the platform starts and every existing feature works.
Two things send email: **email verification** and **password recovery**
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Invitations
do not exist.
An installation with no SMTP configured is still a complete, valid deployment — the platform starts,
every existing feature works, and the initial platform owner can sign in. What it cannot do is verify
a new account or recover a password, because both need a message to arrive. See
[When no relay is configured](#when-no-relay-is-configured).
## Configuring a relay
@@ -121,8 +126,36 @@ transport refusing to continue when the upgrade is not offered.
**No test sends real email and no live credential exists in this repository or in CI.**
## Next
## What the platform actually sends
Phase 7C Pass 2 wires Better Auth's `sendResetPassword` and `emailVerification.sendVerificationEmail`
hooks — both currently unimplemented — to this transport. See
[Identity](identity.md#outbound-email-and-the-verification-seam).
| message | when | contents |
| --- | --- | --- |
| Verify your Ekklesia email address | a new account signs in unverified, or asks for a resend | one link, an expiry sentence, and ignore-if-unexpected wording |
| Reset your Ekklesia password | someone asks to recover an account | one link, an expiry sentence, and a note that the current password still works |
Both are plain text and HTML, both are generic Ekklesia templates with no branding and no marketing
content, and both are built in `core/src/identity/notifications.ts` — which knows a recipient, a
subject, and two bodies, and nothing about SMTP.
Links are rebuilt from validated configuration rather than taken from the request that triggered
them, so no caller can steer where a recipient lands. They point at
`EKKLESIA_DASHBOARD_ORIGIN` (defaulting to the identity base URL). See
[Identity](identity.md#email-verification-and-password-recovery).
## When no relay is configured
Nothing crashes and nothing changes shape. A verification or recovery request answers exactly as it
would with a working relay — the response must not reveal whether an account exists, and it must not
start revealing it the moment SMTP breaks — and the operator is told out of band:
```text
[identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED
```
That line carries a flow and a sanitized code. It never carries the address, the token, or the link.
`pnpm doctor` reports the same absence under `outbound-email.configuration`.
The operator requirement is therefore:
- **normal production use of verification and recovery** → requires configured outbound email
- **initial platform-owner bootstrap** (`pnpm identity:create-initial-owner`) → does not
+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
+1 -1
View File
@@ -166,7 +166,7 @@ Object storage
Outbound email
○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.
No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
Result: READY WITH WARNINGS
```
+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.
+47
View File
@@ -271,6 +271,53 @@ demonstrate what it *cannot* say — its whole world is a `ModuleStorage` and tw
manifest — and it asserts that the module's durable state holds keys while only the URL carries
host detail. It also proves that forty URLs cost no provider round trips.
A ninth suite (`apps/api/tests/email-verification.integration.ts`, same harness) proves email
verification and password recovery **end to end**
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Everything
in it is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better Auth endpoints,
the actual SMTP provider, and an in-process `smtp-server` accepting the message. The verification and
reset links are **extracted from the delivered message** and then followed verbatim — the API is
bound to a port reserved before it is configured, precisely so the emailed URL is the one the test
resolves rather than one it had to rewrite. A fake mail function would have proved that a callback
ran and nothing about whether the resulting link works.
It pins: an unverified account refused a session with `403 EMAIL_NOT_VERIFIED` and no cookie; the
full unverified → emailed link → verified → signed in path; a malformed and an expired token
reported as separate reasons on the host's own landing; verification replay as an idempotent no-op
that mints nothing; a reset link that changes the password, invalidates the old one, is consumed on
use, and signs every existing session out; the identity system's own 8–128 policy enforced at reset
with no reset-specific rule; an unknown address answered byte-identically to a real one; a
mail-less installation answering identically while recording `MAIL_NOT_CONFIGURED` out of band;
attacker-supplied `redirectTo`/`callbackURL` values refused, and a *trusted* one still ignored
because the link is rebuilt from configuration; public signup still refused; and a paired device row
untouched across a human password reset.
The expired-token case is the one place any test constructs a token rather than reading one from a
delivered message: the verification token is a plain HS256 JWS over the auth secret this suite
configured, so an already-expired one is forged from that secret with `node:crypto` rather than by
waiting an hour.
**Nothing here reaches the internet and no live credential exists anywhere in it.**
`apps/api/tests/initial-owner.integration.ts` additionally proves the trusted-bootstrap exception:
that the owner is created verified (asserted in the database *and* through a real browser sign-in),
that neither bootstrap nor sign-in needs any mail configuration, that an adopted identity is marked
verified too, and that the exception is narrow — an ordinary account created by the identical trusted
creator stays unverified and is refused.
Browser-side, `surfaces/shell/tests/auth-recovery.test.tsx` proves the host-owned screens: that an
unverified sign-in offers verification rather than a credentials error and never renders the
Dashboard, that recovery shows one generic completion state whatever the address, that a repeated
submit sends one email, that a mismatched confirmation is blocked before anything is sent, and that a
reset token never reaches `localStorage`, `sessionStorage`, the DOM, or the address bar. The landing
parser is exercised as the pure function it is, including that it names no destination at all.
`core/tests/identity-verification-boundaries.test.ts` reads the repository itself for the properties
no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia
schema gained a token column and no identity code hashes or compares a token, that nothing
interpolates a token, link, address, or body into a log line, that a device has no email, password,
or verification concept, and that no browser source imports Better Auth or Core's mail.
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
binding determinism and collision-safety, scoping and cross-module isolation, key validation, lazy