think
16px
820px

Registration v2 (email verification, domain restriction, auth audit) — Design

Date: 2026-07-06 · Status: Approved (user directive: password confirmation, email
confirmation, admin domain restriction, auth events in the audit log)
Module: CORE. Builds directly on the auth-hardening feature (spec 2026-07-06-internal-auth-hardening).

1. Password confirmation (web only)

RegisterPage gains a "Confirm password" field with a client-side match check (same UX as
the forced-change gate). No API change — the server never needs the duplicate.

2. Email verification on self-registration

  • Migration 00089: users.email_verified_at timestamptzbackfilled now() for every
    existing account
    (nobody is retro-locked); email_verifications(token_hash text PK, user_id uuid FK CASCADE, expires_at timestamptz); auth_settings gains
    require_email_verification bool NOT NULL DEFAULT true and
    allowed_email_domains text NOT NULL DEFAULT ''.
  • Register (when verification is on): the account is created unverified, NO session is
    issued — the response is 201 {verification_required:true} and a mail goes out via the
    existing notify SMTP channel (dev = mailpit) with a link
    {X-Forwarded-Proto|http}://{Host}/verify-email?token=<raw> (32-byte token, sha256
    stored, 24h TTL, single-success). A mail-send failure is logged + audited but does not
    fail registration (resend covers it). Verification OFF → today's behavior (session).
  • Login: a provider='local' account with email_verified_at IS NULL (verification on)
    gets 403 auth.email.unverified — deliberately actionable, not generic: open
    registration already reveals account existence via the 409, and "check your inbox" is the
    correct UX. Dev/directory users and admin-created accounts are exempt (admin create sets
    email_verified_at = now()).
  • POST /auth/verify-email {token} (rate-limited, pre-session): consume + mark verified.
    POST /auth/resend-verification {email}: ALWAYS returns 200 with a generic body (no
    enumeration); internally re-issues (delete old tokens → insert new → send) only for an
    existing unverified local account. SPA route /verify-email posts the ?token= and
    shows success ("you can now sign in") or an invalid/expired state with a resend box.

3. Admin-restricted email domains

allowed_email_domains — comma-separated, case-insensitive, empty = any. Enforced ONLY on
self-registration (RegisterUser): the part after the final @ must match one of the
listed domains → else 400 auth.register.domain_not_allowed (policy is public, error is
actionable). Admin create bypasses (admins may onboard anyone). Edited in Admin → Security
(a "Registration" group: the existing self-registration toggle + the verification toggle +
a domains text field, e.g. val.id, obscura.local).

4. Auth events in the audit log

The auditMutations middleware already records every successful authenticated mutation —
including logout and all the admin actions (user create/reset/unlock, settings changes).
What's missing is the PRE-SESSION surface; the handlers now audit.Append (partition
auth, best-effort, never failing the request):

Action Actor Payload
auth.login (success) user id {email, ip}
auth.login.failed attempted email {ip} (reason stays internal — the wire error is generic)
auth.login.totp_pending user id {ip} (password ok, awaiting code)
auth.totp.verify / auth.totp.verify_failed user id / - {ip}
auth.register new user id {email, ip, verification_required}
auth.register.denied attempted email {ip, code} (disabled/domain/policy)
auth.email.verified user id {ip}
auth.email.resend attempted email {ip}

IP via the existing clientIP(r) helper. Failed logins deliberately audit the attempted
email as actor (searchable trail) — never the password.

Testing

Build/vet + tsc/vite; e2e on the throwaway dind stack: register (verification on) → no
session + mail captured in mailpit (dind:8025 API) → extract link → verify → login works;
unverified login → 403 auth.email.unverified; resend → 200 generic for both existing and
absent emails; domain restriction on (test.local) → other domains 400, listed domain
passes; audit rows present for login/failed/register (query audit_events); director +
demo untouched; cleanup scratch + settings reset.

Out of scope (YAGNI)

HTML email templates (plain text), email change re-verification, SMTP configuration UI
(env-only as today), verification reminders/expiry sweeps beyond opportunistic delete.