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',