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