Skip to content

fix(auth): support TOTP second factors when updating a user - #3264

Open
rootkiller6788 wants to merge 3 commits into
firebase:mainfrom
rootkiller6788:fix-auth-totp-second-factor-update
Open

rootkiller6788 wants to merge 3 commits into
firebase:mainfrom
rootkiller6788:fix-auth-totp-second-factor-update

Conversation

@rootkiller6788

Copy link
Copy Markdown

Fixes #2995.

When you update a user that already has TOTP enrolled, you normally do something like this:

const user = await auth.getUser(uid);
await auth.updateUser(uid, {
  multiFactor: {
    enrolledFactors: [...user.multiFactor.enrolledFactors, { factorId: 'phone', phoneNumber }],
  },
});

because enrolledFactors replaces the whole list. That blows up with auth/unsupported-second-factor the moment one of those factors is TOTP, so there is no way to touch a TOTP user's factors from the Admin SDK at all.

Three places had phone baked in:

  • UpdateMultiFactorInfoRequest in auth-config.ts only listed the phone variant (that stray leading | on the union looked like a leftover of a longer list).
  • convertMultiFactorInfoToServerFormat() threw for anything that wasn't factorId === 'phone'.
  • validateAuthFactorInfo() treated totpInfo as an unknown key, deleted it, and then failed the request as invalid.

TOTP factors are just a marker server side (totpInfo: {}), and the fields the server hands back are the same ones it accepts, so for those it's a plain round trip.

One thing I deliberately did not do: accept { factorId: 'totp', secret: '...' }. The Admin SDK can't enrol TOTP for a user, and turning a raw secret into totpInfo would leave them with a factor their authenticator app can no longer generate codes for, so those still get the existing error. That's also why the existing TOTP tests didn't need to change.

Tests: added a mixed TOTP + phone update, a bad totpInfo rejection, and an importUsers() case with a TOTP factor. npm run test:unit is 6069 passing, npm run lint clean. (The MachineLearning "should throw given invalid credential" test is flaky on a loaded machine and times out sometimes; it fails the same way on a clean checkout.)

UpdateMultiFactorInfoRequest only had the phone variant, so there was no
way to describe a TOTP factor in an update request at all. Add the missing
member and export it, along with the totpInfo response type it refers to.
convertMultiFactorInfoToServerFormat() only knew about phone factors and
threw for anything else, so passing a user's existing factors back into
updateUser() failed as soon as one of them was TOTP (issue firebase#2995). Handle
the totp case and let totpInfo through the request validator.

Factors that only carry a secret still error out: the Admin SDK cannot
enroll TOTP for a user, and quietly dropping the secret would leave them
with a factor their authenticator app can no longer produce codes for.
@rootkiller6788
rootkiller6788 requested a review from a team September 25, 2026 14:05

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for TOTP (Time-based One-Time Password) multi-factor authentication (MFA) in the Firebase Auth Admin SDK, allowing TOTP second factors to be validated, updated, and imported. The changes include adding the UpdateTotpMultiFactorInfoRequest interface, updating request validation and conversion logic, and adding corresponding unit tests. Feedback from the reviewer focuses on improving type safety and error handling: specifically, making the uid property required for TOTP updates, validating that mfaEnrollmentId is present during TOTP validation, and throwing a more descriptive error if totpInfo is missing rather than falling back to an unsupported factor error.

Comment thread src/auth/auth-config.ts
Comment on lines +101 to +109
export interface UpdateTotpMultiFactorInfoRequest extends BaseUpdateMultiFactorInfoRequest {

/**
* The TOTP specific metadata of the second factor, as returned by the Auth server when
* the factor was enrolled. The Admin SDK cannot enroll a new TOTP factor on behalf of a
* user, so this is only ever populated from a previously enrolled factor.
*/
totpInfo: TotpInfoResponse;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Since TOTP factors cannot be newly enrolled via the Admin SDK and must always be carried over from a previously enrolled factor, the enrollment ID (uid) is strictly required for TOTP updates. Making uid required in UpdateTotpMultiFactorInfoRequest improves type safety and prevents developers from omitting it.

Suggested change
export interface UpdateTotpMultiFactorInfoRequest extends BaseUpdateMultiFactorInfoRequest {
/**
* The TOTP specific metadata of the second factor, as returned by the Auth server when
* the factor was enrolled. The Admin SDK cannot enroll a new TOTP factor on behalf of a
* user, so this is only ever populated from a previously enrolled factor.
*/
totpInfo: TotpInfoResponse;
}
export interface UpdateTotpMultiFactorInfoRequest extends BaseUpdateMultiFactorInfoRequest {
uid: string;
/**
* The TOTP specific metadata of the second factor, as returned by the Auth server when
* the factor was enrolled. The Admin SDK cannot enroll a new TOTP factor on behalf of a
* user, so this is only ever populated from a previously enrolled factor.
*/
totpInfo: TotpInfoResponse;
}

Comment on lines +307 to +314
} else if (typeof request.totpInfo !== 'undefined') {
// totpInfo is an opaque struct handed back by the server, so there is nothing to
// validate beyond it being an object.
if (!validator.isNonNullObject(request.totpInfo)) {
throw new FirebaseAuthError(
authClientErrorCode.INVALID_ENROLLED_FACTORS,
`The second factor "totpInfo" for "${authFactorInfoIdentifier}" must be a non-null object.`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Since TOTP factors cannot be newly enrolled via the Admin SDK, updating or importing a TOTP factor requires an existing enrollment ID (uid / mfaEnrollmentId). Adding a check to ensure mfaEnrollmentId is defined prevents invalid requests from being sent to the server.

  } else if (typeof request.totpInfo !== 'undefined') {
    // totpInfo is an opaque struct handed back by the server, so there is nothing to
    // validate beyond it being an object.
    if (!validator.isNonNullObject(request.totpInfo)) {
      throw new FirebaseAuthError(
        authClientErrorCode.INVALID_ENROLLED_FACTORS,
        'The second factor "totpInfo" for "' + authFactorInfoIdentifier + '" must be a non-null object.');
    }
    if (typeof request.mfaEnrollmentId === 'undefined') {
      throw new FirebaseAuthError(
        authClientErrorCode.INVALID_UID,
        'The second factor "uid" must be a valid non-empty string for TOTP.');
    }
  }

Comment on lines +351 to 361
} else if (isTotpFactor(multiFactorInfo)) {
// TOTP factors are always carried over from a previously enrolled factor, so the
// enrollment ID and the TOTP metadata are both preserved as is.
const authFactorInfo: AuthFactorInfo = {
mfaEnrollmentId: multiFactorInfo.uid,
displayName: multiFactorInfo.displayName,
totpInfo: multiFactorInfo.totpInfo,
enrolledAt,
};
removeUndefinedFields(authFactorInfo);
return authFactorInfo;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If a developer attempts to update a TOTP factor but omits the totpInfo property, falling through to the generic UNSUPPORTED_SECOND_FACTOR error is misleading because TOTP is supported. Throwing a more specific error clarifies that the totpInfo metadata is required.

Suggested change
} else if (isTotpFactor(multiFactorInfo)) {
// TOTP factors are always carried over from a previously enrolled factor, so the
// enrollment ID and the TOTP metadata are both preserved as is.
const authFactorInfo: AuthFactorInfo = {
mfaEnrollmentId: multiFactorInfo.uid,
displayName: multiFactorInfo.displayName,
totpInfo: multiFactorInfo.totpInfo,
enrolledAt,
};
removeUndefinedFields(authFactorInfo);
return authFactorInfo;
} else if (isTotpFactor(multiFactorInfo)) {
// TOTP factors are always carried over from a previously enrolled factor, so the
// enrollment ID and the TOTP metadata are both preserved as is.
const authFactorInfo: AuthFactorInfo = {
mfaEnrollmentId: multiFactorInfo.uid,
displayName: multiFactorInfo.displayName,
totpInfo: multiFactorInfo.totpInfo,
enrolledAt,
};
removeUndefinedFields(authFactorInfo);
return authFactorInfo;
} else if (multiFactorInfo.factorId === 'totp') {
throw new FirebaseAuthError(
authClientErrorCode.INVALID_ARGUMENT,
'TOTP second factor must carry the "totpInfo" metadata.');
} else {

This branch has not been deployed

No deployments
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.

Updating MFA methods for user with TOTP active results in error

1 participant