Skip to content

feat(auth): email-verification flow — close account squatting (security PR2b)#80

Open
Omarear wants to merge 1 commit into
mainfrom
feat/sec-email-verification
Open

feat(auth): email-verification flow — close account squatting (security PR2b)#80
Omarear wants to merge 1 commit into
mainfrom
feat/sec-email-verification

Conversation

@Omarear

@Omarear Omarear commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Security Hardening — PR 2b (#111): email-verification flow

The identity half of the auth hardening (2a closed the account-takeover; this closes account squatting). Email/password sign-up must prove ownership before getting a session. OAuth (live in prod) is untouched — the gate is email/password-scoped.

Two paths

  • Email/password (gated): sign-up → user row, no session → verification email → click link → emailVerified:true + session (autoSignInAfterVerification) → /verify-email/onboarding → paywall. Unverified sign-in → 403 EMAIL_NOT_VERIFIED (BA auto-resends).
  • OAuth (pass-through, unchanged): callback.mjs has no requireEmailVerification check; a provider-verified user gets a session with no verify step. Proven by an explicit handleOAuthUserInfo test.

API

emailAndPassword.requireEmailVerification: true + emailVerification: { sendOnSignUp, autoSignInAfterVerification, sendVerificationEmail }. sendVerificationEmail builds the link deterministically with a fixed callbackURL=${DASHBOARD_URL}/verify-email (one trusted landing for success + ?error), reusing lib/email.ts (Resend, workerd-safe). No new secret.

Dashboard (ck/ds, "support agent" never "chatbot")

  • A — sign-up success → inline "check your email" + resend (replaces the now-broken /onboarding nav).
  • B — public /verify-email landing: no ?error → success → hard-nav /onboarding (4s stall fallback to sign-in); ?error=CODE → friendly "link expired" + resend form.
  • C — sign-in EMAIL_NOT_VERIFIED → inline "we re-sent the link" + resend. Matched on the stable error code, never status 403 (adversarial-review fix — bad credentials are 401, so 403-matching was fragile).
  • use-resend-verification hook (30s cooldown, 429-safe); auth-client gains the native sendVerificationEmail.

Migration / seed / backfill

No migration (emailVerified exists). Dev seed already sets email_verified=1 for admin. No prod backfill (post-reset: OAuth accounts already verified; credential test accounts self-heal via sign-in auto-resend) — the approved hard-gate decision.

Tests (the deliverable)

  • API e2e (real sqlite + real BA handler): sign-up → unverified + no session; link verify round-trip → verified + session minted; unverified sign-in → 403; OAuth → session + no gate; config assertion; verification-link construction.
  • Dashboard: the three surfaces + the invalid-credentials (401) negative case (does NOT show the verify notice). Also fixed PR2a's "sessions stay in D1" test for the new no-auto-session-on-sign-up behavior.
  • Gates: api 370 + dashboard 397 pass; oxlint + prettier + tsc clean.

Process

Plan-first → pre-build design review (OAuth no-regression confirmed 12 ways) → build → adversarial review (OAuth + verify round-trip + regression sweep + frontend) → 1 confirmed finding fixed (the 403 breadth) → PR.

Deploy note

2b touches the dashboard frontend → its preview runs through the kind: nextjs builder that flaked transiently before. If a preview fails with No executable pnpm found, it's the known transient Ploy flake — re-trigger once.

🤖 Generated with Claude Code

…ty PR2b)

The identity half of the auth hardening. Email/password sign-up must now prove
ownership before getting a session; OAuth (live in prod) is untouched.

- API (auth.ts): emailAndPassword.requireEmailVerification + emailVerification
  { sendOnSignUp, autoSignInAfterVerification, sendVerificationEmail }. The
  verification link is built deterministically with a fixed
  callbackURL=${DASHBOARD_URL}/verify-email (one trusted landing for success +
  ?error). Reuses lib/email.ts (Resend, workerd-safe) — no new secret.
- OAuth unchanged: the callback issues a session with no verify step; the gate
  is email/password-scoped. Proven by an explicit handleOAuthUserInfo test.
- Dashboard surfaces (ck/ds): sign-up success → inline "check your email" +
  resend (replaces the now-broken /onboarding nav); public /verify-email landing
  (success → /onboarding, ?error → resend); sign-in EMAIL_NOT_VERIFIED → inline
  resend notice (matched on the stable error CODE, never status 403).
- use-resend-verification hook (30s cooldown, 429-safe); auth-client gains the
  native sendVerificationEmail.

No migration (emailVerified exists); dev seed already sets it for admin. No prod
backfill (post-reset: OAuth accounts verified; credential test accounts self-heal
via sign-in auto-resend).

Tests (the deliverable): API e2e — sign-up unverified + no session; link verify
round-trip flips verified + mints a session; unverified sign-in 403; OAuth
session + no gate; config assertion. Dashboard — the three surfaces + the
invalid-credentials (401) negative case. Fixed PR2a's "sessions stay in D1" test
for the new no-auto-session-on-signup behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@meet-ploy

meet-ploy Bot commented Jun 23, 2026

Copy link
Copy Markdown

✅ All deployments successful!

Project Deployment Branch Preview Commit Preview
llmchat-marketing Ready Preview Preview
llmchat-showcase Ready Preview Preview
llmchat-dashboard Ready Preview Preview
llmchat-api Ready Preview Preview

Deployed with Ploy

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants