Skip to content
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
40 changes: 36 additions & 4 deletions 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, AuthFactorInfo,
UserImportResult, AuthFactorInfo, convertMultiFactorInfoToServerFormat,
} from './user-import-builder';
import * as utils from '../utils/index';
import {ActionCodeSettings, ActionCodeSettingsBuilder} from './action-code-settings-builder';
Expand Down Expand Up @@ -304,6 +304,8 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean =
lastLoginAt: uploadAccountRequest,
providerUserInfo: uploadAccountRequest,
mfaInfo: uploadAccountRequest,
// Only for non-uploadAccount requests.
mfa: !uploadAccountRequest,
};
// Remove invalid keys from original request.
for (const key in request) {
Expand Down Expand Up @@ -442,12 +444,20 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean =
validateProviderUserInfo(providerUserInfoEntry);
});
}
// mfaInfo has to be an array of valid AuthFactorInfo requests.
// mfaInfo is used for importUsers.
// mfa.enrollments is used for setAccountInfo.
// enrollments has to be an array of valid AuthFactorInfo requests.
let enrollments: AuthFactorInfo[];
if (request.mfaInfo) {
if (!validator.isArray(request.mfaInfo)) {
enrollments = request.mfaInfo;
} else if (request.mfa && request.mfa.enrollments) {
enrollments = request.mfa.enrollments;
}
if (enrollments) {
if (!validator.isArray(enrollments)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ENROLLED_FACTORS);
}
request.mfaInfo.forEach((authFactorInfoEntry: AuthFactorInfo) => {
enrollments.forEach((authFactorInfoEntry: AuthFactorInfo) => {
validateAuthFactorInfo(authFactorInfoEntry);
});
}
Expand Down Expand Up @@ -1049,6 +1059,28 @@ export abstract class AbstractAuthRequestHandler {
request.disableUser = request.disabled;
delete request.disabled;
}
// Construct mfa related user data.
if (validator.isNonNullObject(request.multiFactor)) {
if (request.multiFactor.enrolledFactors === null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps this is slightly more clear:

request.mfa = {};
const enrollments = [];
if (validator.isArray(request.multiFactor.enrolledFactors)) {
   // iterate and push
}

if (enrollments.length > 0) {
  request.mfa.enrollments = enrollments;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm it is a bit more complicated as passing request.mfa = {} will clear all existing second factors on the user.
We should only pass that if the developers passing explicitly an empty array for enrolledFactors or null for enrolledFactors.

// Remove all second factors.
request.mfa = {};
} else if (validator.isArray(request.multiFactor.enrolledFactors)) {
request.mfa = {
enrollments: [],
};
try {
request.multiFactor.enrolledFactors.forEach((multiFactorInfo: any) => {
request.mfa.enrollments.push(convertMultiFactorInfoToServerFormat(multiFactorInfo));
});
} catch (e) {
return Promise.reject(e);
}
if (request.mfa.enrollments.length === 0) {
delete request.mfa.enrollments;
}
}
delete request.multiFactor;
}
return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_SET_ACCOUNT_INFO, request)
.then((response: any) => {
return response.localId as string;
Expand Down
94 changes: 53 additions & 41 deletions src/auth/user-import-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export interface UserImportOptions {
};
}

interface SecondFactor {
uid: string;
phoneNumber: string;
displayName?: string;
enrollmentTime?: string;
factorId: string;
}


/** User import record as accepted from developer. */
export interface UserImportRecord {
Expand All @@ -61,13 +69,7 @@ export interface UserImportRecord {
providerId: string,
}>;
multiFactor?: {
enrolledFactors: Array<{
uid: string;
phoneNumber: string;
displayName?: string;
enrollmentTime?: string;
factorId: string;
}>;
enrolledFactors: SecondFactor[];
};
customClaims?: object;
passwordHash?: Buffer;
Expand Down Expand Up @@ -143,6 +145,49 @@ export interface UserImportResult {
export type ValidatorFunction = (data: UploadAccountUser) => void;


/**
* Converts a client format second factor object to server format.
* @param multiFactorInfo The client format second factor.
* @return The corresponding AuthFactorInfo server request format.
*/
export function convertMultiFactorInfoToServerFormat(multiFactorInfo: SecondFactor): AuthFactorInfo {
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();
} 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];
}
}
return authFactorInfo;
} else {
// Unsupported second factor.
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ENROLLED_FACTORS,
`Unsupported second factor "${JSON.stringify(multiFactorInfo)}" provided.`);
}
}


/**
* @param {any} obj The object to check for number field within.
* @param {string} key The entry key.
Expand Down Expand Up @@ -218,40 +263,7 @@ function populateUploadAccountUser(
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();
} 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.`);
}
result.mfaInfo.push(convertMultiFactorInfoToServerFormat(multiFactorInfo));
});
}

Expand Down
11 changes: 11 additions & 0 deletions src/auth/user-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ function parseDate(time: any): string {
return null;
}

interface SecondFactor {
uid: string;
phoneNumber: string;
displayName?: string;
enrollmentTime?: string;
factorId: string;
}

/** Parameters for update user operation */
export interface UpdateRequest {
disabled?: boolean;
Expand All @@ -51,6 +59,9 @@ export interface UpdateRequest {
password?: string;
phoneNumber?: string | null;
photoURL?: string | null;
multiFactor?: {
enrolledFactors: SecondFactor[] | null;
};
}

/** Parameters for create user operation */
Expand Down
30 changes: 30 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,16 @@ declare namespace admin.auth {
*/
tenantId?: string | null;

multiFactor?: {
enrolledFactors: Array<{
uid: string;
phoneNumber: string;
displayName?: string;
enrollmentTime?: string;
factorId: string;
}>;
};

/**
* @return A JSON-serializable representation of this object.
*/
Expand Down Expand Up @@ -685,6 +695,16 @@ declare namespace admin.auth {
* The user's photo URL.
*/
photoURL?: string | null;

multiFactor?: {
enrolledFactors: Array<{
uid: string;
phoneNumber: string;
displayName?: string;
enrollmentTime?: string;
factorId: string;
}> | null;
};
}

/**
Expand Down Expand Up @@ -1000,6 +1020,16 @@ declare namespace admin.auth {
* to the tenant corresponding to that `TenantAwareAuth` instance's tenant ID.
*/
tenantId?: string | null;

multiFactor?: {
enrolledFactors: Array<{
Copy link
Contributor

Choose a reason for hiding this comment

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

Does it make sense to reduce this duplication by introducing an interface? Like the SecondFactor type used in the implementation?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I think we can define an interface for this. I can do it in the next PR. This is going to be modified and more documentation added later anyway.

uid: string;
phoneNumber: string;
displayName?: string;
enrollmentTime?: string;
factorId: string;
}>;
};
}

/**
Expand Down
45 changes: 45 additions & 0 deletions test/integration/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,31 @@ describe('admin.auth', () => {

it('updateUser() updates the user record with the given parameters', () => {
const updatedDisplayName = 'Updated User ' + newUserUid;
const now = new Date(1476235905000).toUTCString();
const 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,
},
];
return admin.auth().updateUser(newUserUid, {
email: updatedEmail,
phoneNumber: updatedPhone,
emailVerified: true,
displayName: updatedDisplayName,
multiFactor: {
enrolledFactors,
},
})
.then((userRecord) => {
expect(userRecord.emailVerified).to.be.true;
Expand All @@ -328,6 +348,31 @@ describe('admin.auth', () => {
expect(userRecord.email).to.equal(updatedEmail);
// Confirm expected phone number.
expect(userRecord.phoneNumber).to.equal(updatedPhone);
// 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(enrolledFactors);
// Update list of second factors.
return admin.auth().updateUser(newUserUid, {
multiFactor: {
enrolledFactors: [enrolledFactors[0]],
},
});
})
.then((userRecord) => {
expect(userRecord.multiFactor.enrolledFactors.length).to.equal(1);
const actualUserRecord: {[key: string]: any} = userRecord.toJSON();
expect(actualUserRecord.multiFactor.enrolledFactors[0]).to.deep.equal(enrolledFactors[0]);
// Remove all second factors.
return admin.auth().updateUser(newUserUid, {
multiFactor: {
enrolledFactors: null,
},
});
})
.then((userRecord) => {
// Confirm all second factors removed.
expect(userRecord.multiFactor).to.be.undefined;
});
});

Expand Down
Loading