Skip to content

Commit b59b17a

Browse files
Extends createUser to support multi-factor user creation. (#718)
* Extends createUser to support multi-factor user creation. Note that only phoneNumber and displayName are allowed to be passed in this operation. Adds relevant unit and integration tests. This capability is only available in staging.
1 parent 70b7da9 commit b59b17a

File tree

6 files changed

+251
-15
lines changed

6 files changed

+251
-15
lines changed

src/auth/auth-api-request.ts

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ const FIREBASE_AUTH_TENANT_URL_FORMAT = FIREBASE_AUTH_BASE_URL_FORMAT.replace(
8686
const MAX_LIST_TENANT_PAGE_SIZE = 1000;
8787

8888

89+
/**
90+
* Enum for the user write operation type.
91+
*/
92+
enum WriteOperationType {
93+
Create = 'create',
94+
Update = 'update',
95+
Upload = 'upload',
96+
}
97+
98+
8999
/** Defines a base utility to help with resource URL construction. */
90100
class AuthResourceUrlBuilder {
91101
protected urlFormat: string;
@@ -157,8 +167,9 @@ class TenantAwareAuthResourceUrlBuilder extends AuthResourceUrlBuilder {
157167
* an error is thrown.
158168
*
159169
* @param request The AuthFactorInfo request object.
170+
* @param writeOperationType The write operation type.
160171
*/
161-
function validateAuthFactorInfo(request: AuthFactorInfo) {
172+
function validateAuthFactorInfo(request: AuthFactorInfo, writeOperationType: WriteOperationType) {
162173
const validKeys = {
163174
mfaEnrollmentId: true,
164175
displayName: true,
@@ -171,7 +182,12 @@ function validateAuthFactorInfo(request: AuthFactorInfo) {
171182
delete request[key];
172183
}
173184
}
174-
if (!validator.isNonEmptyString(request.mfaEnrollmentId)) {
185+
// No enrollment ID is available for signupNewUser. Use another identifier.
186+
const authFactorInfoIdentifier =
187+
request.mfaEnrollmentId || request.phoneInfo || JSON.stringify(request);
188+
const uidRequired = writeOperationType !== WriteOperationType.Create;
189+
if ((typeof request.mfaEnrollmentId !== 'undefined' || uidRequired) &&
190+
!validator.isNonEmptyString(request.mfaEnrollmentId)) {
175191
throw new FirebaseAuthError(
176192
AuthClientErrorCode.INVALID_UID,
177193
`The second factor "uid" must be a valid non-empty string.`,
@@ -181,15 +197,15 @@ function validateAuthFactorInfo(request: AuthFactorInfo) {
181197
!validator.isString(request.displayName)) {
182198
throw new FirebaseAuthError(
183199
AuthClientErrorCode.INVALID_DISPLAY_NAME,
184-
`The second factor "displayName" for "${request.mfaEnrollmentId}" must be a valid string.`,
200+
`The second factor "displayName" for "${authFactorInfoIdentifier}" must be a valid string.`,
185201
);
186202
}
187203
// enrolledAt must be a valid UTC date string.
188204
if (typeof request.enrolledAt !== 'undefined' &&
189205
!validator.isISODateString(request.enrolledAt)) {
190206
throw new FirebaseAuthError(
191207
AuthClientErrorCode.INVALID_ENROLLMENT_TIME,
192-
`The second factor "enrollmentTime" for "${request.mfaEnrollmentId}" must be a valid ` +
208+
`The second factor "enrollmentTime" for "${authFactorInfoIdentifier}" must be a valid ` +
193209
`UTC date string.`);
194210
}
195211
// Validate required fields depending on second factor type.
@@ -198,7 +214,7 @@ function validateAuthFactorInfo(request: AuthFactorInfo) {
198214
if (!validator.isPhoneNumber(request.phoneInfo)) {
199215
throw new FirebaseAuthError(
200216
AuthClientErrorCode.INVALID_PHONE_NUMBER,
201-
`The second factor "phoneNumber" for "${request.mfaEnrollmentId}" must be a non-empty ` +
217+
`The second factor "phoneNumber" for "${authFactorInfoIdentifier}" must be a non-empty ` +
202218
`E.164 standard compliant identifier string.`);
203219
}
204220
} else {
@@ -275,10 +291,11 @@ function validateProviderUserInfo(request: any) {
275291
* are removed from the original request. If an invalid field is passed
276292
* an error is thrown.
277293
*
278-
* @param {any} request The create/edit request object.
279-
* @param {boolean=} uploadAccountRequest Whether to validate as an uploadAccount request.
294+
* @param request The create/edit request object.
295+
* @param writeOperationType The write operation type.
280296
*/
281-
function validateCreateEditRequest(request: any, uploadAccountRequest: boolean = false) {
297+
function validateCreateEditRequest(request: any, writeOperationType: WriteOperationType) {
298+
const uploadAccountRequest = writeOperationType === WriteOperationType.Upload;
282299
// Hash set of whitelisted parameters.
283300
const validKeys = {
284301
displayName: true,
@@ -458,7 +475,7 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean =
458475
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ENROLLED_FACTORS);
459476
}
460477
enrollments.forEach((authFactorInfoEntry: AuthFactorInfo) => {
461-
validateAuthFactorInfo(authFactorInfoEntry);
478+
validateAuthFactorInfo(authFactorInfoEntry, writeOperationType);
462479
});
463480
}
464481
}
@@ -559,7 +576,7 @@ export const FIREBASE_AUTH_SET_ACCOUNT_INFO = new ApiSettings('/accounts:update'
559576
AuthClientErrorCode.INVALID_ARGUMENT,
560577
'"tenantId" is an invalid "UpdateRequest" property.');
561578
}
562-
validateCreateEditRequest(request);
579+
validateCreateEditRequest(request, WriteOperationType.Update);
563580
})
564581
// Set response validator.
565582
.setResponseValidator((response: any) => {
@@ -596,7 +613,7 @@ export const FIREBASE_AUTH_SIGN_UP_NEW_USER = new ApiSettings('/accounts', 'POST
596613
AuthClientErrorCode.INVALID_ARGUMENT,
597614
'"tenantId" is an invalid "CreateRequest" property.');
598615
}
599-
validateCreateEditRequest(request);
616+
validateCreateEditRequest(request, WriteOperationType.Create);
600617
})
601618
// Set response validator.
602619
.setResponseValidator((response: any) => {
@@ -912,7 +929,7 @@ export abstract class AbstractAuthRequestHandler {
912929
// No need to validate raw request or raw response as this is done in UserImportBuilder.
913930
const userImportBuilder = new UserImportBuilder(users, options, (userRequest: any) => {
914931
// Pass true to validate the uploadAccount specific fields.
915-
validateCreateEditRequest(userRequest, true);
932+
validateCreateEditRequest(userRequest, WriteOperationType.Upload);
916933
});
917934
const request = userImportBuilder.buildRequest();
918935
// Fail quickly if more users than allowed are to be imported.
@@ -1145,6 +1162,32 @@ export abstract class AbstractAuthRequestHandler {
11451162
request.localId = request.uid;
11461163
delete request.uid;
11471164
}
1165+
// Construct mfa related user data.
1166+
if (validator.isNonNullObject(request.multiFactor)) {
1167+
if (validator.isNonEmptyArray(request.multiFactor.enrolledFactors)) {
1168+
const mfaInfo: AuthFactorInfo[] = [];
1169+
try {
1170+
request.multiFactor.enrolledFactors.forEach((multiFactorInfo: any) => {
1171+
// Enrollment time and uid are not allowed for signupNewUser endpoint.
1172+
// They will automatically be provisioned server side.
1173+
if (multiFactorInfo.enrollmentTime) {
1174+
throw new FirebaseAuthError(
1175+
AuthClientErrorCode.INVALID_ARGUMENT,
1176+
'"enrollmentTime" is not supported when adding second factors via "createUser()"');
1177+
} else if (multiFactorInfo.uid) {
1178+
throw new FirebaseAuthError(
1179+
AuthClientErrorCode.INVALID_ARGUMENT,
1180+
'"uid" is not supported when adding second factors via "createUser()"');
1181+
}
1182+
mfaInfo.push(convertMultiFactorInfoToServerFormat(multiFactorInfo));
1183+
});
1184+
} catch (e) {
1185+
return Promise.reject(e);
1186+
}
1187+
request.mfaInfo = mfaInfo;
1188+
}
1189+
delete request.multiFactor;
1190+
}
11481191
return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_SIGN_UP_NEW_USER, request)
11491192
.then((response: any) => {
11501193
// Return the user id.

src/auth/user-import-builder.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ export interface UserImportRecord {
7979

8080
/** Interface representing an Auth second factor in Auth server format. */
8181
export interface AuthFactorInfo {
82-
mfaEnrollmentId: string;
82+
// Not required for signupNewUser endpoint.
83+
mfaEnrollmentId?: string;
8384
displayName?: string;
8485
phoneInfo?: string;
8586
enrolledAt?: string;

src/auth/user-record.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ function parseDate(time: any): string {
4343
}
4444

4545
interface SecondFactor {
46-
uid: string;
46+
uid?: string;
4747
phoneNumber: string;
4848
displayName?: string;
4949
enrollmentTime?: string;
@@ -67,6 +67,9 @@ export interface UpdateRequest {
6767
/** Parameters for create user operation */
6868
export interface CreateRequest extends UpdateRequest {
6969
uid?: string;
70+
multiFactor?: {
71+
enrolledFactors: SecondFactor[];
72+
};
7073
}
7174

7275
export interface AuthFactorInfo {

src/index.d.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,7 @@ declare namespace admin.auth {
698698

699699
multiFactor?: {
700700
enrolledFactors: Array<{
701-
uid: string;
701+
uid?: string;
702702
phoneNumber: string;
703703
displayName?: string;
704704
enrollmentTime?: string;
@@ -717,6 +717,14 @@ declare namespace admin.auth {
717717
* The user's `uid`.
718718
*/
719719
uid?: string;
720+
721+
multiFactor?: {
722+
enrolledFactors: Array<{
723+
phoneNumber: string;
724+
displayName?: string;
725+
factorId: string;
726+
}>;
727+
};
720728
}
721729

722730
/**

test/integration/auth.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const expect = chai.expect;
4242

4343
const newUserUid = generateRandomString(20);
4444
const nonexistentUid = generateRandomString(20);
45+
const newMultiFactorUserUid = generateRandomString(20);
4546
const sessionCookieUids = [
4647
generateRandomString(20),
4748
generateRandomString(20),
@@ -135,6 +136,52 @@ describe('admin.auth', () => {
135136
});
136137
});
137138

139+
it('createUser() creates a new user with enrolled second factors', () => {
140+
const enrolledFactors = [
141+
{
142+
phoneNumber: '+16505550001',
143+
displayName: 'Work phone number',
144+
factorId: 'phone',
145+
},
146+
{
147+
phoneNumber: '+16505550002',
148+
displayName: 'Personal phone number',
149+
factorId: 'phone',
150+
},
151+
];
152+
const newUserData: any = {
153+
uid: newMultiFactorUserUid,
154+
email: generateRandomString(20).toLowerCase() + '@example.com',
155+
emailVerified: true,
156+
password: 'password',
157+
multiFactor: {
158+
enrolledFactors,
159+
},
160+
};
161+
return admin.auth().createUser(newUserData)
162+
.then((userRecord) => {
163+
expect(userRecord.uid).to.equal(newMultiFactorUserUid);
164+
// Confirm expected email.
165+
expect(userRecord.email).to.equal(newUserData.email);
166+
// Confirm second factors added to user.
167+
expect(userRecord.multiFactor.enrolledFactors.length).to.equal(2);
168+
// Confirm first enrolled second factor.
169+
const firstMultiFactor = userRecord.multiFactor.enrolledFactors[0];
170+
expect(firstMultiFactor.uid).not.to.be.undefined;
171+
expect(firstMultiFactor.enrollmentTime).not.to.be.undefined;
172+
expect(firstMultiFactor.phoneNumber).to.equal(enrolledFactors[0].phoneNumber);
173+
expect(firstMultiFactor.displayName).to.equal(enrolledFactors[0].displayName);
174+
expect(firstMultiFactor.factorId).to.equal(enrolledFactors[0].factorId);
175+
// Confirm second enrolled second factor.
176+
const secondMultiFactor = userRecord.multiFactor.enrolledFactors[1];
177+
expect(secondMultiFactor.uid).not.to.be.undefined;
178+
expect(secondMultiFactor.enrollmentTime).not.to.be.undefined;
179+
expect(secondMultiFactor.phoneNumber).to.equal(enrolledFactors[1].phoneNumber);
180+
expect(secondMultiFactor.displayName).to.equal(enrolledFactors[1].displayName);
181+
expect(secondMultiFactor.factorId).to.equal(enrolledFactors[1].factorId);
182+
});
183+
});
184+
138185
it('createUser() fails when the UID is already in use', () => {
139186
const newUserData: any = clone(mockUserData);
140187
newUserData.uid = newUserUid;
@@ -1220,6 +1267,7 @@ describe('admin.auth', () => {
12201267
it('deleteUser() deletes the user with the given UID', () => {
12211268
return Promise.all([
12221269
admin.auth().deleteUser(newUserUid),
1270+
admin.auth().deleteUser(newMultiFactorUserUid),
12231271
admin.auth().deleteUser(uidFromCreateUserWithoutUid),
12241272
]).should.eventually.be.fulfilled;
12251273
});

0 commit comments

Comments
 (0)