feat(auth): reliable email verification for in-app browsers — idempotent verify-on-load + in-app PIN fallback - #268
Conversation
21399f7 to
127e0f4
Compare
127e0f4 to
148c4f6
Compare
NotThatKindOfDrLiz
left a comment
There was a problem hiding this comment.
Requesting changes for one blocking BYOK retryability regression. The inline comments cover the required fix plus two low-severity GET verification UX cleanups. The main blocker is that a verified BYOK flow can consume the exchange code and terminally mark the pending row before the BYOK nsec is validated/stored, stranding retryability on token-exchange failure.
…n-app verify (keycast#262)
…y idempotent (keycast#262)
…adless register (keycast#262)
… attempt cap (keycast#262)
…full suite (keycast#262)
…e 401 timing (keycast#262)
…races (keycast#262)
… extension, single live exchange code, consumed marker, cleanup) (keycast#262)
…e instead of delete+remint (keycast#262)
…attempts lock (keycast#262)
…never a reused one (keycast#262)
… CI Postgres auth works (keycast#262)
…esh one (keycast#262)
…as Unknown policy (keycast#262)
…onflict stays terminal after rebase (keycast#262)
…stration row (keycast#262)
…erify instead of a catch-all failure page (keycast#262)
…mail conflict, matching the link path (keycast#262)
a093b8d to
fb87d0d
Compare
The blocking retryability issue is addressed by 34cc7e7, which delays terminal pending-row consumption until issuance succeeds and adds a downstream-failure re-arm regression. Commits bfa67ae and bda7b30 also bound verify-PIN bcrypt work and keep first-party session issuance on interactive POST. |
NotThatKindOfDrLiz
left a comment
There was a problem hiding this comment.
Re-reviewed the updated auth/email verification changes. The previous blocking BYOK retryability issue and GET verification nits are addressed, and I did not find a remaining blocker. Approving; not merging.
Summary
Android users who tapped the email-confirmation link inside a sandboxed in-app browser (Gmail Custom Tab / WebView) got stuck: verification ran only in client JavaScript that those webviews never execute, so nothing reached the server and registration never finished.
This change verifies server-side on the GET of the email link, so no client JS is needed and any webview works. It also adds a 6-digit PIN, emailed alongside the link, that the user can type in the app as a transport-independent fallback. The pending-registration row is hardened for the concurrency and replay realities of production (link GET + PIN, mail-scanner prefetch, multiple Cloud Run instances).
Closes #262
Motivation
Email verification is the registration-completion step for app sign-up, and it was failing for real users whose mail client opened the link in a webview. Android App Links also do not escalate out of an in-app browser, so the deep link could not open the app either. We keep verification (it anchors custodial-key recovery) but remove the in-app-browser dead-end.
What Changed
Verification logic was extracted into a shared
finalize_pending_registrationused by every path, so the link and PIN flows behave identically.GET /api/auth/verify-email, a Rust handler that verifies on the server and returns an HTML page (headless), a redirect (third-party OAuth), or a session cookie (first-party). The POST endpoint and SPA page stay for in-flight emails.POST /api/headless/verify-pin {device_code, pin}: looks the row up by the 64-char device_code (the real gate), constant-time-checks the bcrypt-hashed PIN (defense-in-depth), finalizes via the same path, and returns the OAuth code synchronously (no Redis dependency).POST /api/headless/resend-pin {device_code}re-mints PIN+token and resets the counter, gated by a 5-minute cooldown (pin_sent_at), keyed by device_code since pending headless registrations have no users row yet.oauth_codesgainspin_hash,pin_attempts,pin_sent_at, andconsumed_at.Concurrency / replay hardening:
consumed_atterminal marker (set at token redemption) makes finalize refuse to re-mint after completion, and the pending row is never deleted, so re-clicks stay harmless.reserve_pin_attemptincrements the cap counter before bcrypt, and a successful verify now resetspin_attemptsto 0. This keeps the documented invariant and lets an idempotent re-verify of a correct PIN succeed instead of locking out. The reset is reachable only with a correct PIN, so it does not weaken the brute-force cap.UPDATE ... WHERE pin_attempts < cap RETURNINGreserves a slot before bcrypt, so concurrent requests cannot exceed the cap of comparisons.ON CONFLICT (pubkey) DO NOTHING(RETURNING-gated) so concurrent finalize calls do not 500/503; duplicate-email 409 preserved.Testing
This cycle:
cargo fmt --all -- --checkclean,cargo clippy --workspace --all-targets --all-features -- -D warnings -A deprecatedclean, andcargo test --workspacepasses (54 test groups, 0 failures). Thekeycast_coreoauth_code integration module (10 tests, incl.find_live_exchange_codereuse/expiry andreset_pin_attempts) passes against local Postgres. The Redis-delivery-failure branch is not covered by an automated test: the harness cannot deterministically force asetexfailure (ConnectionManager auto-reconnects) and no Redis was available in this environment. The Redis-dependent finalize acceptance test passed in earlier cycles. I did not run the live app by hand.cargo test --workspace --verbosecargo clippy --workspace --all-targets --all-features -- -D warnings -A deprecatedcargo fmt --all -- --checkRisks
The 10-minute exchange window and 24h verify window are unchanged. Not deleting the pending row means it lives the full 24h and can mint several short-lived codes, each expiring on its own. Reusing a live code instead of replacing it means a rare double-mint race (two near-simultaneous finalizes before either code is delivered) can leave more than one live code; both expire harmlessly and later finalizes converge on one. PIN guessing stays bounded by the 5-attempt cap.
Visuals
web/is unchanged. The only new rendered surface is a minimal server-side HTML status page shown to webviews that open the GET link.