fix: never send account email to the browser on OAuth handle sign-in#3
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When an OAuth
login_hintis 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
auth_flowrow. Theemailcolumn already existed;createAuthFlownow writes it and a newupdateAuthFlowEmailsets it after the handle is resolved. No DB migration.POST /auth/otp/{send,verify}-by-flow(routes/otp-by-flow.ts): read the email from theauth_flowkeyed by theepds_auth_flowcookie and call better-auth server-side.verifyforwards better-auth's sessionSet-Cookie(same pattern asrecovery.ts), so/auth/completestill finishes via the session.login-page.tshandle path: emptyloginHint(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
code/state/iss; token response has no email). This is about the ePDS auth page itself./_internal/*).Test plan
pnpm buildcleanpnpm test— 1007/1007, incl. new: render redaction (no email/domain/hidden-value on handle path),auth_flowemail roundtrip, flow-keyed endpoints (CSRF enforced, anti-enum send,SessionExpired,InvalidCode, Set-Cookie forwarding)pnpm lintclean/auth/otp/send-by-flow→ 403 without CSRF, 200{ok}with CSRF + no flow (no send),verify-by-flow→ 400SessionExpiredwith no flowdave.self.surf→ view-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