Skip to content

Commit f3094c8

Browse files
committed
Account defender support for reCAPTCHA (#1616)
* Support use_account_defender add-on feature for reCAPTCHA config. * Added integration test for account defender feature
1 parent 50ef232 commit f3094c8

File tree

6 files changed

+108
-3
lines changed

6 files changed

+108
-3
lines changed

etc/firebase-admin.auth.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ export interface RecaptchaConfig {
363363
emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
364364
managedRules?: RecaptchaManagedRule[];
365365
recaptchaKeys?: RecaptchaKey[];
366+
useAccountDefender?: boolean;
366367
}
367368

368369
// @public

src/auth/auth-config.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1663,17 +1663,25 @@ export interface RecaptchaConfig {
16631663
* The reCAPTCHA keys.
16641664
*/
16651665
recaptchaKeys?: RecaptchaKey[];
1666+
1667+
/**
1668+
* Whether to use account defender for reCAPTCHA assessment.
1669+
* The default value is false.
1670+
*/
1671+
useAccountDefender?: boolean;
16661672
}
16671673

16681674
export class RecaptchaAuthConfig implements RecaptchaConfig {
16691675
public readonly emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
16701676
public readonly managedRules?: RecaptchaManagedRule[];
16711677
public readonly recaptchaKeys?: RecaptchaKey[];
1678+
public readonly useAccountDefender?: boolean;
16721679

16731680
constructor(recaptchaConfig: RecaptchaConfig) {
16741681
this.emailPasswordEnforcementState = recaptchaConfig.emailPasswordEnforcementState;
16751682
this.managedRules = recaptchaConfig.managedRules;
16761683
this.recaptchaKeys = recaptchaConfig.recaptchaKeys;
1684+
this.useAccountDefender = recaptchaConfig.useAccountDefender;
16771685
}
16781686

16791687
/**
@@ -1685,6 +1693,7 @@ export class RecaptchaAuthConfig implements RecaptchaConfig {
16851693
emailPasswordEnforcementState: true,
16861694
managedRules: true,
16871695
recaptchaKeys: true,
1696+
useAccountDefender: true,
16881697
};
16891698

16901699
if (!validator.isNonNullObject(options)) {
@@ -1735,6 +1744,15 @@ export class RecaptchaAuthConfig implements RecaptchaConfig {
17351744
RecaptchaAuthConfig.validateManagedRule(managedRule);
17361745
});
17371746
}
1747+
1748+
if (typeof options.useAccountDefender != 'undefined') {
1749+
if (!validator.isBoolean(options.useAccountDefender)) {
1750+
throw new FirebaseAuthError(
1751+
AuthClientErrorCode.INVALID_CONFIG,
1752+
'"RecaptchaConfig.useAccountDefender" must be a boolean value".',
1753+
);
1754+
}
1755+
}
17381756
}
17391757

17401758
/**
@@ -1780,7 +1798,8 @@ export class RecaptchaAuthConfig implements RecaptchaConfig {
17801798
const json: any = {
17811799
emailPasswordEnforcementState: this.emailPasswordEnforcementState,
17821800
managedRules: deepCopy(this.managedRules),
1783-
recaptchaKeys: deepCopy(this.recaptchaKeys)
1801+
recaptchaKeys: deepCopy(this.recaptchaKeys),
1802+
useAccountDefender: this.useAccountDefender,
17841803
}
17851804

17861805
if (typeof json.emailPasswordEnforcementState === 'undefined') {
@@ -1793,6 +1812,10 @@ export class RecaptchaAuthConfig implements RecaptchaConfig {
17931812
delete json.recaptchaKeys;
17941813
}
17951814

1815+
if (typeof json.useAccountDefender === 'undefined') {
1816+
delete json.useAccountDefender;
1817+
}
1818+
17961819
return json;
17971820
}
17981821
}

src/utils/error.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,10 @@ export class AuthClientErrorCode {
745745
code: 'invalid-recaptcha-enforcement-state',
746746
message: 'reCAPTCHA enforcement state must be either "OFF", "AUDIT" or "ENFORCE".'
747747
}
748+
public static RECAPTCHA_NOT_ENABLED = {
749+
code: 'racaptcha-not-enabled',
750+
message: 'reCAPTCHA enterprise is not enabled.'
751+
}
748752
}
749753

750754
/**
@@ -1008,6 +1012,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = {
10081012
INVALID_RECAPTCHA_ACTION: 'INVALID_RECAPTCHA_ACTION',
10091013
// Unrecognized reCAPTCHA enforcement state.
10101014
INVALID_RECAPTCHA_ENFORCEMENT_STATE: 'INVALID_RECAPTCHA_ENFORCEMENT_STATE',
1015+
// reCAPTCHA is not enabled for account defender.
1016+
RECAPTCHA_NOT_ENABLED: 'RECAPTCHA_NOT_ENABLED'
10111017
};
10121018

10131019
/** @const {ServerToClientCode} Messaging server to client enum error codes. */

test/integration/auth.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,29 +1265,41 @@ describe('admin.auth', () => {
12651265
recaptchaConfig: {
12661266
emailPasswordEnforcementState: 'AUDIT',
12671267
managedRules: [{ endScore: 0.1, action: 'BLOCK' }],
1268+
useAccountDefender: true,
12681269
},
12691270
};
12701271
const projectConfigOption2: UpdateProjectConfigRequest = {
12711272
recaptchaConfig: {
12721273
emailPasswordEnforcementState: 'OFF',
1274+
useAccountDefender: false,
1275+
},
1276+
};
1277+
const projectConfigOption3: UpdateProjectConfigRequest = {
1278+
recaptchaConfig: {
1279+
emailPasswordEnforcementState: 'OFF',
1280+
useAccountDefender: true,
12731281
},
12741282
};
12751283
const expectedProjectConfig1: any = {
12761284
recaptchaConfig: {
12771285
emailPasswordEnforcementState: 'AUDIT',
12781286
managedRules: [{ endScore: 0.1, action: 'BLOCK' }],
1287+
useAccountDefender: true,
12791288
},
12801289
};
12811290
const expectedProjectConfig2: any = {
12821291
recaptchaConfig: {
12831292
emailPasswordEnforcementState: 'OFF',
12841293
managedRules: [{ endScore: 0.1, action: 'BLOCK' }],
1294+
useAccountDefender: false,
12851295
},
12861296
};
12871297

12881298
it('updateProjectConfig() should resolve with the updated project config', () => {
12891299
return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption1)
12901300
.then((actualProjectConfig) => {
1301+
// ReCAPTCHA keys are generated differently each time.
1302+
delete actualProjectConfig.recaptchaConfig?.recaptchaKeys;
12911303
expect(actualProjectConfig.toJSON()).to.deep.equal(expectedProjectConfig1);
12921304
return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption2);
12931305
})
@@ -1303,6 +1315,11 @@ describe('admin.auth', () => {
13031315
expect(actualConfigObj).to.deep.equal(expectedProjectConfig2);
13041316
});
13051317
});
1318+
1319+
it('updateProjectConfig() should reject when trying to enable Account Defender while reCAPTCHA is disabled', () => {
1320+
return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption3)
1321+
.should.eventually.be.rejected.and.have.property('code', 'auth/racaptcha-not-enabled');
1322+
});
13061323
});
13071324

13081325
describe('Tenant management operations', () => {
@@ -1369,6 +1386,7 @@ describe('admin.auth', () => {
13691386
action: 'BLOCK',
13701387
},
13711388
],
1389+
useAccountDefender: true,
13721390
},
13731391
};
13741392
const expectedUpdatedTenant2: any = {
@@ -1395,6 +1413,7 @@ describe('admin.auth', () => {
13951413
action: 'BLOCK',
13961414
},
13971415
],
1416+
useAccountDefender: false,
13981417
},
13991418
};
14001419

@@ -1893,6 +1912,25 @@ describe('admin.auth', () => {
18931912
});
18941913
});
18951914

1915+
it('updateTenant() enable Account Defender should be rejected when tenant reCAPTCHA is disabled',
1916+
function () {
1917+
// Skipping for now as Emulator resolves this operation, which is not expected.
1918+
// TODO: investigate with Rest API and Access team for this behavior.
1919+
if (authEmulatorHost) {
1920+
return this.skip();
1921+
}
1922+
expectedUpdatedTenant.tenantId = createdTenantId;
1923+
const updatedOptions: UpdateTenantRequest = {
1924+
displayName: expectedUpdatedTenant2.displayName,
1925+
recaptchaConfig: {
1926+
emailPasswordEnforcementState: 'OFF',
1927+
useAccountDefender: true,
1928+
},
1929+
};
1930+
return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions)
1931+
.should.eventually.be.rejected.and.have.property('code', 'auth/racaptcha-not-enabled');
1932+
});
1933+
18961934
it('updateTenant() should be able to enable/disable anon provider', async () => {
18971935
const tenantManager = getAuth().tenantManager();
18981936
let tenant = await tenantManager.createTenant({

test/unit/auth/project-config.spec.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ describe('ProjectConfig', () => {
7777
type: 'WEB',
7878
key: 'test-key-1' }
7979
],
80+
useAccountDefender: true,
8081
}
8182
};
8283

@@ -86,7 +87,8 @@ describe('ProjectConfig', () => {
8687
managedRules: [ {
8788
endScore: 0.2,
8889
action: 'BLOCK'
89-
} ]
90+
} ],
91+
useAccountDefender: true,
9092
}
9193
};
9294

@@ -191,6 +193,17 @@ describe('ProjectConfig', () => {
191193
}).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".');
192194
});
193195

196+
const invalidUseAccountDefender = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop];
197+
invalidUseAccountDefender.forEach((useAccountDefender) => {
198+
it(`should throw given invalid useAccountDefender parameter: ${JSON.stringify(useAccountDefender)}`, () => {
199+
const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any;
200+
configOptionsClientRequest.recaptchaConfig.useAccountDefender = useAccountDefender;
201+
expect(() => {
202+
ProjectConfig.buildServerRequest(configOptionsClientRequest);
203+
}).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".');
204+
});
205+
});
206+
194207
it('should throw on non-array managedRules attribute', () => {
195208
const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any;
196209
configOptionsClientRequest.recaptchaConfig.managedRules = 'non-array';
@@ -264,6 +277,7 @@ describe('ProjectConfig', () => {
264277
type: 'WEB',
265278
key: 'test-key-1' }
266279
],
280+
useAccountDefender: true,
267281
}
268282
);
269283
expect(projectConfig.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig);
@@ -284,6 +298,7 @@ describe('ProjectConfig', () => {
284298
delete serverResponseOptionalCopy.smsRegionConfig;
285299
delete serverResponseOptionalCopy.recaptchaConfig?.emailPasswordEnforcementState;
286300
delete serverResponseOptionalCopy.recaptchaConfig?.managedRules;
301+
delete serverResponseOptionalCopy.recaptchaConfig?.useAccountDefender;
287302

288303
expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({
289304
recaptchaConfig: {

test/unit/auth/tenant.spec.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ describe('Tenant', () => {
116116
type: 'WEB',
117117
key: 'test-key-1' }
118118
],
119+
useAccountDefender: true,
119120
},
120121
smsRegionConfig: smsAllowByDefault,
121122
};
@@ -139,7 +140,8 @@ describe('Tenant', () => {
139140
endScore: 0.2,
140141
action: 'BLOCK'
141142
}],
142-
emailPasswordEnforcementState: 'AUDIT'
143+
emailPasswordEnforcementState: 'AUDIT',
144+
useAccountDefender: true,
143145
},
144146
};
145147

@@ -227,6 +229,14 @@ describe('Tenant', () => {
227229
}).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".');
228230
});
229231

232+
it('should throw on non-boolean useAccountDefender attribute', () => {
233+
const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any;
234+
tenantOptionsClientRequest.recaptchaConfig.useAccountDefender = 'yes';
235+
expect(() => {
236+
Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest);
237+
}).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".');
238+
});
239+
230240
it('should throw on invalid managedRules attribute', () => {
231241
const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any;
232242
tenantOptionsClientRequest.recaptchaConfig.managedRules =
@@ -434,6 +444,17 @@ describe('Tenant', () => {
434444
}).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".');
435445
});
436446

447+
const invalidUseAccountDefender = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop];
448+
invalidUseAccountDefender.forEach((useAccountDefender) => {
449+
it('should throw on non-boolean useAccountDefender attribute', () => {
450+
const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any;
451+
tenantOptionsClientRequest.recaptchaConfig.useAccountDefender = useAccountDefender;
452+
expect(() => {
453+
Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest);
454+
}).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".');
455+
});
456+
});
457+
437458
it('should throw on invalid managedRules attribute', () => {
438459
const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any;
439460
tenantOptionsClientRequest.recaptchaConfig.managedRules =
@@ -621,6 +642,7 @@ describe('Tenant', () => {
621642
type: 'WEB',
622643
key: 'test-key-1' }
623644
],
645+
useAccountDefender: true,
624646
});
625647
expect(tenantWithRecaptcha.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig);
626648
});

0 commit comments

Comments
 (0)