From 4a16294c08fdd4f01fd2f3545d50af7b34609d65 Mon Sep 17 00:00:00 2001 From: Pragati Date: Tue, 22 Nov 2022 18:49:49 -0800 Subject: [PATCH] Adding TOTP support for MFA --- package-lock.json | 2 +- src/auth/auth-api-request.ts | 158 ++++++++++++++------------- src/auth/auth-config.ts | 170 +++++++++++++++++++++-------- src/auth/project-config.ts | 12 ++ test/integration/auth.spec.ts | 24 ++++ test/unit/auth/auth-config.spec.ts | 20 +++- test/unit/auth/tenant.spec.ts | 42 +++++-- 7 files changed, 294 insertions(+), 134 deletions(-) diff --git a/package-lock.json b/package-lock.json index 929ff06ab3..b2ff3d7b80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10315,4 +10315,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 2893d49a9d..943ac7931b 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -34,7 +34,7 @@ import { ActionCodeSettings, ActionCodeSettingsBuilder } from './action-code-set import { Tenant, TenantServerResponse, CreateTenantRequest, UpdateTenantRequest } from './tenant'; import { isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier, - UserIdentifier, UidIdentifier, EmailIdentifier,PhoneIdentifier, ProviderIdentifier, + UserIdentifier, UidIdentifier, EmailIdentifier, PhoneIdentifier, ProviderIdentifier, } from './identifier'; import { SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, @@ -89,7 +89,7 @@ const MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE = 100; /** The Firebase Auth backend base URL format. */ const FIREBASE_AUTH_BASE_URL_FORMAT = - 'https://identitytoolkit.googleapis.com/{version}/projects/{projectId}{api}'; + 'https://identitytoolkit.googleapis.com/{version}/projects/{projectId}{api}'; /** Firebase Auth base URlLformat when using the auth emultor. */ const FIREBASE_AUTH_EMULATOR_BASE_URL_FORMAT = @@ -173,8 +173,8 @@ class AuthResourceUrlBuilder { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CREDENTIAL, 'Failed to determine project ID for Auth. Initialize the ' - + 'SDK with service account credentials or set project ID as an app option. ' - + 'Alternatively set the GOOGLE_CLOUD_PROJECT environment variable.', + + 'SDK with service account credentials or set project ID as an app option. ' + + 'Alternatively set the GOOGLE_CLOUD_PROJECT environment variable.', ); } @@ -260,17 +260,17 @@ function validateAuthFactorInfo(request: AuthFactorInfo): void { } // No enrollment ID is available for signupNewUser. Use another identifier. const authFactorInfoIdentifier = - request.mfaEnrollmentId || request.phoneInfo || JSON.stringify(request); + request.mfaEnrollmentId || request.phoneInfo || JSON.stringify(request); // Enrollment uid may or may not be specified for update operations. if (typeof request.mfaEnrollmentId !== 'undefined' && - !validator.isNonEmptyString(request.mfaEnrollmentId)) { + !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)) { + !validator.isString(request.displayName)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_DISPLAY_NAME, `The second factor "displayName" for "${authFactorInfoIdentifier}" must be a valid string.`, @@ -278,7 +278,7 @@ function validateAuthFactorInfo(request: AuthFactorInfo): void { } // enrolledAt must be a valid UTC date string. if (typeof request.enrolledAt !== 'undefined' && - !validator.isISODateString(request.enrolledAt)) { + !validator.isISODateString(request.enrolledAt)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ENROLLMENT_TIME, `The second factor "enrollmentTime" for "${authFactorInfoIdentifier}" must be a valid ` + @@ -328,7 +328,7 @@ function validateProviderUserInfo(request: any): void { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); } if (typeof request.displayName !== 'undefined' && - typeof request.displayName !== 'string') { + typeof request.displayName !== 'string') { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_DISPLAY_NAME, `The provider "displayName" for "${request.providerId}" must be a valid string.`, @@ -351,7 +351,7 @@ function validateProviderUserInfo(request: any): void { } // photoUrl should be a URL. if (typeof request.photoUrl !== 'undefined' && - !validator.isURL(request.photoUrl)) { + !validator.isURL(request.photoUrl)) { // This is called photoUrl on the backend but the developer specifies this as // photoURL externally. So the error message should use the client facing name. throw new FirebaseAuthError( @@ -409,17 +409,17 @@ function validateCreateEditRequest(request: any, writeOperationType: WriteOperat } } if (typeof request.tenantId !== 'undefined' && - !validator.isNonEmptyString(request.tenantId)) { + !validator.isNonEmptyString(request.tenantId)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); } // For any invalid parameter, use the external key name in the error description. // displayName should be a string. if (typeof request.displayName !== 'undefined' && - !validator.isString(request.displayName)) { + !validator.isString(request.displayName)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_DISPLAY_NAME); } if ((typeof request.localId !== 'undefined' || uploadAccountRequest) && - !validator.isUid(request.localId)) { + !validator.isUid(request.localId)) { // This is called localId on the backend but the developer specifies this as // uid externally. So the error message should use the client facing name. throw new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); @@ -430,56 +430,56 @@ function validateCreateEditRequest(request: any, writeOperationType: WriteOperat } // phoneNumber should be a string and a valid phone number. if (typeof request.phoneNumber !== 'undefined' && - !validator.isPhoneNumber(request.phoneNumber)) { + !validator.isPhoneNumber(request.phoneNumber)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER); } // password should be a string and a minimum of 6 chars. if (typeof request.password !== 'undefined' && - !validator.isPassword(request.password)) { + !validator.isPassword(request.password)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD); } // rawPassword should be a string and a minimum of 6 chars. if (typeof request.rawPassword !== 'undefined' && - !validator.isPassword(request.rawPassword)) { + !validator.isPassword(request.rawPassword)) { // This is called rawPassword on the backend but the developer specifies this as // password externally. So the error message should use the client facing name. throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD); } // emailVerified should be a boolean. if (typeof request.emailVerified !== 'undefined' && - typeof request.emailVerified !== 'boolean') { + typeof request.emailVerified !== 'boolean') { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL_VERIFIED); } // photoUrl should be a URL. if (typeof request.photoUrl !== 'undefined' && - !validator.isURL(request.photoUrl)) { + !validator.isURL(request.photoUrl)) { // This is called photoUrl on the backend but the developer specifies this as // photoURL externally. So the error message should use the client facing name. throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PHOTO_URL); } // disabled should be a boolean. if (typeof request.disabled !== 'undefined' && - typeof request.disabled !== 'boolean') { + typeof request.disabled !== 'boolean') { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_DISABLED_FIELD); } // validSince should be a number. if (typeof request.validSince !== 'undefined' && - !validator.isNumber(request.validSince)) { + !validator.isNumber(request.validSince)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TOKENS_VALID_AFTER_TIME); } // createdAt should be a number. if (typeof request.createdAt !== 'undefined' && - !validator.isNumber(request.createdAt)) { + !validator.isNumber(request.createdAt)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_CREATION_TIME); } // lastSignInAt should be a number. if (typeof request.lastLoginAt !== 'undefined' && - !validator.isNumber(request.lastLoginAt)) { + !validator.isNumber(request.lastLoginAt)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_LAST_SIGN_IN_TIME); } // disableUser should be a boolean. if (typeof request.disableUser !== 'undefined' && - typeof request.disableUser !== 'boolean') { + typeof request.disableUser !== 'boolean') { // This is called disableUser on the backend but the developer specifies this as // disabled externally. So the error message should use the client facing name. throw new FirebaseAuthError(AuthClientErrorCode.INVALID_DISABLED_FIELD); @@ -522,17 +522,17 @@ function validateCreateEditRequest(request: any, writeOperationType: WriteOperat } // passwordHash has to be a base64 encoded string. if (typeof request.passwordHash !== 'undefined' && - !validator.isString(request.passwordHash)) { + !validator.isString(request.passwordHash)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_HASH); } // salt has to be a base64 encoded string. if (typeof request.salt !== 'undefined' && - !validator.isString(request.salt)) { + !validator.isString(request.salt)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_SALT); } // providerUserInfo has to be an array of valid UserInfo requests. if (typeof request.providerUserInfo !== 'undefined' && - !validator.isArray(request.providerUserInfo)) { + !validator.isArray(request.providerUserInfo)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_DATA); } else if (validator.isArray(request.providerUserInfo)) { request.providerUserInfo.forEach((providerUserInfoEntry: any) => { @@ -571,27 +571,27 @@ function validateCreateEditRequest(request: any, writeOperationType: WriteOperat * @internal */ export const FIREBASE_AUTH_CREATE_SESSION_COOKIE = - new ApiSettings(':createSessionCookie', 'POST') + new ApiSettings(':createSessionCookie', 'POST') // Set request validator. - .setRequestValidator((request: any) => { - // Validate the ID token is a non-empty string. - if (!validator.isNonEmptyString(request.idToken)) { - throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN); - } - // Validate the custom session cookie duration. - if (!validator.isNumber(request.validDuration) || - request.validDuration < MIN_SESSION_COOKIE_DURATION_SECS || - request.validDuration > MAX_SESSION_COOKIE_DURATION_SECS) { - throw new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION); - } - }) + .setRequestValidator((request: any) => { + // Validate the ID token is a non-empty string. + if (!validator.isNonEmptyString(request.idToken)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN); + } + // Validate the custom session cookie duration. + if (!validator.isNumber(request.validDuration) || + request.validDuration < MIN_SESSION_COOKIE_DURATION_SECS || + request.validDuration > MAX_SESSION_COOKIE_DURATION_SECS) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION); + } + }) // Set response validator. - .setResponseValidator((response: any) => { - // Response should always contain the session cookie. - if (!validator.isNonEmptyString(response.sessionCookie)) { - throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); - } - }); + .setResponseValidator((response: any) => { + // Response should always contain the session cookie. + if (!validator.isNonEmptyString(response.sessionCookie)) { + throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); + } + }); /** @@ -612,13 +612,13 @@ export const FIREBASE_AUTH_DOWNLOAD_ACCOUNT = new ApiSettings('/accounts:batchGe .setRequestValidator((request: any) => { // Validate next page token. if (typeof request.nextPageToken !== 'undefined' && - !validator.isNonEmptyString(request.nextPageToken)) { + !validator.isNonEmptyString(request.nextPageToken)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PAGE_TOKEN); } // Validate max results. if (!validator.isNumber(request.maxResults) || - request.maxResults <= 0 || - request.maxResults > MAX_DOWNLOAD_ACCOUNT_PAGE_SIZE) { + request.maxResults <= 0 || + request.maxResults > MAX_DOWNLOAD_ACCOUNT_PAGE_SIZE) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, 'Required "maxResults" must be a positive integer that does not exceed ' + @@ -907,13 +907,13 @@ const LIST_OAUTH_IDP_CONFIGS = new ApiSettings('/oauthIdpConfigs', 'GET') .setRequestValidator((request: any) => { // Validate next page token. if (typeof request.pageToken !== 'undefined' && - !validator.isNonEmptyString(request.pageToken)) { + !validator.isNonEmptyString(request.pageToken)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PAGE_TOKEN); } // Validate max results. if (!validator.isNumber(request.pageSize) || - request.pageSize <= 0 || - request.pageSize > MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE) { + request.pageSize <= 0 || + request.pageSize > MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, 'Required "maxResults" must be a positive integer that does not exceed ' + @@ -990,13 +990,13 @@ const LIST_INBOUND_SAML_CONFIGS = new ApiSettings('/inboundSamlConfigs', 'GET') .setRequestValidator((request: any) => { // Validate next page token. if (typeof request.pageToken !== 'undefined' && - !validator.isNonEmptyString(request.pageToken)) { + !validator.isNonEmptyString(request.pageToken)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PAGE_TOKEN); } // Validate max results. if (!validator.isNumber(request.pageSize) || - request.pageSize <= 0 || - request.pageSize > MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE) { + request.pageSize <= 0 || + request.pageSize > MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, 'Required "maxResults" must be a positive integer that does not exceed ' + @@ -1219,7 +1219,7 @@ export abstract class AbstractAuthRequestHandler { */ public downloadAccount( maxResults: number = MAX_DOWNLOAD_ACCOUNT_PAGE_SIZE, - pageToken?: string): Promise<{users: object[]; nextPageToken?: string}> { + pageToken?: string): Promise<{ users: object[]; nextPageToken?: string }> { // Construct request. const request = { maxResults, @@ -1235,7 +1235,7 @@ export abstract class AbstractAuthRequestHandler { if (!response.users) { response.users = []; } - return response as {users: object[]; nextPageToken?: string}; + return response as { users: object[]; nextPageToken?: string }; }); } @@ -1278,7 +1278,7 @@ export abstract class AbstractAuthRequestHandler { return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_UPLOAD_ACCOUNT, request) .then((response: any) => { // No error object is returned if no error encountered. - const failedUploads = (response.error || []) as Array<{index: number; message: string}>; + const failedUploads = (response.error || []) as Array<{ index: number; message: string }>; // Rewrite response as UserImportResult and re-insert client previously detected errors. return userImportBuilder.buildResponse(failedUploads); }); @@ -1417,7 +1417,7 @@ export abstract class AbstractAuthRequestHandler { // Parameters that are deletable and their deleteAttribute names. // Use client facing names, photoURL instead of photoUrl. - const deletableParams: {[key: string]: string} = { + const deletableParams: { [key: string]: string } = { displayName: 'DISPLAY_NAME', photoURL: 'PHOTO_URL', }; @@ -1444,7 +1444,7 @@ export abstract class AbstractAuthRequestHandler { delete request.phoneNumber; } - if (typeof(request.providerToLink) !== 'undefined') { + if (typeof (request.providerToLink) !== 'undefined') { request.linkProviderUserInfo = deepCopy(request.providerToLink); delete request.providerToLink; @@ -1452,7 +1452,7 @@ export abstract class AbstractAuthRequestHandler { delete request.linkProviderUserInfo.uid; } - if (typeof(request.providersToUnlink) !== 'undefined') { + if (typeof (request.providersToUnlink) !== 'undefined') { if (!validator.isArray(request.deleteProvider)) { request.deleteProvider = []; } @@ -1611,9 +1611,9 @@ export abstract class AbstractAuthRequestHandler { public getEmailActionLink( requestType: string, email: string, actionCodeSettings?: ActionCodeSettings, newEmail?: string): Promise { - let request = { - requestType, - email, + let request = { + requestType, + email, returnOobLink: true, ...(typeof newEmail !== 'undefined') && { newEmail }, }; @@ -1679,7 +1679,7 @@ export abstract class AbstractAuthRequestHandler { public listOAuthIdpConfigs( maxResults: number = MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE, pageToken?: string): Promise { - const request: {pageSize: number; pageToken?: string} = { + const request: { pageSize: number; pageToken?: string } = { pageSize: maxResults, }; // Add next page token if provided. @@ -1692,7 +1692,7 @@ export abstract class AbstractAuthRequestHandler { response.oauthIdpConfigs = []; delete response.nextPageToken; } - return response as {oauthIdpConfigs: object[]; nextPageToken?: string}; + return response as { oauthIdpConfigs: object[]; nextPageToken?: string }; }); } @@ -1802,7 +1802,7 @@ export abstract class AbstractAuthRequestHandler { public listInboundSamlConfigs( maxResults: number = MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE, pageToken?: string): Promise { - const request: {pageSize: number; pageToken?: string} = { + const request: { pageSize: number; pageToken?: string } = { pageSize: maxResults, }; // Add next page token if provided. @@ -1815,7 +1815,7 @@ export abstract class AbstractAuthRequestHandler { response.inboundSamlConfigs = []; delete response.nextPageToken; } - return response as {inboundSamlConfigs: object[]; nextPageToken?: string}; + return response as { inboundSamlConfigs: object[]; nextPageToken?: string }; }); } @@ -2007,7 +2007,7 @@ const UPDATE_PROJECT_CONFIG = new ApiSettings('/config?updateMask={updateMask}', /** Instantiates the getTenant endpoint settings. */ const GET_TENANT = new ApiSettings('/tenants/{tenantId}', 'GET') -// Set response validator. + // Set response validator. .setResponseValidator((response: any) => { // Response should always contain at least the tenant name. if (!validator.isNonEmptyString(response.name)) { @@ -2027,7 +2027,7 @@ const UPDATE_TENANT = new ApiSettings('/tenants/{tenantId}?updateMask={updateMas .setResponseValidator((response: any) => { // Response should always contain at least the tenant name. if (!validator.isNonEmptyString(response.name) || - !Tenant.getTenantIdFromResourceName(response.name)) { + !Tenant.getTenantIdFromResourceName(response.name)) { throw new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Unable to update tenant', @@ -2041,13 +2041,13 @@ const LIST_TENANTS = new ApiSettings('/tenants', 'GET') .setRequestValidator((request: any) => { // Validate next page token. if (typeof request.pageToken !== 'undefined' && - !validator.isNonEmptyString(request.pageToken)) { + !validator.isNonEmptyString(request.pageToken)) { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PAGE_TOKEN); } // Validate max results. if (!validator.isNumber(request.pageSize) || - request.pageSize <= 0 || - request.pageSize > MAX_LIST_TENANT_PAGE_SIZE) { + request.pageSize <= 0 || + request.pageSize > MAX_LIST_TENANT_PAGE_SIZE) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, 'Required "maxResults" must be a positive non-zero number that does not exceed ' + @@ -2058,11 +2058,12 @@ const LIST_TENANTS = new ApiSettings('/tenants', 'GET') /** Instantiates the createTenant endpoint settings. */ const CREATE_TENANT = new ApiSettings('/tenants', 'POST') -// Set response validator. + // Set response validator. .setResponseValidator((response: any) => { + console.log("RESPONSE:===", JSON.stringify(response)); // Response should always contain at least the tenant name. if (!validator.isNonEmptyString(response.name) || - !Tenant.getTenantIdFromResourceName(response.name)) { + !Tenant.getTenantIdFromResourceName(response.name)) { throw new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Unable to create new tenant', @@ -2088,7 +2089,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { */ constructor(app: App) { super(app); - this.authResourceUrlBuilder = new AuthResourceUrlBuilder(app, 'v2'); + this.authResourceUrlBuilder = new AuthResourceUrlBuilder(app, 'v2'); } /** @@ -2165,7 +2166,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { */ public listTenants( maxResults: number = MAX_LIST_TENANT_PAGE_SIZE, - pageToken?: string): Promise<{tenants: TenantServerResponse[]; nextPageToken?: string}> { + pageToken?: string): Promise<{ tenants: TenantServerResponse[]; nextPageToken?: string }> { const request = { pageSize: maxResults, pageToken, @@ -2180,7 +2181,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { response.tenants = []; delete response.nextPageToken; } - return response as {tenants: TenantServerResponse[]; nextPageToken?: string}; + return response as { tenants: TenantServerResponse[]; nextPageToken?: string }; }); } @@ -2210,6 +2211,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { try { // Construct backend request. const request = Tenant.buildServerRequest(tenantOptions, true); + //console.log("PRINTING_REQUEST===", JSON.stringify(request)); return this.invokeRequestHandler(this.authResourceUrlBuilder, CREATE_TENANT, request) .then((response: any) => { return response as TenantServerResponse; @@ -2300,7 +2302,7 @@ export class TenantAwareAuthRequestHandler extends AbstractAuthRequestHandler { // Add additional check to match tenant ID of imported user records. users.forEach((user: UserImportRecord, index: number) => { if (validator.isNonEmptyString(user.tenantId) && - user.tenantId !== this.tenantId) { + user.tenantId !== this.tenantId) { throw new FirebaseAuthError( AuthClientErrorCode.MISMATCHING_TENANT_ID, `UserRecord of index "${index}" has mismatching tenant ID "${user.tenantId}"`); diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 45ca3ef2d0..71f2bbab9d 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -462,14 +462,14 @@ export interface EmailSignInConfigServerRequest { type AuthFactorServerType = 'PHONE_SMS'; /** Client Auth factor type to server auth factor type mapping. */ -const AUTH_FACTOR_CLIENT_TO_SERVER_TYPE: {[key: string]: AuthFactorServerType} = { +const AUTH_FACTOR_CLIENT_TO_SERVER_TYPE: { [key: string]: AuthFactorServerType } = { phone: 'PHONE_SMS', }; /** Server Auth factor type to client auth factor type mapping. */ -const AUTH_FACTOR_SERVER_TO_CLIENT_TYPE: {[key: string]: AuthFactorType} = +const AUTH_FACTOR_SERVER_TO_CLIENT_TYPE: { [key: string]: AuthFactorType } = Object.keys(AUTH_FACTOR_CLIENT_TO_SERVER_TYPE) - .reduce((res: {[key: string]: AuthFactorType}, key) => { + .reduce((res: { [key: string]: AuthFactorType }, key) => { res[AUTH_FACTOR_CLIENT_TO_SERVER_TYPE[key]] = key as AuthFactorType; return res; }, {}); @@ -478,6 +478,7 @@ const AUTH_FACTOR_SERVER_TO_CLIENT_TYPE: {[key: string]: AuthFactorType} = export interface MultiFactorAuthServerConfig { state?: MultiFactorConfigState; enabledProviders?: AuthFactorServerType[]; + providerConfigs?: MultiFactorProviderConfig[]; } /** @@ -506,6 +507,27 @@ export interface MultiFactorConfig { * Currently only ‘phone’ is supported. */ factorIds?: AuthFactorType[]; + /** + * A list of multi-factor provider specific config. + */ + providerConfigs?: MultiFactorProviderConfig[]; +} + +export interface MultiFactorProviderConfig { + /* indicates whether this multi-factor provider is enabled/disabled. */ + state: MultiFactorConfigState; + /** + * TOTP MultiFactor provider config. + */ + totpProviderConfig?: TotpMultiFactorProviderConfig; +} + +export interface TotpMultiFactorProviderConfig { + /** + * The allowed number of adjacent intervals that will be used for verification + * to avoid clock skew. + */ + adjacentIntervals?: number; } /** @@ -516,6 +538,7 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { public readonly state: MultiFactorConfigState; public readonly factorIds: AuthFactorType[]; + public readonly providerConfigs: MultiFactorProviderConfig[]; /** * Static method to convert a client side request to a MultiFactorAuthServerConfig. @@ -543,6 +566,14 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { request.enabledProviders = []; } } + if (Object.prototype.hasOwnProperty.call(options, 'providerConfigs')) { + (options.providerConfigs || []).forEach((providerConfig) => { + if (typeof request.providerConfigs === 'undefined') { + request.providerConfigs = [] + } + request.providerConfigs.push(providerConfig); + }); + } return request; } @@ -555,6 +586,7 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { const validKeys = { state: true, factorIds: true, + providerConfigs: true, }; if (!validator.isNonNullObject(options)) { throw new FirebaseAuthError( @@ -573,8 +605,8 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { } // Validate content. if (typeof options.state !== 'undefined' && - options.state !== 'ENABLED' && - options.state !== 'DISABLED') { + options.state !== 'ENABLED' && + options.state !== 'DISABLED') { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, '"MultiFactorConfig.state" must be either "ENABLED" or "DISABLED".', @@ -599,6 +631,41 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { } }); } + + if (typeof options.providerConfigs !== 'undefined') { + if (!validator.isArray(options.providerConfigs)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.providerConfigs" must be an array of valid "MultiFactorProviderConfigs."', + ); + } + //Validate content of array. + options.providerConfigs.forEach((multiFactorProviderConfig) => { + if (typeof multiFactorProviderConfig === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${multiFactorProviderConfig}" is not a valid "MultiFactorProviderConfigType".` + ) + } + if (typeof multiFactorProviderConfig.state !== 'undefined' && + multiFactorProviderConfig.state !== 'ENABLED' && + multiFactorProviderConfig.state !== 'DISABLED') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.providerConfigs.state" must be either "ENABLED" or "DISABLED".', + ) + } + if (typeof multiFactorProviderConfig.totpProviderConfig !== 'undefined') { + if (multiFactorProviderConfig.totpProviderConfig.adjacentIntervals !== undefined && + !validator.isNumber(multiFactorProviderConfig.totpProviderConfig.adjacentIntervals)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"MultiFactorConfig.providerConfigs.totpProviderConfig.adjacentIntervals" must be a valid number.' + ) + } + } + }); + } } /** @@ -624,6 +691,20 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { this.factorIds.push(AUTH_FACTOR_SERVER_TO_CLIENT_TYPE[enabledProvider]); } }) + this.providerConfigs = []; + (response.providerConfigs || []).forEach((providerConfig) => { + if (typeof providerConfig !== 'undefined') { + if (typeof providerConfig.state === 'undefined' || + typeof providerConfig.totpProviderConfig === 'undefined' || + (typeof providerConfig.totpProviderConfig.adjacentIntervals !== 'undefined' && + typeof providerConfig.totpProviderConfig.adjacentIntervals !== 'number')) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor configuration response'); + } + this.providerConfigs.push(providerConfig); + } + }) } /** @returns The plain object representation of the multi-factor config instance. */ @@ -631,6 +712,7 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { return { state: this.state, factorIds: this.factorIds, + providerConfigs: this.providerConfigs }; } } @@ -641,7 +723,7 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { * @param testPhoneNumbers - The phone number / code pairs to validate. */ export function validateTestPhoneNumbers( - testPhoneNumbers: {[phoneNumber: string]: string}, + testPhoneNumbers: { [phoneNumber: string]: string }, ): void { if (!validator.isObject(testPhoneNumbers)) { throw new FirebaseAuthError( @@ -663,7 +745,7 @@ export function validateTestPhoneNumbers( // Validate code. if (!validator.isString(testPhoneNumbers[phoneNumber]) || - !/^[\d]{6}$/.test(testPhoneNumbers[phoneNumber])) { + !/^[\d]{6}$/.test(testPhoneNumbers[phoneNumber])) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_TESTING_PHONE_NUMBER, `"${testPhoneNumbers[phoneNumber]}" is not a valid 6 digit code string.` @@ -747,14 +829,14 @@ export class EmailSignInConfig implements EmailSignInProviderConfig { } // Validate content. if (typeof options.enabled !== 'undefined' && - !validator.isBoolean(options.enabled)) { + !validator.isBoolean(options.enabled)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, '"EmailSignInConfig.enabled" must be a boolean.', ); } if (typeof options.passwordRequired !== 'undefined' && - !validator.isBoolean(options.passwordRequired)) { + !validator.isBoolean(options.passwordRequired)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, '"EmailSignInConfig.passwordRequired" must be a boolean.', @@ -769,7 +851,7 @@ export class EmailSignInConfig implements EmailSignInProviderConfig { * EmailSignInConfig object. * @constructor */ - constructor(response: {[key: string]: any}) { + constructor(response: { [key: string]: any }) { if (typeof response.allowPasswordSignup === 'undefined') { throw new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, @@ -961,7 +1043,7 @@ export class SAMLConfig implements SAMLAuthProviderConfig { options: Partial, ignoreMissingFields = false): SAMLConfigServerRequest | null { const makeRequest = validator.isNonNullObject(options) && - (options.providerId || ignoreMissingFields); + (options.providerId || ignoreMissingFields); if (!makeRequest) { return null; } @@ -1066,21 +1148,21 @@ export class SAMLConfig implements SAMLAuthProviderConfig { ); } if (!(ignoreMissingFields && typeof options.idpEntityId === 'undefined') && - !validator.isNonEmptyString(options.idpEntityId)) { + !validator.isNonEmptyString(options.idpEntityId)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, '"SAMLAuthProviderConfig.idpEntityId" must be a valid non-empty string.', ); } if (!(ignoreMissingFields && typeof options.ssoURL === 'undefined') && - !validator.isURL(options.ssoURL)) { + !validator.isURL(options.ssoURL)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, '"SAMLAuthProviderConfig.ssoURL" must be a valid URL string.', ); } if (!(ignoreMissingFields && typeof options.rpEntityId === 'undefined') && - !validator.isNonEmptyString(options.rpEntityId)) { + !validator.isNonEmptyString(options.rpEntityId)) { throw new FirebaseAuthError( !options.rpEntityId ? AuthClientErrorCode.MISSING_SAML_RELYING_PARTY_CONFIG : AuthClientErrorCode.INVALID_CONFIG, @@ -1088,14 +1170,14 @@ export class SAMLConfig implements SAMLAuthProviderConfig { ); } if (!(ignoreMissingFields && typeof options.callbackURL === 'undefined') && - !validator.isURL(options.callbackURL)) { + !validator.isURL(options.callbackURL)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, '"SAMLAuthProviderConfig.callbackURL" must be a valid URL string.', ); } if (!(ignoreMissingFields && typeof options.x509Certificates === 'undefined') && - !validator.isArray(options.x509Certificates)) { + !validator.isArray(options.x509Certificates)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', @@ -1110,21 +1192,21 @@ export class SAMLConfig implements SAMLAuthProviderConfig { } }); if (typeof (options as any).enableRequestSigning !== 'undefined' && - !validator.isBoolean((options as any).enableRequestSigning)) { + !validator.isBoolean((options as any).enableRequestSigning)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, '"SAMLAuthProviderConfig.enableRequestSigning" must be a boolean.', ); } if (typeof options.enabled !== 'undefined' && - !validator.isBoolean(options.enabled)) { + !validator.isBoolean(options.enabled)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, '"SAMLAuthProviderConfig.enabled" must be a boolean.', ); } if (typeof options.displayName !== 'undefined' && - !validator.isString(options.displayName)) { + !validator.isString(options.displayName)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, '"SAMLAuthProviderConfig.displayName" must be a valid string.', @@ -1140,14 +1222,14 @@ export class SAMLConfig implements SAMLAuthProviderConfig { */ constructor(response: SAMLConfigServerResponse) { if (!response || - !response.idpConfig || - !response.idpConfig.idpEntityId || - !response.idpConfig.ssoUrl || - !response.spConfig || - !response.spConfig.spEntityId || - !response.name || - !(validator.isString(response.name) && - SAMLConfig.getProviderIdFromResourceName(response.name))) { + !response.idpConfig || + !response.idpConfig.idpEntityId || + !response.idpConfig.ssoUrl || + !response.spConfig || + !response.spConfig.spEntityId || + !response.name || + !(validator.isString(response.name) && + SAMLConfig.getProviderIdFromResourceName(response.name))) { throw new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); @@ -1225,7 +1307,7 @@ export class OIDCConfig implements OIDCAuthProviderConfig { options: Partial, ignoreMissingFields = false): OIDCConfigServerRequest | null { const makeRequest = validator.isNonNullObject(options) && - (options.providerId || ignoreMissingFields); + (options.providerId || ignoreMissingFields); if (!makeRequest) { return null; } @@ -1318,35 +1400,35 @@ export class OIDCConfig implements OIDCAuthProviderConfig { ); } if (!(ignoreMissingFields && typeof options.clientId === 'undefined') && - !validator.isNonEmptyString(options.clientId)) { + !validator.isNonEmptyString(options.clientId)) { throw new FirebaseAuthError( !options.clientId ? AuthClientErrorCode.MISSING_OAUTH_CLIENT_ID : AuthClientErrorCode.INVALID_OAUTH_CLIENT_ID, '"OIDCAuthProviderConfig.clientId" must be a valid non-empty string.', ); } if (!(ignoreMissingFields && typeof options.issuer === 'undefined') && - !validator.isURL(options.issuer)) { + !validator.isURL(options.issuer)) { throw new FirebaseAuthError( !options.issuer ? AuthClientErrorCode.MISSING_ISSUER : AuthClientErrorCode.INVALID_CONFIG, '"OIDCAuthProviderConfig.issuer" must be a valid URL string.', ); } if (typeof options.enabled !== 'undefined' && - !validator.isBoolean(options.enabled)) { + !validator.isBoolean(options.enabled)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, '"OIDCAuthProviderConfig.enabled" must be a boolean.', ); } if (typeof options.displayName !== 'undefined' && - !validator.isString(options.displayName)) { + !validator.isString(options.displayName)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, '"OIDCAuthProviderConfig.displayName" must be a valid string.', ); } if (typeof options.clientSecret !== 'undefined' && - !validator.isNonEmptyString(options.clientSecret)) { + !validator.isNonEmptyString(options.clientSecret)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, '"OIDCAuthProviderConfig.clientSecret" must be a valid string.', @@ -1406,11 +1488,11 @@ export class OIDCConfig implements OIDCAuthProviderConfig { */ constructor(response: OIDCConfigServerResponse) { if (!response || - !response.issuer || - !response.clientId || - !response.name || - !(validator.isString(response.name) && - OIDCConfig.getProviderIdFromResourceName(response.name))) { + !response.issuer || + !response.clientId || + !response.name || + !(validator.isString(response.name) && + OIDCConfig.getProviderIdFromResourceName(response.name))) { throw new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Invalid OIDC configuration response'); @@ -1503,12 +1585,12 @@ export interface AllowByDefault { * allowlist. */ export interface AllowlistOnly { - /** - * Two letter unicode region codes to allow as defined by - * https://cldr.unicode.org/ - * The full list of these region codes is here: - * https://github.com/unicode-cldr/cldr-localenames-full/blob/master/main/en/territories.json - */ + /** + * Two letter unicode region codes to allow as defined by + * https://cldr.unicode.org/ + * The full list of these region codes is here: + * https://github.com/unicode-cldr/cldr-localenames-full/blob/master/main/en/territories.json + */ allowedRegions: string[]; } diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts index 54dcfb3b9c..5244e65408 100644 --- a/src/auth/project-config.ts +++ b/src/auth/project-config.ts @@ -18,6 +18,7 @@ import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; import { SmsRegionsAuthConfig, SmsRegionConfig, + MultiFactorConfig, } from './auth-config'; import { deepCopy } from '../utils/deep-copy'; @@ -29,6 +30,10 @@ export interface UpdateProjectConfigRequest { * The SMS configuration to update on the project. */ smsRegionConfig?: SmsRegionConfig; + /** + * The multi-factor auth configuration to update on the project. + */ + multiFactorConfig?: MultiFactorConfig; } /** @@ -57,6 +62,13 @@ export class ProjectConfig { * This is based on the calling code of the destination phone number. */ public readonly smsRegionConfig?: SmsRegionConfig; + private readonly multiFactorConfig_?: MultiFactorConfig; + /** + * The multi-factor auth configuration. + */ + get multiFactorConfig(): MultiFactorConfig | undefined { + return this.multiFactorConfig_; + } /** * Validates a project config options object. Throws an error on failure. diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 0184d47b4e..3875f171ff 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -1247,6 +1247,12 @@ describe('admin.auth', () => { multiFactorConfig: { state: 'ENABLED', factorIds: ['phone'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: {}, + }, + ], }, // Add random phone number / code pairs. testPhoneNumbers: { @@ -1264,6 +1270,12 @@ describe('admin.auth', () => { multiFactorConfig: { state: 'ENABLED', factorIds: ['phone'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: {}, + }, + ], }, // These test phone numbers will not be checked when running integration // tests against the emulator suite and are ignored in auth emulator @@ -1284,6 +1296,12 @@ describe('admin.auth', () => { multiFactorConfig: { state: 'DISABLED', factorIds: [], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: {}, + }, + ], }, // Test phone numbers will not be checked when running integration tests // against emulator suite. For more information, please refer to: @@ -1302,6 +1320,12 @@ describe('admin.auth', () => { multiFactorConfig: { state: 'ENABLED', factorIds: ['phone'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: {}, + }, + ], }, smsRegionConfig: { allowByDefault: { diff --git a/test/unit/auth/auth-config.spec.ts b/test/unit/auth/auth-config.spec.ts index 62e7d4e3f6..c811b2fb3d 100644 --- a/test/unit/auth/auth-config.spec.ts +++ b/test/unit/auth/auth-config.spec.ts @@ -206,10 +206,26 @@ describe('MultiFactorAuthConfig', () => { const config = new MultiFactorAuthConfig({ state: 'ENABLED', enabledProviders: ['PHONE_SMS'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }); expect(config.toJSON()).to.deep.equal({ state: 'ENABLED', factorIds: ['phone'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }); }); }); @@ -316,7 +332,7 @@ describe('validateTestPhoneNumbers', () => { }); it(`should not throw when ${MAXIMUM_TEST_PHONE_NUMBERS} pairs are provided`, () => { - const pairs: {[key: string]: string} = {}; + const pairs: { [key: string]: string } = {}; for (let i = 0; i < MAXIMUM_TEST_PHONE_NUMBERS; i++) { pairs[`+1650555${'0'.repeat(4 - i.toString().length)}${i}`] = '012938'; } @@ -325,7 +341,7 @@ describe('validateTestPhoneNumbers', () => { }); it(`should throw when >${MAXIMUM_TEST_PHONE_NUMBERS} pairs are provided`, () => { - const pairs: {[key: string]: string} = {}; + const pairs: { [key: string]: string } = {}; for (let i = 0; i < MAXIMUM_TEST_PHONE_NUMBERS + 1; i++) { pairs[`+1650555${'0'.repeat(4 - i.toString().length)}${i}`] = '012938'; } diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 44885ecafa..27f51dbb6a 100644 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -35,13 +35,13 @@ const expect = chai.expect; describe('Tenant', () => { const smsAllowByDefault = { allowByDefault: { - disallowedRegions: [ 'AC', 'AD' ], + disallowedRegions: ['AC', 'AD'], }, }; const smsAllowlistOnly = { allowlistOnly: { - allowedRegions: [ 'AC', 'AD' ], + allowedRegions: ['AC', 'AD'], }, }; @@ -53,6 +53,14 @@ describe('Tenant', () => { mfaConfig: { state: 'ENABLED', enabledProviders: ['PHONE_SMS'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }, testPhoneNumbers: { '+16505551234': '019287', @@ -70,6 +78,14 @@ describe('Tenant', () => { multiFactorConfig: { state: 'ENABLED', factorIds: ['phone'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }, testPhoneNumbers: { '+16505551234': '019287', @@ -174,7 +190,7 @@ describe('Tenant', () => { it('should throw on invalid allowlistOnly attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.smsRegionConfig = deepCopy(smsAllowlistOnly); - tenantOptionsClientRequest.smsRegionConfig.allowlistOnly.disallowedRegions = [ 'AC', 'AD' ]; + tenantOptionsClientRequest.smsRegionConfig.allowlistOnly.disallowedRegions = ['AC', 'AD']; expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); }).to.throw('"disallowedRegions" is not a valid SmsRegionConfig.allowlistOnly parameter.'); @@ -182,7 +198,7 @@ describe('Tenant', () => { it('should throw on invalid allowByDefault attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; - tenantOptionsClientRequest.smsRegionConfig.allowByDefault.allowedRegions = [ 'AC', 'AD' ]; + tenantOptionsClientRequest.smsRegionConfig.allowByDefault.allowedRegions = ['AC', 'AD']; expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); }).to.throw('"allowedRegions" is not a valid SmsRegionConfig.allowByDefault parameter.'); @@ -190,7 +206,7 @@ describe('Tenant', () => { it('should throw on non-array disallowedRegions attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; - tenantOptionsClientRequest.smsRegionConfig.allowByDefault.disallowedRegions = 'non-array'; + tenantOptionsClientRequest.smsRegionConfig.allowByDefault.disallowedRegions = 'non-array'; expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); }).to.throw('"SmsRegionConfig.allowByDefault.disallowedRegions" must be a valid string array.'); @@ -276,7 +292,7 @@ describe('Tenant', () => { .to.throw('"EmailSignInConfig" must be a non-null object.'); }); - it('should throw on invalid MultiFactorConfig attribute', () => { + it('should throw on invalid MultiFactorConfig.factorIds attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.multiFactorConfig.factorIds = ['invalid']; expect(() => { @@ -323,7 +339,7 @@ describe('Tenant', () => { it('should throw on invalid allowlistOnly attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.smsRegionConfig = deepCopy(smsAllowlistOnly); - tenantOptionsClientRequest.smsRegionConfig.allowlistOnly.disallowedRegions = [ 'AC', 'AD' ]; + tenantOptionsClientRequest.smsRegionConfig.allowlistOnly.disallowedRegions = ['AC', 'AD']; expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); }).to.throw('"disallowedRegions" is not a valid SmsRegionConfig.allowlistOnly parameter.'); @@ -331,7 +347,7 @@ describe('Tenant', () => { it('should throw on invalid allowByDefault attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; - tenantOptionsClientRequest.smsRegionConfig.allowByDefault.allowedRegions = [ 'AC', 'AD' ]; + tenantOptionsClientRequest.smsRegionConfig.allowByDefault.allowedRegions = ['AC', 'AD']; expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); }).to.throw('"allowedRegions" is not a valid SmsRegionConfig.allowByDefault parameter.'); @@ -339,7 +355,7 @@ describe('Tenant', () => { it('should throw on non-array disallowedRegions attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; - tenantOptionsClientRequest.smsRegionConfig.allowByDefault.disallowedRegions = 'non-array'; + tenantOptionsClientRequest.smsRegionConfig.allowByDefault.disallowedRegions = 'non-array'; expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); }).to.throw('"SmsRegionConfig.allowByDefault.disallowedRegions" must be a valid string array.'); @@ -435,6 +451,14 @@ describe('Tenant', () => { const expectedMultiFactorConfig = new MultiFactorAuthConfig({ state: 'ENABLED', enabledProviders: ['PHONE_SMS'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }); expect(tenant.multiFactorConfig).to.deep.equal(expectedMultiFactorConfig); });