Skip to content

fix: never send account email to the browser on OAuth handle sign-in#3

Merged
daveselfsurf merged 2 commits into
mainfrom
fix/oauth-otp-email-redaction
May 28, 2026
Merged

fix: never send account email to the browser on OAuth handle sign-in#3
daveselfsurf merged 2 commits into
mainfrom
fix/oauth-otp-email-redaction

Conversation

@daveselfsurf

@daveselfsurf daveselfsurf commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

When an OAuth login_hint is a public handle/DID, ePDS resolved it to the account email and put that email in the sign-in page — both displayed (weakly masked, da***@attpslabs.com) and unmasked in the page source (a hidden input + JS var), because the browser called better-auth's OTP send/verify directly with the email. Anyone who knew a public handle could start a sign-in on any OAuth app (reproduced on leaflet.pub) and read the account's email off the page or its source.

This decouples the handle path so the email never reaches the browser.

Supersedes #2 (which only hid the visible display and left the email in the source).

How

  • Store the resolved email server-side on the auth_flow row. The email column already existed; createAuthFlow now writes it and a new updateAuthFlowEmail sets it after the handle is resolved. No DB migration.
  • New same-origin endpoints POST /auth/otp/{send,verify}-by-flow (routes/otp-by-flow.ts): read the email from the auth_flow keyed by the epds_auth_flow cookie and call better-auth server-side. verify forwards better-auth's session Set-Cookie (same pattern as recovery.ts), so /auth/complete still finishes via the session.
  • login-page.ts handle path: empty loginHint (no email in hidden input or JS), a constant anti-enumeration subtitle — "We've sent a 6-digit code to your account email." — and send/verify driven via the flow-keyed endpoints with the CSRF token. "Use different email" clears the handle flag so a user-typed email reverts to the normal path.

Scope / non-goals

  • Email-typed path unchanged — the user supplied their own email, so it's still pre-filled/shown.
  • Third-party apps still never receive the email (verified: redirect carries only code/state/iss; token response has no email). This is about the ePDS auth page itself.
  • New endpoints sit after CSRF and enforce it (same-origin browser routes, not /_internal/*).

Test plan

  • pnpm build clean
  • pnpm test — 1007/1007, incl. new: render redaction (no email/domain/hidden-value on handle path), auth_flow email roundtrip, flow-keyed endpoints (CSRF enforced, anti-enum send, SessionExpired, InvalidCode, Set-Cookie forwarding)
  • pnpm lint clean
  • Local: /auth/otp/send-by-flow → 403 without CSRF, 200 {ok} with CSRF + no flow (no send), verify-by-flow → 400 SessionExpired with no flow
  • Staging: deploy branch to self.surf, OAuth from leaflet.pub with dave.self.surfview-source the code screen, grep for the email — must be absent; confirm the code still arrives and verifying completes login; confirm a typed-email login still works.

🤖 Generated with Claude Code

daveselfsurf and others added 2 commits May 28, 2026 18:15
…th handle sign-in

When an OAuth login_hint is a public handle/DID, ePDS resolved it to the
account email and put that email in the sign-in page — both displayed
(weakly masked: da***@attpslabs.com) and unmasked in the page source
(hidden input + JS var), because the browser called better-auth's OTP
send/verify directly with the email. Anyone who knew a public handle
could start a sign-in (on any OAuth app, e.g. leaflet.pub) and read the
account's email off the page or its source.

Decouple the handle path so the email never reaches the browser:

- Store the resolved email on the auth_flow row (column already existed;
  createAuthFlow now writes it, new updateAuthFlowEmail sets it after the
  handle is resolved). No DB migration.
- New same-origin endpoints POST /auth/otp/{send,verify}-by-flow
  (routes/otp-by-flow.ts) read the email from the auth_flow keyed by the
  epds_auth_flow cookie and call better-auth server-side. verify forwards
  better-auth's session Set-Cookie (pattern from recovery.ts), so
  /auth/complete still finishes via the session.
- login-page.ts: on the handle path, render an empty loginHint (no email
  in the hidden input or JS), a constant anti-enumeration subtitle
  ("We've sent a 6-digit code if an account matches that email."), and
  drive send/verify via the flow-keyed endpoints with the CSRF token.
  "Use different email" clears the handle flag so a user-typed email
  reverts to the normal path.

The email-typed path is unchanged (the user supplied their own email).
Third-party apps still never receive the email (redirect carries only
code/state/iss). New endpoints sit after CSRF and enforce it.

Tests: render redaction (no email/domain/hidden-value on handle path),
createAuthFlow/updateAuthFlowEmail/getAuthFlow email roundtrip, and the
flow-keyed endpoints (CSRF enforced, anti-enumeration send, graceful
SessionExpired, InvalidCode, Set-Cookie forwarding). Full happy path
needs a live pds-core and is verified on staging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per request, change the handle-path code-entry message from "We've sent
a 6-digit code if an account matches that email." to "We've sent a
6-digit code to your account email." Updates the server pre-render, the
client JS constant, the test assertion, and the changeset.

Note: the new wording confirms an account exists (drops the
anti-enumeration ambiguity); the email itself is still never shown or
present in the page source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@daveselfsurf daveselfsurf merged commit db13c83 into main May 28, 2026
0 of 5 checks passed
@daveselfsurf daveselfsurf deleted the fix/oauth-otp-email-redaction branch May 28, 2026 19:28
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.

1 participant