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
+116
-24
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user