Skip to content

Extends createUser to support multi-factor user creation. #718

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 3 commits into from
Dec 10, 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
67 changes: 55 additions & 12 deletions src/auth/auth-api-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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.`,
Expand All @@ -181,15 +197,15 @@ 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.
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 ` +
`The second factor "enrollmentTime" for "${authFactorInfoIdentifier}" must be a valid ` +
`UTC date string.`);
}
// Validate required fields depending on second factor type.
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
}
}
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/auth/user-import-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/auth/user-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function parseDate(time: any): string {
}

interface SecondFactor {
uid: string;
uid?: string;
phoneNumber: string;
displayName?: string;
enrollmentTime?: string;
Expand All @@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,7 @@ declare namespace admin.auth {

multiFactor?: {
enrolledFactors: Array<{
uid: string;
uid?: string;
phoneNumber: string;
displayName?: string;
enrollmentTime?: string;
Expand All @@ -717,6 +717,14 @@ declare namespace admin.auth {
* The user's `uid`.
*/
uid?: string;

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

/**
Expand Down
48 changes: 48 additions & 0 deletions test/integration/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
});
Expand Down
Loading