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
|
||||
|
||||
@@ -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