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