From b3000939ee746df88aec141a51705e6e725df00d Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Mon, 14 Feb 2022 13:45:55 -0800 Subject: [PATCH 1/5] Defined reCAPTCHA config. (#1574) * Defined reCAPTCHA config. - Added reCAPTCHA protection states. - Added reCAPTCHA action rule. - Added reCAPTCHA key config. --- src/auth/auth-config.ts | 65 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index ce45713f97..a640cc3534 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1451,3 +1451,68 @@ export class OIDCConfig implements OIDCAuthProviderConfig { }; } } + +/** +* Enforcement state of reCAPTCHA protection. +* - 'OFF': Unenforced. +* - 'AUDIT': Assessment is created but result is not used to enforce. +* - 'ENFORCE': Assessment is created and result is used to enforce. +*/ +export type RecaptchaProviderEnforcementState = 'OFF' | 'AUDIT' | 'ENFORCE'; + +/** +* The actions for reCAPTCHA-protected requests. +* - 'BLOCK': The reCAPTCHA-protected request will be blocked. +*/ +export type RecaptchaAction = 'BLOCK'; + +/** +* The config for a reCAPTCHA action rule. +*/ +export interface RecaptchaManagedRule { + /** + * The action will be enforced if the reCAPTCHA score of a request is larger than endScore. + */ + endScore: number; + /** + * The action for reCAPTCHA-protected requests. + */ + action?: RecaptchaAction; +} + +/** + * The key's platform type: only web supported now. + */ +export type RecaptchaKeyClientType = 'WEB'; + +/** + * The reCAPTCHA key config. + */ +export interface RecaptchaKey { + /** + * The key's client platform type. + */ + type?: RecaptchaKeyClientType; + + /** + * The reCAPTCHA site key. + */ + key: string; +} + +export interface RecaptchaConfig { + /** + * The enforcement state of email password provider. + */ + emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + + /** + * The reCAPTCHA managed rules. + */ + managedRules: RecaptchaManagedRule[]; + + /** + * The reCAPTCHA keys. + */ + recaptchaKeys?: RecaptchaKey[]; +} From 0d1a56f02b5c765a81467ec33f6ac5b2fb357a89 Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Mon, 7 Mar 2022 13:38:34 -0800 Subject: [PATCH 2/5] Create/Update tenant with ReCAPTCHA Config (#1586) * Support reCaptcha config /create update on tenants. - Support create and update tenants with reCaptcha config. - Added reCaptcha unit tests on tenants operations. --- etc/firebase-admin.auth.api.md | 30 +++++ src/auth/auth-config.ts | 156 ++++++++++++++++++++++++-- src/auth/index.ts | 6 + src/auth/tenant.ts | 35 +++++- test/unit/auth/tenant.spec.ts | 195 ++++++++++++++++++++++++++++++++- 5 files changed, 406 insertions(+), 16 deletions(-) diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index 36b2dcf686..83866b5081 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -264,6 +264,34 @@ export interface ProviderIdentifier { providerUid: string; } +// @public +export type RecaptchaAction = 'BLOCK'; + +// @public +export interface RecaptchaConfig { + emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + managedRules?: RecaptchaManagedRule[]; + recaptchaKeys?: RecaptchaKey[]; +} + +// @public +export interface RecaptchaKey { + key: string; + type?: RecaptchaKeyClientType; +} + +// @public +export type RecaptchaKeyClientType = 'WEB'; + +// @public +export interface RecaptchaManagedRule { + action?: RecaptchaAction; + endScore: number; +} + +// @public +export type RecaptchaProviderEnforcementState = 'OFF' | 'AUDIT' | 'ENFORCE'; + // @public export interface SAMLAuthProviderConfig extends BaseAuthProviderConfig { callbackURL?: string; @@ -296,6 +324,7 @@ export class Tenant { readonly displayName?: string; get emailSignInConfig(): EmailSignInProviderConfig | undefined; get multiFactorConfig(): MultiFactorConfig | undefined; + get recaptchaConfig(): RecaptchaConfig | undefined; readonly tenantId: string; readonly testPhoneNumbers?: { [phoneNumber: string]: string; @@ -358,6 +387,7 @@ export interface UpdateTenantRequest { displayName?: string; emailSignInConfig?: EmailSignInProviderConfig; multiFactorConfig?: MultiFactorConfig; + recaptchaConfig?: RecaptchaConfig; testPhoneNumbers?: { [phoneNumber: string]: string; } | null; diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index a640cc3534..aecd686201 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1497,22 +1497,156 @@ export interface RecaptchaKey { /** * The reCAPTCHA site key. */ - key: string; + key: string; } +/** + * The request interface for updating a reCAPTCHA Config. + */ export interface RecaptchaConfig { - /** + /** * The enforcement state of email password provider. */ - emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + /** + * The reCAPTCHA managed rules. + */ + managedRules?: RecaptchaManagedRule[]; - /** - * The reCAPTCHA managed rules. - */ - managedRules: RecaptchaManagedRule[]; + /** + * The reCAPTCHA keys. + */ + recaptchaKeys?: RecaptchaKey[]; +} - /** - * The reCAPTCHA keys. - */ - recaptchaKeys?: RecaptchaKey[]; +export class RecaptchaAuthConfig implements RecaptchaConfig { + public readonly emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + public readonly managedRules?: RecaptchaManagedRule[]; + public readonly recaptchaKeys?: RecaptchaKey[]; + + constructor(recaptchaConfig: RecaptchaConfig) { + this.emailPasswordEnforcementState = recaptchaConfig.emailPasswordEnforcementState; + this.managedRules = recaptchaConfig.managedRules; + this.recaptchaKeys = recaptchaConfig.recaptchaKeys; + } + + /** + * Validates the RecaptchaConfig options object. Throws an error on failure. + * @param options - The options object to validate. + */ + public static validate(options: RecaptchaConfig): void { + const validKeys = { + emailPasswordEnforcementState: true, + managedRules: true, + recaptchaKeys: true, + }; + + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaConfig" must be a non-null object.', + ); + } + + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid RecaptchaConfig parameter.`, + ); + } + } + + // Validation + if (typeof options.emailPasswordEnforcementState !== undefined) { + if (!validator.isNonEmptyString(options.emailPasswordEnforcementState)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"RecaptchaConfig.emailPasswordEnforcementState" must be a valid non-empty string.', + ); + } + + if (options.emailPasswordEnforcementState !== 'OFF' && + options.emailPasswordEnforcementState !== 'AUDIT' && + options.emailPasswordEnforcementState !== 'ENFORCE') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".', + ); + } + } + + if (typeof options.managedRules !== 'undefined') { + // Validate array + if (!validator.isArray(options.managedRules)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".', + ); + } + // Validate each rule of the array + options.managedRules.forEach((managedRule) => { + RecaptchaAuthConfig.validateManagedRule(managedRule); + }); + } + } + + /** + * Validate each element in ManagedRule array + * @param options - The options object to validate. + */ + private static validateManagedRule(options: RecaptchaManagedRule): void { + const validKeys = { + endScore: true, + action: true, + } + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaManagedRule" 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 RecaptchaManagedRule parameter.`, + ); + } + } + + // Validate content. + if (typeof options.action !== 'undefined' && + options.action !== 'BLOCK') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaManagedRule.action" must be "BLOCK".', + ); + } + } + + /** + * Returns a JSON-serializable representation of this object. + * @returns The JSON-serializable object representation of the ReCaptcha config instance + */ + public toJSON(): object { + const json: any = { + emailPasswordEnforcementState: this.emailPasswordEnforcementState, + managedRules: deepCopy(this.managedRules), + recaptchaKeys: deepCopy(this.recaptchaKeys) + } + + if (typeof json.emailPasswordEnforcementState === 'undefined') { + delete json.emailPasswordEnforcementState; + } + if (typeof json.managedRules === 'undefined') { + delete json.managedRules; + } + if (typeof json.recaptchaKeys === 'undefined') { + delete json.recaptchaKeys; + } + + return json; + } } diff --git a/src/auth/index.ts b/src/auth/index.ts index 0b92a796cf..978cdfe8a1 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -79,6 +79,12 @@ export { OAuthResponseType, OIDCAuthProviderConfig, OIDCUpdateAuthProviderRequest, + RecaptchaAction, + RecaptchaConfig, + RecaptchaKey, + RecaptchaKeyClientType, + RecaptchaManagedRule, + RecaptchaProviderEnforcementState, SAMLAuthProviderConfig, SAMLUpdateAuthProviderRequest, UserProvider, diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index e489fa3b09..a0ff7b242d 100644 --- a/src/auth/tenant.ts +++ b/src/auth/tenant.ts @@ -21,7 +21,7 @@ import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; import { EmailSignInConfig, EmailSignInConfigServerRequest, MultiFactorAuthServerConfig, MultiFactorConfig, validateTestPhoneNumbers, EmailSignInProviderConfig, - MultiFactorAuthConfig, + MultiFactorAuthConfig, RecaptchaAuthConfig, RecaptchaConfig } from './auth-config'; /** @@ -54,6 +54,11 @@ export interface UpdateTenantRequest { * Passing null clears the previously save phone number / code pairs. */ testPhoneNumbers?: { [phoneNumber: string]: string } | null; + + /** + * The recaptcha configuration to update on the tenant. + */ + recaptchaConfig?: RecaptchaConfig; } /** @@ -68,6 +73,7 @@ export interface TenantOptionsServerRequest extends EmailSignInConfigServerReque enableAnonymousUser?: boolean; mfaConfig?: MultiFactorAuthServerConfig; testPhoneNumbers?: {[key: string]: string}; + recaptchaConfig?: RecaptchaConfig; } /** The tenant server response interface. */ @@ -79,6 +85,7 @@ export interface TenantServerResponse { enableAnonymousUser?: boolean; mfaConfig?: MultiFactorAuthServerConfig; testPhoneNumbers?: {[key: string]: string}; + recaptchaConfig? : RecaptchaConfig; } /** @@ -123,6 +130,10 @@ export class Tenant { private readonly emailSignInConfig_?: EmailSignInConfig; private readonly multiFactorConfig_?: MultiFactorAuthConfig; + /* + * The map conatining the reCAPTCHA config. + */ + private readonly recaptchaConfig_?: RecaptchaAuthConfig; /** * Builds the corresponding server request for a TenantOptions object. * @@ -152,6 +163,9 @@ export class Tenant { // null will clear existing test phone numbers. Translate to empty object. request.testPhoneNumbers = tenantOptions.testPhoneNumbers ?? {}; } + if (typeof tenantOptions.recaptchaConfig !== 'undefined') { + request.recaptchaConfig = tenantOptions.recaptchaConfig; + } return request; } @@ -185,6 +199,7 @@ export class Tenant { anonymousSignInEnabled: true, multiFactorConfig: true, testPhoneNumbers: true, + recaptchaConfig: true, }; const label = createRequest ? 'CreateTenantRequest' : 'UpdateTenantRequest'; if (!validator.isNonNullObject(request)) { @@ -231,6 +246,10 @@ export class Tenant { // This will throw an error if invalid. MultiFactorAuthConfig.buildServerRequest(request.multiFactorConfig); } + // Validate reCAPTCHAConfig type if provided. + if (typeof request.recaptchaConfig !== 'undefined') { + RecaptchaAuthConfig.validate(request.recaptchaConfig); + } } /** @@ -265,6 +284,9 @@ export class Tenant { if (typeof response.testPhoneNumbers !== 'undefined') { this.testPhoneNumbers = deepCopy(response.testPhoneNumbers || {}); } + if (typeof response.recaptchaConfig !== 'undefined') { + this.recaptchaConfig_ = new RecaptchaAuthConfig(response.recaptchaConfig); + } } /** @@ -281,6 +303,13 @@ export class Tenant { return this.multiFactorConfig_; } + /** + * The recaptcha config auth configuration of the current tenant. + */ + get recaptchaConfig(): RecaptchaConfig | undefined { + return this.recaptchaConfig_; + } + /** * Returns a JSON-serializable representation of this object. * @@ -294,6 +323,7 @@ export class Tenant { multiFactorConfig: this.multiFactorConfig_?.toJSON(), anonymousSignInEnabled: this.anonymousSignInEnabled, testPhoneNumbers: this.testPhoneNumbers, + recaptchaConfig: this.recaptchaConfig_?.toJSON(), }; if (typeof json.multiFactorConfig === 'undefined') { delete json.multiFactorConfig; @@ -301,6 +331,9 @@ export class Tenant { if (typeof json.testPhoneNumbers === 'undefined') { delete json.testPhoneNumbers; } + if (typeof json.recaptchaConfig === 'undefined') { + delete json.recaptchaConfig; + } return json; } } diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 0f14856faa..f3fa5f6059 100644 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -20,7 +20,7 @@ import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import { deepCopy } from '../../../src/utils/deep-copy'; -import { EmailSignInConfig, MultiFactorAuthConfig } from '../../../src/auth/auth-config'; +import { EmailSignInConfig, MultiFactorAuthConfig, RecaptchaAuthConfig } from '../../../src/auth/auth-config'; import { TenantServerResponse } from '../../../src/auth/tenant'; import { CreateTenantRequest, UpdateTenantRequest, EmailSignInProviderConfig, Tenant, @@ -79,6 +79,55 @@ describe('Tenant', () => { }, }; + const serverResponseWithRecaptcha: TenantServerResponse = { + name: 'projects/project1/tenants/TENANT-ID', + displayName: 'TENANT-DISPLAY-NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: true, + mfaConfig: { + state: 'ENABLED', + enabledProviders: ['PHONE_SMS'], + }, + testPhoneNumbers: { + '+16505551234': '019287', + '+16505550676': '985235', + }, + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ { + endScore: 0.2, + action: 'BLOCK' + } ], + recaptchaKeys: [ { + type: 'WEB', + key: 'test-key-1' } + ], + } + }; + + 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' + }, + }; + describe('buildServerRequest()', () => { const createRequest = true; @@ -122,6 +171,65 @@ describe('Tenant', () => { }).to.throw('"MultiFactorConfig.state" must be either "ENABLED" or "DISABLED".'); }); + it('should throw on null RecaptchaConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"RecaptchaConfig" must be a non-null object.'); + }); + + it('should throw on invalid RecaptchaConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.invalidParameter = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"invalidParameter" is not a valid RecaptchaConfig parameter.'); + }); + + it('should throw on null emailPasswordEnforcementState attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.emailPasswordEnforcementState = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be a valid non-empty string.'); + }); + + it('should throw on invalid emailPasswordEnforcementState attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig + .emailPasswordEnforcementState = 'INVALID'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".'); + }); + + it('should throw on non-array managedRules attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.managedRules = 'non-array'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".'); + }); + + it('should throw on invalid managedRules attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.managedRules = + [{ 'score': 0.1, 'action': 'BLOCK' }]; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"score" is not a valid RecaptchaManagedRule parameter.'); + }); + + it('should throw on invalid RecaptchaManagedRule.action attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.managedRules = + [{ 'endScore': 0.1, 'action': 'ALLOW' }]; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"RecaptchaManagedRule.action" must be "BLOCK".'); + }); + it('should throw on invalid testPhoneNumbers attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.testPhoneNumbers = 'invalid'; @@ -142,7 +250,7 @@ describe('Tenant', () => { }); it('should not throw on valid client request object', () => { - const tenantOptionsClientRequest = deepCopy(clientRequest); + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha); expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); }).not.to.throw; @@ -212,6 +320,65 @@ describe('Tenant', () => { }).to.throw('"invalid" is not a valid "AuthFactorType".',); }); + it('should throw on null RecaptchaConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"RecaptchaConfig" must be a non-null object.'); + }); + + it('should throw on invalid RecaptchaConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.invalidParameter = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"invalidParameter" is not a valid RecaptchaConfig parameter.'); + }); + + it('should throw on null emailPasswordEnforcementState attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.emailPasswordEnforcementState = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be a valid non-empty string.'); + }); + + it('should throw on invalid emailPasswordEnforcementState attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig + .emailPasswordEnforcementState = 'INVALID'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".'); + }); + + it('should throw on non-array managedRules attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.managedRules = 'non-array'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".'); + }); + + it('should throw on invalid managedRules attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.managedRules = + [{ 'score': 0.1, 'action': 'BLOCK' }]; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"score" is not a valid RecaptchaManagedRule parameter.'); + }); + + it('should throw on invalid RecaptchaManagedRule.action attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.managedRules = + [{ 'endScore': 0.1, 'action': 'ALLOW' }]; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"RecaptchaManagedRule.action" must be "BLOCK".'); + }); + it('should throw on invalid testPhoneNumbers attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.testPhoneNumbers = { 'invalid': '123456' }; @@ -309,6 +476,24 @@ describe('Tenant', () => { expect(tenant.multiFactorConfig).to.deep.equal(expectedMultiFactorConfig); }); + it('should set readonly property recaptchaConfig', () => { + const serverRequestWithRecaptchaCopy: TenantServerResponse = + deepCopy(serverResponseWithRecaptcha); + const tenantWithRecaptcha = new Tenant(serverRequestWithRecaptchaCopy); + const expectedRecaptchaConfig = new RecaptchaAuthConfig({ + emailPasswordEnforcementState: 'AUDIT', + managedRules: [{ + endScore: 0.2, + action: 'BLOCK' + }], + recaptchaKeys: [ { + type: 'WEB', + key: 'test-key-1' } + ], + }); + expect(tenantWithRecaptcha.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig); + }); + it('should set readonly property testPhoneNumbers', () => { expect(tenant.testPhoneNumbers).to.deep.equal( deepCopy(clientRequest.testPhoneNumbers)); @@ -340,7 +525,7 @@ describe('Tenant', () => { }); describe('toJSON()', () => { - const serverRequestCopy: TenantServerResponse = deepCopy(serverRequest); + const serverRequestCopy: TenantServerResponse = deepCopy(serverResponseWithRecaptcha); it('should return the expected object representation of a tenant', () => { expect(new Tenant(serverRequestCopy).toJSON()).to.deep.equal({ tenantId: 'TENANT-ID', @@ -352,13 +537,15 @@ describe('Tenant', () => { anonymousSignInEnabled: false, multiFactorConfig: deepCopy(clientRequest.multiFactorConfig), testPhoneNumbers: deepCopy(clientRequest.testPhoneNumbers), + recaptchaConfig: deepCopy(serverResponseWithRecaptcha.recaptchaConfig), }); }); it('should not populate optional fields if not available', () => { - const serverRequestCopyWithoutMfa: TenantServerResponse = deepCopy(serverRequest); + const serverRequestCopyWithoutMfa: TenantServerResponse = deepCopy(serverResponseWithRecaptcha); delete serverRequestCopyWithoutMfa.mfaConfig; delete serverRequestCopyWithoutMfa.testPhoneNumbers; + delete serverRequestCopyWithoutMfa.recaptchaConfig; expect(new Tenant(serverRequestCopyWithoutMfa).toJSON()).to.deep.equal({ tenantId: 'TENANT-ID', From c01f7fb360c942ffb6f6a47090c80f52b1d13d9b Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Tue, 8 Mar 2022 15:38:31 -0800 Subject: [PATCH 3/5] Project config - Recaptcha config (#1595) * Recaptcha config changes in project config. - Implemented getProjectConfig. - Implemented updateProjectConfig. - Updated error code. - Add Term of Service consents. --- etc/firebase-admin.auth.api.md | 18 ++ src/auth/auth-api-request.ts | 72 ++++++- src/auth/auth-config.ts | 3 + src/auth/auth.ts | 12 ++ src/auth/index.ts | 9 + src/auth/project-config-manager.ts | 67 ++++++ src/auth/project-config.ts | 139 ++++++++++++ src/auth/tenant.ts | 12 +- src/utils/error.ts | 12 ++ test/unit/auth/project-config-manager.spec.ts | 204 ++++++++++++++++++ test/unit/auth/project-config.spec.ts | 195 +++++++++++++++++ 11 files changed, 730 insertions(+), 13 deletions(-) create mode 100644 src/auth/project-config-manager.ts create mode 100644 src/auth/project-config.ts create mode 100644 test/unit/auth/project-config-manager.spec.ts create mode 100644 test/unit/auth/project-config.spec.ts diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index 83866b5081..fe83c48b6e 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -27,6 +27,7 @@ export interface ActionCodeSettings { export class Auth extends BaseAuth { // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts get app(): App; + projectConfigManager(): ProjectConfigManager; tenantManager(): TenantManager; } @@ -256,6 +257,18 @@ export class PhoneMultiFactorInfo extends MultiFactorInfo { toJSON(): object; } +// @public +export class ProjectConfig { + get recaptchaConfig(): RecaptchaConfig | undefined; + toJSON(): object; +} + +// @public +export class ProjectConfigManager { + getProjectConfig(): Promise; + updateProjectConfig(projectConfigOptions: UpdateProjectConfigRequest): Promise; +} + // @public export interface ProviderIdentifier { // (undocumented) @@ -367,6 +380,11 @@ export interface UpdatePhoneMultiFactorInfoRequest extends BaseUpdateMultiFactor phoneNumber: string; } +// @public +export interface UpdateProjectConfigRequest { + recaptchaConfig?: RecaptchaConfig; +} + // @public export interface UpdateRequest { disabled?: boolean; diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 13018337da..2e9c22fc83 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -42,6 +42,7 @@ import { OIDCAuthProviderConfig, SAMLAuthProviderConfig, OIDCUpdateAuthProviderRequest, SAMLUpdateAuthProviderRequest } from './auth-config'; +import { ProjectConfig, ProjectConfigServerResponse, UpdateProjectConfigRequest } from './project-config'; /** Firebase Auth request header. */ const FIREBASE_AUTH_HEADER = { @@ -102,7 +103,6 @@ const FIREBASE_AUTH_TENANT_URL_FORMAT = FIREBASE_AUTH_BASE_URL_FORMAT.replace( const FIREBASE_AUTH_EMULATOR_TENANT_URL_FORMAT = FIREBASE_AUTH_EMULATOR_BASE_URL_FORMAT.replace( 'projects/{projectId}', 'projects/{projectId}/tenants/{tenantId}'); - /** Maximum allowed number of tenants to download at one time. */ const MAX_LIST_TENANT_PAGE_SIZE = 1000; @@ -1961,6 +1961,29 @@ export abstract class AbstractAuthRequestHandler { } } +/** Instantiates the getConfig endpoint settings. */ +const GET_PROJECT_CONFIG = new ApiSettings('/config', 'GET') + .setResponseValidator((response: any) => { + // Response should always contain at least the config name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to get project config', + ); + } + }); + +/** Instantiates the updateConfig endpoint settings. */ +const UPDATE_PROJECT_CONFIG = new ApiSettings('/config?updateMask={updateMask}', 'PATCH') + .setResponseValidator((response: any) => { + // Response should always contain at least the config name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update project config', + ); + } + }); /** Instantiates the getTenant endpoint settings. */ const GET_TENANT = new ApiSettings('/tenants/{tenantId}', 'GET') @@ -2029,13 +2052,13 @@ const CREATE_TENANT = new ApiSettings('/tenants', 'POST') /** - * Utility for sending requests to Auth server that are Auth instance related. This includes user and - * tenant management related APIs. This extends the BaseFirebaseAuthRequestHandler class and defines + * Utility for sending requests to Auth server that are Auth instance related. This includes user, tenant, + * and project config management related APIs. This extends the BaseFirebaseAuthRequestHandler class and defines * additional tenant management related APIs. */ export class AuthRequestHandler extends AbstractAuthRequestHandler { - protected readonly tenantMgmtResourceBuilder: AuthResourceUrlBuilder; + protected readonly authResourceUrlBuilder: AuthResourceUrlBuilder; /** * The FirebaseAuthRequestHandler constructor used to initialize an instance using a FirebaseApp. @@ -2045,7 +2068,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { */ constructor(app: App) { super(app); - this.tenantMgmtResourceBuilder = new AuthResourceUrlBuilder(app, 'v2'); + this.authResourceUrlBuilder = new AuthResourceUrlBuilder(app, 'v2'); } /** @@ -2062,6 +2085,35 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { return new AuthResourceUrlBuilder(this.app, 'v2'); } + /** + * Get the current project's config + * @returns A promise that resolves with the project config information. + */ + public getProjectConfig(): Promise { + return this.invokeRequestHandler(this.authResourceUrlBuilder, GET_PROJECT_CONFIG, {}, {}) + .then((response: any) => { + return response as ProjectConfigServerResponse; + }); + } + + /** + * Update the current project's config. + * @returns A promise that resolves with the project config information. + */ + public updateProjectConfig(recaptchaOptions: UpdateProjectConfigRequest): Promise { + try { + const request = ProjectConfig.buildServerRequest(recaptchaOptions); + const updateMask = utils.generateUpdateMask(request); + return this.invokeRequestHandler( + this.authResourceUrlBuilder, UPDATE_PROJECT_CONFIG, request, { updateMask: updateMask.join(',') }) + .then((response: any) => { + return response as ProjectConfigServerResponse; + }); + } catch (e) { + return Promise.reject(e); + } + } + /** * Looks up a tenant by tenant ID. * @@ -2072,7 +2124,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { if (!validator.isNonEmptyString(tenantId)) { return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID)); } - return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, GET_TENANT, {}, { tenantId }) + return this.invokeRequestHandler(this.authResourceUrlBuilder, GET_TENANT, {}, { tenantId }) .then((response: any) => { return response as TenantServerResponse; }); @@ -2102,7 +2154,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { if (typeof request.pageToken === 'undefined') { delete request.pageToken; } - return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, LIST_TENANTS, request) + return this.invokeRequestHandler(this.authResourceUrlBuilder, LIST_TENANTS, request) .then((response: any) => { if (!response.tenants) { response.tenants = []; @@ -2122,7 +2174,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { if (!validator.isNonEmptyString(tenantId)) { return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID)); } - return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, DELETE_TENANT, undefined, { tenantId }) + return this.invokeRequestHandler(this.authResourceUrlBuilder, DELETE_TENANT, undefined, { tenantId }) .then(() => { // Return nothing. }); @@ -2138,7 +2190,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { try { // Construct backend request. const request = Tenant.buildServerRequest(tenantOptions, true); - return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, CREATE_TENANT, request) + return this.invokeRequestHandler(this.authResourceUrlBuilder, CREATE_TENANT, request) .then((response: any) => { return response as TenantServerResponse; }); @@ -2164,7 +2216,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { // Do not traverse deep into testPhoneNumbers. The entire content should be replaced // and not just specific phone numbers. const updateMask = utils.generateUpdateMask(request, ['testPhoneNumbers']); - return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, UPDATE_TENANT, request, + return this.invokeRequestHandler(this.authResourceUrlBuilder, UPDATE_TENANT, request, { tenantId, updateMask: updateMask.join(',') }) .then((response: any) => { return response as TenantServerResponse; diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index aecd686201..f4504392e7 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1502,6 +1502,9 @@ export interface RecaptchaKey { /** * The request interface for updating a reCAPTCHA Config. + * By enabling reCAPTCHA Enterprise Integration you are + * agreeing to reCAPTCHA Enterprise + * {@link https://cloud.google.com/terms/service-terms | Term of Service}. */ export interface RecaptchaConfig { /** diff --git a/src/auth/auth.ts b/src/auth/auth.ts index d9b5aa7978..4808fbbdc0 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -19,6 +19,7 @@ import { App } from '../app/index'; import { AuthRequestHandler } from './auth-api-request'; import { TenantManager } from './tenant-manager'; import { BaseAuth } from './base-auth'; +import { ProjectConfigManager } from './project-config-manager'; /** * Auth service bound to the provided app. @@ -27,6 +28,7 @@ import { BaseAuth } from './base-auth'; export class Auth extends BaseAuth { private readonly tenantManager_: TenantManager; + private readonly projectConfigManager_: ProjectConfigManager; private readonly app_: App; /** @@ -38,6 +40,7 @@ export class Auth extends BaseAuth { super(app, new AuthRequestHandler(app)); this.app_ = app; this.tenantManager_ = new TenantManager(app); + this.projectConfigManager_ = new ProjectConfigManager(app); } /** @@ -57,4 +60,13 @@ export class Auth extends BaseAuth { public tenantManager(): TenantManager { return this.tenantManager_; } + + /** + * Returns the project config manager instance associated with the current project. + * + * @returns The project config manager instance associated with the current project. + */ + public projectConfigManager(): ProjectConfigManager { + return this.projectConfigManager_; + } } diff --git a/src/auth/index.ts b/src/auth/index.ts index 978cdfe8a1..3faa47cac3 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -122,6 +122,15 @@ export { TenantManager, } from './tenant-manager'; +export { + UpdateProjectConfigRequest, + ProjectConfig, +} from './project-config'; + +export { + ProjectConfigManager, +} from './project-config-manager'; + export { DecodedIdToken } from './token-verifier'; export { diff --git a/src/auth/project-config-manager.ts b/src/auth/project-config-manager.ts new file mode 100644 index 0000000000..3f95a8ddf3 --- /dev/null +++ b/src/auth/project-config-manager.ts @@ -0,0 +1,67 @@ +/*! + * Copyright 2022 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { App } from '../app'; +import { ProjectConfig, ProjectConfigServerResponse, UpdateProjectConfigRequest } from './project-config'; +import { + AuthRequestHandler, +} from './auth-api-request'; + +/** + * Defines the project config manager used to help manage project config related operations. + * This includes: + *
    + *
  • The ability to update and get project config.
  • + */ +export class ProjectConfigManager { + private readonly authRequestHandler: AuthRequestHandler; + + /** + * Initializes a ProjectConfigManager instance for a specified FirebaseApp. + * + * @param app - The app for this ProjectConfigManager instance. + * + * @constructor + * @internal + */ + constructor(app: App) { + this.authRequestHandler = new AuthRequestHandler(app); + } + + /** + * Get the project configuration. + * + * @returns A promise fulfilled with the project configuration. + */ + public getProjectConfig(): Promise { + return this.authRequestHandler.getProjectConfig() + .then((response: ProjectConfigServerResponse) => { + return new ProjectConfig(response); + }) + } + /** + * Updates an existing project configuration. + * + * @param projectConfigOptions - The properties to update on the project. + * + * @returns A promise fulfilled with the update project config. + */ + public updateProjectConfig(projectConfigOptions: UpdateProjectConfigRequest): Promise { + return this.authRequestHandler.updateProjectConfig(projectConfigOptions) + .then((response: ProjectConfigServerResponse) => { + return new ProjectConfig(response); + }) + } +} diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts new file mode 100644 index 0000000000..749a64bbb6 --- /dev/null +++ b/src/auth/project-config.ts @@ -0,0 +1,139 @@ +/*! + * Copyright 2022 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as validator from '../utils/validator'; +import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; +import { + RecaptchaConfig, + RecaptchaAuthConfig, +} from './auth-config'; + +/** + * Interface representing the properties to update on the provided project config. + */ +export interface UpdateProjectConfigRequest { + /** + * The recaptcha configuration to update on the project. + * By enabling reCAPTCHA Enterprise Integration you are + * agreeing to reCAPTCHA Enterprise + * {@link https://cloud.google.com/terms/service-terms | Term of Service}. + */ + recaptchaConfig?: RecaptchaConfig; +} + +/** + * Response received from get/update project config. + * We are only exposing the recaptcha config for now. + */ +export interface ProjectConfigServerResponse { + recaptchaConfig?: RecaptchaConfig; +} + +/** + * Request sent to update project config. + * We are only updating the recaptcha config for now. + */ +export interface ProjectConfigClientRequest { + recaptchaConfig?: RecaptchaConfig; +} + +/** +* Represents a project configuration. +*/ +export class ProjectConfig { + /** + * The recaptcha configuration to update on the project config. + * By enabling reCAPTCHA Enterprise Integration you are + * agreeing to reCAPTCHA Enterprise + * {@link https://cloud.google.com/terms/service-terms | Term of Service}. + */ + private readonly recaptchaConfig_?: RecaptchaAuthConfig; + + /** + * Validates a project config options object. Throws an error on failure. + * + * @param request - The project config options object to validate. + */ + private static validate(request: any): void { + if (!validator.isNonNullObject(request)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"UpdateProjectConfigRequest" must be a valid non-null object.', + ); + } + const validKeys = { + recaptchaConfig: true, + } + // Check for unsupported top level attributes. + for (const key in request) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${key}" is not a valid UpdateProjectConfigRequest parameter.`, + ); + } + } + + // Validate reCAPTCHA config attribute. + RecaptchaAuthConfig.validate(request.recaptchaConfig); + } + + /** + * Build the corresponding server request for a UpdateProjectConfigRequest object. + * @param configOptions - The properties to convert to a server request. + * @returns The equivalent server request. + * + * @internal + */ + public static buildServerRequest(configOptions: UpdateProjectConfigRequest): ProjectConfigClientRequest { + ProjectConfig.validate(configOptions); + return configOptions as ProjectConfigClientRequest; + } + + /** + * The recaptcha configuration. + */ + get recaptchaConfig(): RecaptchaConfig | undefined { + return this.recaptchaConfig_; + } + /** + * The Project Config object constructor. + * + * @param response - The server side response used to initialize the Project Config object. + * @constructor + * @internal + */ + constructor(response: ProjectConfigServerResponse) { + if (typeof response.recaptchaConfig !== 'undefined') { + this.recaptchaConfig_ = new RecaptchaAuthConfig(response.recaptchaConfig); + } + } + /** + * Returns a JSON-serializable representation of this object. + * + * @returns A JSON-serializable representation of this object. + */ + public toJSON(): object { + // JSON serialization + const json = { + recaptchaConfig: this.recaptchaConfig_?.toJSON(), + }; + if (typeof json.recaptchaConfig === 'undefined') { + delete json.recaptchaConfig; + } + return json; + } +} + diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index a0ff7b242d..734e64569c 100644 --- a/src/auth/tenant.ts +++ b/src/auth/tenant.ts @@ -57,6 +57,9 @@ export interface UpdateTenantRequest { /** * The recaptcha configuration to update on the tenant. + * By enabling reCAPTCHA Enterprise Integration you are + * agreeing to reCAPTCHA Enterprise + * {@link https://cloud.google.com/terms/service-terms | Term of Service}. */ recaptchaConfig?: RecaptchaConfig; } @@ -130,9 +133,12 @@ export class Tenant { private readonly emailSignInConfig_?: EmailSignInConfig; private readonly multiFactorConfig_?: MultiFactorAuthConfig; - /* - * The map conatining the reCAPTCHA config. - */ + /** + * The map conatining the reCAPTCHA config. + * By enabling reCAPTCHA Enterprise Integration you are + * agreeing to reCAPTCHA Enterprise + * {@link https://cloud.google.com/terms/service-terms | Term of Service}. + */ private readonly recaptchaConfig_?: RecaptchaAuthConfig; /** * Builds the corresponding server request for a TenantOptions object. diff --git a/src/utils/error.ts b/src/utils/error.ts index 10483da861..1f68bbba55 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -729,6 +729,14 @@ export class AuthClientErrorCode { code: 'user-not-disabled', message: 'The user must be disabled in order to bulk delete it (or you must pass force=true).', }; + public static INVALID_RECAPTCHA_ACTION = { + code: 'invalid-recaptcha-action', + message: 'reCAPTCHA action must be "BLOCK".' + } + public static INVALID_RECAPTCHA_ENFORCEMENT_STATE = { + code: 'invalid-recaptcha-enforcement-state', + message: 'reCAPTCHA enforcement state must be either "OFF", "AUDIT" or "ENFORCE".' + } } /** @@ -986,6 +994,10 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { USER_DISABLED: 'USER_DISABLED', // Password provided is too weak. WEAK_PASSWORD: 'INVALID_PASSWORD', + // Unrecognized reCAPTCHA action. + INVALID_RECAPTCHA_ACTION: 'INVALID_RECAPTCHA_ACTION', + // Unrecognized reCAPTCHA enforcement state. + INVALID_RECAPTCHA_ENFORCEMENT_STATE: 'INVALID_RECAPTCHA_ENFORCEMENT_STATE', }; /** @const {ServerToClientCode} Messaging server to client enum error codes. */ diff --git a/test/unit/auth/project-config-manager.spec.ts b/test/unit/auth/project-config-manager.spec.ts new file mode 100644 index 0000000000..22c0fbd50f --- /dev/null +++ b/test/unit/auth/project-config-manager.spec.ts @@ -0,0 +1,204 @@ +/*! + * Copyright 2022 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import * as sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +import * as mocks from '../../resources/mocks'; +import { FirebaseApp } from '../../../src/app/firebase-app'; +import { AuthRequestHandler } from '../../../src/auth/auth-api-request'; +import { AuthClientErrorCode, FirebaseAuthError } from '../../../src/utils/error'; +import { ProjectConfigManager } from '../../../src/auth/project-config-manager'; +import { + ProjectConfig, + ProjectConfigServerResponse, + UpdateProjectConfigRequest +} from '../../../src/auth/project-config'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('ProjectConfigManager', () => { + let mockApp: FirebaseApp; + let projectConfigManager: ProjectConfigManager; + let nullAccessTokenProjectConfigManager: ProjectConfigManager; + let malformedAccessTokenProjectConfigManager: ProjectConfigManager; + let rejectedPromiseAccessTokenProjectConfigManager: ProjectConfigManager; + const GET_CONFIG_RESPONSE: ProjectConfigServerResponse = { + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ { + endScore: 0.2, + action: 'BLOCK' + } ], + recaptchaKeys: [ { + type: 'WEB', + key: 'test-key-1' } + ], + } + }; + + before(() => { + mockApp = mocks.app(); + projectConfigManager = new ProjectConfigManager(mockApp); + nullAccessTokenProjectConfigManager = new ProjectConfigManager( + mocks.appReturningNullAccessToken()); + malformedAccessTokenProjectConfigManager = new ProjectConfigManager( + mocks.appReturningMalformedAccessToken()); + rejectedPromiseAccessTokenProjectConfigManager = new ProjectConfigManager( + mocks.appRejectedWhileFetchingAccessToken()); + }); + + after(() => { + return mockApp.delete(); + }); + + describe('getProjectConfig()', () => { + const expectedProjectConfig = new ProjectConfig(GET_CONFIG_RESPONSE); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenProjectConfigManager.getProjectConfig() + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenProjectConfigManager.getProjectConfig() + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenProjectConfigManager.getProjectConfig() + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a Project Config on success', () => { + // Stub getProjectConfig to return expected result. + const stub = sinon.stub(AuthRequestHandler.prototype, 'getProjectConfig') + .returns(Promise.resolve(GET_CONFIG_RESPONSE)); + stubs.push(stub); + return projectConfigManager.getProjectConfig() + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce; + // Confirm expected project config returned. + expect(result).to.deep.equal(expectedProjectConfig); + }); + }); + + it('should throw an error when the backend returns an error', () => { + // Stub getConfig to throw a backend error. + const stub = sinon.stub(AuthRequestHandler.prototype, 'getProjectConfig') + .returns(Promise.reject(expectedError)); + stubs.push(stub); + return projectConfigManager.getProjectConfig() + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce; + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('updateProjectConfig()', () => { + const projectConfigOptions: UpdateProjectConfigRequest = { + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ { + endScore: 0.2, + action: 'BLOCK' + } ], + } + }; + const expectedProjectConfig = new ProjectConfig(GET_CONFIG_RESPONSE); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to update the config provided.'); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no projectConfigOptions', () => { + return (projectConfigManager as any).updateProjectConfig(null as unknown as UpdateProjectConfigRequest) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenProjectConfigManager.updateProjectConfig(projectConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenProjectConfigManager.updateProjectConfig(projectConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenProjectConfigManager.updateProjectConfig(projectConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a ProjectConfig on updateProjectConfig request success', () => { + // Stub updateProjectConfig to return expected result. + const updateConfigStub = sinon.stub(AuthRequestHandler.prototype, 'updateProjectConfig') + .returns(Promise.resolve(GET_CONFIG_RESPONSE)); + stubs.push(updateConfigStub); + return projectConfigManager.updateProjectConfig(projectConfigOptions) + .then((actualProjectConfig) => { + // Confirm underlying API called with expected parameters. + expect(updateConfigStub).to.have.been.calledOnce.and.calledWith(projectConfigOptions); + // Confirm expected Project Config object returned. + expect(actualProjectConfig).to.deep.equal(expectedProjectConfig); + }); + }); + + it('should throw an error when updateProjectConfig returns an error', () => { + // Stub updateProjectConfig to throw a backend error. + const updateConfigStub = sinon.stub(AuthRequestHandler.prototype, 'updateProjectConfig') + .returns(Promise.reject(expectedError)); + stubs.push(updateConfigStub); + return projectConfigManager.updateProjectConfig(projectConfigOptions) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(updateConfigStub).to.have.been.calledOnce.and.calledWith(projectConfigOptions); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); +}); diff --git a/test/unit/auth/project-config.spec.ts b/test/unit/auth/project-config.spec.ts new file mode 100644 index 0000000000..d0a5ec9575 --- /dev/null +++ b/test/unit/auth/project-config.spec.ts @@ -0,0 +1,195 @@ +/*! + * Copyright 2022 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +import { deepCopy } from '../../../src/utils/deep-copy'; +import { RecaptchaAuthConfig } from '../../../src/auth/auth-config'; +import { + ProjectConfig, + ProjectConfigServerResponse, + UpdateProjectConfigRequest, +} from '../../../src/auth/project-config'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('ProjectConfig', () => { + const serverResponse: ProjectConfigServerResponse = { + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ { + endScore: 0.2, + action: 'BLOCK' + } ], + recaptchaKeys: [ { + type: 'WEB', + key: 'test-key-1' } + ], + } + }; + + const updateProjectConfigRequest: UpdateProjectConfigRequest = { + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ { + endScore: 0.2, + action: 'BLOCK' + } ] + } + }; + + describe('buildServerRequest()', () => { + + describe('for an update request', () => { + it('should throw on null RecaptchaConfig attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig = null; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"RecaptchaConfig" must be a non-null object.'); + }); + + it('should throw on invalid RecaptchaConfig attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig.invalidParameter = 'invalid'; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"invalidParameter" is not a valid RecaptchaConfig parameter.'); + }); + + it('should throw on null emailPasswordEnforcementState attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig.emailPasswordEnforcementState = null; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be a valid non-empty string.'); + }); + + it('should throw on invalid emailPasswordEnforcementState attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig + .emailPasswordEnforcementState = 'INVALID'; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".'); + }); + + it('should throw on non-array managedRules attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig.managedRules = 'non-array'; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".'); + }); + + it('should throw on invalid managedRules attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig.managedRules = + [{ 'score': 0.1, 'action': 'BLOCK' }]; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"score" is not a valid RecaptchaManagedRule parameter.'); + }); + + it('should throw on invalid RecaptchaManagedRule.action attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig.managedRules = + [{ 'endScore': 0.1, 'action': 'ALLOW' }]; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"RecaptchaManagedRule.action" must be "BLOCK".'); + }); + + it('should not throw on valid client request object', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest); + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).not.to.throw; + }); + + const nonObjects = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; + nonObjects.forEach((request) => { + it('should throw on invalid UpdateProjectConfigRequest:' + JSON.stringify(request), () => { + expect(() => { + ProjectConfig.buildServerRequest(request as any); + }).to.throw('"UpdateProjectConfigRequest" must be a valid non-null object.'); + }); + }); + + it('should throw on unsupported attribute for update request', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.unsupported = 'value'; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"unsupported" is not a valid UpdateProjectConfigRequest parameter.'); + }); + }); + }); + + describe('constructor', () => { + const serverResponseCopy: ProjectConfigServerResponse = deepCopy(serverResponse); + const projectConfig = new ProjectConfig(serverResponseCopy); + + it('should not throw on valid initialization', () => { + expect(() => new ProjectConfig(serverResponse)).not.to.throw(); + }); + + it('should set readonly property recaptchaConfig', () => { + const expectedRecaptchaConfig = new RecaptchaAuthConfig( + { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ { + endScore: 0.2, + action: 'BLOCK' + } ], + recaptchaKeys: [ { + type: 'WEB', + key: 'test-key-1' } + ], + } + ); + expect(projectConfig.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig); + }); + }); + + describe('toJSON()', () => { + const serverResponseCopy: ProjectConfigServerResponse = deepCopy(serverResponse); + it('should return the expected object representation of project config', () => { + expect(new ProjectConfig(serverResponseCopy).toJSON()).to.deep.equal({ + recaptchaConfig: deepCopy(serverResponse.recaptchaConfig) + }); + }); + + it('should not populate optional fields if not available', () => { + const serverResponseOptionalCopy: ProjectConfigServerResponse = deepCopy(serverResponse); + delete serverResponseOptionalCopy.recaptchaConfig?.emailPasswordEnforcementState; + delete serverResponseOptionalCopy.recaptchaConfig?.managedRules; + + expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({ + recaptchaConfig: { + recaptchaKeys: deepCopy(serverResponse.recaptchaConfig?.recaptchaKeys), + } + }); + }); + }); +}); From 13810eb57eb200d5707930dae0f5471f5ca9df58 Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Wed, 9 Mar 2022 15:40:26 -0800 Subject: [PATCH 4/5] Recapcha integ test (#1599) * Added integ test for Project Config and Tenants update on reCAPTCHA config --- test/integration/auth.spec.ts | 94 ++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 2308ca6879..cf9444d9bb 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -31,7 +31,7 @@ import { deepExtend, deepCopy } from '../../src/utils/deep-copy'; import { AuthProviderConfig, CreateTenantRequest, DeleteUsersResult, PhoneMultiFactorInfo, TenantAwareAuth, UpdatePhoneMultiFactorInfoRequest, UpdateTenantRequest, UserImportOptions, - UserImportRecord, UserRecord, getAuth, + UserImportRecord, UserRecord, getAuth, UpdateProjectConfigRequest, } from '../../lib/auth/index'; const chalk = require('chalk'); // eslint-disable-line @typescript-eslint/no-var-requires @@ -1154,6 +1154,56 @@ describe('admin.auth', () => { }); }); + describe('Project config management operations', () => { + before(function() { + if (authEmulatorHost) { + this.skip(); // getConfig is not supported in Auth Emulator + } + }); + const projectConfigOption1: UpdateProjectConfigRequest = { + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [{ endScore: 0.1, action: 'BLOCK' }], + }, + }; + const projectConfigOption2: UpdateProjectConfigRequest = { + recaptchaConfig: { + emailPasswordEnforcementState: 'OFF', + }, + }; + const expectedProjectConfig1: any = { + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [{ endScore: 0.1, action: 'BLOCK' }], + }, + }; + const expectedProjectConfig2: any = { + recaptchaConfig: { + emailPasswordEnforcementState: 'OFF', + managedRules: [{ endScore: 0.1, action: 'BLOCK' }], + }, + }; + + it('updateProjectConfig() should resolve with the updated project config', () => { + return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption1) + .then((actualProjectConfig) => { + expect(actualProjectConfig.toJSON()).to.deep.equal(expectedProjectConfig1); + return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption2); + }) + .then((actualProjectConfig) => { + expect(actualProjectConfig.toJSON()).to.deep.equal(expectedProjectConfig2); + }); + }); + + it('getProjectConfig() should resolve with expected project config', () => { + return getAuth().projectConfigManager().getProjectConfig() + .then((actualConfig) => { + const actualConfigObj = actualConfig.toJSON(); + expect(actualConfigObj).to.deep.equal(expectedProjectConfig2); + }); + }); + }); + describe('Tenant management operations', () => { let createdTenantId: string; const createdTenants: string[] = []; @@ -1210,6 +1260,15 @@ describe('admin.auth', () => { testPhoneNumbers: { '+16505551234': '123456', }, + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ + { + endScore: 0.3, + action: 'BLOCK', + }, + ], + }, }; const expectedUpdatedTenant2: any = { displayName: 'testTenantUpdated', @@ -1222,6 +1281,15 @@ describe('admin.auth', () => { state: 'ENABLED', factorIds: ['phone'], }, + recaptchaConfig: { + emailPasswordEnforcementState: 'OFF', + managedRules: [ + { + endScore: 0.3, + action: 'BLOCK', + }, + ], + }, }; // https://mochajs.org/ @@ -1634,6 +1702,7 @@ describe('admin.auth', () => { }, multiFactorConfig: deepCopy(expectedUpdatedTenant.multiFactorConfig), testPhoneNumbers: deepCopy(expectedUpdatedTenant.testPhoneNumbers), + recaptchaConfig: deepCopy(expectedUpdatedTenant.recaptchaConfig), }; const updatedOptions2: UpdateTenantRequest = { emailSignInConfig: { @@ -1643,6 +1712,7 @@ describe('admin.auth', () => { multiFactorConfig: deepCopy(expectedUpdatedTenant2.multiFactorConfig), // Test clearing of phone numbers. testPhoneNumbers: null, + recaptchaConfig: deepCopy(expectedUpdatedTenant2.recaptchaConfig), }; if (authEmulatorHost) { return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions) @@ -1672,6 +1742,28 @@ describe('admin.auth', () => { }); }); + it('updateTenant() should not update tenant reCAPTCHA config is undefined', () => { + expectedUpdatedTenant.tenantId = createdTenantId; + const updatedOptions2: UpdateTenantRequest = { + displayName: expectedUpdatedTenant2.displayName, + recaptchaConfig: undefined, + }; + if (authEmulatorHost) { + return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2) + .then((actualTenant) => { + const actualTenantObj = actualTenant.toJSON(); + // Not supported in Auth Emulator + delete (actualTenantObj as {testPhoneNumbers: Record}).testPhoneNumbers; + delete expectedUpdatedTenant2.testPhoneNumbers; + expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant2); + }); + } + return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2) + .then((actualTenant) => { + expect(actualTenant.toJSON()).to.deep.equal(expectedUpdatedTenant2); + }); + }); + it('updateTenant() should be able to enable/disable anon provider', async () => { const tenantManager = getAuth().tenantManager(); let tenant = await tenantManager.createTenant({ From 583f9e077115bdd1d85d4924430410ed3d3f7a25 Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Tue, 3 May 2022 12:14:58 -0700 Subject: [PATCH 5/5] Account defender support for reCAPTCHA (#1616) * Support use_account_defender add-on feature for reCAPTCHA config. * Added integration test for account defender feature --- etc/firebase-admin.auth.api.md | 1 + src/auth/auth-config.ts | 25 +++++++++++++++++- src/utils/error.ts | 6 +++++ test/integration/auth.spec.ts | 38 +++++++++++++++++++++++++++ test/unit/auth/project-config.spec.ts | 17 +++++++++++- test/unit/auth/tenant.spec.ts | 24 ++++++++++++++++- 6 files changed, 108 insertions(+), 3 deletions(-) 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); });