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
@@ -0,0 +1,181 @@
|
||||
# 0038: Email verification and password recovery, with a trusted-bootstrap exception
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Extends: [0010](0010-core-identity-and-better-auth-adoption.md),
|
||||
[0011](0011-identity-http-transport-and-public-enrollment.md),
|
||||
[0034](0034-host-owned-browser-authentication.md),
|
||||
[0035](0035-initial-platform-owner-bootstrap.md),
|
||||
[0036](0036-managed-device-identity-and-pairing.md),
|
||||
[0037](0037-host-provided-outbound-email.md)
|
||||
|
||||
## Context
|
||||
|
||||
Ekklesia can authenticate a person, hold a session, pair a device, and — since
|
||||
[ADR 0037](0037-host-provided-outbound-email.md) — send a transactional email. What it cannot do is
|
||||
either half of the ordinary human identity lifecycle: it has no way to establish that an address
|
||||
belongs to the person who claimed it, and no way for someone who has forgotten their password to
|
||||
get back in. The Better Auth hooks for both have existed and been deliberately unimplemented since
|
||||
[ADR 0011](0011-identity-http-transport-and-public-enrollment.md); Pass 1 built the transport they
|
||||
were waiting for.
|
||||
|
||||
Adding them is not simply "turn the feature on", because two things about this platform are unusual.
|
||||
|
||||
The **initial platform owner** is created by an operator-run command
|
||||
([ADR 0035](0035-initial-platform-owner-bootstrap.md)) on a freshly provisioned server, at a moment
|
||||
when SMTP may not be configured, may not be reachable, and may not yet have a domain to send from.
|
||||
A verification requirement applied naively would produce a correctly installed platform whose only
|
||||
administrator can never sign in to configure the very mail service the requirement depends on.
|
||||
|
||||
And **password recovery is an anonymous, unauthenticated mutation** that sends mail to an address
|
||||
the caller chose. That is the classic account-enumeration surface, and it is also the classic way to
|
||||
turn a platform into someone else's mail cannon.
|
||||
|
||||
## Decision
|
||||
|
||||
### Better Auth owns the mechanics; Ekklesia owns the experience
|
||||
|
||||
```text
|
||||
host-owned browser screens ← Ekklesia
|
||||
↓
|
||||
Ekklesia auth boundary ← Ekklesia
|
||||
↓
|
||||
Better Auth ← tokens, hashing, expiry, consumption, session policy
|
||||
↓ "deliver this"
|
||||
identity notification adapter ← Ekklesia: recipient, subject, bodies
|
||||
↓
|
||||
MailProvider seam → SMTP ← ADR 0037
|
||||
```
|
||||
|
||||
Ekklesia implements **no** token table, token hashing, token expiry, or token consumption. Those
|
||||
exist and are correct in the pinned release, and a second implementation would be a second thing to
|
||||
get wrong. A structural test asserts that no Ekklesia schema gains a reset or verification token
|
||||
column and that identity code never hashes or compares one.
|
||||
|
||||
### A verified address is a precondition for holding a session
|
||||
|
||||
`emailAndPassword.requireEmailVerification` is enabled. In the pinned release this is enforced
|
||||
*after* the password is validated and *before* any session is created: an unverified sign-in fails
|
||||
with `403 EMAIL_NOT_VERIFIED` and **no cookie is set**.
|
||||
|
||||
That single fact is what keeps the principal model unchanged. There is no "unverified principal",
|
||||
because there is no session to attach one to — a correct password on an unverified address produces
|
||||
exactly the same authority as no password at all. `/api/v1/principal` continues to describe only
|
||||
`anonymous`, an authenticated human, and a managed device, and the Dashboard shell continues to
|
||||
render authenticated content only after the principal endpoint says so. Nothing was added to the
|
||||
public principal union, and nothing needed to be.
|
||||
|
||||
### The initial platform owner is created already verified
|
||||
|
||||
`createInitialPlatformOwner` marks the address verified — writing Better Auth's own `emailVerified`
|
||||
column, so the resulting row is indistinguishable from one that verified normally — before it grants
|
||||
ownership. If that write fails, the grant and the one-time sentinel roll back together and the
|
||||
command remains available.
|
||||
|
||||
The exception's authority is the same authority that already makes the command safe: **possession of
|
||||
the database, not possession of the mailbox.** It is not a weaker policy, it is a different and
|
||||
stronger proof, available exactly once per installation. `pnpm identity:create-initial-owner` gains
|
||||
no mail prerequisite of any kind.
|
||||
|
||||
An adopted identity is marked verified too. The alternative — bootstrap succeeding and producing an
|
||||
owner who cannot sign in — is the failure this whole exception exists to prevent.
|
||||
|
||||
Migration `0007_verified_bootstrap_owners` marks currently-granted platform owners of *existing*
|
||||
installations verified, so enabling this policy cannot lock an operator out of a platform they
|
||||
already run. It is deliberately narrow: it touches only identities the authorization tables already
|
||||
record as owners, and no other account.
|
||||
|
||||
### Links are built from configuration, never from a request
|
||||
|
||||
Better Auth hands its callbacks a prebuilt URL embedding whatever `callbackURL` or `redirectTo` the
|
||||
requesting client supplied, and validates those against its trusted origins. That validation is
|
||||
sound. It is nonetheless not what Ekklesia relies on: the notification adapter takes **only the
|
||||
token** and rebuilds the link from validated configuration.
|
||||
|
||||
The result is that no request parameter can influence where a recipient lands, independently of
|
||||
whether any origin check behaves as expected. An emailed link is the one artifact a person clicks
|
||||
without looking.
|
||||
|
||||
The destination is a new validated `IdentityConfig.dashboardOrigin` — the **Dashboard** browser
|
||||
origin, which defaults to the identity base URL (the ordinary same-origin shape) and may be
|
||||
configured separately (`EKKLESIA_DASHBOARD_ORIGIN`) for a deployment that splits its API from its
|
||||
Dashboard. It is validated to be a canonical origin, https and non-loopback in production, and a
|
||||
Better Auth trusted origin — so a misconfiguration fails at startup instead of producing a link
|
||||
every recipient is refused at. It is explicitly **not** the public church website origin: this is
|
||||
the authenticated Dashboard host and nothing else.
|
||||
|
||||
### Following a link never mints a session
|
||||
|
||||
`emailVerification.autoSignInAfterVerification` is `false`. Verification links travel through mail
|
||||
infrastructure and are routinely dereferenced by things that are not the recipient — link scanners,
|
||||
prefetchers, corporate mail gateways. Turning that GET into a logged-in session would hand a session
|
||||
to whatever fetched the URL. Verification flips the flag and the person signs in afterwards.
|
||||
|
||||
This also makes replay harmless, which matters: the verification token is a signed JWT rather than a
|
||||
stored row, so it is **not single-use** — it is valid until it expires (one hour). Replaying it on an
|
||||
already-verified address is a no-op that mints nothing. The reset token *is* a stored row and *is*
|
||||
consumed, so a captured reset link cannot be replayed.
|
||||
|
||||
### Password reset revokes that human's sessions
|
||||
|
||||
`emailAndPassword.revokeSessionsOnPasswordReset` is enabled. Recovery is what someone uses when they
|
||||
believe their credential is compromised; leaving the attacker's session alive would make it
|
||||
cosmetic. This is Better Auth's own supported option, not a Core reach into the session table.
|
||||
|
||||
It deletes *all* of that person's sessions, including the browser performing the reset — so the
|
||||
completion screen returns to sign-in rather than pretending to be authenticated. **Managed device
|
||||
sessions are unaffected**: a device is a separate credential digest keyed by device
|
||||
([ADR 0036](0036-managed-device-identity-and-pairing.md)), holds no email and no password, and has
|
||||
no row in the human session table.
|
||||
|
||||
### Recovery is enumeration-safe, including when mail is broken
|
||||
|
||||
The user-facing answer is identical for an address with an account, an address without one, an
|
||||
already-verified address, and an address whose message could not be delivered.
|
||||
|
||||
Making that true required one thing beyond the library's own behaviour. Better Auth's
|
||||
`/send-verification-email` rethrows whatever the delivery callback throws, which would make "this
|
||||
address has an unverified account" observable the moment SMTP broke. **The notification adapter
|
||||
therefore never throws.** A failure is reported out of band with a sanitized code and no address,
|
||||
token, or link, where no HTTP response shape depends on it.
|
||||
|
||||
### Sending is still direct, non-retrying, and rate-limited per process
|
||||
|
||||
[ADR 0037](0037-host-provided-outbound-email.md)'s discipline is unchanged: one bounded attempt, no
|
||||
application-level retry. A timeout after SMTP DATA is genuinely ambiguous, and retrying it sends the
|
||||
same reset link twice.
|
||||
|
||||
Better Auth's own limiter applies **3 requests per 60 seconds per IP** to password-reset requests and
|
||||
verification resends, and 3 per 10 seconds to sign-in. That limiter is in-memory and per process.
|
||||
It is a real abuse control for a single deployment and is deliberately **not** claimed to be a
|
||||
distributed one; a multi-process deployment gets that budget per process. Resending is also reachable
|
||||
by simply attempting to sign in (`emailVerification.sendOnSignIn`), which costs nothing to secure
|
||||
because it requires the correct password.
|
||||
|
||||
### Mail-less installations still run
|
||||
|
||||
An installation with no SMTP configured starts, serves, and signs its bootstrap owner in. It
|
||||
configures the identical verification policy and simply cannot deliver; every attempt reports
|
||||
`MAIL_NOT_CONFIGURED` through the reporter. Absent mail does **not** relax
|
||||
`requireEmailVerification` — which is precisely why the bootstrap exception exists.
|
||||
|
||||
The operator requirement is therefore: **normal production use of verification and recovery requires
|
||||
configured outbound email; initial platform-owner bootstrap does not.**
|
||||
|
||||
### Public signup remains disabled
|
||||
|
||||
Email verification is not a licence to self-register. `POST /api/auth/sign-up/email` stays refused,
|
||||
production still cannot enable it at all, and the browser shell offers no registration control. A
|
||||
regression test asserts each. Future human accounts will arrive through deliberate administration or
|
||||
invitation, which this decision does not design.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A human account's lifecycle is complete: it exists, verifies, signs in, recovers, and signs out.
|
||||
- The principal union, the device model, and the module-facing surface are all unchanged.
|
||||
- Ekklesia Lite is unaffected: it has no human authentication, so it acquires no SMTP requirement.
|
||||
- Verification and recovery are now the second and third things that will break if outbound email is
|
||||
misconfigured. `pnpm doctor`'s `outbound-email.configuration` check is the place an operator finds
|
||||
that out.
|
||||
- Deferred deliberately: invitations, user administration, email-change verification, multiple
|
||||
addresses, MFA, OAuth/SSO, a durable mail outbox, and distributed rate limiting.
|
||||
@@ -46,3 +46,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
||||
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||
|
||||
+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
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
Ekklesia can send transactional email through a host-configured SMTP transport
|
||||
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
|
||||
|
||||
**Nothing in the platform sends email yet.** This pass added the transport; email verification,
|
||||
password reset, and invitations are Phase 7C Pass 2. An installation with no SMTP configured is a
|
||||
complete, valid deployment — the platform starts and every existing feature works.
|
||||
Two things send email: **email verification** and **password recovery**
|
||||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Invitations
|
||||
do not exist.
|
||||
|
||||
An installation with no SMTP configured is still a complete, valid deployment — the platform starts,
|
||||
every existing feature works, and the initial platform owner can sign in. What it cannot do is verify
|
||||
a new account or recover a password, because both need a message to arrive. See
|
||||
[When no relay is configured](#when-no-relay-is-configured).
|
||||
|
||||
## Configuring a relay
|
||||
|
||||
@@ -121,8 +126,36 @@ transport refusing to continue when the upgrade is not offered.
|
||||
|
||||
**No test sends real email and no live credential exists in this repository or in CI.**
|
||||
|
||||
## Next
|
||||
## What the platform actually sends
|
||||
|
||||
Phase 7C Pass 2 wires Better Auth's `sendResetPassword` and `emailVerification.sendVerificationEmail`
|
||||
hooks — both currently unimplemented — to this transport. See
|
||||
[Identity](identity.md#outbound-email-and-the-verification-seam).
|
||||
| message | when | contents |
|
||||
| --- | --- | --- |
|
||||
| Verify your Ekklesia email address | a new account signs in unverified, or asks for a resend | one link, an expiry sentence, and ignore-if-unexpected wording |
|
||||
| Reset your Ekklesia password | someone asks to recover an account | one link, an expiry sentence, and a note that the current password still works |
|
||||
|
||||
Both are plain text and HTML, both are generic Ekklesia templates with no branding and no marketing
|
||||
content, and both are built in `core/src/identity/notifications.ts` — which knows a recipient, a
|
||||
subject, and two bodies, and nothing about SMTP.
|
||||
|
||||
Links are rebuilt from validated configuration rather than taken from the request that triggered
|
||||
them, so no caller can steer where a recipient lands. They point at
|
||||
`EKKLESIA_DASHBOARD_ORIGIN` (defaulting to the identity base URL). See
|
||||
[Identity](identity.md#email-verification-and-password-recovery).
|
||||
|
||||
## When no relay is configured
|
||||
|
||||
Nothing crashes and nothing changes shape. A verification or recovery request answers exactly as it
|
||||
would with a working relay — the response must not reveal whether an account exists, and it must not
|
||||
start revealing it the moment SMTP breaks — and the operator is told out of band:
|
||||
|
||||
```text
|
||||
[identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED
|
||||
```
|
||||
|
||||
That line carries a flow and a sanitized code. It never carries the address, the token, or the link.
|
||||
`pnpm doctor` reports the same absence under `outbound-email.configuration`.
|
||||
|
||||
The operator requirement is therefore:
|
||||
|
||||
- **normal production use of verification and recovery** → requires configured outbound email
|
||||
- **initial platform-owner bootstrap** (`pnpm identity:create-initial-owner`) → does not
|
||||
|
||||
+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
|
||||
|
||||
@@ -166,7 +166,7 @@ Object storage
|
||||
|
||||
Outbound email
|
||||
○ Outbound email not configured
|
||||
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.
|
||||
No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
|
||||
|
||||
Result: READY WITH WARNINGS
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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