Skip to content

app: drop fetchSignInMethodsForEmail from login flow - #735

Merged
bpowers merged 1 commit into
mainfrom
login-enumeration-safe
Jun 9, 2026
Merged

bpowers merged 1 commit into
mainfrom
login-enumeration-safe

Conversation

@bpowers

@bpowers bpowers commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Closes #692.

Problem

The email login flow in src/app/Login.tsx decided which card to show by calling Firebase's deprecated fetchSignInMethodsForEmail and branching on the returned methods. That API was deprecated precisely because it enables email enumeration, and it carries a latent footgun:

When a project enables Email Enumeration Protection (a single Firebase-console toggle Firebase recommends, default-ON for projects created after 2023-09-15), fetchSignInMethodsForEmail returns [] for every address. The old flow read that as "no account exists" and routed every existing email/password user to the "Create account" card, where signup then failed with auth/email-already-in-use — with no in-UI path to the password card or the "Trouble signing in?" recovery link. Enabling a recommended security setting would have silently and completely locked out all email/password users.

Fix

Replace the method-prefetch router with a single combined sign-in card (email + password together) that attempts signInWithEmailAndPassword directly and branches on the Firebase error code. The flow is now correct whether Enumeration Protection is on or off:

  • invalid-credential / wrong-password / user-not-found and any non-Firebase throw collapse to one generic, enumeration-safe message; invalid-email gets a distinct message.
  • network-request-failed / too-many-requests are account-agnostic (they leak nothing), so they report a transient "try again" message instead of masquerading as a bad password.
  • Password reset shows a neutral confirmation ("If an account exists for …") so it stays enumeration-safe even though sendPasswordResetEmail silently succeeds for unknown addresses.
  • New users reach signup via an always-present "Create account" link rather than auto-routing on user-not-found (which would itself leak existence when protection is off).

Known tradeoff

Removing the prefetch means we can no longer name which provider an OAuth-only user signed up with — a wrong password and an OAuth-only account are indistinguishable under protection. The provider-named "you already have an account" redirect card is therefore gone; such users get the generic message and the Apple/Google buttons remain one "Cancel" away on the landing screen. This is inherent to removing enumeration (you can't hide which provider an email uses and name it).

Scope

Login.tsx only drives the Firebase client SDK into a signed-in state; App.tsx's onAuthStateChanged observer still handles the idToken → POST /session handoff, so the change is confined to Login.tsx and its tests.

Testing

  • 37 tests in src/app/tests/login.test.tsx, 100% statement/branch/function/line coverage on Login.tsx.
  • Covers: combined card renders both fields with no prefetch; fetchSignInMethodsForEmail never called in any path; each error-code branch (incl. transient and non-Firebase throws); create-account + email-already-in-use → "sign in instead"; recovery → neutral confirmation and failure-stays-with-error; Enter-to-submit structural guard (Sign in is type=submit, Cancel is not); cross-card error clearing; disabled render guard.
  • Full pre-commit gate (build, tsc, clippy, rust+python+ts tests, lint) passes.

Process

Design brainstormed, then adversarially reviewed by a sub-agent before implementation; the completed change was adversarially reviewed by a second fresh sub-agent and iterated to a clean "ship" verdict.

Note (out of scope)

Passwords are .trim()'d before being sent to Firebase on both sign-in and signup (pre-existing on main, carried forward unchanged). It's consistent between the two paths, but altering passwords with intentional leading/trailing whitespace is latent; flagging for a possible follow-up.

The email login flow decided which card to show by calling Firebase's
deprecated fetchSignInMethodsForEmail and branching on the returned
methods. That API was deprecated precisely because it enables email
enumeration, and it has a latent footgun: when a project enables Email
Enumeration Protection (a single console toggle Firebase recommends and
defaults to ON for projects created after 2023-09-15), the call returns
[] for every address. The old flow read that as no account exists and
routed every existing password user to the Create account card, where
signup then failed with email-already-in-use -- with no in-UI path to
the password card or the Trouble signing in? recovery link. Enabling a
recommended security setting would have silently locked out all
email/password users.

Replace the method-prefetch router with a single combined sign-in card
(email + password together) that attempts signInWithEmailAndPassword
directly and branches on the Firebase error code. The flow is now
correct whether Enumeration Protection is on or off:

- invalid-credential / wrong-password / user-not-found and any
  non-Firebase throw collapse to one generic, enumeration-safe message;
  invalid-email gets a distinct message; network-request-failed and
  too-many-requests (account-agnostic, so they leak nothing) report a
  transient try-again message instead of masquerading as a bad password.
- Password reset shows a neutral confirmation (If an account exists...)
  so it stays enumeration-safe even though sendPasswordResetEmail
  silently succeeds for unknown addresses.
- New users reach signup via an always-present Create account link
  rather than auto-routing on user-not-found, which would itself leak
  existence when protection is off.

Removing the prefetch means we can no longer name which provider an
OAuth-only user signed up with (wrong password and OAuth-only account
are indistinguishable under protection), so the provider-named redirect
card is gone; such users get the generic message and the provider
buttons remain one Cancel away on the landing screen.

The component only drives the Firebase client SDK into a signed-in
state; App's onAuthStateChanged observer still handles the /session
handoff, so the change is confined to Login.tsx and its tests. Tests
cover the full flow at 100% statement/branch/line coverage.

Closes #692

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: df3ac30ad0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/app/Login.tsx
// Neutral confirmation only: sendPasswordResetEmail succeeds even for
// addresses with no account when Email Enumeration Protection is on, so we
// must not assert an email was actually sent to a real account.
setState({ emailLoginFlow: 'recoverSent' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep recovery responses neutral on user-not-found

When Email Enumeration Protection is disabled (or in environments such as older Firebase projects/emulators), sendPasswordResetEmail rejects unknown addresses with auth/user-not-found; this handler returns from the catch and renders the raw error instead of showing the neutral confirmation. Because the new flow exposes “Trouble signing in?” without first proving the account exists, arbitrary emails can now be enumerated through password recovery, despite the neutral copy added here. Treat auth/user-not-found like success (while still surfacing invalid-email/network/config errors) so the recovery path stays enumeration-safe in both Firebase modes.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review

I reviewed the diff against the original method-prefetch flow and against the stated goal of being correct whether Email Enumeration Protection is on or off. I did not find any concrete bugs introduced by this PR that meet the flagging bar.

Things I checked carefully and consider sound:

  • firebaseErrorCode correctly handles non-object throws (null, strings, primitives) and objects lacking a code — all flow to the generic credential branch via code === undefined.
  • The new error-code dispatch in onSignIn keeps auth/invalid-credential / auth/wrong-password / auth/user-not-found / unknown throws collapsed to one enumeration-safe message; transient codes (network-request-failed, too-many-requests) are reported as transient rather than as bad credentials.
  • recoverSent uses neutral copy ("If an account exists for …"), preserving enumeration safety even though sendPasswordResetEmail silently succeeds for unknown addresses.
  • Submit-button ordering in every card (Cancel first as non-submit, primary action as type=\"submit\") keeps Enter-to-submit firing the right action; the test explicitly asserts both the structural enabler and the negative case for Cancel.
  • autoFocus={state.email === ''} / autoFocus={state.email !== ''} is evaluated on mount of the signin card, which is the only time React honors autoFocus; the transitions that remount the card (landing→signin, recover→signin, signup→signin) all set the appropriate field.
  • signinError is consistently cleared on either field edit and on every cross-card transition, so it cannot stale against an emptied email field.
  • The signup card's autoComplete correctly switched from current-password to new-password.
  • Test mock still imports and asserts fetchSignInMethodsForEmail is never called — useful regression guard for Login email flow uses deprecated fetchSignInMethodsForEmail; Email Enumeration Protection toggle locks out all email/password users #692.

Pre-existing and noted by the PR description (not introduced here): password.trim() strips intentional leading/trailing whitespace before sign-in/signup, and state.password carries across signin↔signup navigation. Not blocking; reasonable follow-up candidates.

Overall correctness verdict

Correct. No blocking issues found.

@codecov

codecov Bot commented Jun 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.60%. Comparing base (d42c8a7) to head (df3ac30).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #735   +/-   ##
=======================================
  Coverage   90.60%   90.60%           
=======================================
  Files         224      224           
  Lines      136247   136247           
=======================================
  Hits       123447   123447           
  Misses      12800    12800           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@bpowers
bpowers merged commit ec21750 into main Jun 9, 2026
17 checks passed
@bpowers
bpowers deleted the login-enumeration-safe branch June 9, 2026 01:29
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.

Login email flow uses deprecated fetchSignInMethodsForEmail; Email Enumeration Protection toggle locks out all email/password users

1 participant