diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index 7a1df820d6..f8ae6d897a 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -138,6 +138,16 @@ export interface CreateRequest extends UpdateRequest { // @public export type CreateTenantRequest = UpdateTenantRequest; +// @public +export interface CustomStrengthOptionsConfig { + maxLength?: number; + minLength?: number; + requireLowercase?: boolean; + requireNonAlphanumeric?: boolean; + requireNumeric?: boolean; + requireUppercase?: boolean; +} + // @alpha (undocumented) export interface DecodedAuthBlockingToken { // (undocumented) @@ -329,6 +339,16 @@ export interface OIDCUpdateAuthProviderRequest { responseType?: OAuthResponseType; } +// @public +export interface PasswordPolicyConfig { + constraints?: CustomStrengthOptionsConfig; + enforcementState?: PasswordPolicyEnforcementState; + forceUpgradeOnSignin?: boolean; +} + +// @public +export type PasswordPolicyEnforcementState = 'ENFORCE' | 'OFF'; + // @public export interface PhoneIdentifier { // (undocumented) @@ -344,6 +364,7 @@ export class PhoneMultiFactorInfo extends MultiFactorInfo { // @public export class ProjectConfig { get multiFactorConfig(): MultiFactorConfig | undefined; + readonly passwordPolicyConfig?: PasswordPolicyConfig; get recaptchaConfig(): RecaptchaConfig | undefined; readonly smsRegionConfig?: SmsRegionConfig; toJSON(): object; @@ -427,6 +448,7 @@ export class Tenant { readonly displayName?: string; get emailSignInConfig(): EmailSignInProviderConfig | undefined; get multiFactorConfig(): MultiFactorConfig | undefined; + readonly passwordPolicyConfig?: PasswordPolicyConfig; get recaptchaConfig(): RecaptchaConfig | undefined; readonly smsRegionConfig?: SmsRegionConfig; readonly tenantId: string; @@ -479,6 +501,7 @@ export interface UpdatePhoneMultiFactorInfoRequest extends BaseUpdateMultiFactor // @public export interface UpdateProjectConfigRequest { multiFactorConfig?: MultiFactorConfig; + passwordPolicyConfig?: PasswordPolicyConfig; recaptchaConfig?: RecaptchaConfig; smsRegionConfig?: SmsRegionConfig; } @@ -503,6 +526,7 @@ export interface UpdateTenantRequest { displayName?: string; emailSignInConfig?: EmailSignInProviderConfig; multiFactorConfig?: MultiFactorConfig; + passwordPolicyConfig?: PasswordPolicyConfig; recaptchaConfig?: RecaptchaConfig; smsRegionConfig?: SmsRegionConfig; testPhoneNumbers?: { diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 5ca4ed0b96..902eb65121 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1946,3 +1946,338 @@ export class RecaptchaAuthConfig implements RecaptchaConfig { return json; } } + +/** + * A password policy configuration for a project or tenant +*/ +export interface PasswordPolicyConfig { + /** + * Enforcement state of the password policy + */ + enforcementState?: PasswordPolicyEnforcementState; + /** + * Require users to have a policy-compliant password to sign in + */ + forceUpgradeOnSignin?: boolean; + /** + * The constraints that make up the password strength policy + */ + constraints?: CustomStrengthOptionsConfig; +} + +/** + * A password policy's enforcement state. + */ +export type PasswordPolicyEnforcementState = 'ENFORCE' | 'OFF'; + +/** + * Constraints to be enforced on the password policy + */ +export interface CustomStrengthOptionsConfig { + /** + * The password must contain an upper case character + */ + requireUppercase?: boolean; + /** + * The password must contain a lower case character + */ + requireLowercase?: boolean; + /** + * The password must contain a non-alphanumeric character + */ + requireNonAlphanumeric?: boolean; + /** + * The password must contain a number + */ + requireNumeric?: boolean; + /** + * Minimum password length. Valid values are from 6 to 30 + */ + minLength?: number; + /** + * Maximum password length. No default max length + */ + maxLength?: number; +} + +/** + * Defines the password policy config class used to convert client side PasswordPolicyConfig + * to a format that is understood by the Auth server. + * + * @internal + */ +export class PasswordPolicyAuthConfig implements PasswordPolicyConfig { + + /** + * Identifies a password policy configuration state. + */ + public readonly enforcementState: PasswordPolicyEnforcementState; + /** + * Users must have a password compliant with the password policy to sign-in + */ + public readonly forceUpgradeOnSignin: boolean; + /** + * Must be of length 1. Contains the strength attributes for the password policy + */ + public readonly constraints?: CustomStrengthOptionsConfig; + + /** + * Static method to convert a client side request to a PasswordPolicyAuthServerConfig. + * Throws an error if validation fails. + * + * @param options - The options object to convert to a server request. + * @returns The resulting server request. + * @internal + */ + public static buildServerRequest(options: PasswordPolicyConfig): PasswordPolicyAuthServerConfig { + const request: PasswordPolicyAuthServerConfig = {}; + PasswordPolicyAuthConfig.validate(options); + if (Object.prototype.hasOwnProperty.call(options, 'enforcementState')) { + request.passwordPolicyEnforcementState = options.enforcementState; + } + request.forceUpgradeOnSignin = false; + if (Object.prototype.hasOwnProperty.call(options, 'forceUpgradeOnSignin')) { + request.forceUpgradeOnSignin = options.forceUpgradeOnSignin; + } + const constraintsRequest: CustomStrengthOptionsAuthServerConfig = { + containsUppercaseCharacter: false, + containsLowercaseCharacter: false, + containsNonAlphanumericCharacter: false, + containsNumericCharacter: false, + minPasswordLength: 6, + maxPasswordLength: 4096, + }; + request.passwordPolicyVersions = []; + if (Object.prototype.hasOwnProperty.call(options, 'constraints')) { + if (options) { + if (options.constraints?.requireUppercase !== undefined) { + constraintsRequest.containsUppercaseCharacter = options.constraints.requireUppercase; + } + if (options.constraints?.requireLowercase !== undefined) { + constraintsRequest.containsLowercaseCharacter = options.constraints.requireLowercase; + } + if (options.constraints?.requireNonAlphanumeric !== undefined) { + constraintsRequest.containsNonAlphanumericCharacter = options.constraints.requireNonAlphanumeric; + } + if (options.constraints?.requireNumeric !== undefined) { + constraintsRequest.containsNumericCharacter = options.constraints.requireNumeric; + } + if (options.constraints?.minLength !== undefined) { + constraintsRequest.minPasswordLength = options.constraints.minLength; + } + if (options.constraints?.maxLength !== undefined) { + constraintsRequest.maxPasswordLength = options.constraints.maxLength; + } + } + } + request.passwordPolicyVersions.push({ customStrengthOptions: constraintsRequest }); + return request; + } + + /** + * Validates the PasswordPolicyConfig options object. Throws an error on failure. + * + * @param options - The options object to validate. + * @internal + */ + public static validate(options: PasswordPolicyConfig): void { + const validKeys = { + enforcementState: true, + forceUpgradeOnSignin: true, + constraints: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig" must be a non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid PasswordPolicyConfig parameter.`, + ); + } + } + // Validate content. + if (typeof options.enforcementState === 'undefined' || + !(options.enforcementState === 'ENFORCE' || + options.enforcementState === 'OFF')) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.enforcementState" must be either "ENFORCE" or "OFF".', + ); + } + + if (typeof options.forceUpgradeOnSignin !== 'undefined') { + if (!validator.isBoolean(options.forceUpgradeOnSignin)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.forceUpgradeOnSignin" must be a boolean.', + ); + } + } + + if (typeof options.constraints !== 'undefined') { + if (options.enforcementState === 'ENFORCE' && !validator.isNonNullObject(options.constraints)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints" must be a non-empty object.', + ); + } + + const validCharKeys = { + requireUppercase: true, + requireLowercase: true, + requireNumeric: true, + requireNonAlphanumeric: true, + minLength: true, + maxLength: true, + }; + + // Check for unsupported attributes. + for (const key in options.constraints) { + if (!(key in validCharKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid PasswordPolicyConfig.constraints parameter.`, + ); + } + } + if (typeof options.constraints.requireUppercase !== undefined && + !validator.isBoolean(options.constraints.requireUppercase)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.requireUppercase" must be a boolean.', + ); + } + if (typeof options.constraints.requireLowercase !== undefined && + !validator.isBoolean(options.constraints.requireLowercase)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.requireLowercase" must be a boolean.', + ); + } + if (typeof options.constraints.requireNonAlphanumeric !== undefined && + !validator.isBoolean(options.constraints.requireNonAlphanumeric)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.requireNonAlphanumeric"' + + ' must be a boolean.', + ); + } + if (typeof options.constraints.requireNumeric !== undefined && + !validator.isBoolean(options.constraints.requireNumeric)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.requireNumeric" must be a boolean.', + ); + } + if (!validator.isNumber(options.constraints.minLength)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.minLength" must be a number.', + ); + } + if (!validator.isNumber(options.constraints.maxLength)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.maxLength" must be a number.', + ); + } + if (options.constraints.minLength === undefined) { + options.constraints.minLength = 6; + } else { + if (!(options.constraints.minLength >= 6 + && options.constraints.minLength <= 30)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.minLength"' + + ' must be an integer between 6 and 30, inclusive.', + ); + } + } + if (options.constraints.maxLength === undefined) { + options.constraints.maxLength = 4096; + } else { + if (!(options.constraints.maxLength >= options.constraints.minLength && + options.constraints.maxLength <= 4096)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.maxLength"' + + ' must be greater than or equal to minLength and at max 4096.', + ); + } + } + } else { + if (options.enforcementState === 'ENFORCE') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints" must be defined.', + ); + } + } + } + + /** + * The PasswordPolicyAuthConfig constructor. + * + * @param response - The server side response used to initialize the + * PasswordPolicyAuthConfig object. + * @constructor + * @internal + */ + constructor(response: PasswordPolicyAuthServerConfig) { + if (typeof response.passwordPolicyEnforcementState === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid password policy configuration response'); + } + this.enforcementState = response.passwordPolicyEnforcementState; + let constraintsResponse: CustomStrengthOptionsConfig = {}; + if (typeof response.passwordPolicyVersions !== 'undefined') { + (response.passwordPolicyVersions || []).forEach((policyVersion) => { + constraintsResponse = { + requireLowercase: policyVersion.customStrengthOptions?.containsLowercaseCharacter, + requireUppercase: policyVersion.customStrengthOptions?.containsUppercaseCharacter, + requireNonAlphanumeric: policyVersion.customStrengthOptions?.containsNonAlphanumericCharacter, + requireNumeric: policyVersion.customStrengthOptions?.containsNumericCharacter, + minLength: policyVersion.customStrengthOptions?.minPasswordLength, + maxLength: policyVersion.customStrengthOptions?.maxPasswordLength, + }; + }); + } + this.constraints = constraintsResponse; + this.forceUpgradeOnSignin = response.forceUpgradeOnSignin?true:false; + } +} + +/** + * Server side password policy configuration. + */ +export interface PasswordPolicyAuthServerConfig { + passwordPolicyEnforcementState?: PasswordPolicyEnforcementState; + passwordPolicyVersions?: PasswordPolicyVersionsAuthServerConfig[]; + forceUpgradeOnSignin?: boolean; +} + +/** + * Server side password policy versions configuration. + */ +export interface PasswordPolicyVersionsAuthServerConfig { + customStrengthOptions?: CustomStrengthOptionsAuthServerConfig; +} + +/** + * Server side password policy constraints configuration. + */ +export interface CustomStrengthOptionsAuthServerConfig { + containsLowercaseCharacter?: boolean; + containsUppercaseCharacter?: boolean; + containsNumericCharacter?: boolean; + containsNonAlphanumericCharacter?: boolean; + minPasswordLength?: number; + maxPasswordLength?: number; +} diff --git a/src/auth/index.ts b/src/auth/index.ts index 8af9c7e246..2450dd1adf 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -99,6 +99,9 @@ export { UpdatePhoneMultiFactorInfoRequest, UpdateRequest, TotpMultiFactorProviderConfig, + PasswordPolicyConfig, + PasswordPolicyEnforcementState, + CustomStrengthOptionsConfig, } from './auth-config'; export { diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts index 7d2786bc85..2748be0423 100644 --- a/src/auth/project-config.ts +++ b/src/auth/project-config.ts @@ -23,6 +23,9 @@ import { MultiFactorAuthServerConfig, RecaptchaConfig, RecaptchaAuthConfig, + PasswordPolicyAuthConfig, + PasswordPolicyAuthServerConfig, + PasswordPolicyConfig, } from './auth-config'; import { deepCopy } from '../utils/deep-copy'; @@ -46,6 +49,10 @@ export interface UpdateProjectConfigRequest { * {@link https://cloud.google.com/terms/service-terms | Term of Service}. */ recaptchaConfig?: RecaptchaConfig; + /** + * The password policy configuration to update on the project + */ + passwordPolicyConfig?: PasswordPolicyConfig; } /** @@ -55,6 +62,7 @@ export interface ProjectConfigServerResponse { smsRegionConfig?: SmsRegionConfig; mfa?: MultiFactorAuthServerConfig; recaptchaConfig?: RecaptchaConfig; + passwordPolicyConfig?: PasswordPolicyAuthServerConfig; } /** @@ -64,6 +72,7 @@ export interface ProjectConfigClientRequest { smsRegionConfig?: SmsRegionConfig; mfa?: MultiFactorAuthServerConfig; recaptchaConfig?: RecaptchaConfig; + passwordPolicyConfig?: PasswordPolicyAuthServerConfig; } /** @@ -97,6 +106,10 @@ export class ProjectConfig { get multiFactorConfig(): MultiFactorConfig | undefined { return this.multiFactorConfig_; } + /** + * The password policy configuration for the project + */ + public readonly passwordPolicyConfig?: PasswordPolicyConfig; /** * Validates a project config options object. Throws an error on failure. @@ -114,6 +127,7 @@ export class ProjectConfig { smsRegionConfig: true, multiFactorConfig: true, recaptchaConfig: true, + passwordPolicyConfig: true, } // Check for unsupported top level attributes. for (const key in request) { @@ -137,6 +151,11 @@ export class ProjectConfig { if (typeof request.recaptchaConfig !== 'undefined') { RecaptchaAuthConfig.validate(request.recaptchaConfig); } + + // Validate Password policy Config if provided + if (typeof request.passwordPolicyConfig !== 'undefined') { + PasswordPolicyAuthConfig.validate(request.passwordPolicyConfig); + } } /** @@ -148,16 +167,20 @@ export class ProjectConfig { */ public static buildServerRequest(configOptions: UpdateProjectConfigRequest): ProjectConfigClientRequest { ProjectConfig.validate(configOptions); - const request = configOptions as any; - if (configOptions.multiFactorConfig !== undefined) { + const request: ProjectConfigClientRequest = {}; + if (typeof configOptions.smsRegionConfig !== 'undefined') { + request.smsRegionConfig = configOptions.smsRegionConfig; + } + if (typeof configOptions.multiFactorConfig !== 'undefined') { request.mfa = MultiFactorAuthConfig.buildServerRequest(configOptions.multiFactorConfig); } - // Backend API returns "mfa" in case of project config and "mfaConfig" in case of tenant config. - // The SDK exposes it as multiFactorConfig always. - // See https://cloud.google.com/identity-platform/docs/reference/rest/v2/projects.tenants#resource:-tenant - // and https://cloud.google.com/identity-platform/docs/reference/rest/v2/Config - delete request.multiFactorConfig; - return request as ProjectConfigClientRequest; + if (typeof configOptions.recaptchaConfig !== 'undefined') { + request.recaptchaConfig = configOptions.recaptchaConfig; + } + if (typeof configOptions.passwordPolicyConfig !== 'undefined') { + request.passwordPolicyConfig = PasswordPolicyAuthConfig.buildServerRequest(configOptions.passwordPolicyConfig); + } + return request; } /** @@ -185,6 +208,9 @@ export class ProjectConfig { if (typeof response.recaptchaConfig !== 'undefined') { this.recaptchaConfig_ = new RecaptchaAuthConfig(response.recaptchaConfig); } + if (typeof response.passwordPolicyConfig !== 'undefined') { + this.passwordPolicyConfig = new PasswordPolicyAuthConfig(response.passwordPolicyConfig); + } } /** * Returns a JSON-serializable representation of this object. @@ -197,6 +223,7 @@ export class ProjectConfig { smsRegionConfig: deepCopy(this.smsRegionConfig), multiFactorConfig: deepCopy(this.multiFactorConfig), recaptchaConfig: this.recaptchaConfig_?.toJSON(), + passwordPolicyConfig: deepCopy(this.passwordPolicyConfig), }; if (typeof json.smsRegionConfig === 'undefined') { delete json.smsRegionConfig; @@ -207,6 +234,9 @@ export class ProjectConfig { if (typeof json.recaptchaConfig === 'undefined') { delete json.recaptchaConfig; } + if (typeof json.passwordPolicyConfig === 'undefined') { + delete json.passwordPolicyConfig; + } return json; } } diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index fdb7b1e199..40bfa74b52 100644 --- a/src/auth/tenant.ts +++ b/src/auth/tenant.ts @@ -21,7 +21,9 @@ import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; import { EmailSignInConfig, EmailSignInConfigServerRequest, MultiFactorAuthServerConfig, MultiFactorConfig, validateTestPhoneNumbers, EmailSignInProviderConfig, - MultiFactorAuthConfig, SmsRegionConfig, SmsRegionsAuthConfig, RecaptchaAuthConfig, RecaptchaConfig + MultiFactorAuthConfig, SmsRegionConfig, SmsRegionsAuthConfig, RecaptchaAuthConfig, RecaptchaConfig, + PasswordPolicyConfig, + PasswordPolicyAuthConfig, PasswordPolicyAuthServerConfig, } from './auth-config'; /** @@ -67,6 +69,10 @@ export interface UpdateTenantRequest { * {@link https://cloud.google.com/terms/service-terms | Term of Service}. */ recaptchaConfig?: RecaptchaConfig; + /** + * The password policy configuration for the tenant + */ + passwordPolicyConfig?: PasswordPolicyConfig; } /** @@ -83,6 +89,7 @@ export interface TenantOptionsServerRequest extends EmailSignInConfigServerReque testPhoneNumbers?: {[key: string]: string}; smsRegionConfig?: SmsRegionConfig; recaptchaConfig?: RecaptchaConfig; + passwordPolicyConfig?: PasswordPolicyAuthServerConfig; } /** The tenant server response interface. */ @@ -96,6 +103,7 @@ export interface TenantServerResponse { testPhoneNumbers?: {[key: string]: string}; smsRegionConfig?: SmsRegionConfig; recaptchaConfig? : RecaptchaConfig; + passwordPolicyConfig?: PasswordPolicyAuthServerConfig; } /** @@ -153,6 +161,10 @@ export class Tenant { * This is based on the calling code of the destination phone number. */ public readonly smsRegionConfig?: SmsRegionConfig; + /** + * The password policy configuration for the tenant + */ + public readonly passwordPolicyConfig?: PasswordPolicyConfig; /** * Builds the corresponding server request for a TenantOptions object. @@ -189,6 +201,9 @@ export class Tenant { if (typeof tenantOptions.recaptchaConfig !== 'undefined') { request.recaptchaConfig = tenantOptions.recaptchaConfig; } + if (typeof tenantOptions.passwordPolicyConfig !== 'undefined') { + request.passwordPolicyConfig = PasswordPolicyAuthConfig.buildServerRequest(tenantOptions.passwordPolicyConfig); + } return request; } @@ -224,6 +239,7 @@ export class Tenant { testPhoneNumbers: true, smsRegionConfig: true, recaptchaConfig: true, + passwordPolicyConfig: true, }; const label = createRequest ? 'CreateTenantRequest' : 'UpdateTenantRequest'; if (!validator.isNonNullObject(request)) { @@ -278,6 +294,11 @@ export class Tenant { if (typeof request.recaptchaConfig !== 'undefined') { RecaptchaAuthConfig.validate(request.recaptchaConfig); } + // Validate passwordPolicyConfig type if provided. + if (typeof request.passwordPolicyConfig !== 'undefined') { + // This will throw an error if invalid. + PasswordPolicyAuthConfig.buildServerRequest(request.passwordPolicyConfig); + } } /** @@ -318,6 +339,9 @@ export class Tenant { if (typeof response.recaptchaConfig !== 'undefined') { this.recaptchaConfig_ = new RecaptchaAuthConfig(response.recaptchaConfig); } + if (typeof response.passwordPolicyConfig !== 'undefined') { + this.passwordPolicyConfig = new PasswordPolicyAuthConfig(response.passwordPolicyConfig); + } } /** @@ -356,6 +380,7 @@ export class Tenant { testPhoneNumbers: this.testPhoneNumbers, smsRegionConfig: deepCopy(this.smsRegionConfig), recaptchaConfig: this.recaptchaConfig_?.toJSON(), + passwordPolicyConfig: deepCopy(this.passwordPolicyConfig), }; if (typeof json.multiFactorConfig === 'undefined') { delete json.multiFactorConfig; @@ -369,6 +394,9 @@ export class Tenant { if (typeof json.recaptchaConfig === 'undefined') { delete json.recaptchaConfig; } + if (typeof json.passwordPolicyConfig === 'undefined') { + delete json.passwordPolicyConfig; + } return json; } } diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index ccfc5c3b40..c88fdd8722 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -32,6 +32,7 @@ import { AuthProviderConfig, CreateTenantRequest, DeleteUsersResult, PhoneMultiFactorInfo, TenantAwareAuth, UpdatePhoneMultiFactorInfoRequest, UpdateTenantRequest, UserImportOptions, UserImportRecord, UserRecord, getAuth, UpdateProjectConfigRequest, UserMetadata, MultiFactorConfig, + PasswordPolicyConfig, SmsRegionConfig, } from '../../lib/auth/index'; import * as sinon from 'sinon'; import * as sinonChai from 'sinon-chai'; @@ -1206,7 +1207,25 @@ describe('admin.auth', () => { this.skip(); // getConfig is not supported in Auth Emulator } }); - const mfaConfig: MultiFactorConfig = { + + after(() => { + getAuth().projectConfigManager().updateProjectConfig({ + passwordPolicyConfig: { + enforcementState: 'OFF', + forceUpgradeOnSignin: false, + constraints: { + requireLowercase: false, + requireNonAlphanumeric: false, + requireNumeric: false, + requireUppercase: false, + maxLength: 4096, + minLength: 6, + } + } + }) + }); + + const mfaSmsEnabledTotpEnabledConfig: MultiFactorConfig = { state: 'ENABLED', factorIds: ['phone'], providerConfigs: [ @@ -1218,13 +1237,42 @@ describe('admin.auth', () => { }, ], }; - const projectConfigOption1: UpdateProjectConfigRequest = { - smsRegionConfig: { - allowByDefault: { - disallowedRegions: ['AC', 'AD'], + const mfaSmsEnabledTotpDisabledConfig: MultiFactorConfig = { + state: 'ENABLED', + factorIds: ['phone'], + providerConfigs: [ + { + state: 'DISABLED', + totpProviderConfig: {}, } + ], + }; + const passwordConfig: PasswordPolicyConfig = { + enforcementState: 'ENFORCE', + forceUpgradeOnSignin: true, + constraints: { + requireUppercase: true, + requireLowercase: true, + requireNonAlphanumeric: true, + requireNumeric: true, + minLength: 8, + maxLength: 30, }, - multiFactorConfig: mfaConfig, + }; + const smsRegionAllowByDefaultConfig: SmsRegionConfig = { + allowByDefault: { + disallowedRegions: ['AC', 'AD'], + } + }; + const smsRegionAllowlistOnlyConfig: SmsRegionConfig = { + allowlistOnly: { + allowedRegions: ['AC', 'AD'], + } + }; + const projectConfigOption1: UpdateProjectConfigRequest = { + smsRegionConfig: smsRegionAllowByDefaultConfig, + multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, + passwordPolicyConfig: passwordConfig, recaptchaConfig: { emailPasswordEnforcementState: 'AUDIT', managedRules: [ @@ -1237,35 +1285,20 @@ describe('admin.auth', () => { }, }; const projectConfigOption2: UpdateProjectConfigRequest = { - smsRegionConfig: { - allowlistOnly: { - allowedRegions: ['AC', 'AD'], - } - }, + smsRegionConfig: smsRegionAllowlistOnlyConfig, recaptchaConfig: { emailPasswordEnforcementState: 'OFF', useAccountDefender: false, }, }; const projectConfigOptionSmsEnabledTotpDisabled: UpdateProjectConfigRequest = { - multiFactorConfig: { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'DISABLED', - totpProviderConfig: {}, - }, - ], - }, + smsRegionConfig: smsRegionAllowlistOnlyConfig, + multiFactorConfig: mfaSmsEnabledTotpDisabledConfig, }; const expectedProjectConfig1: any = { - smsRegionConfig: { - allowByDefault: { - disallowedRegions: ['AC', 'AD'], - } - }, - multiFactorConfig: mfaConfig, + smsRegionConfig: smsRegionAllowByDefaultConfig, + multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, + passwordPolicyConfig: passwordConfig, recaptchaConfig: { emailPasswordEnforcementState: 'AUDIT', managedRules: [ @@ -1278,12 +1311,9 @@ describe('admin.auth', () => { }, }; const expectedProjectConfig2: any = { - smsRegionConfig: { - allowlistOnly: { - allowedRegions: ['AC', 'AD'], - } - }, - multiFactorConfig: mfaConfig, + smsRegionConfig: smsRegionAllowlistOnlyConfig, + multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, + passwordPolicyConfig: passwordConfig, recaptchaConfig: { emailPasswordEnforcementState: 'OFF', managedRules: [ @@ -1295,17 +1325,9 @@ describe('admin.auth', () => { }, }; const expectedProjectConfigSmsEnabledTotpDisabled: any = { - smsRegionConfig: expectedProjectConfig2.smsRegionConfig, - multiFactorConfig: { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'DISABLED', - totpProviderConfig: {}, - } - ], - }, + smsRegionConfig: smsRegionAllowlistOnlyConfig, + multiFactorConfig: mfaSmsEnabledTotpDisabledConfig, + passwordPolicyConfig: passwordConfig, recaptchaConfig: { emailPasswordEnforcementState: 'OFF', managedRules: [ @@ -1346,24 +1368,50 @@ describe('admin.auth', () => { describe('Tenant management operations', () => { let createdTenantId: string; const createdTenants: string[] = []; + const mfaSmsEnabledTotpEnabledConfig: MultiFactorConfig = { + state: 'ENABLED', + factorIds: ['phone'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], + }; + const mfaSmsEnabledTotpDisabledConfig: MultiFactorConfig = { + state: 'ENABLED', + factorIds: ['phone'], + providerConfigs: [ + { + state: 'DISABLED', + totpProviderConfig: {}, + } + ], + } + const mfaSmsDisabledTotpEnabledConfig: MultiFactorConfig = { + state: 'DISABLED', + factorIds: [], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: {}, + } + ], + } + const smsRegionAllowByDefaultConfig: SmsRegionConfig = { + allowByDefault: { + disallowedRegions: ['AC', 'AD'], + } + } const tenantOptions: CreateTenantRequest = { displayName: 'testTenant1', emailSignInConfig: { enabled: true, passwordRequired: true, }, - multiFactorConfig: { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'ENABLED', - totpProviderConfig: { - adjacentIntervals: 5, - }, - }, - ], - }, + multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, // Add random phone number / code pairs. testPhoneNumbers: { '+16505551234': '019287', @@ -1377,18 +1425,7 @@ describe('admin.auth', () => { passwordRequired: true, }, anonymousSignInEnabled: false, - multiFactorConfig: { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'ENABLED', - totpProviderConfig: { - adjacentIntervals: 5, - }, - }, - ], - }, + multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, // These test phone numbers will not be checked when running integration // tests against the emulator suite and are ignored in auth emulator // altogether. For more information, please refer to this section of the @@ -1405,18 +1442,7 @@ describe('admin.auth', () => { passwordRequired: true, }, anonymousSignInEnabled: false, - multiFactorConfig: { - state: 'DISABLED', - factorIds: [], - providerConfigs: [ - { - state: 'ENABLED', - totpProviderConfig: { - adjacentIntervals: 5, - }, - }, - ], - }, + multiFactorConfig: mfaSmsDisabledTotpEnabledConfig, // Test phone numbers will not be checked when running integration tests // against emulator suite. For more information, please refer to: // go/firebase-auth-emulator-dd#heading=h.odk06so2ydjd @@ -1441,21 +1467,8 @@ describe('admin.auth', () => { passwordRequired: false, }, anonymousSignInEnabled: false, - multiFactorConfig: { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'ENABLED', - totpProviderConfig: {}, - }, - ], - }, - smsRegionConfig: { - allowByDefault: { - disallowedRegions: ['AC', 'AD'], - } - }, + multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, + smsRegionConfig: smsRegionAllowByDefaultConfig, recaptchaConfig: { emailPasswordEnforcementState: 'OFF', managedRules: [ @@ -1474,21 +1487,8 @@ describe('admin.auth', () => { passwordRequired: false, }, anonymousSignInEnabled: false, - multiFactorConfig: { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'DISABLED', - totpProviderConfig: {}, - }, - ], - }, - smsRegionConfig: { - allowByDefault: { - disallowedRegions: ['AC', 'AD'], - } - }, + multiFactorConfig: mfaSmsEnabledTotpDisabledConfig, + smsRegionConfig: smsRegionAllowByDefaultConfig, recaptchaConfig: { emailPasswordEnforcementState: 'OFF', managedRules: [ @@ -2076,6 +2076,47 @@ describe('admin.auth', () => { expect(tenant.anonymousSignInEnabled).to.be.false; }); + it('updateTenant() should enforce password policies on tenant', () => { + const passwordConfig: PasswordPolicyConfig = { + enforcementState: 'ENFORCE', + forceUpgradeOnSignin: true, + constraints: { + requireLowercase: true, + requireNonAlphanumeric: true, + requireNumeric: true, + requireUppercase: true, + minLength: 6, + maxLength: 30, + }, + }; + return getAuth().tenantManager().updateTenant(createdTenantId, { passwordPolicyConfig: passwordConfig }) + .then((actualTenant) => { + expect(deepCopy(actualTenant.passwordPolicyConfig)).to.deep.equal(passwordConfig as any); + }); + }); + + it('updateTenant() should disable password policies on tenant', () => { + const passwordConfig: PasswordPolicyConfig = { + enforcementState: 'OFF', + }; + const expectedPasswordConfig: any = { + enforcementState: 'OFF', + forceUpgradeOnSignin: false, + constraints: { + requireLowercase: false, + requireNonAlphanumeric: false, + requireNumeric: false, + requireUppercase: false, + minLength: 6, + maxLength: 4096, + }, + }; + return getAuth().tenantManager().updateTenant(createdTenantId, { passwordPolicyConfig: passwordConfig }) + .then((actualTenant) => { + expect(deepCopy(actualTenant.passwordPolicyConfig)).to.deep.equal(expectedPasswordConfig); + }); + }); + it('listTenants() should resolve with expected number of tenants', () => { const allTenantIds: string[] = []; const tenantOptions2 = deepCopy(tenantOptions); diff --git a/test/unit/auth/auth-config.spec.ts b/test/unit/auth/auth-config.spec.ts index 79c8609134..b94db6d38c 100644 --- a/test/unit/auth/auth-config.spec.ts +++ b/test/unit/auth/auth-config.spec.ts @@ -26,6 +26,8 @@ import { OIDCConfigServerResponse, EmailSignInConfig, MultiFactorAuthConfig, validateTestPhoneNumbers, MAXIMUM_TEST_PHONE_NUMBERS, + PasswordPolicyAuthConfig, + CustomStrengthOptionsConfig, } from '../../../src/auth/auth-config'; import { SAMLUpdateAuthProviderRequest, OIDCUpdateAuthProviderRequest, @@ -1237,4 +1239,60 @@ describe('OIDCConfig', () => { }); }); }); -}); + describe('PasswordPolicyAuthConfig',() => { + describe('constructor',() => { + const validConfig = new PasswordPolicyAuthConfig({ + passwordPolicyEnforcementState: 'ENFORCE', + passwordPolicyVersions: [ + { + customStrengthOptions: { + containsNumericCharacter: true, + containsLowercaseCharacter: true, + containsNonAlphanumericCharacter: true, + containsUppercaseCharacter: true, + minPasswordLength: 8, + maxPasswordLength: 30, + }, + }, + ], + forceUpgradeOnSignin: true, + }); + + it('should throw an error on missing state',() => { + expect(() => new PasswordPolicyAuthConfig({ + passwordPolicyVersions: [ + { + customStrengthOptions: {}, + } + ], + } as any)).to.throw('INTERNAL ASSERT FAILED: Invalid password policy configuration response'); + }); + + it('should set readonly property "enforcementState" to ENFORCE on state enforced',() => { + expect(validConfig.enforcementState).to.equal('ENFORCE'); + }); + + it('should set readonly property "enforcementState" to OFF on state disabling',() => { + const offStateConfig=new PasswordPolicyAuthConfig({ + passwordPolicyEnforcementState: 'OFF', + }); + expect(offStateConfig.enforcementState).to.equal('OFF'); + }); + + it('should set readonly property "constraints"',() => { + const expectedConstraints: CustomStrengthOptionsConfig = { + requireUppercase: true, + requireLowercase: true, + requireNonAlphanumeric: true, + requireNumeric: true, + minLength: 8, + maxLength: 30, + } + expect(validConfig.constraints).to.deep.equal(expectedConstraints); + }); + + it('should set readonly property "forceUpgradeOnSignin"',() => { + expect(validConfig.forceUpgradeOnSignin).to.deep.equal(true); + }); + }); + });}); diff --git a/test/unit/auth/project-config.spec.ts b/test/unit/auth/project-config.spec.ts index 2a02e72cdc..eb32cae88e 100644 --- a/test/unit/auth/project-config.spec.ts +++ b/test/unit/auth/project-config.spec.ts @@ -51,6 +51,22 @@ describe('ProjectConfig', () => { }, ], }, + passwordPolicyConfig: { + passwordPolicyEnforcementState: 'ENFORCE', + forceUpgradeOnSignin: true, + passwordPolicyVersions: [ + { + customStrengthOptions: { + containsLowercaseCharacter: true, + containsNonAlphanumericCharacter: true, + containsNumericCharacter: true, + containsUppercaseCharacter: true, + minPasswordLength: 8, + maxPasswordLength: 30, + }, + }, + ], + }, }; const updateProjectConfigRequest1: UpdateProjectConfigRequest = { @@ -59,6 +75,18 @@ describe('ProjectConfig', () => { disallowedRegions: [ 'AC', 'AD' ], }, }, + passwordPolicyConfig: { + enforcementState: 'ENFORCE', + forceUpgradeOnSignin: true, + constraints: { + requireLowercase: true, + requireNonAlphanumeric: true, + requireNumeric: true, + requireUppercase: true, + minLength: 8, + maxLength: 30, + }, + }, }; const updateProjectConfigRequest2: UpdateProjectConfigRequest = { @@ -241,6 +269,158 @@ describe('ProjectConfig', () => { }).to.throw('"RecaptchaManagedRule.action" must be "BLOCK".'); }); + it('should throw on null PasswordPolicyConfig attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + configOptionsClientRequest.passwordPolicyConfig = null; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig" must be a non-null object.'); + }); + + it('should throw on invalid PasswordPolicyConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.invalidParameter = 'invalid', + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"invalidParameter" is not a valid PasswordPolicyConfig parameter.'); + }); + + it('should throw on missing enforcementState', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + delete tenantOptionsClientRequest.passwordPolicyConfig.enforcementState; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.enforcementState" must be either "ENFORCE" or "OFF".'); + }); + + it('should throw on invalid enforcementState', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.enforcementState = 'INVALID_STATE'; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.enforcementState" must be either "ENFORCE" or "OFF".'); + }); + + it('should throw on invalid forceUpgradeOnSignin', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.forceUpgradeOnSignin = 'INVALID'; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.forceUpgradeOnSignin" must be a boolean.'); + }); + + it('should throw on undefined constraints when state is enforced', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + delete tenantOptionsClientRequest.passwordPolicyConfig.constraints; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.constraints" must be defined.'); + }); + + it('should throw on invalid constraints attribute', ()=> { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.invalidParameter = 'invalid'; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"invalidParameter" is not a valid PasswordPolicyConfig.constraints parameter.'); + }); + + it('should throw on null constraints object', ()=> { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints = null; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.constraints" must be a non-empty object.'); + }); + + it('should throw on invalid constraints object', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints = 'invalid'; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.constraints" must be a non-empty object.'); + }); + + it('should throw on invalid uppercase type', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.requireUppercase = 'invalid'; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.constraints.requireUppercase"' + + ' must be a boolean.'); + }); + + it('should throw on invalid lowercase type', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.requireLowercase = 'invalid'; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.constraints.requireLowercase"' + + ' must be a boolean.'); + }); + + it('should throw on invalid numeric type', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.requireNumeric = 'invalid'; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.constraints.requireNumeric"' + + ' must be a boolean.'); + }); + + it('should throw on invalid non-alphanumeric type', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.requireNonAlphanumeric = 'invalid'; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.constraints.requireNonAlphanumeric"' + + ' must be a boolean.'); + }); + + it('should throw on invalid minLength type', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.minLength = 'invalid'; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.constraints.minLength" must be a number.'); + }); + + it('should throw on invalid maxLength type', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.maxLength = 'invalid'; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.constraints.maxLength" must be a number.'); + }); + + it('should throw on invalid minLength range', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.minLength = 45; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.constraints.minLength"' + + ' must be an integer between 6 and 30, inclusive.'); + }); + + it('should throw on invalid maxLength range', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.maxLength = 5000; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.constraints.maxLength"' + + ' must be greater than or equal to minLength and at max 4096.'); + }); + + it('should throw if minLength is greater than maxLength', () => { + const tenantOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.minLength = 20; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.maxLength = 7; + expect(() => { + ProjectConfig.buildServerRequest(tenantOptionsClientRequest); + }).to.throw('"PasswordPolicyConfig.constraints.maxLength"' + + ' must be greater than or equal to minLength and at max 4096.'); + }); + const nonObjects = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; nonObjects.forEach((request) => { it('should throw on invalid UpdateProjectConfigRequest:' + JSON.stringify(request), () => { @@ -309,6 +489,22 @@ describe('ProjectConfig', () => { ); expect(projectConfig.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig); }); + + it('should set readonly property passwordPolicyConfig', () => { + const expectedPasswordPolicyConfig = { + enforcementState: 'ENFORCE', + forceUpgradeOnSignin: true, + constraints: { + requireLowercase: true, + requireNonAlphanumeric: true, + requireNumeric: true, + requireUppercase: true, + minLength: 8, + maxLength: 30, + }, + }; + expect(projectConfig.passwordPolicyConfig).to.deep.equal(expectedPasswordPolicyConfig); + }); }); describe('toJSON()', () => { @@ -317,7 +513,8 @@ describe('ProjectConfig', () => { expect(new ProjectConfig(serverResponseCopy).toJSON()).to.deep.equal({ smsRegionConfig: deepCopy(serverResponse.smsRegionConfig), multiFactorConfig: deepCopy(serverResponse.mfa), - recaptchaConfig: deepCopy(serverResponse.recaptchaConfig) + recaptchaConfig: deepCopy(serverResponse.recaptchaConfig), + passwordPolicyConfig: deepCopy(serverResponse.passwordPolicyConfig), }); }); @@ -328,7 +525,7 @@ describe('ProjectConfig', () => { delete serverResponseOptionalCopy.recaptchaConfig?.emailPasswordEnforcementState; delete serverResponseOptionalCopy.recaptchaConfig?.managedRules; delete serverResponseOptionalCopy.recaptchaConfig?.useAccountDefender; - + delete serverResponseOptionalCopy.passwordPolicyConfig; expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({ recaptchaConfig: { recaptchaKeys: deepCopy(serverResponse.recaptchaConfig?.recaptchaKeys), diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 27ce7c4d45..0d4d9e8a90 100644 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -20,7 +20,9 @@ import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import { deepCopy } from '../../../src/utils/deep-copy'; -import { EmailSignInConfig, MultiFactorAuthConfig, RecaptchaAuthConfig } from '../../../src/auth/auth-config'; +import { EmailSignInConfig, MultiFactorAuthConfig, RecaptchaAuthConfig, + PasswordPolicyAuthServerConfig, PasswordPolicyConfig, +} from '../../../src/auth/auth-config'; import { TenantServerResponse } from '../../../src/auth/tenant'; import { CreateTenantRequest, UpdateTenantRequest, EmailSignInProviderConfig, Tenant, @@ -45,6 +47,36 @@ describe('Tenant', () => { }, }; + const passwordPolicyClientConfig: PasswordPolicyConfig = { + enforcementState: 'ENFORCE', + forceUpgradeOnSignin: true, + constraints: { + requireLowercase: true, + requireUppercase: true, + requireNonAlphanumeric: true, + requireNumeric: true, + minLength: 8, + maxLength: 30, + } + }; + + const passwordPolicyServerConfig: PasswordPolicyAuthServerConfig = { + passwordPolicyEnforcementState: 'ENFORCE', + forceUpgradeOnSignin: true, + passwordPolicyVersions: [ + { + customStrengthOptions: { + containsLowercaseCharacter: true, + containsNonAlphanumericCharacter: true, + containsNumericCharacter: true, + containsUppercaseCharacter: true, + minPasswordLength: 8, + maxPasswordLength: 30, + }, + }, + ], + }; + const serverRequest: TenantServerResponse = { name: 'projects/project1/tenants/TENANT-ID', displayName: 'TENANT-DISPLAY-NAME', @@ -67,6 +99,7 @@ describe('Tenant', () => { '+16505550676': '985235', }, smsRegionConfig: smsAllowByDefault, + passwordPolicyConfig: passwordPolicyServerConfig, }; const clientRequest: UpdateTenantRequest = { @@ -92,6 +125,7 @@ describe('Tenant', () => { '+16505550676': '985235', }, smsRegionConfig: smsAllowByDefault, + passwordPolicyConfig: passwordPolicyClientConfig, }; const serverRequestWithoutMfa: TenantServerResponse = { @@ -99,6 +133,7 @@ describe('Tenant', () => { displayName: 'TENANT-DISPLAY-NAME', allowPasswordSignup: true, enableEmailLinkSignin: true, + passwordPolicyConfig: passwordPolicyServerConfig, }; const clientRequestWithoutMfa: UpdateTenantRequest = { @@ -107,6 +142,31 @@ describe('Tenant', () => { enabled: true, passwordRequired: false, }, + passwordPolicyConfig: passwordPolicyClientConfig, + }; + + const clientRequestWithRecaptcha: UpdateTenantRequest = { + displayName: 'TENANT-DISPLAY-NAME', + emailSignInConfig: { + enabled: true, + passwordRequired: false, + }, + multiFactorConfig: { + state: 'ENABLED', + factorIds: ['phone'], + }, + testPhoneNumbers: { + '+16505551234': '019287', + '+16505550676': '985235', + }, + recaptchaConfig: { + managedRules: [{ + endScore: 0.2, + action: 'BLOCK' + }], + emailPasswordEnforcementState: 'AUDIT', + useAccountDefender: true, + }, }; const serverResponseWithRecaptcha: TenantServerResponse = { @@ -145,30 +205,6 @@ describe('Tenant', () => { smsRegionConfig: smsAllowByDefault, }; - const clientRequestWithRecaptcha: UpdateTenantRequest = { - displayName: 'TENANT-DISPLAY-NAME', - emailSignInConfig: { - enabled: true, - passwordRequired: false, - }, - multiFactorConfig: { - state: 'ENABLED', - factorIds: ['phone'], - }, - testPhoneNumbers: { - '+16505551234': '019287', - '+16505550676': '985235', - }, - recaptchaConfig: { - managedRules: [{ - endScore: 0.2, - action: 'BLOCK' - }], - emailPasswordEnforcementState: 'AUDIT', - useAccountDefender: true, - }, - }; - describe('buildServerRequest()', () => { const createRequest = true; @@ -356,6 +392,158 @@ describe('Tenant', () => { }).to.throw('SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameters.'); }); + it('should throw on null PasswordPolicyConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig" must be a non-null object.'); + }); + + it('should throw on invalid PasswordPolicyConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.invalidParameter = 'invalid', + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"invalidParameter" is not a valid PasswordPolicyConfig parameter.'); + }); + + it('should throw on missing enforcementState', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + delete tenantOptionsClientRequest.passwordPolicyConfig.enforcementState; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.enforcementState" must be either "ENFORCE" or "OFF".'); + }); + + it('should throw on invalid enforcementState', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.enforcementState = 'INVALID_STATE'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.enforcementState" must be either "ENFORCE" or "OFF".'); + }); + + it('should throw on invalid forceUpgradeOnSignin', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.forceUpgradeOnSignin = 'INVALID'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.forceUpgradeOnSignin" must be a boolean.'); + }); + + it('should throw on undefined constraints when state is enforced', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + delete tenantOptionsClientRequest.passwordPolicyConfig.constraints; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.constraints" must be defined.'); + }); + + it('should throw on invalid constraints attribute', ()=> { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.invalidParameter = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"invalidParameter" is not a valid PasswordPolicyConfig.constraints parameter.'); + }); + + it('should throw on null constraints object', ()=> { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.constraints" must be a non-empty object.'); + }); + + it('should throw on invalid constraints object', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.constraints" must be a non-empty object.'); + }); + + it('should throw on invalid uppercase type', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.requireUppercase = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.requireUppercase"' + + ' must be a boolean.'); + }); + + it('should throw on invalid lowercase type', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.requireLowercase = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.requireLowercase"' + + ' must be a boolean.'); + }); + + it('should throw on invalid numeric type', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.requireNumeric = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.requireNumeric"' + + ' must be a boolean.'); + }); + + it('should throw on invalid non-alphanumeric type', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.requireNonAlphanumeric = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.requireNonAlphanumeric"' + + ' must be a boolean.'); + }); + + it('should throw on invalid minLength type', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.minLength = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.minLength" must be a number.'); + }); + + it('should throw on invalid maxLength type', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.maxLength = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.maxLength" must be a number.'); + }); + + it('should throw on invalid minLength range', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.minLength = 45; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.minLength"' + + ' must be an integer between 6 and 30, inclusive.'); + }); + + it('should throw on invalid maxLength range', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.maxLength = 5000; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.maxLength"' + + ' must be greater than or equal to minLength and at max 4096.'); + }); + + it('should throw if minLength is greater than maxLength', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.minLength = 20; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.maxLength = 7; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.maxLength"' + + ' must be greater than or equal to minLength and at max 4096.'); + }); + it('should not throw on valid client request object', () => { const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha); expect(() => { @@ -575,6 +763,158 @@ describe('Tenant', () => { }).to.throw('SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameters.'); }); + it('should throw on null PasswordPolicyConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig" must be a non-null object.'); + }); + + it('should throw on invalid PasswordPolicyConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.invalidParameter = 'invalid', + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"invalidParameter" is not a valid PasswordPolicyConfig parameter.'); + }); + + it('should throw on missing enforcementState', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + delete tenantOptionsClientRequest.passwordPolicyConfig.enforcementState; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.enforcementState" must be either "ENFORCE" or "OFF".'); + }); + + it('should throw on invalid enforcementState', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.enforcementState = 'INVALID_STATE'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.enforcementState" must be either "ENFORCE" or "OFF".'); + }); + + it('should throw on invalid forceUpgradeOnSignin', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.forceUpgradeOnSignin = 'INVALID'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.forceUpgradeOnSignin" must be a boolean.'); + }); + + it('should throw on undefined constraints when state is enforced', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + delete tenantOptionsClientRequest.passwordPolicyConfig.constraints; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.constraints" must be defined.'); + }); + + it('should throw on invalid constraints attribute', ()=> { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.invalidParameter = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"invalidParameter" is not a valid PasswordPolicyConfig.constraints parameter.'); + }); + + it('should throw on null constraints object', ()=> { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.constraints" must be a non-empty object.'); + }); + + it('should throw on invalid constraints object', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.constraints" must be a non-empty object.'); + }); + + it('should throw on invalid uppercase type', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.requireUppercase = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.requireUppercase"' + + ' must be a boolean.'); + }); + + it('should throw on invalid lowercase type', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.requireLowercase = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.requireLowercase"' + + ' must be a boolean.'); + }); + + it('should throw on invalid numeric type', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.requireNumeric = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.requireNumeric"' + + ' must be a boolean.'); + }); + + it('should throw on invalid non-alphanumeric type', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.requireNonAlphanumeric = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.requireNonAlphanumeric"' + + ' must be a boolean.'); + }); + + it('should throw on invalid minLength type', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.minLength = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.minLength" must be a number.'); + }); + + it('should throw on invalid maxLength type', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.maxLength = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.maxLength" must be a number.'); + }); + + it('should throw on invalid minLength range', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.minLength = 45; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.minLength"' + + ' must be an integer between 6 and 30, inclusive.'); + }); + + it('should throw on invalid maxLength range', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.maxLength = 5000; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.maxLength"' + + ' must be greater than or equal to minLength and at max 4096.'); + }); + + it('should throw if minLength is greater than maxLength', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.minLength = 20; + tenantOptionsClientRequest.passwordPolicyConfig.constraints.maxLength = 7; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"PasswordPolicyConfig.constraints.maxLength"' + + ' must be greater than or equal to minLength and at max 4096.'); + }); + const nonObjects = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; nonObjects.forEach((request) => { it('should throw on invalid CreateTenantRequest:' + JSON.stringify(request), () => { @@ -689,6 +1029,11 @@ describe('Tenant', () => { deepCopy(clientRequest.smsRegionConfig)); }); + it('should set readonly property passwordPolicyConfig', () => { + expect(tenant.passwordPolicyConfig).to.deep.equal( + deepCopy(clientRequest.passwordPolicyConfig)); + }); + it('should throw when no tenant ID is provided', () => { const invalidOptions = deepCopy(serverRequest); // Use resource name that does not include a tenant ID. @@ -738,7 +1083,7 @@ describe('Tenant', () => { delete serverRequestCopyWithoutMfa.testPhoneNumbers; delete serverRequestCopyWithoutMfa.smsRegionConfig; delete serverRequestCopyWithoutMfa.recaptchaConfig; - + delete serverRequestCopyWithoutMfa.passwordPolicyConfig; expect(new Tenant(serverRequestCopyWithoutMfa).toJSON()).to.deep.equal({ tenantId: 'TENANT-ID', displayName: 'TENANT-DISPLAY-NAME',