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