diff --git a/package-lock.json b/package-lock.json index 60a964a4bb..dc29746a59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4041,14 +4041,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4068,8 +4066,7 @@ "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", @@ -4217,7 +4214,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -4336,8 +4332,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 17b14dba91..3f26f87985 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, + UserImportResult, AuthFactorInfo, convertMultiFactorInfoToServerFormat, } from './user-import-builder'; import * as utils from '../utils/index'; import {ActionCodeSettings, ActionCodeSettingsBuilder} from './action-code-settings-builder'; @@ -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 { @@ -180,6 +190,72 @@ class TenantAwareAuthResourceUrlBuilder extends AuthResourceUrlBuilder { } +/** + * Validates an AuthFactorInfo object. All unsupported parameters + * are removed from the original request. If an invalid field is passed + * an error is thrown. + * + * @param request The AuthFactorInfo request object. + * @param writeOperationType The write operation type. + */ +function validateAuthFactorInfo(request: AuthFactorInfo, writeOperationType: WriteOperationType): void { + const validKeys = { + mfaEnrollmentId: true, + displayName: true, + phoneInfo: true, + enrolledAt: true, + }; + // Remove unsupported keys from the original request. + for (const key in request) { + if (!(key in validKeys)) { + delete request[key]; + } + } + // 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.`, + ); + } + if (typeof request.displayName !== 'undefined' && + !validator.isString(request.displayName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_DISPLAY_NAME, + `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 "${authFactorInfoIdentifier}" must be a valid ` + + `UTC date string.`); + } + // Validate required fields depending on second factor type. + if (typeof request.phoneInfo !== 'undefined') { + // phoneNumber should be a string and a valid phone number. + if (!validator.isPhoneNumber(request.phoneInfo)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_PHONE_NUMBER, + `The second factor "phoneNumber" for "${authFactorInfoIdentifier}" must be a non-empty ` + + `E.164 standard compliant identifier string.`); + } + } else { + // Invalid second factor. For example, a phone second factor may have been provided without + // a phone number. A TOTP based second factor may require a secret key, etc. + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLED_FACTORS, + `MFAInfo object provided is invalid.`); + } +} + + /** * Validates a providerUserInfo object. All unsupported parameters * are removed from the original request. If an invalid field is passed @@ -244,10 +320,11 @@ function validateProviderUserInfo(request: any): void { * 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 = false): void { +function validateCreateEditRequest(request: any, writeOperationType: WriteOperationType): void { + const uploadAccountRequest = writeOperationType === WriteOperationType.Upload; // Hash set of whitelisted parameters. const validKeys = { displayName: true, @@ -272,6 +349,9 @@ function validateCreateEditRequest(request: any, uploadAccountRequest = false): createdAt: uploadAccountRequest, lastLoginAt: uploadAccountRequest, providerUserInfo: uploadAccountRequest, + mfaInfo: uploadAccountRequest, + // Only for non-uploadAccount requests. + mfa: !uploadAccountRequest, }; // Remove invalid keys from original request. for (const key in request) { @@ -410,6 +490,23 @@ function validateCreateEditRequest(request: any, uploadAccountRequest = false): validateProviderUserInfo(providerUserInfoEntry); }); } + // mfaInfo is used for importUsers. + // mfa.enrollments is used for setAccountInfo. + // enrollments has to be an array of valid AuthFactorInfo requests. + let enrollments: AuthFactorInfo[] | null = null; + if (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); + } + enrollments.forEach((authFactorInfoEntry: AuthFactorInfo) => { + validateAuthFactorInfo(authFactorInfoEntry, writeOperationType); + }); + } } @@ -508,7 +605,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) => { @@ -545,7 +642,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) => { @@ -867,7 +964,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. @@ -1014,6 +1111,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; @@ -1078,6 +1197,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/auth.ts b/src/auth/auth.ts index a79b83c597..cbc22cd9d4 100755 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -70,6 +70,8 @@ export interface DecodedIdToken { [key: string]: any; }; sign_in_provider: string; + sign_in_second_factor?: string; + second_factor_identifier?: string; [key: string]: any; }; iat: number; diff --git a/src/auth/user-import-builder.ts b/src/auth/user-import-builder.ts old mode 100644 new mode 100755 index fdce19c764..97650ec227 --- 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 { @@ -60,12 +68,25 @@ export interface UserImportRecord { photoURL?: string; providerId: string; }>; + multiFactor?: { + enrolledFactors: SecondFactor[]; + }; customClaims?: object; passwordHash?: Buffer; passwordSalt?: Buffer; tenantId?: string; } +/** Interface representing an Auth second factor in Auth server format. */ +export interface AuthFactorInfo { + // Not required for signupNewUser endpoint. + mfaEnrollmentId?: string; + displayName?: string; + phoneInfo?: string; + enrolledAt?: string; + [key: string]: any; +} + /** UploadAccount endpoint request user interface. */ interface UploadAccountUser { @@ -83,6 +104,7 @@ interface UploadAccountUser { displayName?: string; photoUrl?: string; }>; + mfaInfo?: AuthFactorInfo[]; passwordHash?: string; salt?: string; lastLoginAt?: number; @@ -124,6 +146,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.UNSUPPORTED_SECOND_FACTOR, + `Unsupported second factor "${JSON.stringify(multiFactorInfo)}" provided.`); + } +} + + /** * @param {any} obj The object to check for number field within. * @param {string} key The entry key. @@ -155,6 +220,7 @@ function populateUploadAccountUser( photoUrl: user.photoURL, phoneNumber: user.phoneNumber, providerUserInfo: [], + mfaInfo: [], tenantId: user.tenantId, customAttributes: user.customClaims && JSON.stringify(user.customClaims), }; @@ -193,6 +259,15 @@ function populateUploadAccountUser( }); }); } + + // Convert user.multiFactor.enrolledFactors to server format. + if (validator.isNonNullObject(user.multiFactor) && + validator.isNonEmptyArray(user.multiFactor.enrolledFactors)) { + user.multiFactor.enrolledFactors.forEach((multiFactorInfo) => { + result.mfaInfo!.push(convertMultiFactorInfoToServerFormat(multiFactorInfo)); + }); + } + // Remove blank fields. let key: keyof UploadAccountUser; for (key in result) { @@ -203,6 +278,9 @@ function populateUploadAccountUser( if (result.providerUserInfo!.length === 0) { delete result.providerUserInfo; } + if (result.mfaInfo!.length === 0) { + delete result.mfaInfo; + } // Validate the constructured user individual request. This will throw if an error // is detected. if (typeof userValidator === 'function') { diff --git a/src/auth/user-record.ts b/src/auth/user-record.ts index 28f63fc972..64422c1969 100644 --- a/src/auth/user-record.ts +++ b/src/auth/user-record.ts @@ -15,6 +15,7 @@ */ import {deepCopy} from '../utils/deep-copy'; +import {isNonNullObject} from '../utils/validator'; import * as utils from '../utils'; import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; @@ -41,6 +42,42 @@ function parseDate(time: any): string | null { return null; } +/** + * Interface representing base properties of a user enrolled second factor for a + * `CreateRequest`. + */ +export interface CreateMultiFactorInfoRequest { + displayName?: string; + factorId: string; +} + +/** + * Interface representing a phone specific user enrolled second factor for a + * `CreateRequest`. + */ +export interface CreatePhoneMultiFactorInfoRequest extends CreateMultiFactorInfoRequest { + phoneNumber: string; +} + +/** + * Interface representing common properties of a user enrolled second factor + * for an `UpdateRequest`. + */ +export interface UpdateMultiFactorInfoRequest { + uid?: string; + displayName?: string; + enrollmentTime?: string; + factorId: string; +} + +/** + * Interface representing a phone specific user enrolled second factor + * for an `UpdateRequest`. + */ +export interface UpdatePhoneMultiFactorInfoRequest extends UpdateMultiFactorInfoRequest { + phoneNumber: string; +} + /** Parameters for update user operation */ export interface UpdateRequest { disabled?: boolean; @@ -50,18 +87,224 @@ export interface UpdateRequest { password?: string; phoneNumber?: string | null; photoURL?: string | null; + multiFactor?: { + enrolledFactors: UpdateMultiFactorInfoRequest[] | null; + }; } /** Parameters for create user operation */ export interface CreateRequest extends UpdateRequest { uid?: string; + multiFactor?: { + enrolledFactors: CreateMultiFactorInfoRequest[]; + }; +} + +export interface MultiFactorInfoResponse { + mfaEnrollmentId: string; + displayName?: string; + phoneInfo?: string; + enrolledAt?: string; + [key: string]: any; +} + +export interface ProviderUserInfoResponse { + rawId: string; + displayName?: string; + email?: string; + photoUrl?: string; + phoneNumber?: string; + providerId: string; + federatedId?: string; +} + +export interface GetAccountInfoUserResponse { + localId: string; + email?: string; + emailVerified?: boolean; + phoneNumber?: string; + displayName?: string; + photoUrl?: string; + disabled?: boolean; + passwordHash?: string; + salt?: string; + customAttributes?: string; + validSince?: string; + tenantId?: string; + providerUserInfo?: ProviderUserInfoResponse[]; + mfaInfo?: MultiFactorInfoResponse[]; + createdAt?: string; + lastLoginAt?: string; + [key: string]: any; +} + +/** Enums for multi-factor identifiers. */ +export enum MultiFactorId { + Phone = 'phone', +} + +/** + * Abstract class representing a multi-factor info interface. + */ +export abstract class MultiFactorInfo { + public readonly uid: string; + public readonly displayName: string | null; + public readonly factorId: MultiFactorId; + public readonly enrollmentTime: string; + + /** + * Initializes the MultiFactorInfo associated subclass using the server side. + * If no MultiFactorInfo is associated with the response, null is returned. + * + * @param response The server side response. + * @constructor + */ + public static initMultiFactorInfo(response: MultiFactorInfoResponse): MultiFactorInfo | null { + let multiFactorInfo: MultiFactorInfo | null = null; + // Only PhoneMultiFactorInfo currently available. + try { + multiFactorInfo = new PhoneMultiFactorInfo(response); + } catch (e) { + // Ignore error. + } + return multiFactorInfo; + } + + /** + * Initializes the MultiFactorInfo object using the server side response. + * + * @param response The server side response. + * @constructor + */ + constructor(response: MultiFactorInfoResponse) { + this.initFromServerResponse(response); + } + + /** @return The plain object representation. */ + public toJSON(): any { + return { + uid: this.uid, + displayName: this.displayName, + factorId: this.factorId, + enrollmentTime: this.enrollmentTime, + }; + } + + /** + * Returns the factor ID based on the response provided. + * + * @param response The server side response. + * @return The multi-factor ID associated with the provided response. If the response is + * not associated with any known multi-factor ID, null is returned. + */ + protected abstract getFactorId(response: MultiFactorInfoResponse): MultiFactorId | null; + + /** + * Initializes the MultiFactorInfo object using the provided server response. + * + * @param response The server side response. + */ + private initFromServerResponse(response: MultiFactorInfoResponse): void { + const factorId = response && this.getFactorId(response); + if (!factorId || !response || !response.mfaEnrollmentId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + } + utils.addReadonlyGetter(this, 'uid', response.mfaEnrollmentId); + utils.addReadonlyGetter(this, 'factorId', factorId); + utils.addReadonlyGetter(this, 'displayName', response.displayName || null); + // Encoded using [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. + // For example, "2017-01-15T01:30:15.01Z". + // This can be parsed directly via Date constructor. + // This can be computed using Data.prototype.toISOString. + if (response.enrolledAt) { + utils.addReadonlyGetter( + this, 'enrollmentTime', new Date(response.enrolledAt).toUTCString()); + } else { + utils.addReadonlyGetter(this, 'enrollmentTime', null); + } + } +} + +/** Class representing a phone MultiFactorInfo object. */ +export class PhoneMultiFactorInfo extends MultiFactorInfo { + public readonly phoneNumber: string; + + /** + * Initializes the PhoneMultiFactorInfo object using the server side response. + * + * @param response The server side response. + * @constructor + */ + constructor(response: MultiFactorInfoResponse) { + super(response); + utils.addReadonlyGetter(this, 'phoneNumber', response.phoneInfo); + } + + /** @return The plain object representation. */ + public toJSON(): any { + return Object.assign( + super.toJSON(), + { + phoneNumber: this.phoneNumber, + }); + } + + /** + * Returns the factor ID based on the response provided. + * + * @param response The server side response. + * @return The multi-factor ID associated with the provided response. If the response is + * not associated with any known multi-factor ID, null is returned. + */ + protected getFactorId(response: MultiFactorInfoResponse): MultiFactorId | null { + return (response && response.phoneInfo) ? MultiFactorId.Phone : null; + } +} + +/** Class representing multi-factor related properties of a user. */ +export class MultiFactor { + public readonly enrolledFactors: ReadonlyArray; + + /** + * Initializes the MultiFactor object using the server side or JWT format response. + * + * @param response The server side response. + * @constructor + */ + constructor(response: GetAccountInfoUserResponse) { + const parsedEnrolledFactors: MultiFactorInfo[] = []; + if (!isNonNullObject(response)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor response'); + } else if (response.mfaInfo) { + response.mfaInfo.forEach((factorResponse) => { + const multiFactorInfo = MultiFactorInfo.initMultiFactorInfo(factorResponse); + if (multiFactorInfo) { + parsedEnrolledFactors.push(multiFactorInfo); + } + }); + } + // Make enrolled factors immutable. + utils.addReadonlyGetter( + this, 'enrolledFactors', Object.freeze(parsedEnrolledFactors)); + } + + /** @return The plain object representation. */ + public toJSON(): any { + return { + enrolledFactors: this.enrolledFactors.map((info) => info.toJSON()), + }; + } } /** * User metadata class that provides metadata information like user account creation * and last sign in time. * - * @param {object} response The server side response returned from the getAccountInfo + * @param response The server side response returned from the getAccountInfo * endpoint. * @constructor */ @@ -69,7 +312,7 @@ export class UserMetadata { public readonly creationTime: string; public readonly lastSignInTime: string; - constructor(response: any) { + constructor(response: GetAccountInfoUserResponse) { // Creation date should always be available but due to some backend bugs there // were cases in the past where users did not have creation date properly set. // This included legacy Firebase migrating project users and some anonymous users. @@ -78,7 +321,7 @@ export class UserMetadata { utils.addReadonlyGetter(this, 'lastSignInTime', parseDate(response.lastLoginAt)); } - /** @return {object} The plain object representation of the user's metadata. */ + /** @return The plain object representation of the user's metadata. */ public toJSON(): object { return { lastSignInTime: this.lastSignInTime, @@ -91,7 +334,7 @@ export class UserMetadata { * User info class that provides provider user information for different * Firebase providers like google.com, facebook.com, password, etc. * - * @param {object} response The server side response returned from the getAccountInfo + * @param response The server side response returned from the getAccountInfo * endpoint. * @constructor */ @@ -103,7 +346,7 @@ export class UserInfo { public readonly providerId: string; public readonly phoneNumber: string; - constructor(response: any) { + constructor(response: ProviderUserInfoResponse) { // Provider user id and provider id are required. if (!response.rawId || !response.providerId) { throw new FirebaseAuthError( @@ -119,7 +362,7 @@ export class UserInfo { utils.addReadonlyGetter(this, 'phoneNumber', response.phoneNumber); } - /** @return {object} The plain object representation of the current provider data. */ + /** @return The plain object representation of the current provider data. */ public toJSON(): object { return { uid: this.uid, @@ -136,7 +379,7 @@ export class UserInfo { * User record class that defines the Firebase user object populated from * the Firebase Auth getAccountInfo response. * - * @param {any} response The server side response returned from the getAccountInfo + * @param response The server side response returned from the getAccountInfo * endpoint. * @constructor */ @@ -155,8 +398,9 @@ export class UserRecord { public readonly customClaims: object; public readonly tenantId?: string | null; public readonly tokensValidAfterTime?: string; + public readonly multiFactor?: MultiFactor; - constructor(response: any) { + constructor(response: GetAccountInfoUserResponse) { // The Firebase user id is required. if (!response.localId) { throw new FirebaseAuthError( @@ -189,23 +433,25 @@ export class UserRecord { } utils.addReadonlyGetter(this, 'passwordSalt', response.salt); - try { + if (response.customAttributes) { utils.addReadonlyGetter( this, 'customClaims', JSON.parse(response.customAttributes)); - } catch (e) { - // Ignore error. - utils.addReadonlyGetter(this, 'customClaims', undefined); } + let validAfterTime: string | null = null; // Convert validSince first to UTC milliseconds and then to UTC date string. if (typeof response.validSince !== 'undefined') { - validAfterTime = parseDate(response.validSince * 1000); + validAfterTime = parseDate(parseInt(response.validSince, 10) * 1000); } utils.addReadonlyGetter(this, 'tokensValidAfterTime', validAfterTime || undefined); utils.addReadonlyGetter(this, 'tenantId', response.tenantId); + const multiFactor = new MultiFactor(response); + if (multiFactor.enrolledFactors.length > 0) { + utils.addReadonlyGetter(this, 'multiFactor', multiFactor); + } } - /** @return {object} The plain object representation of the user record. */ + /** @return The plain object representation of the user record. */ public toJSON(): object { const json: any = { uid: this.uid, @@ -223,6 +469,9 @@ export class UserRecord { tokensValidAfterTime: this.tokensValidAfterTime, tenantId: this.tenantId, }; + if (this.multiFactor) { + json.multiFactor = this.multiFactor.toJSON(); + } json.providerData = []; for (const entry of this.providerData) { // Convert each provider data to json. diff --git a/src/index.d.ts b/src/index.d.ts index af8a938766..a246ac1475 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -547,6 +547,48 @@ declare namespace admin.auth { toJSON(): Object; } + /** + * Interface representing the common properties of a user enrolled second factor. + */ + interface MultiFactorInfo { + + /** + * The ID of the enrolled second factor. This ID is unique to the user. + */ + uid: string; + + /** + * The optional display name of the enrolled second factor. + */ + displayName?: string; + + /** + * The optional date the second factor was enrolled, formatted as a UTC string. + */ + enrollmentTime?: string; + + /** + * The type identifier of the second factor. For SMS second factors, this is `phone`. + */ + factorId: string; + + /** + * @return A JSON-serializable representation of this object. + */ + toJSON(): Object; + } + + /** + * Interface representing a phone specific user enrolled second factor. + */ + interface PhoneMultiFactorInfo extends MultiFactorInfo { + + /** + * The phone number associated with a phone second factor. + */ + phoneNumber: string; + } + /** * Interface representing a user. */ @@ -644,12 +686,69 @@ declare namespace admin.auth { */ tenantId?: string | null; + /** + * The multi-factor related properties for the current user, if available. + */ + multiFactor?: { + + /** + * List of second factors enrolled with the current user. + * Currently only phone second factors are supported. + */ + enrolledFactors: MultiFactorInfo[]; + + /** + * @return A JSON-serializable representation of this multi-factor object. + */ + toJSON(): Object; + }; + /** * @return A JSON-serializable representation of this object. */ toJSON(): Object; } + /** + * Interface representing common properties of a user enrolled second factor + * for an `UpdateRequest`. + */ + interface UpdateMultiFactorInfoRequest { + + /** + * The ID of the enrolled second factor. This ID is unique to the user. When not provided, + * a new one is provisioned by the Auth server. + */ + uid?: string; + + /** + * The optional display name for an enrolled second factor. + */ + displayName?: string; + + /** + * The optional date the second factor was enrolled, formatted as a UTC string. + */ + enrollmentTime?: string; + + /** + * The type identifier of the second factor. For SMS second factors, this is `phone`. + */ + factorId: string; + } + + /** + * Interface representing a phone specific user enrolled second factor + * for an `UpdateRequest`. + */ + interface UpdatePhoneMultiFactorInfoRequest extends UpdateMultiFactorInfoRequest { + + /** + * The phone number associated with a phone second factor. + */ + phoneNumber: string; + } + /** * Interface representing the properties to update on the provided user. */ @@ -690,6 +789,48 @@ declare namespace admin.auth { * The user's photo URL. */ photoURL?: string | null; + + /** + * The user's updated multi-factor related properties. + */ + multiFactor?: { + + /** + * The updated list of enrolled second factors. The provided list overwrites the user's + * existing list of second factors. + * When null is passed, all of the user's existing second factors are removed. + */ + enrolledFactors: UpdateMultiFactorInfoRequest[] | null; + }; + } + + /** + * Interface representing base properties of a user enrolled second factor for a + * `CreateRequest`. + */ + interface CreateMultiFactorInfoRequest { + + /** + * The optional display name for an enrolled second factor. + */ + displayName?: string; + + /** + * The type identifier of the second factor. For SMS second factors, this is `phone`. + */ + factorId: string; + } + + /** + * Interface representing a phone specific user enrolled second factor for a + * `CreateRequest`. + */ + interface CreatePhoneMultiFactorInfoRequest extends CreateMultiFactorInfoRequest { + + /** + * The phone number associated with a phone second factor. + */ + phoneNumber: string; } /** @@ -702,6 +843,17 @@ declare namespace admin.auth { * The user's `uid`. */ uid?: string; + + /** + * The user's multi-factor related properties. + */ + multiFactor?: { + + /** + * The user's list of enrolled second factors. + */ + enrolledFactors: CreateMultiFactorInfoRequest[]; + }; } /** @@ -768,6 +920,19 @@ declare namespace admin.auth { */ sign_in_provider: string; + /** + * The type identifier or `factorId` of the second factor, provided the + * ID token was obtained from a multi-factor authenticated user. + * For phone, this is `“phone”`. + */ + sign_in_second_factor?: string; + + /** + * The `uid` of the second factor used to sign in, provided the + * ID token was obtained from a multi-factor authenticated user. + */ + second_factor_identifier?: string; + /** * The ID of the tenant the user belongs to, if available. */ @@ -1005,6 +1170,17 @@ declare namespace admin.auth { * to the tenant corresponding to that `TenantAwareAuth` instance's tenant ID. */ tenantId?: string | null; + + /** + * The multi-factor related properties for the imported user if available. + */ + multiFactor?: { + + /** + * List of enrolled second factors on the user to import. + */ + enrolledFactors: MultiFactorInfo[]; + }; } /** diff --git a/src/utils/error.ts b/src/utils/error.ts index 9f1a8e479d..297447697c 100755 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -430,6 +430,14 @@ export class AuthClientErrorCode { code: 'invalid-email', message: 'The email address is improperly formatted.', }; + public static INVALID_ENROLLED_FACTORS = { + code: 'invalid-enrolled-factors', + message: 'The enrolled factors must be a valid array of MultiFactorInfo objects.', + }; + public static INVALID_ENROLLMENT_TIME = { + code: 'invalid-enrollment-time', + message: 'The second factor enrollment time must be a valid UTC date string.', + }; public static INVALID_HASH_ALGORITHM = { code: 'invalid-hash-algorithm', message: 'The hash algorithm must match one of the strings in the list of ' + @@ -558,6 +566,11 @@ export class AuthClientErrorCode { code: 'missing-display-name', message: 'The resource being created or edited is missing a valid display name.', }; + public static MISSING_EMAIL = { + code: 'missing-email', + message: 'The email is required for the specified action. For example, a multi-factor user ' + + 'requires a verified email.', + }; public static MISSING_IOS_BUNDLE_ID = { code: 'missing-ios-bundle-id', message: 'The request is missing an iOS Bundle ID.', @@ -616,6 +629,14 @@ export class AuthClientErrorCode { code: 'quota-exceeded', message: 'The project quota for the specified operation has been exceeded.', }; + public static SECOND_FACTOR_LIMIT_EXCEEDED = { + code: 'second-factor-limit-exceeded', + message: 'The maximum number of allowed second factors on a user has been exceeded.', + }; + public static SECOND_FACTOR_UID_ALREADY_EXISTS = { + code: 'second-factor-uid-already-exists', + message: 'The specified second factor "uid" already exists.', + }; public static SESSION_COOKIE_EXPIRED = { code: 'session-cookie-expired', message: 'The Firebase session cookie is expired.', @@ -637,10 +658,23 @@ export class AuthClientErrorCode { message: 'The domain of the continue URL is not whitelisted. Whitelist the domain in the ' + 'Firebase console.', }; + public static UNSUPPORTED_FIRST_FACTOR = { + code: 'unsupported-first-factor', + message: 'A multi-factor user requires a supported first factor.', + }; + public static UNSUPPORTED_SECOND_FACTOR = { + code: 'unsupported-second-factor', + message: 'The request specified an unsupported type of second factor.', + }; public static UNSUPPORTED_TENANT_OPERATION = { code: 'unsupported-tenant-operation', message: 'This operation is not supported in a multi-tenant context.', }; + public static UNVERIFIED_EMAIL = { + code: 'unverified-email', + message: 'A verified email is required for the specified action. For example, a multi-factor user ' + + 'requires a verified email.', + }; public static USER_NOT_FOUND = { code: 'user-not-found', message: 'There is no user record corresponding to the provided identifier.', @@ -803,6 +837,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { DUPLICATE_EMAIL: 'EMAIL_ALREADY_EXISTS', // uploadAccount provides a localId that already exists. DUPLICATE_LOCAL_ID: 'UID_ALREADY_EXISTS', + // Request specified a multi-factor enrollment ID that already exists. + DUPLICATE_MFA_ENROLLMENT_ID: 'SECOND_FACTOR_UID_ALREADY_EXISTS', // setAccountInfo email already exists. EMAIL_EXISTS: 'EMAIL_ALREADY_EXISTS', // Reserved claim name. @@ -841,6 +877,9 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { MISSING_CONFIG_ID: 'MISSING_PROVIDER_ID', // Missing tenant display name: This can be thrown on CreateTenant and UpdateTenant. MISSING_DISPLAY_NAME: 'MISSING_DISPLAY_NAME', + // Email is required for the specified action. For example a multi-factor user requires + // a verified email. + MISSING_EMAIL: 'MISSING_EMAIL', // Missing iOS bundle ID. MISSING_IOS_BUNDLE_ID: 'MISSING_IOS_BUNDLE_ID', // Missing OIDC issuer. @@ -865,6 +904,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND', // In multi-tenancy context: project creation quota exceeded. QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', + // Currently only 5 second factors can be set on the same user. + SECOND_FACTOR_LIMIT_EXCEEDED: 'SECOND_FACTOR_LIMIT_EXCEEDED', // Tenant not found. TENANT_NOT_FOUND: 'TENANT_NOT_FOUND', // Tenant ID mismatch. @@ -873,8 +914,15 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { TOKEN_EXPIRED: 'ID_TOKEN_EXPIRED', // Continue URL provided in ActionCodeSettings has a domain that is not whitelisted. UNAUTHORIZED_DOMAIN: 'UNAUTHORIZED_DOMAIN', + // A multi-factor user requires a supported first factor. + UNSUPPORTED_FIRST_FACTOR: 'UNSUPPORTED_FIRST_FACTOR', + // The request specified an unsupported type of second factor. + UNSUPPORTED_SECOND_FACTOR: 'UNSUPPORTED_SECOND_FACTOR', // Operation is not supported in a multi-tenant context. UNSUPPORTED_TENANT_OPERATION: 'UNSUPPORTED_TENANT_OPERATION', + // A verified email is required for the specified action. For example a multi-factor user + // requires a verified email. + UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL', // User on which action is to be performed is not found. USER_NOT_FOUND: 'USER_NOT_FOUND', // Password provided is too weak. diff --git a/src/utils/validator.ts b/src/utils/validator.ts old mode 100644 new mode 100755 index 9c24d78211..d5159ae836 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -186,6 +186,37 @@ export function isPhoneNumber(phoneNumber: any): boolean { } +/** + * Validates that a string is a valid ISO date string. + * + * @param dateString The string to validate. + * @return Whether the string is a valid ISO date string. + */ +export function isISODateString(dateString: any): boolean { + try { + return isNonEmptyString(dateString) && + (new Date(dateString).toISOString() === dateString); + } catch (e) { + return false; + } +} + + +/** + * Validates that a string is a valid UTC date string. + * + * @param dateString The string to validate. + * @return Whether the string is a valid UTC date string. + */ +export function isUTCDateString(dateString: any): boolean { + try { + return isNonEmptyString(dateString) && + (new Date(dateString).toUTCString() === dateString); + } catch (e) { + return false; + } +} + /** * Validates that a string is a valid web URL. diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 3bd65422cd..209b24ec14 100755 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -41,6 +41,7 @@ const expect = chai.expect; const newUserUid = generateRandomString(20); const nonexistentUid = generateRandomString(20); +const newMultiFactorUserUid = generateRandomString(20); const sessionCookieUids = [ generateRandomString(20), generateRandomString(20), @@ -138,6 +139,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 as admin.auth.PhoneMultiFactorInfo).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 as admin.auth.PhoneMultiFactorInfo).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; @@ -323,11 +370,32 @@ describe('admin.auth', () => { it('updateUser() updates the user record with the given parameters', () => { const updatedDisplayName = 'Updated User ' + newUserUid; + const now = new Date(1476235905000).toUTCString(); + // Update user with enrolled second factors. + 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; @@ -336,6 +404,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; }); }); @@ -1206,6 +1299,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; }); @@ -1554,6 +1648,64 @@ describe('admin.auth', () => { }).should.eventually.be.fulfilled; }); + it('successfully imports users with enrolled second factors', () => { + const uid = generateRandomString(20).toLowerCase(); + const email = uid + '@example.com'; + const now = new Date(1476235905000).toUTCString(); + importUserRecord = { + uid, + email, + emailVerified: true, + displayName: 'Test User', + disabled: false, + metadata: { + lastSignInTime: now, + creationTime: now, + }, + providerData: [ + { + uid: uid + '-facebook', + displayName: 'Facebook User', + email, + providerId: 'facebook.com', + }, + ], + multiFactor: { + 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, + }, + ], + }, + }; + uids.push(importUserRecord.uid); + + return admin.auth().importUsers([importUserRecord]) + .then((result) => { + expect(result.failureCount).to.equal(0); + expect(result.successCount).to.equal(1); + expect(result.errors.length).to.equal(0); + return admin.auth().getUser(uid); + }).then((userRecord) => { + // 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(importUserRecord.multiFactor.enrolledFactors); + }).should.eventually.be.fulfilled; + }); + it('fails when invalid users are provided', () => { const users = [ {uid: generateRandomString(20).toLowerCase(), phoneNumber: '+1error'}, diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index ca880eb7a3..5c44ad6185 100755 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -44,7 +44,7 @@ import { SAMLUpdateAuthProviderRequest, SAMLConfigServerResponse, } from '../../../src/auth/auth-config'; import {TenantOptions} from '../../../src/auth/tenant'; -import { UpdateRequest } from '../../../src/auth/user-record'; +import { UpdateRequest, UpdateMultiFactorInfoRequest } from '../../../src/auth/user-record'; chai.should(); chai.use(sinonChai); @@ -62,6 +62,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. @@ -1139,6 +1145,23 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { providerId: 'google.com', }, ], + multiFactor: { + enrolledFactors: [ + { + uid: 'mfaUid1', + phoneNumber: '+16505550001', + displayName: 'Corp phone number', + factorId: 'phone', + enrollmentTime: new Date().toUTCString(), + }, + { + uid: 'mfaUid2', + phoneNumber: '+16505550002', + displayName: 'Personal phone number', + factorId: 'phone', + }, + ], + }, customClaims: {admin: true}, // Tenant ID accepted on user batch upload. tenantId, @@ -1344,6 +1367,80 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }, {uid: 'user16', providerData: [{}]}, {email: 'user17@example.com'}, + { + uid: 'user18', + email: 'user18@example.com', + multiFactor: { + enrolledFactors: [ + { + // Invalid mfa enrollment ID. + uid: '', + factorId: 'phone', + phoneNumber: '+16505550001', + }, + ], + }, + }, + { + uid: 'user19', + email: 'user19@example.com', + multiFactor: { + enrolledFactors: [ + { + uid: 'mfaUid1', + factorId: 'phone', + // Invalid display name. + displayName: false, + phoneNumber: '+16505550002', + }, + ], + }, + }, + { + uid: 'user20', + email: 'user20@example.com', + multiFactor: { + enrolledFactors: [ + { + uid: 'mfaUid2', + factorId: 'phone', + // Invalid enrollment time. + enrollmentTime: 'invalid', + phoneNumber: '+16505550003', + }, + ], + }, + }, + { + uid: 'user21', + email: 'user21@example.com', + multiFactor: { + enrolledFactors: [ + { + uid: 'mfaUid3', + factorId: 'phone', + // Invalid phone number + phoneNumber: 'invalid', + enrollmentTime: new Date().toUTCString(), + }, + ], + }, + }, + { + uid: 'user22', + email: 'user22@example.com', + multiFactor: { + enrolledFactors: [ + { + uid: 'mfaUid3', + // Invalid factor ID. + factorId: 'invalid', + phoneNumber: '+16505550003', + enrollmentTime: new Date().toUTCString(), + }, + ], + }, + }, ] as any; const validOptions = { hash: { @@ -1402,6 +1499,42 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }, {index: 16, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)}, {index: 17, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_UID)}, + { + index: 18, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_UID, + `The second factor "uid" must be a valid non-empty string.`, + ), + }, + { + index: 19, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_DISPLAY_NAME, + `The second factor "displayName" for "mfaUid1" must be a valid string.`, + ), + }, + { + index: 20, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLMENT_TIME, + `The second factor "enrollmentTime" for "mfaUid2" must be a valid UTC date string.`, + ), + }, + { + index: 21, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_PHONE_NUMBER, + `The second factor "phoneNumber" for "mfaUid3" must be a non-empty ` + + `E.164 standard compliant identifier string.`, + ), + }, + { + index: 22, + error: new FirebaseAuthError( + AuthClientErrorCode.UNSUPPORTED_SECOND_FACTOR, + `Unsupported second factor "${JSON.stringify(testUsers[22].multiFactor.enrolledFactors[0])}" provided.`, + ), + }, ], }; const stub = sinon.stub(HttpClient.prototype, 'send'); @@ -1595,6 +1728,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'; @@ -1606,6 +1740,22 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { photoURL: 'http://localhost/1234/photo.png', password: 'password', phoneNumber: '+11234567890', + multiFactor: { + enrolledFactors: [ + { + uid: 'enrolledSecondFactor1', + phoneNumber: '+16505557348', + displayName: 'Spouse\'s phone number', + factorId: 'phone', + enrollmentTime: now.toUTCString(), + } as UpdateMultiFactorInfoRequest, + { + uid: 'enrolledSecondFactor2', + phoneNumber: '+16505551000', + factorId: 'phone', + } as UpdateMultiFactorInfoRequest, + ], + }, }; (validData as any).ignoredProperty = 'value'; const expectedValidData = { @@ -1617,11 +1767,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', @@ -1634,6 +1799,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', @@ -1644,6 +1810,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@', @@ -1740,6 +1911,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); @@ -1754,6 +1969,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.UNSUPPORTED_SECOND_FACTOR, + `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(() => { + 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'; @@ -2028,6 +2329,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, @@ -2038,6 +2352,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, @@ -2104,6 +2427,118 @@ 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. + const request: any = {uid, multiFactor: {enrolledFactors: null}}; + return requestHandler.createNewAccount(request) + .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.UNSUPPORTED_SECOND_FACTOR, + `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(() => { + 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( diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 5c453d18e8..c3a867011f 100755 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -103,6 +103,18 @@ function getValidGetAccountInfoResponse(tenantId?: string): {kind: string; users rawId: '+11234567890', }, ], + mfaInfo: [ + { + mfaEnrollmentId: 'enrolledSecondFactor1', + phoneInfo: '+16505557348', + displayName: 'Spouse\'s phone number', + enrolledAt: new Date().toISOString(), + }, + { + mfaEnrollmentId: 'enrolledSecondFactor2', + phoneInfo: '+16505551000', + }, + ], photoUrl: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', validSince: '1476136676', lastLoginAt: '1476235905000', diff --git a/test/unit/auth/user-import-builder.spec.ts b/test/unit/auth/user-import-builder.spec.ts old mode 100644 new mode 100755 index 91cccaf178..b94e22f1f4 --- a/test/unit/auth/user-import-builder.spec.ts +++ b/test/unit/auth/user-import-builder.spec.ts @@ -19,7 +19,10 @@ import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import {deepCopy} from '../../../src/utils/deep-copy'; -import {UserImportBuilder, ValidatorFunction, UserImportResult} from '../../../src/auth/user-import-builder'; +import { + UserImportBuilder, ValidatorFunction, UserImportResult, UserImportRecord, + UploadAccountRequest, +} from '../../../src/auth/user-import-builder'; import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; import {toWebSafeBase64} from '../../../src/utils'; @@ -31,7 +34,8 @@ chai.use(chaiAsPromised); const expect = chai.expect; describe('UserImportBuilder', () => { - const nowString = new Date().toUTCString(); + const now = new Date('2019-10-25T04:30:52.000Z'); + const nowString = now.toUTCString(); const userRequestValidator: ValidatorFunction = () => { // Do not throw an error. }; @@ -75,6 +79,28 @@ describe('UserImportBuilder', () => { passwordSalt: Buffer.from('NaCl'), }, {uid: '5678', phoneNumber: '+16505550101'}, + { + uid: '3456', + email: 'janedoe@example.com', + passwordHash: Buffer.from('password'), + passwordSalt: Buffer.from('NaCl'), + multiFactor: { + enrolledFactors: [ + { + uid: 'enrolledSecondFactor1', + phoneNumber: '+16505557348', + displayName: 'Spouse\'s phone number', + factorId: 'phone', + enrollmentTime: now.toUTCString(), + }, + { + uid: 'enrolledSecondFactor2', + phoneNumber: '+16505551000', + factorId: 'phone', + }, + ], + }, + }, ]; const expectedUsersRequest = [ { @@ -109,6 +135,24 @@ describe('UserImportBuilder', () => { localId: '5678', phoneNumber: '+16505550101', }, + { + localId: '3456', + email: 'janedoe@example.com', + passwordHash: toWebSafeBase64(Buffer.from('password')), + salt: toWebSafeBase64(Buffer.from('NaCl')), + mfaInfo: [ + { + mfaEnrollmentId: 'enrolledSecondFactor1', + phoneInfo: '+16505557348', + displayName: 'Spouse\'s phone number', + enrolledAt: now.toISOString(), + }, + { + mfaEnrollmentId: 'enrolledSecondFactor2', + phoneInfo: '+16505551000', + }, + ], + }, ]; const hmacAlgorithms = ['HMAC_SHA512', 'HMAC_SHA256', 'HMAC_SHA1', 'HMAC_MD5']; @@ -555,7 +599,9 @@ describe('UserImportBuilder', () => { const expectedRequest = { hashAlgorithm: algorithm, // The third user will be removed due to client side error. - users: [expectedUsersRequest[0], expectedUsersRequest[1]], + users: [ + expectedUsersRequest[0], expectedUsersRequest[1], expectedUsersRequest[3], + ], }; const userImportBuilder = new UserImportBuilder(users, validOptions as any, userRequestValidatorWithError); @@ -577,6 +623,77 @@ describe('UserImportBuilder', () => { new UserImportBuilder(noHashUsers, validOptions as any, userRequestValidator); expect(userImportBuilder.buildRequest()).to.deep.equal(expectedRequest); }); + + it('should return expected request with no multi-factor fields when not available', () => { + const noMultiFactorUsers: any[] = [ + {uid: '1234', email: 'user@example.com', multiFactor: null}, + {uid: '5678', phoneNumber: '+16505550101', multiFactor: {enrolledFactors: []}}, + ]; + const expectedRequest = { + users: [ + {localId: '1234', email: 'user@example.com'}, + {localId: '5678', phoneNumber: '+16505550101'}, + ], + }; + const userImportBuilder = + new UserImportBuilder(noMultiFactorUsers, validOptions as any, userRequestValidator); + expect(userImportBuilder.buildRequest()).to.deep.equal(expectedRequest); + }); + + it('should ignore users with invalid second factor enrollment time', () => { + const invalidMultiFactorUsers: UserImportRecord[] = [ + { + uid: '1234', + multiFactor: { + enrolledFactors: [ + { + uid: 'enrolledSecondFactor1', + phoneNumber: '+16505557348', + displayName: 'Spouse\'s phone number', + factorId: 'phone', + enrollmentTime: 'invalid', + }, + ], + }, + }, + {uid: '5678', phoneNumber: '+16505550102'}, + ]; + const expectedRequest: UploadAccountRequest = { + users: [ + {localId: '5678', phoneNumber: '+16505550102'}, + ], + }; + const userImportBuilder = + new UserImportBuilder(invalidMultiFactorUsers, validOptions as any, userRequestValidator); + expect(userImportBuilder.buildRequest()).to.deep.equal(expectedRequest); + }); + + it('should ignore users with unsupported second factors', () => { + const invalidMultiFactorUsers: any = [ + { + uid: '1234', + multiFactor: { + enrolledFactors: [ + { + uid: 'enrolledSecondFactor1', + secret: 'SECRET', + displayName: 'Google Authenticator on personal phone', + factorId: 'totp', + }, + ], + }, + }, + {uid: '5678', phoneNumber: '+16505550102'}, + ]; + const expectedRequest: UploadAccountRequest = { + users: [ + {localId: '5678', phoneNumber: '+16505550102'}, + ], + }; + const userImportBuilder = + new UserImportBuilder(invalidMultiFactorUsers, validOptions as any, userRequestValidator); + expect(userImportBuilder.buildRequest()).to.deep.equal(expectedRequest); + }); }); describe('buildResponse()', () => { @@ -586,10 +703,11 @@ describe('UserImportBuilder', () => { algorithm, }, }; + it('should return the expected response for successful import', () => { const successfulServerResponse: any = []; const successfulUserImportResponse: UserImportResult = { - successCount: 3, + successCount: 4, failureCount: 0, errors: [], }; @@ -604,7 +722,7 @@ describe('UserImportBuilder', () => { {index: 1, message: 'Some error occurred!'}, ]; const serverErrorUserImportResponse = { - successCount: 2, + successCount: 3, failureCount: 1, errors: [ { @@ -626,7 +744,7 @@ describe('UserImportBuilder', () => { it('should return the expected response for import with client side errors', () => { const successfulServerResponse: any = []; const clientErrorUserImportResponse: UserImportResult = { - successCount: 2, + successCount: 3, failureCount: 1, errors: [ {index: 2, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER)}, @@ -656,7 +774,8 @@ describe('UserImportBuilder', () => { // The second and fourth users will throw a client side error. // The third and sixth user will throw a server side error. - // Seventh and eighth user will throw a client side error due to invalid type provided. + // Seventh, eighth and nineth user will throw a client side error due to invalid type provided. + // Tenth user will throw a client side error due to an unsupported second factor. const testUsers = [ {uid: 'USER1'}, {uid: 'USER2', email: 'invalid', passwordHash: Buffer.from('userpass')}, @@ -671,10 +790,36 @@ describe('UserImportBuilder', () => { passwordHash: Buffer.from('password'), passwordSalt: 'not a buffer' as any, }, + { + uid: 'USER9', + multiFactor: { + enrolledFactors: [ + { + uid: 'enrollmentId1', + phoneNumber: '+16505551111', + factorId: 'phone', + enrollmentTime: 'invalid', + }, + ], + }, + }, + { + uid: 'USER10', + multiFactor: { + enrolledFactors: [ + { + uid: 'enrollmentId2', + secret: 'SECRET', + displayName: 'Google Authenticator on personal phone', + factorId: 'totp', + } as any, + ], + }, + }, ]; const mixedErrorUserImportResponse = { successCount: 2, - failureCount: 6, + failureCount: 8, errors: [ // Client side detected error. {index: 1, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL)}, @@ -699,6 +844,19 @@ describe('UserImportBuilder', () => { // Client side errors. {index: 6, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_HASH)}, {index: 7, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_SALT)}, + { + index: 8, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLMENT_TIME, + `The second factor "enrollmentTime" for "enrollmentId1" must be a valid ` + + `UTC date string.`), + }, + { + index: 9, + error: new FirebaseAuthError( + AuthClientErrorCode.UNSUPPORTED_SECOND_FACTOR, + `Unsupported second factor "${JSON.stringify(testUsers[9].multiFactor!.enrolledFactors[0])}" provided.`), + }, ], }; const userImportBuilder = new UserImportBuilder( diff --git a/test/unit/auth/user-record.spec.ts b/test/unit/auth/user-record.spec.ts index 77f079111a..fce2041ec6 100644 --- a/test/unit/auth/user-record.spec.ts +++ b/test/unit/auth/user-record.spec.ts @@ -19,7 +19,10 @@ import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import {deepCopy} from '../../../src/utils/deep-copy'; -import {UserInfo, UserMetadata, UserRecord} from '../../../src/auth/user-record'; +import { + UserInfo, UserMetadata, UserRecord, GetAccountInfoUserResponse, ProviderUserInfoResponse, + MultiFactor, PhoneMultiFactorInfo, MultiFactorInfo, MultiFactorInfoResponse, +} from '../../../src/auth/user-record'; chai.should(); @@ -27,13 +30,14 @@ chai.use(sinonChai); chai.use(chaiAsPromised); const expect = chai.expect; +const now = new Date(); /** - * @param {string=} tenantId The optional tenant ID to add to the response. - * @return {object} A sample valid user response as returned from getAccountInfo + * @param tenantId The optional tenant ID to add to the response. + * @return A sample valid user response as returned from getAccountInfo * endpoint. */ -function getValidUserResponse(tenantId?: string): {[key: string]: any} { +function getValidUserResponse(tenantId?: string): GetAccountInfoUserResponse { const response: any = { localId: 'abcdefghijklmnopqrstuvwxyz', email: 'user@gmail.com', @@ -79,6 +83,19 @@ function getValidUserResponse(tenantId?: string): {[key: string]: any} { customAttributes: JSON.stringify({ admin: true, }), + mfaInfo: [ + { + mfaEnrollmentId: 'enrollmentId1', + displayName: 'displayName1', + enrolledAt: now.toISOString(), + phoneInfo: '+16505551234', + }, + { + mfaEnrollmentId: 'enrollmentId2', + enrolledAt: now.toISOString(), + phoneInfo: '+16505556789', + }, + ], }; if (typeof tenantId !== 'undefined') { response.tenantId = tenantId; @@ -87,8 +104,8 @@ function getValidUserResponse(tenantId?: string): {[key: string]: any} { } /** - * @param {string=} tenantId The optional tenant ID to add to the user. - * @return {object} The expected user JSON representation for the above user + * @param tenantId The optional tenant ID to add to the user. + * @return The expected user JSON representation for the above user * server response. */ function getUserJSON(tenantId?: string): object { @@ -145,14 +162,32 @@ function getUserJSON(tenantId?: string): object { }, tokensValidAfterTime: new Date(1476136676000).toUTCString(), tenantId, + multiFactor: { + enrolledFactors: [ + { + uid: 'enrollmentId1', + displayName: 'displayName1', + enrollmentTime: now.toUTCString(), + phoneNumber: '+16505551234', + factorId: 'phone', + }, + { + uid: 'enrollmentId2', + displayName: null, + enrollmentTime: now.toUTCString(), + phoneNumber: '+16505556789', + factorId: 'phone', + }, + ], + }, }; } /** - * @return {object} A sample user info response as returned from getAccountInfo + * @return A sample user info response as returned from getAccountInfo * endpoint. */ -function getUserInfoResponse(): object { +function getUserInfoResponse(): ProviderUserInfoResponse { return { providerId: 'google.com', displayName: 'John Doe', @@ -164,7 +199,7 @@ function getUserInfoResponse(): object { } /** - * @return {object} The JSON representation of the above user info response. + * @return The JSON representation of the above user info response. */ function getUserInfoJSON(): object { return { @@ -178,10 +213,10 @@ function getUserInfoJSON(): object { } /** - * @return {object} A sample user info response with phone number as returned + * @return A sample user info response with phone number as returned * from getAccountInfo endpoint. */ -function getUserInfoWithPhoneNumberResponse(): object { +function getUserInfoWithPhoneNumberResponse(): ProviderUserInfoResponse { return { providerId: 'phone', phoneNumber: '+11234567890', @@ -190,7 +225,7 @@ function getUserInfoWithPhoneNumberResponse(): object { } /** - * @return {object} The JSON representation of the above user info response + * @return The JSON representation of the above user info response * with a phone number. */ function getUserInfoWithPhoneNumberJSON(): object { @@ -204,11 +239,277 @@ function getUserInfoWithPhoneNumberJSON(): object { }; } +describe('PhoneMultiFactorInfo', () => { + const serverResponse: MultiFactorInfoResponse = { + mfaEnrollmentId: 'enrollmentId1', + displayName: 'displayName1', + enrolledAt: now.toISOString(), + phoneInfo: '+16505551234', + }; + const phoneMultiFactorInfo = new PhoneMultiFactorInfo(serverResponse); + const phoneMultiFactorInfoMissingFields = new PhoneMultiFactorInfo({ + mfaEnrollmentId: serverResponse.mfaEnrollmentId, + phoneInfo: serverResponse.phoneInfo, + }); + + describe('constructor', () => { + it('should throw when an empty object is provided', () => { + expect(() => { + return new PhoneMultiFactorInfo({} as any); + }).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + }); + + it('should throw when an undefined response is provided', () => { + expect(() => { + return new PhoneMultiFactorInfo(undefined as any); + }).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + }); + + it('should succeed when mfaEnrollmentId and phoneInfo are both provided', () => { + expect(() => { + return new PhoneMultiFactorInfo({ + mfaEnrollmentId: 'enrollmentId1', + phoneInfo: '+16505551234', + }); + }).not.to.throw(Error); + }); + + it('should throw when only mfaEnrollmentId is provided', () => { + expect(() => { + return new PhoneMultiFactorInfo({ + mfaEnrollmentId: 'enrollmentId1', + } as any); + }).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + }); + + it('should throw when only phoneInfo is provided', () => { + expect(() => { + return new PhoneMultiFactorInfo({ + phoneInfo: '+16505551234', + } as any); + }).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + }); + }); + + describe('getters', () => { + it('should set missing optional fields to null', () => { + expect(phoneMultiFactorInfoMissingFields.uid).to.equal(serverResponse.mfaEnrollmentId); + expect(phoneMultiFactorInfoMissingFields.displayName).to.be.null; + expect(phoneMultiFactorInfoMissingFields.phoneNumber).to.equal(serverResponse.phoneInfo); + expect(phoneMultiFactorInfoMissingFields.enrollmentTime).to.be.null; + expect(phoneMultiFactorInfoMissingFields.factorId).to.equal('phone'); + }); + + it('should return expected factorId', () => { + expect(phoneMultiFactorInfo.factorId).to.equal('phone'); + }); + + it('should throw when modifying readonly factorId property', () => { + expect(() => { + (phoneMultiFactorInfo as any).factorId = 'other'; + }).to.throw(Error); + }); + + it('should return expected displayName', () => { + expect(phoneMultiFactorInfo.displayName).to.equal(serverResponse.displayName); + }); + + it('should throw when modifying readonly displayName property', () => { + expect(() => { + (phoneMultiFactorInfo as any).displayName = 'Modified'; + }).to.throw(Error); + }); + + it('should return expected phoneNumber', () => { + expect(phoneMultiFactorInfo.phoneNumber).to.equal(serverResponse.phoneInfo); + }); + + it('should throw when modifying readonly phoneNumber property', () => { + expect(() => { + (phoneMultiFactorInfo as any).phoneNumber = '+16505551111'; + }).to.throw(Error); + }); + + it('should return expected uid', () => { + expect(phoneMultiFactorInfo.uid).to.equal(serverResponse.mfaEnrollmentId); + }); + + it('should throw when modifying readonly uid property', () => { + expect(() => { + (phoneMultiFactorInfo as any).uid = 'modifiedEnrollmentId'; + }).to.throw(Error); + }); + + it('should return expected enrollmentTime', () => { + expect(phoneMultiFactorInfo.enrollmentTime).to.equal(now.toUTCString()); + }); + + it('should throw when modifying readonly uid property', () => { + expect(() => { + (phoneMultiFactorInfo as any).enrollmentTime = new Date().toISOString(); + }).to.throw(Error); + }); + }); + + describe('toJSON', () => { + it('should return expected JSON object', () => { + expect(phoneMultiFactorInfo.toJSON()).to.deep.equal({ + uid: 'enrollmentId1', + displayName: 'displayName1', + enrollmentTime: now.toUTCString(), + phoneNumber: '+16505551234', + factorId: 'phone', + }); + }); + + it('should return expected JSON object with missing fields set to null', () => { + expect(phoneMultiFactorInfoMissingFields.toJSON()).to.deep.equal({ + uid: 'enrollmentId1', + displayName: null, + enrollmentTime: null, + phoneNumber: '+16505551234', + factorId: 'phone', + }); + }); + }); +}); + +describe('MultiFactorInfo', () => { + const serverResponse: MultiFactorInfoResponse = { + mfaEnrollmentId: 'enrollmentId1', + displayName: 'displayName1', + enrolledAt: now.toISOString(), + phoneInfo: '+16505551234', + }; + const phoneMultiFactorInfo = new PhoneMultiFactorInfo(serverResponse); + + describe('initMultiFactorInfo', () => { + it('should return expected PhoneMultiFactorInfo', () => { + expect(MultiFactorInfo.initMultiFactorInfo(serverResponse)).to.deep.equal(phoneMultiFactorInfo); + }); + + it('should return null for invalid MultiFactorInfo', () => { + expect(MultiFactorInfo.initMultiFactorInfo(undefined as any)).to.be.null; + }); + }); +}); + +describe('MultiFactor', () => { + const serverResponse = { + localId: 'uid123', + mfaInfo: [ + { + mfaEnrollmentId: 'enrollmentId1', + displayName: 'displayName1', + enrolledAt: now.toISOString(), + phoneInfo: '+16505551234', + }, + { + mfaEnrollmentId: 'enrollmentId2', + enrolledAt: now.toISOString(), + phoneInfo: '+16505556789', + }, + { + // Invalid factor. + mfaEnrollmentId: 'enrollmentId3', + }, + { + // Unsupported factor. + mfaEnrollmentId: 'enrollmentId4', + displayName: 'Backup second factor', + enrolledAt: now.toISOString(), + secretKey: 'SECRET_KEY', + }, + ], + }; + const expectedMultiFactorInfo = [ + new PhoneMultiFactorInfo({ + mfaEnrollmentId: 'enrollmentId1', + displayName: 'displayName1', + enrolledAt: now.toISOString(), + phoneInfo: '+16505551234', + }), + new PhoneMultiFactorInfo({ + mfaEnrollmentId: 'enrollmentId2', + enrolledAt: now.toISOString(), + phoneInfo: '+16505556789', + }), + ]; + + describe('constructor', () => { + it('should throw when a non object is provided', () => { + expect(() => { + return new MultiFactor(undefined as any); + }).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor response'); + }); + + it('should populate an empty enrolledFactors array when given an empty object', () => { + const multiFactor = new MultiFactor({} as any); + + expect(multiFactor.enrolledFactors.length).to.equal(0); + }); + + it('should populate expected enrolledFactors', () => { + const multiFactor = new MultiFactor(serverResponse); + + expect(multiFactor.enrolledFactors.length).to.equal(2); + expect(multiFactor.enrolledFactors[0]).to.deep.equal(expectedMultiFactorInfo[0]); + expect(multiFactor.enrolledFactors[1]).to.deep.equal(expectedMultiFactorInfo[1]); + }); + }); + + describe('getter', () => { + it('should throw when modifying readonly enrolledFactors property', () => { + const multiFactor = new MultiFactor(serverResponse); + + expect(() => { + (multiFactor as any).enrolledFactors = [ + expectedMultiFactorInfo[0], + ]; + }).to.throw(Error); + }); + + it('should throw when modifying readonly enrolledFactors internals', () => { + const multiFactor = new MultiFactor(serverResponse); + + expect(() => { + (multiFactor.enrolledFactors as any)[0] = new PhoneMultiFactorInfo({ + mfaEnrollmentId: 'enrollmentId3', + displayName: 'displayName3', + enrolledAt: now.toISOString(), + phoneInfo: '+16505559999', + }); + }).to.throw(Error); + }); + }); + + describe('toJSON', () => { + it('should return expected JSON object when given an empty response', () => { + const multiFactor = new MultiFactor({} as any); + + expect(multiFactor.toJSON()).to.deep.equal({ + enrolledFactors: [], + }); + }); + + it('should return expected JSON object when given a populated response', () => { + const multiFactor = new MultiFactor(serverResponse); + + expect(multiFactor.toJSON()).to.deep.equal({ + enrolledFactors: [ + expectedMultiFactorInfo[0].toJSON(), + expectedMultiFactorInfo[1].toJSON(), + ], + }); + }); + }); +}); + describe('UserInfo', () => { describe('constructor', () => { it('should throw when an empty object is provided', () => { expect(() => { - return new UserInfo({}); + return new UserInfo({} as any); }).to.throw(Error); }); @@ -220,13 +521,13 @@ describe('UserInfo', () => { it('should throw when only rawId is provided', () => { expect(() => { - return new UserInfo({rawId: '1234567890'}); + return new UserInfo({rawId: '1234567890'} as any); }).to.throw(Error); }); it('should throw when only providerId is provided', () => { expect(() => { - return new UserInfo({providerId: 'google.com'}); + return new UserInfo({providerId: 'google.com'} as any); }).to.throw(Error); }); }); @@ -316,8 +617,9 @@ describe('UserMetadata', () => { const expectedLastLoginAt = 1476235905000; const expectedCreatedAt = 1476136676000; const actualMetadata: UserMetadata = new UserMetadata({ - lastLoginAt: expectedLastLoginAt, - createdAt: expectedCreatedAt, + localId: 'uid123', + lastLoginAt: expectedLastLoginAt.toString(), + createdAt: expectedCreatedAt.toString(), }); const expectedMetadataJSON = { lastSignInTime: new Date(expectedLastLoginAt).toUTCString(), @@ -327,12 +629,12 @@ describe('UserMetadata', () => { describe('constructor', () => { it('should initialize as expected when a valid creationTime is provided', () => { expect(() => { - return new UserMetadata({createdAt: '1476136676000'}); + return new UserMetadata({createdAt: '1476136676000'} as any); }).not.to.throw(Error); }); it('should set creationTime and lastSignInTime to null when not provided', () => { - const metadata = new UserMetadata({}); + const metadata = new UserMetadata({} as any); expect(metadata.creationTime).to.be.null; expect(metadata.lastSignInTime).to.be.null; }); @@ -340,7 +642,7 @@ describe('UserMetadata', () => { it('should set creationTime to null when creationTime value is invalid', () => { const metadata = new UserMetadata({ createdAt: 'invalid', - }); + } as any); expect(metadata.creationTime).to.be.null; expect(metadata.lastSignInTime).to.be.null; }); @@ -349,7 +651,7 @@ describe('UserMetadata', () => { const metadata = new UserMetadata({ createdAt: '1476235905000', lastLoginAt: 'invalid', - }); + } as any); expect(metadata.lastSignInTime).to.be.null; }); }); @@ -387,7 +689,7 @@ describe('UserRecord', () => { describe('constructor', () => { it('should throw when no localId is provided', () => { expect(() => { - return new UserRecord({}); + return new UserRecord({} as any); }).to.throw(Error); }); @@ -570,7 +872,7 @@ describe('UserRecord', () => { const metadata = new UserMetadata({ createdAt: '1476136676000', lastLoginAt: '1476235905000', - }); + } as any); expect(userRecord.metadata).to.deep.equal(metadata); }); @@ -579,7 +881,7 @@ describe('UserRecord', () => { (userRecord as any).metadata = new UserMetadata({ createdAt: new Date().toUTCString(), lastLoginAt: new Date().toUTCString(), - }); + } as any); }).to.throw(Error); }); @@ -648,18 +950,80 @@ describe('UserRecord', () => { }); it('should return expected tenantId', () => { - const resp = deepCopy(getValidUserResponse('TENANT-ID')); + const resp = deepCopy(getValidUserResponse('TENANT-ID')) as GetAccountInfoUserResponse; const tenantUserRecord = new UserRecord(resp); expect(tenantUserRecord.tenantId).to.equal('TENANT-ID'); }); it('should throw when modifying readonly tenantId property', () => { expect(() => { - const resp = deepCopy(getValidUserResponse('TENANT-ID')); + const resp = deepCopy(getValidUserResponse('TENANT-ID')) as GetAccountInfoUserResponse; const tenantUserRecord = new UserRecord(resp); (tenantUserRecord as any).tenantId = 'OTHER-TENANT-ID'; }).to.throw(Error); }); + + it('should return expected multiFactor', () => { + const multiFactor = new MultiFactor({ + localId: 'uid123', + mfaInfo: [ + { + mfaEnrollmentId: 'enrollmentId1', + displayName: 'displayName1', + enrolledAt: now.toISOString(), + phoneInfo: '+16505551234', + }, + { + mfaEnrollmentId: 'enrollmentId2', + enrolledAt: now.toISOString(), + phoneInfo: '+16505556789', + }, + ], + }); + expect(userRecord.multiFactor).to.deep.equal(multiFactor); + expect(userRecord.multiFactor!.enrolledFactors.length).to.equal(2); + }); + + it('should return undefined multiFactor when not available', () => { + const validUserResponseWithoutMultiFactor = deepCopy(validUserResponse); + delete validUserResponseWithoutMultiFactor.mfaInfo; + const userRecordWithoutMultiFactor = new UserRecord(validUserResponseWithoutMultiFactor); + + expect(userRecordWithoutMultiFactor.multiFactor).to.be.undefined; + }); + + it('should throw when modifying readonly multiFactor property', () => { + expect(() => { + (userRecord as any).multiFactor = new MultiFactor({ + localId: 'uid123', + mfaInfo: [{ + mfaEnrollmentId: 'enrollmentId3', + displayName: 'displayName3', + enrolledAt: now.toISOString(), + phoneInfo: '+16505550000', + }], + }); + }).to.throw(Error); + }); + + it('should throw when modifying readonly multiFactor internals', () => { + expect(() => { + (userRecord.multiFactor!.enrolledFactors[0] as any).displayName = 'Modified'; + }).to.throw(Error); + + expect(() => { + (userRecord.multiFactor!.enrolledFactors as any)[0] = new PhoneMultiFactorInfo({ + mfaEnrollmentId: 'enrollmentId3', + displayName: 'displayName3', + enrolledAt: now.toISOString(), + phoneInfo: '+16505550000', + }); + }).to.throw(Error); + + expect(() => { + (userRecord.multiFactor as any).enrolledFactors = []; + }).to.throw(Error); + }); }); describe('toJSON', () => { @@ -676,7 +1040,7 @@ describe('UserRecord', () => { }); it('should return expected JSON object with tenant ID when available', () => { - const resp = deepCopy(getValidUserResponse('TENANT-ID')); + const resp = deepCopy(getValidUserResponse('TENANT-ID') as GetAccountInfoUserResponse); const tenantUserRecord = new UserRecord(resp); expect(tenantUserRecord.toJSON()).to.deep.equal(getUserJSON('TENANT-ID')); }); diff --git a/test/unit/utils/index.spec.ts b/test/unit/utils/index.spec.ts index 705afab3a9..2065e2fb49 100755 --- a/test/unit/utils/index.spec.ts +++ b/test/unit/utils/index.spec.ts @@ -214,6 +214,63 @@ describe('findProjectId()', () => { }); }); +describe('findProjectId()', () => { + let googleCloudProject: string | undefined; + let gcloudProject: string | undefined; + + before(() => { + googleCloudProject = process.env.GOOGLE_CLOUD_PROJECT; + gcloudProject = process.env.GCLOUD_PROJECT; + }); + + after(() => { + if (isNonEmptyString(googleCloudProject)) { + process.env.GOOGLE_CLOUD_PROJECT = googleCloudProject; + } else { + delete process.env.GOOGLE_CLOUD_PROJECT; + } + + if (isNonEmptyString(gcloudProject)) { + process.env.GCLOUD_PROJECT = gcloudProject; + } else { + delete process.env.GCLOUD_PROJECT; + } + }); + + it('should return the explicitly specified project ID from app options', () => { + const options: FirebaseAppOptions = { + credential: new mocks.MockCredential(), + projectId: 'explicit-project-id', + }; + const app: FirebaseApp = mocks.appWithOptions(options); + return findProjectId(app).should.eventually.equal(options.projectId); + }); + + it('should return the project ID from service account', () => { + const app: FirebaseApp = mocks.app(); + return findProjectId(app).should.eventually.equal('project_id'); + }); + + it('should return the project ID set in GOOGLE_CLOUD_PROJECT environment variable', () => { + process.env.GOOGLE_CLOUD_PROJECT = 'env-var-project-id'; + const app: FirebaseApp = mocks.mockCredentialApp(); + return findProjectId(app).should.eventually.equal('env-var-project-id'); + }); + + it('should return the project ID set in GCLOUD_PROJECT environment variable', () => { + process.env.GCLOUD_PROJECT = 'env-var-project-id'; + const app: FirebaseApp = mocks.mockCredentialApp(); + return findProjectId(app).should.eventually.equal('env-var-project-id'); + }); + + it('should return null when project ID is not set', () => { + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GCLOUD_PROJECT; + const app: FirebaseApp = mocks.mockCredentialApp(); + return findProjectId(app).should.eventually.be.null; + }); +}); + describe('formatString()', () => { it('should keep string as is if not parameters are provided', () => { const str = 'projects/{projectId}/{api}/path/api/projectId'; diff --git a/test/unit/utils/validator.spec.ts b/test/unit/utils/validator.spec.ts old mode 100644 new mode 100755 index 42f4882731..c8ab764f93 --- a/test/unit/utils/validator.spec.ts +++ b/test/unit/utils/validator.spec.ts @@ -22,6 +22,7 @@ import * as chaiAsPromised from 'chai-as-promised'; import { isArray, isNonEmptyArray, isBoolean, isNumber, isString, isNonEmptyString, isNonNullObject, isEmail, isPassword, isURL, isUid, isPhoneNumber, isObject, isBuffer, + isUTCDateString, isISODateString, } from '../../../src/utils/validator'; @@ -474,3 +475,57 @@ describe('isBuffer()', () => { expect(isBuffer(Buffer.from('I am a buffer'))).to.be.true; }); }); + +describe('isUTCDateString()', () => { + const validUTCDateString = 'Fri, 25 Oct 2019 04:01:21 GMT'; + it('should return false given no argument', () => { + expect(isUTCDateString(undefined as any)).to.be.false; + }); + + const nonUTCDateStrings = [ + null, NaN, 0, 1, true, false, [], ['a'], {}, { a: 1 }, _.noop, + new Date().getTime(), + new Date().getTime().toString(), + new Date().toISOString(), + 'Fri, 25 Oct 2019 04:01:21', + '25 Oct 2019', 'Fri, 25 Oct 2019', + '2019-10-25', '2019-10-25T04:07:34.036', + new Date().toDateString(), + ]; + nonUTCDateStrings.forEach((nonUTCDateString) => { + it('should return false given an invalid UTC date string: ' + JSON.stringify(nonUTCDateString), () => { + expect(isUTCDateString(nonUTCDateString as any)).to.be.false; + }); + }); + + it('should return true given a valid UTC date string', () => { + expect(isUTCDateString(validUTCDateString)).to.be.true; + }); +}); + +describe('isISODateString()', () => { + const validISODateString = '2019-10-25T04:07:34.036Z'; + it('should return false given no argument', () => { + expect(isISODateString(undefined as any)).to.be.false; + }); + + const nonISODateStrings = [ + null, NaN, 0, 1, true, false, [], ['a'], {}, { a: 1 }, _.noop, + new Date().getTime(), + new Date().getTime().toString(), + new Date().toUTCString(), + 'Fri, 25 Oct 2019 04:01:21', + '25 Oct 2019', 'Fri, 25 Oct 2019', + '2019-10-25', '2019-10-25T04:07:34.036', + new Date().toDateString(), + ]; + nonISODateStrings.forEach((nonISODateString) => { + it('should return false given an invalid ISO date string: ' + JSON.stringify(nonISODateString), () => { + expect(isISODateString(nonISODateString as any)).to.be.false; + }); + }); + + it('should return true given a valid ISO date string', () => { + expect(isISODateString(validISODateString)).to.be.true; + }); +});