Skip to content

Commit 5ac091b

Browse files
Adds ability to import users with phone second factors. (#699)
* Adds ability to import users with phone second factors. * Addresses review comments.
1 parent fa7f2ec commit 5ac091b

9 files changed

+593
-9
lines changed

src/auth/auth-api-request.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
import {CreateRequest, UpdateRequest} from './user-record';
2626
import {
2727
UserImportBuilder, UserImportOptions, UserImportRecord,
28-
UserImportResult,
28+
UserImportResult, AuthFactorInfo,
2929
} from './user-import-builder';
3030
import * as utils from '../utils/index';
3131
import {ActionCodeSettings, ActionCodeSettingsBuilder} from './action-code-settings-builder';
@@ -151,6 +151,66 @@ class TenantAwareAuthResourceUrlBuilder extends AuthResourceUrlBuilder {
151151
}
152152

153153

154+
/**
155+
* Validates an AuthFactorInfo object. All unsupported parameters
156+
* are removed from the original request. If an invalid field is passed
157+
* an error is thrown.
158+
*
159+
* @param request The AuthFactorInfo request object.
160+
*/
161+
function validateAuthFactorInfo(request: AuthFactorInfo) {
162+
const validKeys = {
163+
mfaEnrollmentId: true,
164+
displayName: true,
165+
phoneInfo: true,
166+
enrolledAt: true,
167+
};
168+
// Remove unsupported keys from the original request.
169+
for (const key in request) {
170+
if (!(key in validKeys)) {
171+
delete request[key];
172+
}
173+
}
174+
if (!validator.isNonEmptyString(request.mfaEnrollmentId)) {
175+
throw new FirebaseAuthError(
176+
AuthClientErrorCode.INVALID_UID,
177+
`The second factor "uid" must be a valid non-empty string.`,
178+
);
179+
}
180+
if (typeof request.displayName !== 'undefined' &&
181+
!validator.isString(request.displayName)) {
182+
throw new FirebaseAuthError(
183+
AuthClientErrorCode.INVALID_DISPLAY_NAME,
184+
`The second factor "displayName" for "${request.mfaEnrollmentId}" must be a valid string.`,
185+
);
186+
}
187+
// enrolledAt must be a valid UTC date string.
188+
if (typeof request.enrolledAt !== 'undefined' &&
189+
!validator.isISODateString(request.enrolledAt)) {
190+
throw new FirebaseAuthError(
191+
AuthClientErrorCode.INVALID_ENROLLMENT_TIME,
192+
`The second factor "enrollmentTime" for "${request.mfaEnrollmentId}" must be a valid ` +
193+
`UTC date string.`);
194+
}
195+
// Validate required fields depending on second factor type.
196+
if (typeof request.phoneInfo !== 'undefined') {
197+
// phoneNumber should be a string and a valid phone number.
198+
if (!validator.isPhoneNumber(request.phoneInfo)) {
199+
throw new FirebaseAuthError(
200+
AuthClientErrorCode.INVALID_PHONE_NUMBER,
201+
`The second factor "phoneNumber" for "${request.mfaEnrollmentId}" must be a non-empty ` +
202+
`E.164 standard compliant identifier string.`);
203+
}
204+
} else {
205+
// Invalid second factor. For example, a phone second factor may have been provided without
206+
// a phone number. A TOTP based second factor may require a secret key, etc.
207+
throw new FirebaseAuthError(
208+
AuthClientErrorCode.INVALID_ENROLLED_FACTORS,
209+
`MFAInfo object provided is invalid.`);
210+
}
211+
}
212+
213+
154214
/**
155215
* Validates a providerUserInfo object. All unsupported parameters
156216
* are removed from the original request. If an invalid field is passed
@@ -243,6 +303,7 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean =
243303
createdAt: uploadAccountRequest,
244304
lastLoginAt: uploadAccountRequest,
245305
providerUserInfo: uploadAccountRequest,
306+
mfaInfo: uploadAccountRequest,
246307
};
247308
// Remove invalid keys from original request.
248309
for (const key in request) {
@@ -381,6 +442,15 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean =
381442
validateProviderUserInfo(providerUserInfoEntry);
382443
});
383444
}
445+
// mfaInfo has to be an array of valid AuthFactorInfo requests.
446+
if (request.mfaInfo) {
447+
if (!validator.isArray(request.mfaInfo)) {
448+
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ENROLLED_FACTORS);
449+
}
450+
request.mfaInfo.forEach((authFactorInfoEntry: AuthFactorInfo) => {
451+
validateAuthFactorInfo(authFactorInfoEntry);
452+
});
453+
}
384454
}
385455

386456

src/auth/user-import-builder.ts

100644100755
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,30 @@ export interface UserImportRecord {
6060
photoURL?: string,
6161
providerId: string,
6262
}>;
63+
multiFactor?: {
64+
enrolledFactors: Array<{
65+
uid: string;
66+
phoneNumber: string;
67+
displayName?: string;
68+
enrollmentTime?: string;
69+
factorId: string;
70+
}>;
71+
};
6372
customClaims?: object;
6473
passwordHash?: Buffer;
6574
passwordSalt?: Buffer;
6675
tenantId?: string;
6776
}
6877

78+
/** Interface representing an Auth second factor in Auth server format. */
79+
export interface AuthFactorInfo {
80+
mfaEnrollmentId: string;
81+
displayName?: string;
82+
phoneInfo?: string;
83+
enrolledAt?: string;
84+
[key: string]: any;
85+
}
86+
6987

7088
/** UploadAccount endpoint request user interface. */
7189
interface UploadAccountUser {
@@ -83,6 +101,7 @@ interface UploadAccountUser {
83101
displayName?: string;
84102
photoUrl?: string;
85103
}>;
104+
mfaInfo?: AuthFactorInfo[];
86105
passwordHash?: string;
87106
salt?: string;
88107
lastLoginAt?: number;
@@ -155,6 +174,7 @@ function populateUploadAccountUser(
155174
photoUrl: user.photoURL,
156175
phoneNumber: user.phoneNumber,
157176
providerUserInfo: [],
177+
mfaInfo: [],
158178
tenantId: user.tenantId,
159179
customAttributes: user.customClaims && JSON.stringify(user.customClaims),
160180
};
@@ -193,6 +213,48 @@ function populateUploadAccountUser(
193213
});
194214
});
195215
}
216+
217+
// Convert user.multiFactor.enrolledFactors to server format.
218+
if (validator.isNonNullObject(user.multiFactor) &&
219+
validator.isNonEmptyArray(user.multiFactor.enrolledFactors)) {
220+
user.multiFactor.enrolledFactors.forEach((multiFactorInfo) => {
221+
let enrolledAt;
222+
if (typeof multiFactorInfo.enrollmentTime !== 'undefined') {
223+
if (validator.isUTCDateString(multiFactorInfo.enrollmentTime)) {
224+
// Convert from UTC date string (client side format) to ISO date string (server side format).
225+
enrolledAt = new Date(multiFactorInfo.enrollmentTime).toISOString();
226+
} else {
227+
throw new FirebaseAuthError(
228+
AuthClientErrorCode.INVALID_ENROLLMENT_TIME,
229+
`The second factor "enrollmentTime" for "${multiFactorInfo.uid}" must be a valid ` +
230+
`UTC date string.`);
231+
}
232+
}
233+
// Currently only phone second factors are supported.
234+
if (multiFactorInfo.factorId === 'phone') {
235+
// If any required field is missing or invalid, validation will still fail later.
236+
const authFactorInfo: AuthFactorInfo = {
237+
mfaEnrollmentId: multiFactorInfo.uid,
238+
displayName: multiFactorInfo.displayName,
239+
// Required for all phone second factors.
240+
phoneInfo: multiFactorInfo.phoneNumber,
241+
enrolledAt,
242+
};
243+
for (const objKey in authFactorInfo) {
244+
if (typeof authFactorInfo[objKey] === 'undefined') {
245+
delete authFactorInfo[objKey];
246+
}
247+
}
248+
result.mfaInfo.push(authFactorInfo);
249+
} else {
250+
// Unsupported second factor.
251+
throw new FirebaseAuthError(
252+
AuthClientErrorCode.INVALID_ENROLLED_FACTORS,
253+
`Unsupported second factor "${JSON.stringify(multiFactorInfo)}" provided.`);
254+
}
255+
});
256+
}
257+
196258
// Remove blank fields.
197259
let key: keyof UploadAccountUser;
198260
for (key in result) {
@@ -203,6 +265,9 @@ function populateUploadAccountUser(
203265
if (result.providerUserInfo.length === 0) {
204266
delete result.providerUserInfo;
205267
}
268+
if (result.mfaInfo.length === 0) {
269+
delete result.mfaInfo;
270+
}
206271
// Validate the constructured user individual request. This will throw if an error
207272
// is detected.
208273
if (typeof userValidator === 'function') {

src/utils/error.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,14 @@ export class AuthClientErrorCode {
427427
code: 'invalid-email',
428428
message: 'The email address is improperly formatted.',
429429
};
430+
public static INVALID_ENROLLED_FACTORS = {
431+
code: 'invalid-enrolled-factors',
432+
message: 'The enrolled factors must be a valid array of MultiFactorInfo objects.',
433+
};
434+
public static INVALID_ENROLLMENT_TIME = {
435+
code: 'invalid-enrollment-time',
436+
message: 'The second factor enrollment time must be a valid UTC date string.',
437+
};
430438
public static INVALID_HASH_ALGORITHM = {
431439
code: 'invalid-hash-algorithm',
432440
message: 'The hash algorithm must match one of the strings in the list of ' +

src/utils/validator.ts

100644100755
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,37 @@ export function isPhoneNumber(phoneNumber: any): boolean {
186186
}
187187

188188

189+
/**
190+
* Validates that a string is a valid ISO date string.
191+
*
192+
* @param dateString The string to validate.
193+
* @return Whether the string is a valid ISO date string.
194+
*/
195+
export function isISODateString(dateString: any): boolean {
196+
try {
197+
return isNonEmptyString(dateString) &&
198+
(new Date(dateString).toISOString() === dateString);
199+
} catch (e) {
200+
return false;
201+
}
202+
}
203+
204+
205+
/**
206+
* Validates that a string is a valid UTC date string.
207+
*
208+
* @param dateString The string to validate.
209+
* @return Whether the string is a valid UTC date string.
210+
*/
211+
export function isUTCDateString(dateString: any): boolean {
212+
try {
213+
return isNonEmptyString(dateString) &&
214+
(new Date(dateString).toUTCString() === dateString);
215+
} catch (e) {
216+
return false;
217+
}
218+
}
219+
189220

190221
/**
191222
* Validates that a string is a valid web URL.

test/integration/auth.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1497,6 +1497,64 @@ describe('admin.auth', () => {
14971497
}).should.eventually.be.fulfilled;
14981498
});
14991499

1500+
it('successfully imports users with enrolled second factors', () => {
1501+
const uid = generateRandomString(20).toLowerCase();
1502+
const email = uid + '@example.com';
1503+
const now = new Date(1476235905000).toUTCString();
1504+
importUserRecord = {
1505+
uid,
1506+
email,
1507+
emailVerified: true,
1508+
displayName: 'Test User',
1509+
disabled: false,
1510+
metadata: {
1511+
lastSignInTime: now,
1512+
creationTime: now,
1513+
},
1514+
providerData: [
1515+
{
1516+
uid: uid + '-facebook',
1517+
displayName: 'Facebook User',
1518+
email,
1519+
providerId: 'facebook.com',
1520+
},
1521+
],
1522+
multiFactor: {
1523+
enrolledFactors: [
1524+
{
1525+
uid: 'mfaUid1',
1526+
phoneNumber: '+16505550001',
1527+
displayName: 'Work phone number',
1528+
factorId: 'phone',
1529+
enrollmentTime: now,
1530+
},
1531+
{
1532+
uid: 'mfaUid2',
1533+
phoneNumber: '+16505550002',
1534+
displayName: 'Personal phone number',
1535+
factorId: 'phone',
1536+
enrollmentTime: now,
1537+
},
1538+
],
1539+
},
1540+
};
1541+
uids.push(importUserRecord.uid);
1542+
1543+
return admin.auth().importUsers([importUserRecord])
1544+
.then((result) => {
1545+
expect(result.failureCount).to.equal(0);
1546+
expect(result.successCount).to.equal(1);
1547+
expect(result.errors.length).to.equal(0);
1548+
return admin.auth().getUser(uid);
1549+
}).then((userRecord) => {
1550+
// Confirm second factors added to user.
1551+
const actualUserRecord: {[key: string]: any} = userRecord.toJSON();
1552+
expect(actualUserRecord.multiFactor.enrolledFactors.length).to.equal(2);
1553+
expect(actualUserRecord.multiFactor.enrolledFactors)
1554+
.to.deep.equal(importUserRecord.multiFactor.enrolledFactors);
1555+
}).should.eventually.be.fulfilled;
1556+
});
1557+
15001558
it('fails when invalid users are provided', () => {
15011559
const users = [
15021560
{uid: generateRandomString(20).toLowerCase(), phoneNumber: '+1error'},

0 commit comments

Comments
 (0)