fix(auth): support TOTP second factors when updating a user - #3264
rootkiller6788 wants to merge 3 commits into
Conversation
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.
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| 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; | |
| } |
| } 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.`); | ||
| } |
There was a problem hiding this comment.
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.');
}
}| } 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; |
There was a problem hiding this comment.
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.
| } 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 { |
Fixes #2995.
When you update a user that already has TOTP enrolled, you normally do something like this:
because
enrolledFactorsreplaces the whole list. That blows up withauth/unsupported-second-factorthe 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:
UpdateMultiFactorInfoRequestinauth-config.tsonly listed the phone variant (that stray leading|on the union looked like a leftover of a longer list).convertMultiFactorInfoToServerFormat()threw for anything that wasn'tfactorId === 'phone'.validateAuthFactorInfo()treatedtotpInfoas 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 intototpInfowould 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
totpInforejection, and animportUsers()case with a TOTP factor.npm run test:unitis 6069 passing,npm run lintclean. (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.)