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