Skip to content

Adds ability to import users with phone second factors. #699

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Nov 14, 2019
Merged
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
72 changes: 71 additions & 1 deletion src/auth/auth-api-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
import {CreateRequest, UpdateRequest} from './user-record';
import {
UserImportBuilder, UserImportOptions, UserImportRecord,
UserImportResult,
UserImportResult, AuthFactorInfo,
} from './user-import-builder';
import * as utils from '../utils/index';
import {ActionCodeSettings, ActionCodeSettingsBuilder} from './action-code-settings-builder';
Expand Down Expand Up @@ -151,6 +151,66 @@ class TenantAwareAuthResourceUrlBuilder extends AuthResourceUrlBuilder {
}


/**
* Validates an AuthFactorInfo object. All unsupported parameters
* are removed from the original request. If an invalid field is passed
* an error is thrown.
*
* @param request The AuthFactorInfo request object.
*/
function validateAuthFactorInfo(request: AuthFactorInfo) {
const validKeys = {
mfaEnrollmentId: true,
displayName: true,
phoneInfo: true,
enrolledAt: true,
};
// Remove unsupported keys from the original request.
for (const key in request) {
if (!(key in validKeys)) {
delete request[key];
}
}
if (!validator.isNonEmptyString(request.mfaEnrollmentId)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_UID,
`The second factor "uid" must be a valid non-empty string.`,
);
}
if (typeof request.displayName !== 'undefined' &&
!validator.isString(request.displayName)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_DISPLAY_NAME,
`The second factor "displayName" for "${request.mfaEnrollmentId}" must be a valid string.`,
);
}
// enrolledAt must be a valid UTC date string.
if (typeof request.enrolledAt !== 'undefined' &&
!validator.isISODateString(request.enrolledAt)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ENROLLMENT_TIME,
`The second factor "enrollmentTime" for "${request.mfaEnrollmentId}" must be a valid ` +
`UTC date string.`);
}
// Validate required fields depending on second factor type.
if (typeof request.phoneInfo !== 'undefined') {
// phoneNumber should be a string and a valid phone number.
if (!validator.isPhoneNumber(request.phoneInfo)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_PHONE_NUMBER,
`The second factor "phoneNumber" for "${request.mfaEnrollmentId}" must be a non-empty ` +
`E.164 standard compliant identifier string.`);
}
} 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.
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ENROLLED_FACTORS,
`MFAInfo object provided is invalid.`);
}
}


/**
* Validates a providerUserInfo object. All unsupported parameters
* are removed from the original request. If an invalid field is passed
Expand Down Expand Up @@ -243,6 +303,7 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean =
createdAt: uploadAccountRequest,
lastLoginAt: uploadAccountRequest,
providerUserInfo: uploadAccountRequest,
mfaInfo: uploadAccountRequest,
};
// Remove invalid keys from original request.
for (const key in request) {
Expand Down Expand Up @@ -381,6 +442,15 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean =
validateProviderUserInfo(providerUserInfoEntry);
});
}
// mfaInfo has to be an array of valid AuthFactorInfo requests.
if (request.mfaInfo) {
if (!validator.isArray(request.mfaInfo)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ENROLLED_FACTORS);
}
request.mfaInfo.forEach((authFactorInfoEntry: AuthFactorInfo) => {
validateAuthFactorInfo(authFactorInfoEntry);
});
}
}


Expand Down
65 changes: 65 additions & 0 deletions src/auth/user-import-builder.ts
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,30 @@ export interface UserImportRecord {
photoURL?: string,
providerId: string,
}>;
multiFactor?: {
enrolledFactors: Array<{
uid: string;
phoneNumber: string;
displayName?: string;
enrollmentTime?: string;
factorId: string;
}>;
};
customClaims?: object;
passwordHash?: Buffer;
passwordSalt?: Buffer;
tenantId?: string;
}

/** Interface representing an Auth second factor in Auth server format. */
export interface AuthFactorInfo {
mfaEnrollmentId: string;
displayName?: string;
phoneInfo?: string;
enrolledAt?: string;
[key: string]: any;
}


/** UploadAccount endpoint request user interface. */
interface UploadAccountUser {
Expand All @@ -83,6 +101,7 @@ interface UploadAccountUser {
displayName?: string;
photoUrl?: string;
}>;
mfaInfo?: AuthFactorInfo[];
passwordHash?: string;
salt?: string;
lastLoginAt?: number;
Expand Down Expand Up @@ -155,6 +174,7 @@ function populateUploadAccountUser(
photoUrl: user.photoURL,
phoneNumber: user.phoneNumber,
providerUserInfo: [],
mfaInfo: [],
tenantId: user.tenantId,
customAttributes: user.customClaims && JSON.stringify(user.customClaims),
};
Expand Down Expand Up @@ -193,6 +213,48 @@ function populateUploadAccountUser(
});
});
}

// Convert user.multiFactor.enrolledFactors to server format.
if (validator.isNonNullObject(user.multiFactor) &&
validator.isNonEmptyArray(user.multiFactor.enrolledFactors)) {
user.multiFactor.enrolledFactors.forEach((multiFactorInfo) => {
let enrolledAt;
if (typeof multiFactorInfo.enrollmentTime !== 'undefined') {
if (validator.isUTCDateString(multiFactorInfo.enrollmentTime)) {
// Convert from UTC date string (client side format) to ISO date string (server side format).
enrolledAt = new Date(multiFactorInfo.enrollmentTime).toISOString();
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a timezone conversion happening here? Perhaps add a comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added comment.

} else {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ENROLLMENT_TIME,
`The second factor "enrollmentTime" for "${multiFactorInfo.uid}" must be a valid ` +
`UTC date string.`);
}
}
// Currently only phone second factors are supported.
if (multiFactorInfo.factorId === 'phone') {
// If any required field is missing or invalid, validation will still fail later.
const authFactorInfo: AuthFactorInfo = {
mfaEnrollmentId: multiFactorInfo.uid,
displayName: multiFactorInfo.displayName,
// Required for all phone second factors.
phoneInfo: multiFactorInfo.phoneNumber,
enrolledAt,
};
for (const objKey in authFactorInfo) {
if (typeof authFactorInfo[objKey] === 'undefined') {
delete authFactorInfo[objKey];
}
}
result.mfaInfo.push(authFactorInfo);
} else {
// Unsupported second factor.
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ENROLLED_FACTORS,
`Unsupported second factor "${JSON.stringify(multiFactorInfo)}" provided.`);
}
});
}

// Remove blank fields.
let key: keyof UploadAccountUser;
for (key in result) {
Expand All @@ -203,6 +265,9 @@ function populateUploadAccountUser(
if (result.providerUserInfo.length === 0) {
delete result.providerUserInfo;
}
if (result.mfaInfo.length === 0) {
delete result.mfaInfo;
}
// Validate the constructured user individual request. This will throw if an error
// is detected.
if (typeof userValidator === 'function') {
Expand Down
8 changes: 8 additions & 0 deletions src/utils/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,14 @@ export class AuthClientErrorCode {
code: 'invalid-email',
message: 'The email address is improperly formatted.',
};
public static INVALID_ENROLLED_FACTORS = {
code: 'invalid-enrolled-factors',
message: 'The enrolled factors must be a valid array of MultiFactorInfo objects.',
};
public static INVALID_ENROLLMENT_TIME = {
code: 'invalid-enrollment-time',
message: 'The second factor enrollment time must be a valid UTC date string.',
};
public static INVALID_HASH_ALGORITHM = {
code: 'invalid-hash-algorithm',
message: 'The hash algorithm must match one of the strings in the list of ' +
Expand Down
31 changes: 31 additions & 0 deletions src/utils/validator.ts
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,37 @@ export function isPhoneNumber(phoneNumber: any): boolean {
}


/**
* Validates that a string is a valid ISO date string.
*
* @param dateString The string to validate.
* @return Whether the string is a valid ISO date string.
*/
export function isISODateString(dateString: any): boolean {
try {
return isNonEmptyString(dateString) &&
(new Date(dateString).toISOString() === dateString);
} catch (e) {
return false;
}
}


/**
* Validates that a string is a valid UTC date string.
*
* @param dateString The string to validate.
* @return Whether the string is a valid UTC date string.
*/
export function isUTCDateString(dateString: any): boolean {
try {
return isNonEmptyString(dateString) &&
(new Date(dateString).toUTCString() === dateString);
} catch (e) {
return false;
}
}


/**
* Validates that a string is a valid web URL.
Expand Down
58 changes: 58 additions & 0 deletions test/integration/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1497,6 +1497,64 @@ describe('admin.auth', () => {
}).should.eventually.be.fulfilled;
});

it('successfully imports users with enrolled second factors', () => {
const uid = generateRandomString(20).toLowerCase();
const email = uid + '@example.com';
const now = new Date(1476235905000).toUTCString();
importUserRecord = {
uid,
email,
emailVerified: true,
displayName: 'Test User',
disabled: false,
metadata: {
lastSignInTime: now,
creationTime: now,
},
providerData: [
{
uid: uid + '-facebook',
displayName: 'Facebook User',
email,
providerId: 'facebook.com',
},
],
multiFactor: {
enrolledFactors: [
{
uid: 'mfaUid1',
phoneNumber: '+16505550001',
displayName: 'Work phone number',
factorId: 'phone',
enrollmentTime: now,
},
{
uid: 'mfaUid2',
phoneNumber: '+16505550002',
displayName: 'Personal phone number',
factorId: 'phone',
enrollmentTime: now,
},
],
},
};
uids.push(importUserRecord.uid);

return admin.auth().importUsers([importUserRecord])
.then((result) => {
expect(result.failureCount).to.equal(0);
expect(result.successCount).to.equal(1);
expect(result.errors.length).to.equal(0);
return admin.auth().getUser(uid);
}).then((userRecord) => {
// Confirm second factors added to user.
const actualUserRecord: {[key: string]: any} = userRecord.toJSON();
expect(actualUserRecord.multiFactor.enrolledFactors.length).to.equal(2);
expect(actualUserRecord.multiFactor.enrolledFactors)
.to.deep.equal(importUserRecord.multiFactor.enrolledFactors);
}).should.eventually.be.fulfilled;
});

it('fails when invalid users are provided', () => {
const users = [
{uid: generateRandomString(20).toLowerCase(), phoneNumber: '+1error'},
Expand Down
Loading