Optional email login (v13 candidate)
rullst-auth features email-login-sqlite and email-login-postgres expose
recovery::email_login::EmailLoginService. The umbrella features are
auth-email-login-sqlite and auth-email-login-postgres. They are opt-in and
reuse the authoritative SqlRecoveryStore account and opaque-session registry.
They do not enable JWT, OAuth or email login on existing accounts automatically.
Combined hosted source/package/coverage admission passed in
PR #239;
final release admission remains separate.
Account and application boundaries
Create an immutable EmailLoginConfig with a server-owned application/tenant
namespace, HTTPS landing URL, fixed local destination and capacity (1–100,000
account policies per namespace). Query/fragment/user-info in the landing URL and
encoded/authority/traversal syntax in the destination are rejected. The explicit
development constructor allows HTTP only on localhost/loopback.
Use initialize during deployment and connect during normal startup. Access
accounts() to register/authenticate accounts in this registry. Enable a policy
with set_account_enabled only after recent password authentication plus the
application’s tenant and MFA policy. The proof must come from this service’s
account registry instance and current account epoch. Disabled accounts retain
their policy/revision and count toward capacity. An administrator-provided
namespace is a storage boundary, not proof that a caller belongs to a tenant.
Resolve membership, roles and resource ownership on each authorized request.
Email access is a single factor. Do not enable this route as an alternative that bypasses required MFA, passkeys or a stronger identity assurance policy. This increment does not implement enrollment UX, account linking, invitation-based registration, step-up MFA, verified age or guardian authorization.
Request, delivery and deliberate confirmation
- Generate an independent
BrowserBindingon the server. Keep it in a dedicatedSecure; HttpOnly; SameSite=Laxcookie, scoped to the application, with a maximum age of 15 minutes. Protect the request endpoint with CSRF and ingress limits. Never accept a browser binding chosen through a link or request body. - Call
request_loginwith the email, binding and trusted server clock. Known, unknown, disabled, suppressed and throttled accounts receive the sameLoginRequestAccepted. Each namespace allows 120 requests per minute and each account three per 15 minutes. A 250 ms minimum acknowledgement delay mitigates trivial enumeration; it is not a constant-time guarantee under database contention or faults. Rate-limit at ingress independently. - Issuance creates a distinct random 256-bit emailed token, stores domain-bound HMAC digests of both secrets, binds the account epoch and policy revision, and atomically queues an AES-256-GCM encrypted notice. A new accepted request replaces the prior link and pending/leased notice. Links expire after exactly 15 minutes. Account opt-out, password recovery or another account-epoch change invalidates the pending link. An account has one pending link and one request budget, whichever browser asks. Anyone who knows an enrolled address can therefore replace the owner’s pending link (the new link only works in the requester’s browser) and spend the three requests of each window, blocking email login for that account at about 12 requests per hour. This bounded budget is what prevents mail flooding; a per-browser budget would not, because browser bindings are free to mint. Email login is an optional route: keep password login and recovery available, apply per-client and per-target ingress limits, and let users disable email login if it is abused against them.
- A trusted worker calls
claim_notice, constructs MailAccountEvent::EmailLoginAtwithActionLink, the server-configured origin andexpires_at, then sends with the stabledelivery_id. Use the recorded locale, with explicit fallback. Callcomplete_noticeorfail_noticewith a redacted delivery outcome. Claims last at most 60 seconds, allow six attempts and use bounded backoff. Delivery is at-least-once; transports without idempotency may send duplicates. A bounce or complaint suppresses the account’s future transactional delivery. - The landing GET/HEAD only renders confirmation. It never consumes the
token or creates a session. An explicit CSRF-protected POST passes the emailed
token and cookie binding to
redeem. The initiating browser is required; opening the email on another device requires starting a new request there. Never redeem automatically in JavaScript or on page load. Only a failed token lookup (malformed, unknown, expired, replayed or wrong-browser) is charged, to the presented browser binding. After 60 such failures within 60 seconds, that binding receivesRecoveryError::Throttleduntil its window passes, before any database work, even for a valid link. Other bindings and valid redemptions are never charged or blocked, so one client cannot lock other users out of redemption (request replacement and the per-account request budget are described in step 3). The budget is process-local, keyed by an HMAC digest of the binding and bounded to 10,000 recent bindings. It is not a global brute-force bound: an attacker can rotate bindings, and a multi-host deployment keeps one budget per service instance, so ingress limits remain required. MapThrottledto a generic “try again later” response. - Redemption atomically consumes the link and creates a one-hour opaque session
using the existing account epoch, 20-session limit and inventory/revocation
machinery. Store it in a
Secure; HttpOnly; SameSitecookie, clear the browser binding, and redirect only toEmailLoginSession::destination(). Verify the session and authorization on every request. Logout and account revocation remain authoritative across instances sharing the database.
Use the framework security baseline (CSRF, WAF and secure headers), bounded
request bodies, Cache-Control: no-store and Referrer-Policy: no-referrer on
all landing and authentication responses, including errors. Do not load
third-party scripts, analytics, fonts or images on the landing page. Redact
query strings, cookies and mail bodies before request logging or tracing. Never
serialize the returned session/delivery capabilities into application events.
The test-only /fixture/mail route in the HTTP acceptance harness substitutes
an inbox; it must never be mounted in a deployed application.
The Mail template has deterministic English, Brazilian Portuguese and Spanish HTML/text, absolute expiry, a same-browser confirmation instruction and no tracking pixel. It neither generates tokens nor changes authentication state.
Storage, cancellation and operations
SQLite uses one private connection per instance, WAL, FULL synchronization and
one shared local file. It is not a multi-host database. The deployment owns the
trusted directory, file permissions, encryption-at-rest and backup. PostgreSQL
uses a bounded four-connection pool, verified remote TLS, five-second SQL/lock
limits, permanent tables, fsync/full_page_writes and synchronous commit on a
writable primary. Public methods have a ten-second operation timeout (plus the
minimum request delay). All state mutations serialize through the account
registry’s write lock and recheck clock observations. The namespace keeps a
monotonic clock high-water mark: a host whose clock trails it by at most five
seconds, as synchronized hosts do around each whole-second boundary, adopts the
recorded time, while a larger backward step fails with InvalidAction. A lock
wait or skew adoption cannot extend a link’s expiry. An uncertain commit or cancellation never returns a session.
PostgreSQL bootstrap serializes explicit initializers. Normal startup performs
no DDL or missing-namespace repair. Runtime needs SELECT/INSERT/UPDATE/DELETE on
the account/session/outbox and email-login tables in the trusted public schema;
reserve CREATE/ALTER for the deployment role and revoke schema CREATE from
untrusted roles. Use one authoritative writable database, synchronized clocks,
replication fencing and a bounded number of server-configured namespaces.
Configuration/key drift and non-durable PostgreSQL tables fail closed.
Delivered/terminal email ciphertext is deleted immediately. Run purge_expired
from a trusted retention worker even during idle periods; it removes expired
links and notices. Invalidated encrypted notices that were already claimed may
remain until expiry, and in-flight transport work cannot be recalled. Such links
fail redemption. No recipient activity history is recorded by this component.
A stale database restore can revive consumed links, account epochs and revoked sessions. Quiesce authentication, invalidate restored login challenges/outbox and session state, reconcile account epochs and policies, then resume. This adapter does not provide backup anti-rollback, high availability, secret rotation or a production mail delivery SLA.
Automated evidence
The candidate includes SQLite/PostgreSQL independent-pool races, purpose and namespace isolation, browser mismatch, replacement, opt-out/epoch invalidation, expiry/clock rollback, locked-database expiry, cancellation, SQL failure atomicity, bounded retries and suppression. Process tests use fresh executables; the owned PostgreSQL wrapper also restarts the database service. The Chromium HTTP fixture uses the actual framework security baseline and proves scanner GET/HEAD, CSRF rejection, deliberate POST, secure cookies, tenant authorization, replay rejection and logout across separate database pools.
Run the ordinary SQLite contracts with cargo test -p rullst-auth --features email-login-sqlite. Run the real browser/service contracts with
RULLST_EMAIL_LOGIN_BROWSER_TESTS=1 python3 .github/check-auth-recovery-postgres.py.
The wrapper owns a disposable loopback Docker database; it does not use external
accounts. Packaged-distribution acceptance exercises extracted archives through
the facade. These automated fixtures do not establish live mail-provider or
owner-application acceptance.