Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/auth/auth-api-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ function validateAuthFactorInfo(request: AuthFactorInfo): void {
mfaEnrollmentId: true,
displayName: true,
phoneInfo: true,
totpInfo: true,
enrolledAt: true,
};
// Remove unsupported keys from the original request.
Expand Down Expand Up @@ -303,9 +304,17 @@ function validateAuthFactorInfo(request: AuthFactorInfo): void {
`The second factor "phoneNumber" for "${authFactorInfoIdentifier}" must be a non-empty ` +
'E.164 standard compliant identifier string.');
}
} 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.`);
}
Comment on lines +307 to +314

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.');
    }
  }

} else {
// Invalid second factor. For example, a phone second factor may have been provided without
// a phone number. A TOTP based second factor may require a secret key, etc.
// a phone number.
throw new FirebaseAuthError(
authClientErrorCode.INVALID_ENROLLED_FACTORS,
'MFAInfo object provided is invalid.');
Expand Down
19 changes: 18 additions & 1 deletion src/auth/auth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import * as validator from '../utils/validator';
import { deepCopy } from '../utils/deep-copy';
import { authClientErrorCode, FirebaseAuthError } from './error';
import { TotpInfoResponse } from './user-record';

/**
* Interface representing base properties of a user-enrolled second factor for a
Expand Down Expand Up @@ -93,11 +94,27 @@ export interface UpdatePhoneMultiFactorInfoRequest extends BaseUpdateMultiFactor
phoneNumber: string;
}

/**
* Interface representing a TOTP specific user-enrolled second factor
* for an `UpdateRequest`.
*/
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;
}
Comment on lines +101 to +109

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;
}


/**
* Type representing the properties of a user-enrolled second factor
* for an `UpdateRequest`.
*/
export type UpdateMultiFactorInfoRequest = | UpdatePhoneMultiFactorInfoRequest;
export type UpdateMultiFactorInfoRequest =
| UpdatePhoneMultiFactorInfoRequest
| UpdateTotpMultiFactorInfoRequest;

/**
* The multi-factor related user settings for create operations.
Expand Down
2 changes: 2 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export {
UpdateAuthProviderRequest,
UpdateMultiFactorInfoRequest,
UpdatePhoneMultiFactorInfoRequest,
UpdateTotpMultiFactorInfoRequest,
UpdateRequest,
TotpMultiFactorProviderConfig,
PasswordPolicyConfig,
Expand Down Expand Up @@ -163,6 +164,7 @@ export {
MultiFactorInfo,
MultiFactorSettings,
PhoneMultiFactorInfo,
TotpInfoResponse,
UserInfo,
UserMetadata,
UserRecord,
Expand Down
39 changes: 32 additions & 7 deletions src/auth/user-import-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import * as utils from '../utils';
import * as validator from '../utils/validator';
import { authClientErrorCode, FirebaseAuthError } from './error';
import {
UpdateMultiFactorInfoRequest, UpdatePhoneMultiFactorInfoRequest, MultiFactorUpdateSettings
UpdateMultiFactorInfoRequest, UpdatePhoneMultiFactorInfoRequest,
UpdateTotpMultiFactorInfoRequest, MultiFactorUpdateSettings
} from './auth-config';
import { TotpInfoResponse } from './user-record';

export type HashAlgorithmType = 'SCRYPT' | 'STANDARD_SCRYPT' | 'HMAC_SHA512' |
'HMAC_SHA256' | 'HMAC_SHA1' | 'HMAC_MD5' | 'MD5' | 'PBKDF_SHA1' | 'BCRYPT' |
Expand Down Expand Up @@ -261,6 +263,7 @@ export interface AuthFactorInfo {
mfaEnrollmentId?: string;
displayName?: string;
phoneInfo?: string;
totpInfo?: TotpInfoResponse;
enrolledAt?: string;
[key: string]: any;
}
Expand Down Expand Up @@ -334,7 +337,6 @@ export function convertMultiFactorInfoToServerFormat(multiFactorInfo: UpdateMult
'UTC date string.');
}
}
// Currently only phone second factors are supported.
if (isPhoneFactor(multiFactorInfo)) {
// If any required field is missing or invalid, validation will still fail later.
const authFactorInfo: AuthFactorInfo = {
Expand All @@ -344,11 +346,18 @@ export function convertMultiFactorInfoToServerFormat(multiFactorInfo: UpdateMult
phoneInfo: multiFactorInfo.phoneNumber,
enrolledAt,
};
for (const objKey in authFactorInfo) {
if (typeof authFactorInfo[objKey] === 'undefined') {
delete authFactorInfo[objKey];
}
}
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;
Comment on lines +351 to 361

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 {

} else {
// Unsupported second factor.
Expand All @@ -358,11 +367,27 @@ export function convertMultiFactorInfoToServerFormat(multiFactorInfo: UpdateMult
}
}

function removeUndefinedFields(obj: AuthFactorInfo): void {
for (const objKey in obj) {
if (typeof obj[objKey] === 'undefined') {
delete obj[objKey];
}
}
}

function isPhoneFactor(multiFactorInfo: UpdateMultiFactorInfoRequest):
multiFactorInfo is UpdatePhoneMultiFactorInfoRequest {
return multiFactorInfo.factorId === 'phone';
}

function isTotpFactor(multiFactorInfo: UpdateMultiFactorInfoRequest):
multiFactorInfo is UpdateTotpMultiFactorInfoRequest {
// Only factors that carry the TOTP metadata handed out by the server are accepted. A bare
// secret cannot be enrolled through the Admin SDK, and sending it would leave the user with
// a factor no authenticator app can generate codes for.
return multiFactorInfo.factorId === 'totp' && 'totpInfo' in multiFactorInfo;
}

/**
* @param {any} obj The object to check for number field within.
* @param {string} key The entry key.
Expand Down
59 changes: 59 additions & 0 deletions test/unit/auth/auth-api-request.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2388,6 +2388,17 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
enrollmentTime: 'invalid',
},
},
{
name: 'invalid second factor totp info',
error: new FirebaseAuthError(
authClientErrorCode.INVALID_ENROLLED_FACTORS,
'The second factor "totpInfo" for "enrolledSecondFactor1" must be a non-null object.'),
secondFactor: {
uid: 'enrolledSecondFactor1',
factorId: 'totp',
totpInfo: 'invalid',
},
},
{
name: 'invalid second factor type',
error: new FirebaseAuthError(
Expand All @@ -2414,6 +2425,54 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
});
});

it('should be fulfilled given a valid TOTP second factor', () => {
const expectedResult = utils.responseFrom({ localId: uid });
const data = {
multiFactor: {
enrolledFactors: [
{
uid: 'enrolledSecondFactor1',
displayName: 'Google Authenticator',
factorId: 'totp',
enrollmentTime: now.toUTCString(),
totpInfo: {},
},
{
// A phone factor in the same update must still be converted as before.
uid: 'enrolledSecondFactor2',
phoneNumber: '+16505551000',
factorId: 'phone',
},
],
},
};

const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult);
stubs.push(stub);
const requestHandler = handler.init(mockApp);
return requestHandler.updateExistingAccount(uid, data as any)
.then((result) => {
expect(result).to.equal(uid);
expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, {
localId: uid,
mfa: {
enrollments: [
{
mfaEnrollmentId: 'enrolledSecondFactor1',
displayName: 'Google Authenticator',
totpInfo: {},
enrolledAt: now.toISOString(),
},
{
mfaEnrollmentId: 'enrolledSecondFactor2',
phoneInfo: '+16505551000',
},
],
},
}));
});
});

it('should be rejected given a tenant ID to modify', () => {
const dataWithModifiedTenantId = deepCopy(validData);
(dataWithModifiedTenantId as any).tenantId = 'MODIFIED-TENANT-ID';
Expand Down
39 changes: 39 additions & 0 deletions test/unit/auth/user-import-builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,45 @@ describe('UserImportBuilder', () => {
new UserImportBuilder(invalidMultiFactorUsers, validOptions as any, userRequestValidator);
expect(userImportBuilder.buildRequest()).to.deep.equal(expectedRequest);
});

it('should import users with TOTP second factors', () => {
const totpUsers: any[] = [
{
uid: '1234',
email: 'user@example.com',
multiFactor: {
enrolledFactors: [
{
uid: 'enrolledSecondFactor1',
displayName: 'Google Authenticator',
factorId: 'totp',
enrollmentTime: 'Fri, 24 Oct 2025 09:34:05 GMT',
totpInfo: {},
},
],
},
},
];
const expectedRequest = {
users: [
{
localId: '1234',
email: 'user@example.com',
mfaInfo: [
{
mfaEnrollmentId: 'enrolledSecondFactor1',
displayName: 'Google Authenticator',
totpInfo: {},
enrolledAt: '2025-10-24T09:34:05.000Z',
},
],
},
],
};
const userImportBuilder =
new UserImportBuilder(totpUsers, validOptions as any, userRequestValidator);
expect(userImportBuilder.buildRequest()).to.deep.equal(expectedRequest);
});
});

describe('buildResponse()', () => {
Expand Down
Loading