Skip to content

feat(auth): reliable email verification for in-app browsers — idempotent verify-on-load + in-app PIN fallback - #268

Open
dcadenas wants to merge 34 commits into
mainfrom
feat/262-reliable-email-verification-for-in-app-browsers-ide
Open

feat(auth): reliable email verification for in-app browsers — idempotent verify-on-load + in-app PIN fallback#268
dcadenas wants to merge 34 commits into
mainfrom
feat/262-reliable-email-verification-for-in-app-browsers-ide

Conversation

@dcadenas

@dcadenas dcadenas commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

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_registration used by every path, so the link and PIN flows behave identically.

  • The email link now points at 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.
  • New 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).
  • New 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.
  • 6-digit PIN generated and emailed (stored bcrypt-hashed) at headless register; oauth_codes gains pin_hash, pin_attempts, pin_sent_at, and consumed_at.

Concurrency / replay hardening:

  • Idempotent re-arm: finalize reuses an already-live exchange code for the registration instead of deleting and re-minting one. A re-click or mail-scanner prefetch therefore reuses the code the polling app may already hold rather than stranding it. A consumed_at terminal 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.
  • Delivery-failure cleanup only removes a code this call freshly minted. On the strict (RedisRequired) path, if the Redis poll write fails, finalize deletes the code and returns a retry error only when it minted the code on this call; a reused code is owned by a prior finalize and is left intact, so a delivery failure here can never strand a code the app already holds.
  • Only failed PIN attempts lock: reserve_pin_attempt increments the cap counter before bcrypt, and a successful verify now resets pin_attempts to 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.
  • Atomic PIN attempt cap: a single conditional UPDATE ... WHERE pin_attempts < cap RETURNING reserves a slot before bcrypt, so concurrent requests cannot exceed the cap of comparisons.
  • Uniform timing: all verify-pin 401 paths burn equivalent bcrypt work, so lockout / no-PIN state is not a timing oracle.
  • Idempotent user materialization: ON CONFLICT (pubkey) DO NOTHING (RETURNING-gated) so concurrent finalize calls do not 500/503; duplicate-email 409 preserved.
  • Bounded lifecycle: resend does not extend the 24h window; periodic cleanup deletes expired/consumed rows.

Testing

This cycle: cargo fmt --all -- --check clean, cargo clippy --workspace --all-targets --all-features -- -D warnings -A deprecated clean, and cargo test --workspace passes (54 test groups, 0 failures). The keycast_core oauth_code integration module (10 tests, incl. find_live_exchange_code reuse/expiry and reset_pin_attempts) passes against local Postgres. The Redis-delivery-failure branch is not covered by an automated test: the harness cannot deterministically force a setex failure (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 --verbose
  • cargo clippy --workspace --all-targets --all-features -- -D warnings -A deprecated
  • cargo fmt --all -- --check
  • Manual verification completed

Risks

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

  • UI/web change with screenshots/video attached
  • No visual change
  • Visuals and text avoid sensitive external brand or partner names unless explicitly approved

web/ is unchanged. The only new rendered surface is a minimal server-side HTML status page shown to webviews that open the GET link.

@dcadenas
dcadenas force-pushed the feat/262-reliable-email-verification-for-in-app-browsers-ide branch from 21399f7 to 127e0f4 Compare July 3, 2026 13:18
@dcadenas
dcadenas force-pushed the feat/262-reliable-email-verification-for-in-app-browsers-ide branch from 127e0f4 to 148c4f6 Compare July 17, 2026 20:14
@dcadenas
dcadenas marked this pull request as ready for review July 18, 2026 15:36
Comment thread api/src/api/http/oauth.rs
Comment thread api/src/api/http/auth.rs Outdated
Comment thread api/src/api/http/auth.rs

@NotThatKindOfDrLiz NotThatKindOfDrLiz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

dcadenas added 21 commits July 20, 2026 14:02
… extension, single live exchange code, consumed marker, cleanup) (keycast#262)
@dcadenas

Copy link
Copy Markdown
Contributor Author

@NotThatKindOfDrLiz

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.

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 NotThatKindOfDrLiz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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.

feat(auth): reliable email verification for in-app browsers — idempotent verify-on-load + in-app PIN fallback

2 participants