Skip to content

feat: preserve identity fields (email, uid) on MultiFactorError across web/iOS/Android #974

Description

@woutersteven

Plugin(s)

  • Analytics
  • App
  • App Check
  • Authentication
  • Crashlytics
  • Cloud Firestore
  • Cloud Functions
  • Cloud Messaging
  • Cloud Storage
  • Performance
  • Remote Config

Current problem

Summary

Request a documented, cross-platform shape for the MultiFactorError that
FirebaseAuthentication.signIn* re-throws when MFA is required, so JS callers
can identify the first-factor account without first completing the second
factor. Specifically: surface email and uid (Firebase Auth localId) of
the account on every platform (web, iOS, Android).

Use case

Implementing a recovery-code 2FA bypass on the login screen. When a sign-in
attempt raises auth/multi-factor-auth-required, the user clicks "Use a
recovery code instead" and submits a code to a Cloud Function Callable. The
Callable verifies the hashed code and mints an Auth custom token via
admin.auth().createCustomToken(uid) — the client then calls
signInWithCustomToken(auth, token) to complete sign-in, bypassing the second
factor for this session (the user has lost their authenticator).

The catch: Firebase has not minted an ID token between first and second
factor, so the Callable's request.auth?.uid is null. It MUST identify the
user some other way. The MFA error is the only signal the client has.

Current behaviour (web)

The Firebase JS SDK preserves the raw Identity Toolkit REST response on a
private field of the error object:

catch (err) {
  // err.code = 'auth/multi-factor-auth-required'
  // err.customData.email = null            // null for federated providers
  // err.customData._serverResponse = {
  //   email: 'user@example.com',
  //   localId: 'abc123...',                 // Firebase Auth uid
  //   mfaPendingCredential: '...',
  //   mfaInfo: [{ mfaEnrollmentId, displayName, ... }],
  //   ...
  // }
}

Callers are forced to reach into customData._serverResponse — an
undocumented private field — to get the account email and uid. The leading
underscore signals "don't use this", and the field could disappear in any JS
SDK release.

For password sign-in MFA, customData.email is populated. For
federated providers (Google, Apple), customData.email is null — the
JS SDK does not project the federated provider's email into the public
field. The only reliable surface for federated-OAuth MFA is the private
_serverResponse.

Current behaviour (native, expected once #346 lands)

iOS and Android native Firebase Auth SDKs raise their own MFA error types:

  • iOS: FIRMultiFactorError with info on userInfo dictionary
  • Android: FirebaseAuthMultiFactorException with getter methods on the
    resolver

These are different shapes from the JS SDK error. When the plugin
re-throws across the Capacitor bridge, the underlying native fields don't
make it to the JS side — so even the private _serverResponse escape hatch
that works on web won't help on native.

Preferred solution

Add a documented JS-side error contract for FirebaseAuthentication's MFA
rejections, populated identically across all three platforms:

interface FirebaseAuthenticationMultiFactorError extends FirebaseError {
  code: 'auth/multi-factor-auth-required';
  customData: {
    appName: string;
    /**
     * First-factor account email. Populated for both password and
     * federated providers (Google, Apple). Falls back to null only when
     * the IdP does not return an email claim.
     */
    email: string | null;
    /**
     * First-factor account Firebase Auth uid (the REST API's `localId`).
     * Stable across email changes — the canonical identity hint.
     */
    uid: string | null;
    /** Optional: enrolled second-factor hints for UI selection. */
    factorHints?: ReadonlyArray<{
      uid: string;
      displayName?: string;
      factorId: 'phone' | 'totp';
    }>;
  };
}

Implementation sketch:

  • Web (plugin's existing web wrapper): read
    err.customData._serverResponse.email / .localId and project them into
    the public customData.email / customData.uid fields before re-throwing.
    This insulates callers from JS SDK private-field drift.
  • iOS (when native MFA lands per feat: Enabling multi-factor authentication #346): read
    error.userInfo[FIRAuthErrorUserInfoEmailKey] for email; for uid, lift
    from FIRMultiFactorResolver.currentUser.uid if accessible at error-throw
    time (or surface it once the resolver is constructed JS-side).
  • Android: same idea — read the native exception's email getter and the
    resolver's user uid.

Alternative options

No response

Additional context

Why this matters

Without uid on the error, the only identity hint the client has is email.
That works for typical users, but breaks on this edge case:

A user changes their primary email via verifyBeforeUpdateEmail after
enrolling MFA. Their Google/Apple provider is still linked with the
original email — Firebase Auth doesn't update provider emails when
primary email changes (spec-correct Firebase behaviour).

Google sign-in still works (matches by Google sub claim, not email).
But the MFA-required error carries the Google provider's email, and
server-side adminAuth().getUserByEmail() matches primary emails only
— so the user cannot be resolved by their federated email. The
recovery-code Callable rejects with auth/user-not-found before ever
checking the code.

With uid on the error, the server resolves via adminAuth().getUser(uid)
which is identity-agnostic, and the recovery flow works.

Repro

  1. Add Google as a federated provider for a Firebase Auth user.
  2. Enrol TOTP MFA on that user.
  3. Call FirebaseAuthentication.signInWithGoogle() from a Capacitor web build.
  4. Inspect the caught error:
    • error.customData.email === null (no public surface)
    • error.customData._serverResponse exists, contains email and
      localId (private surface — works today on web, undocumented).

Related

  • feat: Enabling multi-factor authentication #346 — enabling native MFA on iOS. This issue is the API-shape companion:
    even after native MFA support lands, the JS-side error contract needs to
    be standardised across platforms so callers don't have to write three
    platform branches.

Before submitting

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions