Skip to content

Commit 583f9e0

Browse files
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 13810eb commit 583f9e0

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
@@ -285,6 +285,7 @@ export interface RecaptchaConfig {
285285
emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
286286
managedRules?: RecaptchaManagedRule[];
287287
recaptchaKeys?: RecaptchaKey[];
288+
useAccountDefender?: boolean;
288289
}
289290

290291
// @public

src/auth/auth-config.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1520,17 +1520,25 @@ export interface RecaptchaConfig {
15201520
* The reCAPTCHA keys.
15211521
*/
15221522
recaptchaKeys?: RecaptchaKey[];
1523+
1524+
/**
1525+
* Whether to use account defender for reCAPTCHA assessment.
1526+
* The default value is false.
1527+
*/
1528+
useAccountDefender?: boolean;
15231529
}
15241530

15251531
export class RecaptchaAuthConfig implements RecaptchaConfig {
15261532
public readonly emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
15271533
public readonly managedRules?: RecaptchaManagedRule[];
15281534
public readonly recaptchaKeys?: RecaptchaKey[];
1535+
public readonly useAccountDefender?: boolean;
15291536

15301537
constructor(recaptchaConfig: RecaptchaConfig) {
15311538
this.emailPasswordEnforcementState = recaptchaConfig.emailPasswordEnforcementState;
15321539
this.managedRules = recaptchaConfig.managedRules;
15331540
this.recaptchaKeys = recaptchaConfig.recaptchaKeys;
1541+
this.useAccountDefender = recaptchaConfig.useAccountDefender;
15341542
}
15351543

15361544
/**
@@ -1542,6 +1550,7 @@ export class RecaptchaAuthConfig implements RecaptchaConfig {
15421550
emailPasswordEnforcementState: true,
15431551
managedRules: true,
15441552
recaptchaKeys: true,
1553+
useAccountDefender: true,
15451554
};
15461555

15471556
if (!validator.isNonNullObject(options)) {
@@ -1592,6 +1601,15 @@ export class RecaptchaAuthConfig implements RecaptchaConfig {
15921601
RecaptchaAuthConfig.validateManagedRule(managedRule);
15931602
});
15941603
}
1604+
1605+
if (typeof options.useAccountDefender != 'undefined') {
1606+
if (!validator.isBoolean(options.useAccountDefender)) {
1607+
throw new FirebaseAuthError(
1608+
AuthClientErrorCode.INVALID_CONFIG,
1609+
'"RecaptchaConfig.useAccountDefender" must be a boolean value".',
1610+
);
1611+
}
1612+
}
15951613
}
15961614

15971615
/**
@@ -1637,7 +1655,8 @@ export class RecaptchaAuthConfig implements RecaptchaConfig {
16371655
const json: any = {
16381656
emailPasswordEnforcementState: this.emailPasswordEnforcementState,
16391657
managedRules: deepCopy(this.managedRules),
1640-
recaptchaKeys: deepCopy(this.recaptchaKeys)
1658+
recaptchaKeys: deepCopy(this.recaptchaKeys),
1659+
useAccountDefender: this.useAccountDefender,
16411660
}
16421661

16431662
if (typeof json.emailPasswordEnforcementState === 'undefined') {
@@ -1650,6 +1669,10 @@ export class RecaptchaAuthConfig implements RecaptchaConfig {
16501669
delete json.recaptchaKeys;
16511670
}
16521671

1672+
if (typeof json.useAccountDefender === 'undefined') {
1673+
delete json.useAccountDefender;
1674+
}
1675+
16531676
return json;
16541677
}
16551678
}

src/utils/error.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,10 @@ export class AuthClientErrorCode {
737737
code: 'invalid-recaptcha-enforcement-state',
738738
message: 'reCAPTCHA enforcement state must be either "OFF", "AUDIT" or "ENFORCE".'
739739
}
740+
public static RECAPTCHA_NOT_ENABLED = {
741+
code: 'racaptcha-not-enabled',
742+
message: 'reCAPTCHA enterprise is not enabled.'
743+
}
740744
}
741745

742746
/**
@@ -998,6 +1002,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = {
9981002
INVALID_RECAPTCHA_ACTION: 'INVALID_RECAPTCHA_ACTION',
9991003
// Unrecognized reCAPTCHA enforcement state.
10001004
INVALID_RECAPTCHA_ENFORCEMENT_STATE: 'INVALID_RECAPTCHA_ENFORCEMENT_STATE',
1005+
// reCAPTCHA is not enabled for account defender.
1006+
RECAPTCHA_NOT_ENABLED: 'RECAPTCHA_NOT_ENABLED'
10011007
};
10021008

10031009
/** @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
@@ -1164,29 +1164,41 @@ describe('admin.auth', () => {
11641164
recaptchaConfig: {
11651165
emailPasswordEnforcementState: 'AUDIT',
11661166
managedRules: [{ endScore: 0.1, action: 'BLOCK' }],
1167+
useAccountDefender: true,
11671168
},
11681169
};
11691170
const projectConfigOption2: UpdateProjectConfigRequest = {
11701171
recaptchaConfig: {
11711172
emailPasswordEnforcementState: 'OFF',
1173+
useAccountDefender: false,
1174+
},
1175+
};
1176+
const projectConfigOption3: UpdateProjectConfigRequest = {
1177+
recaptchaConfig: {
1178+
emailPasswordEnforcementState: 'OFF',
1179+
useAccountDefender: true,
11721180
},
11731181
};
11741182
const expectedProjectConfig1: any = {
11751183
recaptchaConfig: {
11761184
emailPasswordEnforcementState: 'AUDIT',
11771185
managedRules: [{ endScore: 0.1, action: 'BLOCK' }],
1186+
useAccountDefender: true,
11781187
},
11791188
};
11801189
const expectedProjectConfig2: any = {
11811190
recaptchaConfig: {
11821191
emailPasswordEnforcementState: 'OFF',
11831192
managedRules: [{ endScore: 0.1, action: 'BLOCK' }],
1193+
useAccountDefender: false,
11841194
},
11851195
};
11861196

11871197
it('updateProjectConfig() should resolve with the updated project config', () => {
11881198
return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption1)
11891199
.then((actualProjectConfig) => {
1200+
// ReCAPTCHA keys are generated differently each time.
1201+
delete actualProjectConfig.recaptchaConfig?.recaptchaKeys;
11901202
expect(actualProjectConfig.toJSON()).to.deep.equal(expectedProjectConfig1);
11911203
return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption2);
11921204
})
@@ -1202,6 +1214,11 @@ describe('admin.auth', () => {
12021214
expect(actualConfigObj).to.deep.equal(expectedProjectConfig2);
12031215
});
12041216
});
1217+
1218+
it('updateProjectConfig() should reject when trying to enable Account Defender while reCAPTCHA is disabled', () => {
1219+
return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption3)
1220+
.should.eventually.be.rejected.and.have.property('code', 'auth/racaptcha-not-enabled');
1221+
});
12051222
});
12061223

12071224
describe('Tenant management operations', () => {
@@ -1268,6 +1285,7 @@ describe('admin.auth', () => {
12681285
action: 'BLOCK',
12691286
},
12701287
],
1288+
useAccountDefender: true,
12711289
},
12721290
};
12731291
const expectedUpdatedTenant2: any = {
@@ -1289,6 +1307,7 @@ describe('admin.auth', () => {
12891307
action: 'BLOCK',
12901308
},
12911309
],
1310+
useAccountDefender: false,
12921311
},
12931312
};
12941313

@@ -1764,6 +1783,25 @@ describe('admin.auth', () => {
17641783
});
17651784
});
17661785

1786+
it('updateTenant() enable Account Defender should be rejected when tenant reCAPTCHA is disabled',
1787+
function () {
1788+
// Skipping for now as Emulator resolves this operation, which is not expected.
1789+
// TODO: investigate with Rest API and Access team for this behavior.
1790+
if (authEmulatorHost) {
1791+
return this.skip();
1792+
}
1793+
expectedUpdatedTenant.tenantId = createdTenantId;
1794+
const updatedOptions: UpdateTenantRequest = {
1795+
displayName: expectedUpdatedTenant2.displayName,
1796+
recaptchaConfig: {
1797+
emailPasswordEnforcementState: 'OFF',
1798+
useAccountDefender: true,
1799+
},
1800+
};
1801+
return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions)
1802+
.should.eventually.be.rejected.and.have.property('code', 'auth/racaptcha-not-enabled');
1803+
});
1804+
17671805
it('updateTenant() should be able to enable/disable anon provider', async () => {
17681806
const tenantManager = getAuth().tenantManager();
17691807
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
@@ -45,6 +45,7 @@ describe('ProjectConfig', () => {
4545
type: 'WEB',
4646
key: 'test-key-1' }
4747
],
48+
useAccountDefender: true,
4849
}
4950
};
5051

@@ -54,7 +55,8 @@ describe('ProjectConfig', () => {
5455
managedRules: [ {
5556
endScore: 0.2,
5657
action: 'BLOCK'
57-
} ]
58+
} ],
59+
useAccountDefender: true,
5860
}
5961
};
6062

@@ -94,6 +96,17 @@ describe('ProjectConfig', () => {
9496
}).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".');
9597
});
9698

99+
const invalidUseAccountDefender = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop];
100+
invalidUseAccountDefender.forEach((useAccountDefender) => {
101+
it(`should throw given invalid useAccountDefender parameter: ${JSON.stringify(useAccountDefender)}`, () => {
102+
const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any;
103+
configOptionsClientRequest.recaptchaConfig.useAccountDefender = useAccountDefender;
104+
expect(() => {
105+
ProjectConfig.buildServerRequest(configOptionsClientRequest);
106+
}).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".');
107+
});
108+
});
109+
97110
it('should throw on non-array managedRules attribute', () => {
98111
const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any;
99112
configOptionsClientRequest.recaptchaConfig.managedRules = 'non-array';
@@ -166,6 +179,7 @@ describe('ProjectConfig', () => {
166179
type: 'WEB',
167180
key: 'test-key-1' }
168181
],
182+
useAccountDefender: true,
169183
}
170184
);
171185
expect(projectConfig.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig);
@@ -184,6 +198,7 @@ describe('ProjectConfig', () => {
184198
const serverResponseOptionalCopy: ProjectConfigServerResponse = deepCopy(serverResponse);
185199
delete serverResponseOptionalCopy.recaptchaConfig?.emailPasswordEnforcementState;
186200
delete serverResponseOptionalCopy.recaptchaConfig?.managedRules;
201+
delete serverResponseOptionalCopy.recaptchaConfig?.useAccountDefender;
187202

188203
expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({
189204
recaptchaConfig: {

test/unit/auth/tenant.spec.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ describe('Tenant', () => {
102102
type: 'WEB',
103103
key: 'test-key-1' }
104104
],
105+
useAccountDefender: true,
105106
}
106107
};
107108

@@ -124,7 +125,8 @@ describe('Tenant', () => {
124125
endScore: 0.2,
125126
action: 'BLOCK'
126127
}],
127-
emailPasswordEnforcementState: 'AUDIT'
128+
emailPasswordEnforcementState: 'AUDIT',
129+
useAccountDefender: true,
128130
},
129131
};
130132

@@ -212,6 +214,14 @@ describe('Tenant', () => {
212214
}).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".');
213215
});
214216

217+
it('should throw on non-boolean useAccountDefender attribute', () => {
218+
const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any;
219+
tenantOptionsClientRequest.recaptchaConfig.useAccountDefender = 'yes';
220+
expect(() => {
221+
Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest);
222+
}).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".');
223+
});
224+
215225
it('should throw on invalid managedRules attribute', () => {
216226
const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any;
217227
tenantOptionsClientRequest.recaptchaConfig.managedRules =
@@ -361,6 +371,17 @@ describe('Tenant', () => {
361371
}).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".');
362372
});
363373

374+
const invalidUseAccountDefender = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop];
375+
invalidUseAccountDefender.forEach((useAccountDefender) => {
376+
it('should throw on non-boolean useAccountDefender attribute', () => {
377+
const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any;
378+
tenantOptionsClientRequest.recaptchaConfig.useAccountDefender = useAccountDefender;
379+
expect(() => {
380+
Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest);
381+
}).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".');
382+
});
383+
});
384+
364385
it('should throw on invalid managedRules attribute', () => {
365386
const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any;
366387
tenantOptionsClientRequest.recaptchaConfig.managedRules =
@@ -490,6 +511,7 @@ describe('Tenant', () => {
490511
type: 'WEB',
491512
key: 'test-key-1' }
492513
],
514+
useAccountDefender: true,
493515
});
494516
expect(tenantWithRecaptcha.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig);
495517
});

0 commit comments

Comments
 (0)