diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index fe83c48b6e..f52bb65715 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -285,6 +285,7 @@ export interface RecaptchaConfig { emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; managedRules?: RecaptchaManagedRule[]; recaptchaKeys?: RecaptchaKey[]; + useAccountDefender?: boolean; } // @public diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index f4504392e7..d5ba502208 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1520,17 +1520,25 @@ export interface RecaptchaConfig { * The reCAPTCHA keys. */ recaptchaKeys?: RecaptchaKey[]; + + /** + * Whether to use account defender for reCAPTCHA assessment. + * The default value is false. + */ + useAccountDefender?: boolean; } export class RecaptchaAuthConfig implements RecaptchaConfig { public readonly emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; public readonly managedRules?: RecaptchaManagedRule[]; public readonly recaptchaKeys?: RecaptchaKey[]; + public readonly useAccountDefender?: boolean; constructor(recaptchaConfig: RecaptchaConfig) { this.emailPasswordEnforcementState = recaptchaConfig.emailPasswordEnforcementState; this.managedRules = recaptchaConfig.managedRules; this.recaptchaKeys = recaptchaConfig.recaptchaKeys; + this.useAccountDefender = recaptchaConfig.useAccountDefender; } /** @@ -1542,6 +1550,7 @@ export class RecaptchaAuthConfig implements RecaptchaConfig { emailPasswordEnforcementState: true, managedRules: true, recaptchaKeys: true, + useAccountDefender: true, }; if (!validator.isNonNullObject(options)) { @@ -1592,6 +1601,15 @@ export class RecaptchaAuthConfig implements RecaptchaConfig { RecaptchaAuthConfig.validateManagedRule(managedRule); }); } + + if (typeof options.useAccountDefender != 'undefined') { + if (!validator.isBoolean(options.useAccountDefender)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaConfig.useAccountDefender" must be a boolean value".', + ); + } + } } /** @@ -1637,7 +1655,8 @@ export class RecaptchaAuthConfig implements RecaptchaConfig { const json: any = { emailPasswordEnforcementState: this.emailPasswordEnforcementState, managedRules: deepCopy(this.managedRules), - recaptchaKeys: deepCopy(this.recaptchaKeys) + recaptchaKeys: deepCopy(this.recaptchaKeys), + useAccountDefender: this.useAccountDefender, } if (typeof json.emailPasswordEnforcementState === 'undefined') { @@ -1650,6 +1669,10 @@ export class RecaptchaAuthConfig implements RecaptchaConfig { delete json.recaptchaKeys; } + if (typeof json.useAccountDefender === 'undefined') { + delete json.useAccountDefender; + } + return json; } } diff --git a/src/utils/error.ts b/src/utils/error.ts index 1f68bbba55..d4d606ec0e 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -737,6 +737,10 @@ export class AuthClientErrorCode { code: 'invalid-recaptcha-enforcement-state', message: 'reCAPTCHA enforcement state must be either "OFF", "AUDIT" or "ENFORCE".' } + public static RECAPTCHA_NOT_ENABLED = { + code: 'racaptcha-not-enabled', + message: 'reCAPTCHA enterprise is not enabled.' + } } /** @@ -998,6 +1002,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { INVALID_RECAPTCHA_ACTION: 'INVALID_RECAPTCHA_ACTION', // Unrecognized reCAPTCHA enforcement state. INVALID_RECAPTCHA_ENFORCEMENT_STATE: 'INVALID_RECAPTCHA_ENFORCEMENT_STATE', + // reCAPTCHA is not enabled for account defender. + RECAPTCHA_NOT_ENABLED: 'RECAPTCHA_NOT_ENABLED' }; /** @const {ServerToClientCode} Messaging server to client enum error codes. */ diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index cf9444d9bb..08d75e42c7 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -1164,29 +1164,41 @@ describe('admin.auth', () => { recaptchaConfig: { emailPasswordEnforcementState: 'AUDIT', managedRules: [{ endScore: 0.1, action: 'BLOCK' }], + useAccountDefender: true, }, }; const projectConfigOption2: UpdateProjectConfigRequest = { recaptchaConfig: { emailPasswordEnforcementState: 'OFF', + useAccountDefender: false, + }, + }; + const projectConfigOption3: UpdateProjectConfigRequest = { + recaptchaConfig: { + emailPasswordEnforcementState: 'OFF', + useAccountDefender: true, }, }; const expectedProjectConfig1: any = { recaptchaConfig: { emailPasswordEnforcementState: 'AUDIT', managedRules: [{ endScore: 0.1, action: 'BLOCK' }], + useAccountDefender: true, }, }; const expectedProjectConfig2: any = { recaptchaConfig: { emailPasswordEnforcementState: 'OFF', managedRules: [{ endScore: 0.1, action: 'BLOCK' }], + useAccountDefender: false, }, }; it('updateProjectConfig() should resolve with the updated project config', () => { return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption1) .then((actualProjectConfig) => { + // ReCAPTCHA keys are generated differently each time. + delete actualProjectConfig.recaptchaConfig?.recaptchaKeys; expect(actualProjectConfig.toJSON()).to.deep.equal(expectedProjectConfig1); return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption2); }) @@ -1202,6 +1214,11 @@ describe('admin.auth', () => { expect(actualConfigObj).to.deep.equal(expectedProjectConfig2); }); }); + + it('updateProjectConfig() should reject when trying to enable Account Defender while reCAPTCHA is disabled', () => { + return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption3) + .should.eventually.be.rejected.and.have.property('code', 'auth/racaptcha-not-enabled'); + }); }); describe('Tenant management operations', () => { @@ -1268,6 +1285,7 @@ describe('admin.auth', () => { action: 'BLOCK', }, ], + useAccountDefender: true, }, }; const expectedUpdatedTenant2: any = { @@ -1289,6 +1307,7 @@ describe('admin.auth', () => { action: 'BLOCK', }, ], + useAccountDefender: false, }, }; @@ -1764,6 +1783,25 @@ describe('admin.auth', () => { }); }); + it('updateTenant() enable Account Defender should be rejected when tenant reCAPTCHA is disabled', + function () { + // Skipping for now as Emulator resolves this operation, which is not expected. + // TODO: investigate with Rest API and Access team for this behavior. + if (authEmulatorHost) { + return this.skip(); + } + expectedUpdatedTenant.tenantId = createdTenantId; + const updatedOptions: UpdateTenantRequest = { + displayName: expectedUpdatedTenant2.displayName, + recaptchaConfig: { + emailPasswordEnforcementState: 'OFF', + useAccountDefender: true, + }, + }; + return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions) + .should.eventually.be.rejected.and.have.property('code', 'auth/racaptcha-not-enabled'); + }); + it('updateTenant() should be able to enable/disable anon provider', async () => { const tenantManager = getAuth().tenantManager(); let tenant = await tenantManager.createTenant({ diff --git a/test/unit/auth/project-config.spec.ts b/test/unit/auth/project-config.spec.ts index d0a5ec9575..2c0fdf3b9f 100644 --- a/test/unit/auth/project-config.spec.ts +++ b/test/unit/auth/project-config.spec.ts @@ -45,6 +45,7 @@ describe('ProjectConfig', () => { type: 'WEB', key: 'test-key-1' } ], + useAccountDefender: true, } }; @@ -54,7 +55,8 @@ describe('ProjectConfig', () => { managedRules: [ { endScore: 0.2, action: 'BLOCK' - } ] + } ], + useAccountDefender: true, } }; @@ -94,6 +96,17 @@ describe('ProjectConfig', () => { }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".'); }); + const invalidUseAccountDefender = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidUseAccountDefender.forEach((useAccountDefender) => { + it(`should throw given invalid useAccountDefender parameter: ${JSON.stringify(useAccountDefender)}`, () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig.useAccountDefender = useAccountDefender; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".'); + }); + }); + it('should throw on non-array managedRules attribute', () => { const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; configOptionsClientRequest.recaptchaConfig.managedRules = 'non-array'; @@ -166,6 +179,7 @@ describe('ProjectConfig', () => { type: 'WEB', key: 'test-key-1' } ], + useAccountDefender: true, } ); expect(projectConfig.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig); @@ -184,6 +198,7 @@ describe('ProjectConfig', () => { const serverResponseOptionalCopy: ProjectConfigServerResponse = deepCopy(serverResponse); delete serverResponseOptionalCopy.recaptchaConfig?.emailPasswordEnforcementState; delete serverResponseOptionalCopy.recaptchaConfig?.managedRules; + delete serverResponseOptionalCopy.recaptchaConfig?.useAccountDefender; expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({ recaptchaConfig: { diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index f3fa5f6059..5eea19a7a9 100644 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -102,6 +102,7 @@ describe('Tenant', () => { type: 'WEB', key: 'test-key-1' } ], + useAccountDefender: true, } }; @@ -124,7 +125,8 @@ describe('Tenant', () => { endScore: 0.2, action: 'BLOCK' }], - emailPasswordEnforcementState: 'AUDIT' + emailPasswordEnforcementState: 'AUDIT', + useAccountDefender: true, }, }; @@ -212,6 +214,14 @@ describe('Tenant', () => { }).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".'); }); + it('should throw on non-boolean useAccountDefender attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.useAccountDefender = 'yes'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".'); + }); + it('should throw on invalid managedRules attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; tenantOptionsClientRequest.recaptchaConfig.managedRules = @@ -361,6 +371,17 @@ describe('Tenant', () => { }).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".'); }); + const invalidUseAccountDefender = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidUseAccountDefender.forEach((useAccountDefender) => { + it('should throw on non-boolean useAccountDefender attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.useAccountDefender = useAccountDefender; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".'); + }); + }); + it('should throw on invalid managedRules attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; tenantOptionsClientRequest.recaptchaConfig.managedRules = @@ -490,6 +511,7 @@ describe('Tenant', () => { type: 'WEB', key: 'test-key-1' } ], + useAccountDefender: true, }); expect(tenantWithRecaptcha.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig); });