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), + } + }); + }); + }); +});