diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 330b9108b7..7a8227b0e7 100755 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -86,6 +86,16 @@ const FIREBASE_AUTH_TENANT_URL_FORMAT = FIREBASE_AUTH_BASE_URL_FORMAT.replace( const MAX_LIST_TENANT_PAGE_SIZE = 1000; +/** + * Enum for the user write operation type. + */ +enum WriteOperationType { + Create = 'create', + Update = 'update', + Upload = 'upload', +} + + /** Defines a base utility to help with resource URL construction. */ class AuthResourceUrlBuilder { protected urlFormat: string; @@ -157,8 +167,9 @@ class TenantAwareAuthResourceUrlBuilder extends AuthResourceUrlBuilder { * an error is thrown. * * @param request The AuthFactorInfo request object. + * @param writeOperationType The write operation type. */ -function validateAuthFactorInfo(request: AuthFactorInfo) { +function validateAuthFactorInfo(request: AuthFactorInfo, writeOperationType: WriteOperationType) { const validKeys = { mfaEnrollmentId: true, displayName: true, @@ -171,7 +182,12 @@ function validateAuthFactorInfo(request: AuthFactorInfo) { delete request[key]; } } - if (!validator.isNonEmptyString(request.mfaEnrollmentId)) { + // No enrollment ID is available for signupNewUser. Use another identifier. + const authFactorInfoIdentifier = + request.mfaEnrollmentId || request.phoneInfo || JSON.stringify(request); + const uidRequired = writeOperationType !== WriteOperationType.Create; + if ((typeof request.mfaEnrollmentId !== 'undefined' || uidRequired) && + !validator.isNonEmptyString(request.mfaEnrollmentId)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_UID, `The second factor "uid" must be a valid non-empty string.`, @@ -181,7 +197,7 @@ function validateAuthFactorInfo(request: AuthFactorInfo) { !validator.isString(request.displayName)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_DISPLAY_NAME, - `The second factor "displayName" for "${request.mfaEnrollmentId}" must be a valid string.`, + `The second factor "displayName" for "${authFactorInfoIdentifier}" must be a valid string.`, ); } // enrolledAt must be a valid UTC date string. @@ -189,7 +205,7 @@ function validateAuthFactorInfo(request: AuthFactorInfo) { !validator.isISODateString(request.enrolledAt)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ENROLLMENT_TIME, - `The second factor "enrollmentTime" for "${request.mfaEnrollmentId}" must be a valid ` + + `The second factor "enrollmentTime" for "${authFactorInfoIdentifier}" must be a valid ` + `UTC date string.`); } // Validate required fields depending on second factor type. @@ -198,7 +214,7 @@ function validateAuthFactorInfo(request: AuthFactorInfo) { if (!validator.isPhoneNumber(request.phoneInfo)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_PHONE_NUMBER, - `The second factor "phoneNumber" for "${request.mfaEnrollmentId}" must be a non-empty ` + + `The second factor "phoneNumber" for "${authFactorInfoIdentifier}" must be a non-empty ` + `E.164 standard compliant identifier string.`); } } else { @@ -275,10 +291,11 @@ function validateProviderUserInfo(request: any) { * are removed from the original request. If an invalid field is passed * an error is thrown. * - * @param {any} request The create/edit request object. - * @param {boolean=} uploadAccountRequest Whether to validate as an uploadAccount request. + * @param request The create/edit request object. + * @param writeOperationType The write operation type. */ -function validateCreateEditRequest(request: any, uploadAccountRequest: boolean = false) { +function validateCreateEditRequest(request: any, writeOperationType: WriteOperationType) { + const uploadAccountRequest = writeOperationType === WriteOperationType.Upload; // Hash set of whitelisted parameters. const validKeys = { displayName: true, @@ -458,7 +475,7 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean = throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ENROLLED_FACTORS); } enrollments.forEach((authFactorInfoEntry: AuthFactorInfo) => { - validateAuthFactorInfo(authFactorInfoEntry); + validateAuthFactorInfo(authFactorInfoEntry, writeOperationType); }); } } @@ -559,7 +576,7 @@ export const FIREBASE_AUTH_SET_ACCOUNT_INFO = new ApiSettings('/accounts:update' AuthClientErrorCode.INVALID_ARGUMENT, '"tenantId" is an invalid "UpdateRequest" property.'); } - validateCreateEditRequest(request); + validateCreateEditRequest(request, WriteOperationType.Update); }) // Set response validator. .setResponseValidator((response: any) => { @@ -596,7 +613,7 @@ export const FIREBASE_AUTH_SIGN_UP_NEW_USER = new ApiSettings('/accounts', 'POST AuthClientErrorCode.INVALID_ARGUMENT, '"tenantId" is an invalid "CreateRequest" property.'); } - validateCreateEditRequest(request); + validateCreateEditRequest(request, WriteOperationType.Create); }) // Set response validator. .setResponseValidator((response: any) => { @@ -912,7 +929,7 @@ export abstract class AbstractAuthRequestHandler { // No need to validate raw request or raw response as this is done in UserImportBuilder. const userImportBuilder = new UserImportBuilder(users, options, (userRequest: any) => { // Pass true to validate the uploadAccount specific fields. - validateCreateEditRequest(userRequest, true); + validateCreateEditRequest(userRequest, WriteOperationType.Upload); }); const request = userImportBuilder.buildRequest(); // Fail quickly if more users than allowed are to be imported. @@ -1145,6 +1162,32 @@ export abstract class AbstractAuthRequestHandler { request.localId = request.uid; delete request.uid; } + // Construct mfa related user data. + if (validator.isNonNullObject(request.multiFactor)) { + if (validator.isNonEmptyArray(request.multiFactor.enrolledFactors)) { + const mfaInfo: AuthFactorInfo[] = []; + try { + request.multiFactor.enrolledFactors.forEach((multiFactorInfo: any) => { + // Enrollment time and uid are not allowed for signupNewUser endpoint. + // They will automatically be provisioned server side. + if (multiFactorInfo.enrollmentTime) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"enrollmentTime" is not supported when adding second factors via "createUser()"'); + } else if (multiFactorInfo.uid) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"uid" is not supported when adding second factors via "createUser()"'); + } + mfaInfo.push(convertMultiFactorInfoToServerFormat(multiFactorInfo)); + }); + } catch (e) { + return Promise.reject(e); + } + request.mfaInfo = mfaInfo; + } + delete request.multiFactor; + } return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_SIGN_UP_NEW_USER, request) .then((response: any) => { // Return the user id. diff --git a/src/auth/user-import-builder.ts b/src/auth/user-import-builder.ts index 2672c7d5d9..ecb5f3eb00 100755 --- a/src/auth/user-import-builder.ts +++ b/src/auth/user-import-builder.ts @@ -79,7 +79,8 @@ export interface UserImportRecord { /** Interface representing an Auth second factor in Auth server format. */ export interface AuthFactorInfo { - mfaEnrollmentId: string; + // Not required for signupNewUser endpoint. + mfaEnrollmentId?: string; displayName?: string; phoneInfo?: string; enrolledAt?: string; diff --git a/src/auth/user-record.ts b/src/auth/user-record.ts index daf9e73fde..468951310c 100644 --- a/src/auth/user-record.ts +++ b/src/auth/user-record.ts @@ -43,7 +43,7 @@ function parseDate(time: any): string { } interface SecondFactor { - uid: string; + uid?: string; phoneNumber: string; displayName?: string; enrollmentTime?: string; @@ -67,6 +67,9 @@ export interface UpdateRequest { /** Parameters for create user operation */ export interface CreateRequest extends UpdateRequest { uid?: string; + multiFactor?: { + enrolledFactors: SecondFactor[]; + }; } export interface AuthFactorInfo { diff --git a/src/index.d.ts b/src/index.d.ts index 144d9f91e1..caba04096c 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -698,7 +698,7 @@ declare namespace admin.auth { multiFactor?: { enrolledFactors: Array<{ - uid: string; + uid?: string; phoneNumber: string; displayName?: string; enrollmentTime?: string; @@ -717,6 +717,14 @@ declare namespace admin.auth { * The user's `uid`. */ uid?: string; + + multiFactor?: { + enrolledFactors: Array<{ + phoneNumber: string; + displayName?: string; + factorId: string; + }>; + }; } /** diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 88c41dc9d2..62b45867f9 100755 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -42,6 +42,7 @@ const expect = chai.expect; const newUserUid = generateRandomString(20); const nonexistentUid = generateRandomString(20); +const newMultiFactorUserUid = generateRandomString(20); const sessionCookieUids = [ generateRandomString(20), generateRandomString(20), @@ -135,6 +136,52 @@ describe('admin.auth', () => { }); }); + it('createUser() creates a new user with enrolled second factors', () => { + const enrolledFactors = [ + { + phoneNumber: '+16505550001', + displayName: 'Work phone number', + factorId: 'phone', + }, + { + phoneNumber: '+16505550002', + displayName: 'Personal phone number', + factorId: 'phone', + }, + ]; + const newUserData: any = { + uid: newMultiFactorUserUid, + email: generateRandomString(20).toLowerCase() + '@example.com', + emailVerified: true, + password: 'password', + multiFactor: { + enrolledFactors, + }, + }; + return admin.auth().createUser(newUserData) + .then((userRecord) => { + expect(userRecord.uid).to.equal(newMultiFactorUserUid); + // Confirm expected email. + expect(userRecord.email).to.equal(newUserData.email); + // Confirm second factors added to user. + expect(userRecord.multiFactor.enrolledFactors.length).to.equal(2); + // Confirm first enrolled second factor. + const firstMultiFactor = userRecord.multiFactor.enrolledFactors[0]; + expect(firstMultiFactor.uid).not.to.be.undefined; + expect(firstMultiFactor.enrollmentTime).not.to.be.undefined; + expect(firstMultiFactor.phoneNumber).to.equal(enrolledFactors[0].phoneNumber); + expect(firstMultiFactor.displayName).to.equal(enrolledFactors[0].displayName); + expect(firstMultiFactor.factorId).to.equal(enrolledFactors[0].factorId); + // Confirm second enrolled second factor. + const secondMultiFactor = userRecord.multiFactor.enrolledFactors[1]; + expect(secondMultiFactor.uid).not.to.be.undefined; + expect(secondMultiFactor.enrollmentTime).not.to.be.undefined; + expect(secondMultiFactor.phoneNumber).to.equal(enrolledFactors[1].phoneNumber); + expect(secondMultiFactor.displayName).to.equal(enrolledFactors[1].displayName); + expect(secondMultiFactor.factorId).to.equal(enrolledFactors[1].factorId); + }); + }); + it('createUser() fails when the UID is already in use', () => { const newUserData: any = clone(mockUserData); newUserData.uid = newUserUid; @@ -1220,6 +1267,7 @@ describe('admin.auth', () => { it('deleteUser() deletes the user with the given UID', () => { return Promise.all([ admin.auth().deleteUser(newUserUid), + admin.auth().deleteUser(newMultiFactorUserUid), admin.auth().deleteUser(uidFromCreateUserWithoutUid), ]).should.eventually.be.fulfilled; }); diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index 701c43f20c..465daaf236 100755 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -2328,6 +2328,19 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { password: 'password', phoneNumber: '+11234567890', ignoredProperty: 'value', + multiFactor: { + enrolledFactors: [ + { + phoneNumber: '+16505557348', + displayName: 'Spouse\'s phone number', + factorId: 'phone', + }, + { + phoneNumber: '+16505551000', + factorId: 'phone', + }, + ], + }, }; const expectedValidData = { localId: uid, @@ -2338,6 +2351,15 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { photoUrl: 'http://localhost/1234/photo.png', password: 'password', phoneNumber: '+11234567890', + mfaInfo: [ + { + phoneInfo: '+16505557348', + displayName: 'Spouse\'s phone number', + }, + { + phoneInfo: '+16505551000', + }, + ], }; const invalidData = { uid, @@ -2404,6 +2426,117 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); + const noEnrolledFactors: any[] = [[], null]; + noEnrolledFactors.forEach((arg) => { + it(`should be fulfilled given "${JSON.stringify(arg)}" 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 create new account request with no enrolled factors. + return requestHandler.createNewAccount({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, no mfa info should + // be sent. + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, emptyRequest)); + }); + }); + }); + + const unsupportedSecondFactor = { + secret: 'SECRET', + displayName: 'Google Authenticator on personal phone', + // TOTP is not yet supported. + factorId: 'totp', + }; + const invalidSecondFactorTests: InvalidMultiFactorUpdateTest[] = [ + { + name: 'unsupported second factor uid', + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"uid" is not supported when adding second factors via "createUser()"', + ), + 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 "+16505557348" must be a valid string.`, + ), + secondFactor: { + 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 "invalid" must be a non-empty ` + + `E.164 standard compliant identifier string.`), + secondFactor: { + phoneNumber: 'invalid', + displayName: 'Spouse\'s phone number', + factorId: 'phone', + }, + }, + { + name: 'unsupported second factor enrollment time', + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"enrollmentTime" is not supported when adding second factors via "createUser()"'), + secondFactor: { + phoneNumber: '+16505557348', + displayName: 'Spouse\'s phone number', + factorId: 'phone', + enrollmentTime: new Date().toUTCString(), + }, + }, + { + 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 = { + uid, + email: 'user@example.com', + emailVerified: true, + password: 'secretpassword', + multiFactor: { + enrolledFactors: [invalidSecondFactorTest.secondFactor], + }, + }; + const requestHandler = handler.init(mockApp); + return requestHandler.createNewAccount(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 tenantId in CreateRequest', () => { // Expected error when a tenantId is provided. const expectedError = new FirebaseAuthError(