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
18 changes: 17 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,24 @@ 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 thread
rootkiller6788 marked this conversation as resolved.
// A TOTP factor is always carried over from a previously enrolled one, so the
// enrollment ID is needed to identify which factor is being updated.
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
25 changes: 24 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,33 @@ 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 ID of the enrolled second factor. Always required for TOTP factors, since they can
* only be carried over from a previously enrolled factor and never newly created.
*/
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 thread
rootkiller6788 marked this conversation as resolved.

/**
* 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 thread
rootkiller6788 marked this conversation as resolved.
} 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
69 changes: 69 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,27 @@ 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: 'totp second factor without uid',
error: new FirebaseAuthError(
authClientErrorCode.INVALID_UID,
'The second factor "uid" must be a valid non-empty string for TOTP.'),
secondFactor: {
factorId: 'totp',
totpInfo: {},
},
},
{
name: 'invalid second factor type',
error: new FirebaseAuthError(
Expand All @@ -2414,6 +2435,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