diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 3c99e2610c..330b9108b7 100755 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -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'; @@ -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) { @@ -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); }); } @@ -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) { + // 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; diff --git a/src/auth/user-import-builder.ts b/src/auth/user-import-builder.ts index b6eb2ebd84..2672c7d5d9 100755 --- a/src/auth/user-import-builder.ts +++ b/src/auth/user-import-builder.ts @@ -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 { @@ -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; @@ -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. @@ -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)); }); } diff --git a/src/auth/user-record.ts b/src/auth/user-record.ts index e31e155486..daf9e73fde 100644 --- a/src/auth/user-record.ts +++ b/src/auth/user-record.ts @@ -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; @@ -51,6 +59,9 @@ export interface UpdateRequest { password?: string; phoneNumber?: string | null; photoURL?: string | null; + multiFactor?: { + enrolledFactors: SecondFactor[] | null; + }; } /** Parameters for create user operation */ diff --git a/src/index.d.ts b/src/index.d.ts index 4dde842fb1..144d9f91e1 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -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. */ @@ -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; + }; } /** @@ -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<{ + uid: string; + phoneNumber: string; + displayName?: string; + enrollmentTime?: string; + factorId: string; + }>; + }; } /** diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index ffe9551500..88c41dc9d2 100755 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -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; @@ -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; }); }); diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index 50baaa4600..701c43f20c 100755 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -61,6 +61,12 @@ interface HandlerTest { path(version: string, api: string, projectId: string): string; } +interface InvalidMultiFactorUpdateTest { + name: string; + error: FirebaseAuthError; + secondFactor: any; +} + /** * @param {number} numOfChars The number of random characters within the string. @@ -1721,6 +1727,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); describe('updateExistingAccount', () => { + const now = new Date('2019-10-25T04:30:52.000Z'); const path = handler.path('v1', '/accounts:update', 'project_id'); const method = 'POST'; const uid = '12345678'; @@ -1733,6 +1740,22 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { password: 'password', phoneNumber: '+11234567890', ignoredProperty: 'value', + multiFactor: { + enrolledFactors: [ + { + uid: 'enrolledSecondFactor1', + phoneNumber: '+16505557348', + displayName: 'Spouse\'s phone number', + factorId: 'phone', + enrollmentTime: now.toUTCString(), + }, + { + uid: 'enrolledSecondFactor2', + phoneNumber: '+16505551000', + factorId: 'phone', + }, + ], + }, }; const expectedValidData = { localId: uid, @@ -1743,11 +1766,26 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { photoUrl: 'http://localhost/1234/photo.png', password: 'password', phoneNumber: '+11234567890', + mfa: { + enrollments: [ + { + mfaEnrollmentId: 'enrolledSecondFactor1', + phoneInfo: '+16505557348', + displayName: 'Spouse\'s phone number', + enrolledAt: now.toISOString(), + }, + { + mfaEnrollmentId: 'enrolledSecondFactor2', + phoneInfo: '+16505551000', + }, + ], + }, }; // Valid request to delete photoURL and displayName. const validDeleteData = deepCopy(validData); validDeleteData.displayName = null; validDeleteData.photoURL = null; + delete validDeleteData.multiFactor; const expectedValidDeleteData = { localId: uid, email: 'user@example.com', @@ -1760,6 +1798,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { // Valid request to delete phoneNumber. const validDeletePhoneNumberData = deepCopy(validData); validDeletePhoneNumberData.phoneNumber = null; + delete validDeletePhoneNumberData.multiFactor; const expectedValidDeletePhoneNumberData = { localId: uid, displayName: 'John Doe', @@ -1770,6 +1809,11 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { password: 'password', deleteProvider: ['phone'], }; + // Valid request to delete all second factors. + const expectedValidDeleteMfaData = { + localId: uid, + mfa: {}, + }; const invalidData = { uid, email: 'user@invalid@', @@ -1866,6 +1910,50 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); + it('should be fulfilled given null enrolled factors', () => { + // Successful result server response. + const expectedResult = utils.responseFrom({ + localId: uid, + }); + + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + // Send update request to delete enrolled factors. + return requestHandler.updateExistingAccount(uid, {multiFactor: {enrolledFactors: null}}) + .then((returnedUid: string) => { + // uid should be returned. + expect(returnedUid).to.be.equal(uid); + // Confirm expected rpc request parameters sent. In this case, mfa is set to + // an empty object. + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, expectedValidDeleteMfaData)); + }); + }); + + it('should be fulfilled given empty enrolled factors array', () => { + // Successful result server response. + const expectedResult = utils.responseFrom({ + localId: uid, + }); + + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + // Send update request to delete enrolled factors. + return requestHandler.updateExistingAccount(uid, {multiFactor: {enrolledFactors: []}}) + .then((returnedUid: string) => { + // uid should be returned. + expect(returnedUid).to.be.equal(uid); + // Confirm expected rpc request parameters sent. In this case, mfa is set to + // an empty object. + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, expectedValidDeleteMfaData)); + }); + }); + it('should be rejected given invalid parameters such as email', () => { // Expected error when an invalid email is provided. const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL); @@ -1880,6 +1968,92 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); + const unsupportedSecondFactor = { + uid: 'enrolledSecondFactor1', + secret: 'SECRET', + displayName: 'Google Authenticator on personal phone', + factorId: 'totp', + }; + const invalidSecondFactorTests: InvalidMultiFactorUpdateTest[] = [ + { + name: 'invalid second factor uid', + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_UID, + `The second factor "uid" must be a valid non-empty string.`, + ), + secondFactor: { + uid: ['enrollmentId'], + phoneNumber: '+16505557348', + displayName: 'Spouse\'s phone number', + factorId: 'phone', + }, + }, + { + name: 'invalid second factor display name', + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_DISPLAY_NAME, + `The second factor "displayName" for "enrolledSecondFactor1" must be a valid string.`, + ), + secondFactor: { + uid: 'enrolledSecondFactor1', + phoneNumber: '+16505557348', + displayName: ['Corp phone number'], + factorId: 'phone', + }, + }, + { + name: 'invalid second factor phone number', + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_PHONE_NUMBER, + `The second factor "phoneNumber" for "enrolledSecondFactor1" must be a non-empty ` + + `E.164 standard compliant identifier string.`), + secondFactor: { + uid: 'enrolledSecondFactor1', + phoneNumber: 'invalid', + displayName: 'Spouse\'s phone number', + factorId: 'phone', + }, + }, + { + name: 'invalid second factor enrollment time', + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLMENT_TIME, + `The second factor "enrollmentTime" for "enrolledSecondFactor1" must be a valid ` + + `UTC date string.`), + secondFactor: { + uid: 'enrolledSecondFactor1', + phoneNumber: '+16505557348', + displayName: 'Spouse\'s phone number', + factorId: 'phone', + enrollmentTime: 'invalid', + }, + }, + { + name: 'invalid second factor type', + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLED_FACTORS, + `Unsupported second factor "${JSON.stringify(unsupportedSecondFactor)}" provided.`), + secondFactor: unsupportedSecondFactor, + }, + ]; + invalidSecondFactorTests.forEach((invalidSecondFactorTest) => { + it(`should be rejected given an ${invalidSecondFactorTest.name}`, () => { + const invalidSecondFactorData = { + multiFactor: { + enrolledFactors: [invalidSecondFactorTest.secondFactor], + }, + }; + const requestHandler = handler.init(mockApp); + return requestHandler.updateExistingAccount(uid, invalidSecondFactorData as any) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + // Expected error should be thrown. + expect(error).to.deep.equal(invalidSecondFactorTest.error); + }); + }); + }); + it('should be rejected given a tenant ID to modify', () => { const dataWithModifiedTenantId = deepCopy(validData); (dataWithModifiedTenantId as any).tenantId = 'MODIFIED-TENANT-ID';