From 97bf5475fe619c76dc9e9797800ddcaf5028859e Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Mon, 4 Apr 2022 15:14:33 -0700 Subject: [PATCH 1/8] api proposal sms draft --- src/auth/auth-api-request.ts | 1 + src/auth/auth-config.ts | 131 +++++++++++++++++++++++++++++++ src/auth/project-config.ts | 147 +++++++++++++++++++++++++++++++++++ src/auth/tenant.ts | 15 +++- 4 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/auth/project-config.ts diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 13018337da..4ee785e56d 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -1890,6 +1890,7 @@ export abstract class AbstractAuthRequestHandler { requestData: object | undefined, additionalResourceParams?: object): Promise { return urlBuilder.getUrl(apiSettings.getEndpoint(), additionalResourceParams) .then((url) => { + console.log(url); // Validate request. if (requestData) { const requestValidator = apiSettings.getRequestValidator(); diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index ce45713f97..90cb5ff474 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1451,3 +1451,134 @@ 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; +} + +/** + * 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 { + /** + * The enforcement state of email password provider. + */ + emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + /** + * The reCAPTCHA managed rules. + */ + managedRules?: RecaptchaManagedRule[]; + + /** + * The reCAPTCHA keys. + */ + recaptchaKeys?: RecaptchaKey[]; +} + +/** + * The request interface for updating a SMS Region Config. + * Configures the regions where users are allowed to send verification SMS. + * This is based on the calling code of the destination phone number. + */ +export interface SmsRegionConfig { + /** + * A policy of allowing SMS to every region by default and adding disallowed + * regions to a disallow list. + */ + allowByDefault?: AllowByDefault; + + /** + * A policy of only allowing regions by explicitly adding them to an + * allowlist. + */ + allowlistOnly?: AllowlistOnly; +} + +export type SmsRegionsConfig = AllowByDefaultNew | AllowlistOnlyNew; + +export interface AllowByDefaultNew { + allowByDefault: AllowByDefault; + /** @alpha */ + allowlistOnly: never; +} + +export interface AllowlistOnlyNew { + allowlistOnly: AllowlistOnly; + /** @alpha */ + allowByDefault: never; +} + +/** + * Defines a policy of allowing every region by default and adding disallowed + * regions to a disallow list. + */ +export interface AllowByDefault { + /** + * Two letter unicode region codes to disallow as defined by + * https://cldr.unicode.org/ + * The full list of these region codes is here: + * https://github.com/unicode-cldr/cldr-localenames-full/blob/master/main/en/territories.json + */ + disallowedRegions: string[]; +} + +/** + * Defines a policy of only allowing regions by explicitly adding them to an + * allowlist. + */ +export interface AllowlistOnly { + /** + * Two letter unicode region codes to allow as defined by + * https://cldr.unicode.org/ + * The full list of these region codes is here: + * https://github.com/unicode-cldr/cldr-localenames-full/blob/master/main/en/territories.json + */ + allowedRegions: string[]; +} diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts new file mode 100644 index 0000000000..475785589f --- /dev/null +++ b/src/auth/project-config.ts @@ -0,0 +1,147 @@ +/*! + * 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 { + SmsRegionConfig, +} 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; + + /** + * The SMS configuration to update on the project. + */ + smsRegionConfig?: SmsRegionConfig; +} + +/** + * Response received from get/update project config. + * We are only exposing the recaptcha config for now. + */ +export interface ProjectConfigServerResponse { + smsRegionConfig?: SmsRegionConfig; +} + +/** + * Request sent to update project config. + * We are only updating the recaptcha config for now. + */ +export interface ProjectConfigClientRequest { + smsRegionConfig?: SmsRegionConfig; +} + +/** +* 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 smsRegionConfig_?: SmsRegionConfig; + + /** + * 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 = { + smsRegionConfig: 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.`, + ); + } + } + } + + /** + * 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 SMS Region configuration. + */ + get smsRegionConfig(): SmsRegionConfig | undefined { + return this.smsRegionConfig_; + } + /** + * 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 e489fa3b09..b8cd932567 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, SmsRegionConfig } 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 SMS configuration to update on the project. + */ + smsRegionConfig?: SmsRegionConfig; } /** @@ -123,6 +128,8 @@ export class Tenant { private readonly emailSignInConfig_?: EmailSignInConfig; private readonly multiFactorConfig_?: MultiFactorAuthConfig; + private readonly smsRegionConfig_?: SmsRegionConfig; + /** * Builds the corresponding server request for a TenantOptions object. * @@ -281,6 +288,12 @@ export class Tenant { return this.multiFactorConfig_; } + /** + * The SMS Region configuration. + */ + get smsRegionConfig(): SmsRegionConfig | undefined { + return this.smsRegionConfig_; + } /** * Returns a JSON-serializable representation of this object. * From dbda6a7372507807342d370aa56b09a1c08b07df Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Tue, 26 Apr 2022 15:39:26 -0700 Subject: [PATCH 2/8] support SMS regions config update for project and tenant --- etc/firebase-admin.auth.api.md | 43 ++++ src/auth/auth-api-request.ts | 73 ++++++- src/auth/auth-config.ts | 172 ++++++++------- src/auth/auth.ts | 14 +- src/auth/index.ts | 12 ++ src/auth/project-config-manager.ts | 67 ++++++ src/auth/project-config.ts | 48 ++--- src/auth/tenant.ts | 32 ++- test/integration/auth.spec.ts | 89 +++++++- test/unit/auth/project-config-manager.spec.ts | 196 ++++++++++++++++++ test/unit/auth/project-config.spec.ts | 192 +++++++++++++++++ test/unit/auth/tenant.spec.ts | 137 ++++++++++++ 12 files changed, 931 insertions(+), 144 deletions(-) create mode 100644 src/auth/project-config-manager.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 36b2dcf686..0c5cd975ee 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -23,10 +23,31 @@ export interface ActionCodeSettings { url: string; } +// @public (undocumented) +export interface AllowByDefaultWrap { + // Warning: (ae-forgotten-export) The symbol "AllowByDefault" needs to be exported by the entry point index.d.ts + // + // (undocumented) + allowByDefault: AllowByDefault; + // @alpha (undocumented) + allowlistOnly: never; +} + +// @public (undocumented) +export interface AllowlistOnlyWrap { + // @alpha (undocumented) + allowByDefault: never; + // Warning: (ae-forgotten-export) The symbol "AllowlistOnly" needs to be exported by the entry point index.d.ts + // + // (undocumented) + allowlistOnly: AllowlistOnly; +} + // @public 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 +277,18 @@ export class PhoneMultiFactorInfo extends MultiFactorInfo { toJSON(): object; } +// @public +export class ProjectConfig { + readonly smsRegionConfig?: SmsRegionConfig; + toJSON(): object; +} + +// @public +export class ProjectConfigManager { + getProjectConfig(): Promise; + updateProjectConfig(projectConfigOptions: UpdateProjectConfigRequest): Promise; +} + // @public export interface ProviderIdentifier { // (undocumented) @@ -289,6 +322,9 @@ export interface SessionCookieOptions { expiresIn: number; } +// @public +export type SmsRegionConfig = AllowByDefaultWrap | AllowlistOnlyWrap; + // @public export class Tenant { // (undocumented) @@ -296,6 +332,7 @@ export class Tenant { readonly displayName?: string; get emailSignInConfig(): EmailSignInProviderConfig | undefined; get multiFactorConfig(): MultiFactorConfig | undefined; + readonly smsRegionConfig?: SmsRegionConfig; readonly tenantId: string; readonly testPhoneNumbers?: { [phoneNumber: string]: string; @@ -338,6 +375,11 @@ export interface UpdatePhoneMultiFactorInfoRequest extends BaseUpdateMultiFactor phoneNumber: string; } +// @public +export interface UpdateProjectConfigRequest { + smsRegionConfig?: SmsRegionConfig; +} + // @public export interface UpdateRequest { disabled?: boolean; @@ -358,6 +400,7 @@ export interface UpdateTenantRequest { displayName?: string; emailSignInConfig?: EmailSignInProviderConfig; multiFactorConfig?: MultiFactorConfig; + smsRegionConfig?: SmsRegionConfig; testPhoneNumbers?: { [phoneNumber: string]: string; } | null; diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 4ee785e56d..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; @@ -1890,7 +1890,6 @@ export abstract class AbstractAuthRequestHandler { requestData: object | undefined, additionalResourceParams?: object): Promise { return urlBuilder.getUrl(apiSettings.getEndpoint(), additionalResourceParams) .then((url) => { - console.log(url); // Validate request. if (requestData) { const requestValidator = apiSettings.getRequestValidator(); @@ -1962,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') @@ -2030,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. @@ -2046,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'); } /** @@ -2063,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. * @@ -2073,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; }); @@ -2103,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 = []; @@ -2123,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. }); @@ -2139,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; }); @@ -2165,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 90cb5ff474..7711f8df7c 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1452,107 +1452,23 @@ 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; -} - -/** - * 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 { - /** - * The enforcement state of email password provider. - */ - emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; - /** - * The reCAPTCHA managed rules. - */ - managedRules?: RecaptchaManagedRule[]; - - /** - * The reCAPTCHA keys. - */ - recaptchaKeys?: RecaptchaKey[]; -} - /** * The request interface for updating a SMS Region Config. * Configures the regions where users are allowed to send verification SMS. * This is based on the calling code of the destination phone number. */ -export interface SmsRegionConfig { - /** - * A policy of allowing SMS to every region by default and adding disallowed - * regions to a disallow list. - */ - allowByDefault?: AllowByDefault; +export type SmsRegionConfig = AllowByDefaultWrap | AllowlistOnlyWrap; - /** - * A policy of only allowing regions by explicitly adding them to an - * allowlist. - */ - allowlistOnly?: AllowlistOnly; -} - -export type SmsRegionsConfig = AllowByDefaultNew | AllowlistOnlyNew; - -export interface AllowByDefaultNew { +export interface AllowByDefaultWrap { allowByDefault: AllowByDefault; /** @alpha */ - allowlistOnly: never; + allowlistOnly?: never; } -export interface AllowlistOnlyNew { +export interface AllowlistOnlyWrap { allowlistOnly: AllowlistOnly; /** @alpha */ - allowByDefault: never; + allowByDefault?: never; } /** @@ -1582,3 +1498,81 @@ export interface AllowlistOnly { */ allowedRegions: string[]; } + +export class SmsRegionsAuthConfig { + public static validate(options: SmsRegionConfig) { + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SmsRegionConfig" must be a non-null object.', + ); + } + + const validKeys = { + allowlistOnly: true, + allowByDefault: true, + }; + + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid SmsRegionConfig parameter.`, + ); + } + } + + // validate mutual exclusiveness of allowByDefault and allowlistOnly + if (typeof options.allowByDefault !== 'undefined' && typeof options.allowlistOnly !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + 'SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameter.', + ); + } + // validation for allowByDefault type + if (typeof options.allowByDefault !== 'undefined') { + const allowByDefaultValidKeys = { + disallowedRegions: true, + } + for (const key in options.allowByDefault) { + if (!(key in allowByDefaultValidKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid SmsRegionConfig.allowByDefault parameter.`, + ); + } + } + // disallowedRegion can be empty. + if (typeof options.allowByDefault?.disallowedRegions !== 'undefined' + && !validator.isArray(options.allowByDefault.disallowedRegions)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SmsRegionConfig.allowByDefault.disallowedRegions" must be an array of valid string.', + ); + } + } + + if (typeof options.allowlistOnly !== 'undefined') { + const allowListOnlyValidKeys = { + allowedRegions: true, + } + for (const key in options.allowlistOnly) { + if (!(key in allowListOnlyValidKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid SmsRegionConfig.allowlistOnly parameter.`, + ); + } + } + + // allowedRegions can be empty + if (typeof options.allowlistOnly?.allowedRegions !== 'undefined' + && !validator.isArray(options.allowlistOnly.allowedRegions)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SmsRegionConfig.allowlistOnly.allowedRegions" must be an array of valid string.', + ); + } + } + } +} \ No newline at end of file diff --git a/src/auth/auth.ts b/src/auth/auth.ts index d9b5aa7978..67a64afef2 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_; + } +} \ No newline at end of file diff --git a/src/auth/index.ts b/src/auth/index.ts index 0b92a796cf..43fa50ad0f 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -61,6 +61,8 @@ export { } from './auth'; export { + AllowByDefaultWrap, + AllowlistOnlyWrap, AuthFactorType, AuthProviderConfig, AuthProviderConfigFilter, @@ -81,6 +83,7 @@ export { OIDCUpdateAuthProviderRequest, SAMLAuthProviderConfig, SAMLUpdateAuthProviderRequest, + SmsRegionConfig, UserProvider, UpdateAuthProviderRequest, UpdateMultiFactorInfoRequest, @@ -116,6 +119,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..0174d779fe --- /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); + }) + } +} \ No newline at end of file diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts index 475785589f..a80a3c7ffd 100644 --- a/src/auth/project-config.ts +++ b/src/auth/project-config.ts @@ -16,21 +16,15 @@ import * as validator from '../utils/validator'; import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; import { + SmsRegionsAuthConfig, SmsRegionConfig, } from './auth-config'; +import { deepCopy } from '../utils/deep-copy'; /** * 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; - /** * The SMS configuration to update on the project. */ @@ -47,7 +41,7 @@ export interface ProjectConfigServerResponse { /** * Request sent to update project config. - * We are only updating the recaptcha config for now. + * We are only updating the SMS Regions config for now. */ export interface ProjectConfigClientRequest { smsRegionConfig?: SmsRegionConfig; @@ -58,12 +52,11 @@ export interface ProjectConfigClientRequest { */ 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}. + * The SMS Regions Config to update on the project config. + * Configures the regions where users are allowed to send verification SMS. + * This is based on the calling code of the destination phone number. */ - private readonly smsRegionConfig_?: SmsRegionConfig; + public readonly smsRegionConfig?: SmsRegionConfig; /** * Validates a project config options object. Throws an error on failure. @@ -89,6 +82,10 @@ export class ProjectConfig { ); } } + // Validate SMS Regions Config if provided. + if (typeof request.smsRegionConfig !== 'undefined') { + SmsRegionsAuthConfig.validate(request.smsRegionConfig); + } } /** @@ -102,20 +99,7 @@ export class ProjectConfig { ProjectConfig.validate(configOptions); return configOptions as ProjectConfigClientRequest; } - - /** - * The recaptcha configuration. - */ - get recaptchaConfig(): RecaptchaConfig | undefined { - return this.recaptchaConfig_; - } - /** - * The SMS Region configuration. - */ - get smsRegionConfig(): SmsRegionConfig | undefined { - return this.smsRegionConfig_; - } /** * The Project Config object constructor. * @@ -124,8 +108,8 @@ export class ProjectConfig { * @internal */ constructor(response: ProjectConfigServerResponse) { - if (typeof response.recaptchaConfig !== 'undefined') { - this.recaptchaConfig_ = new RecaptchaAuthConfig(response.recaptchaConfig); + if (typeof response.smsRegionConfig !== 'undefined') { + this.smsRegionConfig = response.smsRegionConfig; } } /** @@ -136,10 +120,10 @@ export class ProjectConfig { public toJSON(): object { // JSON serialization const json = { - recaptchaConfig: this.recaptchaConfig_?.toJSON(), + smsRegionConfig: deepCopy(this.smsRegionConfig), }; - if (typeof json.recaptchaConfig === 'undefined') { - delete json.recaptchaConfig; + if (typeof json.smsRegionConfig === 'undefined') { + delete json.smsRegionConfig; } return json; } diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index b8cd932567..56cf2abd8d 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, SmsRegionConfig + MultiFactorAuthConfig, SmsRegionConfig, SmsRegionsAuthConfig } from './auth-config'; /** @@ -73,6 +73,7 @@ export interface TenantOptionsServerRequest extends EmailSignInConfigServerReque enableAnonymousUser?: boolean; mfaConfig?: MultiFactorAuthServerConfig; testPhoneNumbers?: {[key: string]: string}; + smsRegionConfig?: SmsRegionConfig; } /** The tenant server response interface. */ @@ -84,6 +85,7 @@ export interface TenantServerResponse { enableAnonymousUser?: boolean; mfaConfig?: MultiFactorAuthServerConfig; testPhoneNumbers?: {[key: string]: string}; + smsRegionConfig?: SmsRegionConfig; } /** @@ -128,7 +130,12 @@ export class Tenant { private readonly emailSignInConfig_?: EmailSignInConfig; private readonly multiFactorConfig_?: MultiFactorAuthConfig; - private readonly smsRegionConfig_?: SmsRegionConfig; + /** + * The SMS Regions Config to update a tenant. + * Configures the regions where users are allowed to send verification SMS. + * This is based on the calling code of the destination phone number. + */ + public readonly smsRegionConfig?: SmsRegionConfig; /** * Builds the corresponding server request for a TenantOptions object. @@ -159,6 +166,9 @@ export class Tenant { // null will clear existing test phone numbers. Translate to empty object. request.testPhoneNumbers = tenantOptions.testPhoneNumbers ?? {}; } + if (typeof tenantOptions.smsRegionConfig !== 'undefined') { + request.smsRegionConfig = tenantOptions.smsRegionConfig; + } return request; } @@ -192,6 +202,7 @@ export class Tenant { anonymousSignInEnabled: true, multiFactorConfig: true, testPhoneNumbers: true, + smsRegionConfig: true, }; const label = createRequest ? 'CreateTenantRequest' : 'UpdateTenantRequest'; if (!validator.isNonNullObject(request)) { @@ -238,6 +249,10 @@ export class Tenant { // This will throw an error if invalid. MultiFactorAuthConfig.buildServerRequest(request.multiFactorConfig); } + // Validate SMS Regions Config if provided. + if (typeof request.smsRegionConfig != 'undefined') { + SmsRegionsAuthConfig.validate(request.smsRegionConfig); + } } /** @@ -272,6 +287,9 @@ export class Tenant { if (typeof response.testPhoneNumbers !== 'undefined') { this.testPhoneNumbers = deepCopy(response.testPhoneNumbers || {}); } + if (typeof response.smsRegionConfig !== 'undefined') { + this.smsRegionConfig = deepCopy(response.smsRegionConfig); + } } /** @@ -288,12 +306,6 @@ export class Tenant { return this.multiFactorConfig_; } - /** - * The SMS Region configuration. - */ - get smsRegionConfig(): SmsRegionConfig | undefined { - return this.smsRegionConfig_; - } /** * Returns a JSON-serializable representation of this object. * @@ -307,6 +319,7 @@ export class Tenant { multiFactorConfig: this.multiFactorConfig_?.toJSON(), anonymousSignInEnabled: this.anonymousSignInEnabled, testPhoneNumbers: this.testPhoneNumbers, + smsRegionConfig: deepCopy(this.smsRegionConfig), }; if (typeof json.multiFactorConfig === 'undefined') { delete json.multiFactorConfig; @@ -314,6 +327,9 @@ export class Tenant { if (typeof json.testPhoneNumbers === 'undefined') { delete json.testPhoneNumbers; } + if (typeof json.smsRegionConfig === 'undefined') { + delete json.smsRegionConfig; + } return json; } } diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 2308ca6879..a365c151a7 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,7 +1154,62 @@ describe('admin.auth', () => { }); }); - describe('Tenant management operations', () => { + describe('Project config management operations', () => { + before(function() { + if (authEmulatorHost) { + this.skip(); // getConfig is not supported in Auth Emulator + } + }); + const projectConfigOption1: UpdateProjectConfigRequest = { + smsRegionConfig: { + allowByDefault: { + disallowedRegions: [ 'AC', 'AD' ], + } + }, + }; + const projectConfigOption2: UpdateProjectConfigRequest = { + smsRegionConfig: { + allowlistOnly: { + allowedRegions: [ 'AC', 'AD' ], + } + }, + }; + const expectedProjectConfig1: any = { + smsRegionConfig: { + allowByDefault: { + disallowedRegions: [ 'AC', 'AD' ], + } + }, + }; + const expectedProjectConfig2: any = { + smsRegionConfig: { + allowlistOnly: { + allowedRegions: [ 'AC', 'AD' ], + } + }, + }; + + 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.only('Tenant management operations', () => { let createdTenantId: string; const createdTenants: string[] = []; const tenantOptions: CreateTenantRequest = { @@ -1222,6 +1277,11 @@ describe('admin.auth', () => { state: 'ENABLED', factorIds: ['phone'], }, + smsRegionConfig: { + allowByDefault: { + disallowedRegions: [ 'AC', 'AD' ], + } + }, }; // https://mochajs.org/ @@ -1234,7 +1294,7 @@ describe('admin.auth', () => { // By default we skip multi-tenancy as it is a Google Cloud Identity Platform // feature only and requires to be enabled via the Cloud Console. console.log(chalk.yellow(' Skipping multi-tenancy tests.')); - this.skip(); + //this.skip(); } /* tslint:enable:no-console */ }); @@ -1643,6 +1703,7 @@ describe('admin.auth', () => { multiFactorConfig: deepCopy(expectedUpdatedTenant2.multiFactorConfig), // Test clearing of phone numbers. testPhoneNumbers: null, + smsRegionConfig: deepCopy(expectedUpdatedTenant2.smsRegionConfig), }; if (authEmulatorHost) { return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions) @@ -1672,6 +1733,28 @@ describe('admin.auth', () => { }); }); + it('updateTenant() should not update tenant when SMS region config is undefined', () => { + expectedUpdatedTenant.tenantId = createdTenantId; + const updatedOptions2: UpdateTenantRequest = { + displayName: expectedUpdatedTenant2.displayName, + smsRegionConfig: 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({ 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..f5b87f0c55 --- /dev/null +++ b/test/unit/auth/project-config-manager.spec.ts @@ -0,0 +1,196 @@ +/*! + * 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 = { + smsRegionConfig: { + allowlistOnly: { + allowedRegions: [ 'AC', 'AD' ], + }, + }, + }; + + 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 = { + smsRegionConfig: { + allowByDefault: { + disallowedRegions: [ 'AC', 'AD' ], + }, + }, + }; + 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); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/auth/project-config.spec.ts b/test/unit/auth/project-config.spec.ts new file mode 100644 index 0000000000..921fcbe4e5 --- /dev/null +++ b/test/unit/auth/project-config.spec.ts @@ -0,0 +1,192 @@ +/*! + * 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 { + 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 = { + smsRegionConfig: { + allowByDefault: { + disallowedRegions: [ 'AC', 'AD' ], + }, + }, + }; + + const updateProjectConfigRequest1: UpdateProjectConfigRequest = { + smsRegionConfig: { + allowByDefault: { + disallowedRegions: [ 'AC', 'AD' ], + }, + }, + }; + + const updateProjectConfigRequest2: UpdateProjectConfigRequest = { + smsRegionConfig: { + allowlistOnly: { + allowedRegions: [ 'AC', 'AD' ], + }, + }, + }; + + const updateProjectConfigRequest3: any = { + smsRegionConfig: { + allowlistOnly: { + allowedRegions: [ 'AC', 'AD' ], + }, + allowByDefault: { + disallowedRegions: ['AC', 'AD'], + }, + }, + }; + + describe('buildServerRequest()', () => { + + describe('for an update request', () => { + it('should throw on null SmsRegionConfig attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + configOptionsClientRequest.smsRegionConfig = null; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"SmsRegionConfig" must be a non-null object.'); + }); + + it('should throw on invalid SmsRegionConfig attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + configOptionsClientRequest.smsRegionConfig.invalidParameter = 'invalid'; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"invalidParameter" is not a valid SmsRegionConfig parameter.'); + }); + + it('should throw on invalid allowlistOnly attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest2) as any; + configOptionsClientRequest.smsRegionConfig.allowlistOnly.disallowedRegions = [ 'AC', 'AD' ]; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"disallowedRegions" is not a valid SmsRegionConfig.allowlistOnly parameter.'); + }); + + it('should throw on invalid allowByDefault attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + configOptionsClientRequest.smsRegionConfig.allowByDefault.allowedRegions = [ 'AC', 'AD' ]; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"allowedRegions" is not a valid SmsRegionConfig.allowByDefault parameter.'); + }); + + it('should throw on non-array disallowedRegions attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + configOptionsClientRequest.smsRegionConfig.allowByDefault.disallowedRegions = 'non-array'; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"SmsRegionConfig.allowByDefault.disallowedRegions" must be an array of valid string.'); + }); + + it('should throw on non-array allowedRegions attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest2) as any; + configOptionsClientRequest.smsRegionConfig.allowlistOnly.allowedRegions = 'non-array'; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"SmsRegionConfig.allowlistOnly.allowedRegions" must be an array of valid string.'); + }); + + it('should throw when both allowlistOnly and allowByDefault attributes are presented', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest3) as any; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameter.'); + }); + + it('should not throw on valid client request object', () => { + const configOptionsClientRequest1 = deepCopy(updateProjectConfigRequest1); + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest1); + }).not.to.throw; + const configOptionsClientRequest2 = deepCopy(updateProjectConfigRequest2); + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest2); + }).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(updateProjectConfigRequest1) 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 smsRegionConfig', () => { + const expectedSmsRegionConfig = { + allowByDefault: { + disallowedRegions: [ 'AC', 'AD' ], + }, + }; + expect(projectConfig.smsRegionConfig).to.deep.equal(expectedSmsRegionConfig); + }); + }); + + 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({ + smsRegionConfig: deepCopy(serverResponse.smsRegionConfig) + }); + }); + + it('should not populate optional fields if not available', () => { + const serverResponseOptionalCopy: ProjectConfigServerResponse = deepCopy(serverResponse); + delete serverResponseOptionalCopy.smsRegionConfig; + + expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({}); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 0f14856faa..0a56cc51ea 100644 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -33,6 +33,18 @@ chai.use(chaiAsPromised); const expect = chai.expect; describe('Tenant', () => { + const smsAllowByDefault = { + allowByDefault: { + disallowedRegions: [ 'AC', 'AD' ], + }, + }; + + const smsAllowlistOnly = { + allowlistOnly: { + allowedRegions: [ 'AC', 'AD' ], + }, + }; + const serverRequest: TenantServerResponse = { name: 'projects/project1/tenants/TENANT-ID', displayName: 'TENANT-DISPLAY-NAME', @@ -46,6 +58,7 @@ describe('Tenant', () => { '+16505551234': '019287', '+16505550676': '985235', }, + smsRegionConfig: smsAllowByDefault, }; const clientRequest: UpdateTenantRequest = { @@ -62,6 +75,7 @@ describe('Tenant', () => { '+16505551234': '019287', '+16505550676': '985235', }, + smsRegionConfig: smsAllowByDefault, }; const serverRequestWithoutMfa: TenantServerResponse = { @@ -141,6 +155,64 @@ describe('Tenant', () => { .to.deep.equal(tenantOptionsServerRequest); }); + it('should throw on null SmsRegionConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"SmsRegionConfig" must be a non-null object.'); + }); + + it('should throw on invalid SmsRegionConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig.invalidParameter = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"invalidParameter" is not a valid SmsRegionConfig parameter.'); + }); + + it('should throw on invalid allowlistOnly attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig = deepCopy(smsAllowlistOnly); + tenantOptionsClientRequest.smsRegionConfig.allowlistOnly.disallowedRegions = [ 'AC', 'AD' ]; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"disallowedRegions" is not a valid SmsRegionConfig.allowlistOnly parameter.'); + }); + + it('should throw on invalid allowByDefault attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig.allowByDefault.allowedRegions = [ 'AC', 'AD' ]; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"allowedRegions" is not a valid SmsRegionConfig.allowByDefault parameter.'); + }); + + it('should throw on non-array disallowedRegions attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig.allowByDefault.disallowedRegions = 'non-array'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"SmsRegionConfig.allowByDefault.disallowedRegions" must be an array of valid string.'); + }); + + it('should throw on non-array allowedRegions attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig = deepCopy(smsAllowlistOnly); + tenantOptionsClientRequest.smsRegionConfig.allowlistOnly.allowedRegions = 'non-array'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"SmsRegionConfig.allowlistOnly.allowedRegions" must be an array of valid string.'); + }); + + it('should throw when both allowlistOnly and allowByDefault attributes are presented', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig = { ...smsAllowByDefault, ...smsAllowlistOnly }; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameter.'); + }); + it('should not throw on valid client request object', () => { const tenantOptionsClientRequest = deepCopy(clientRequest); expect(() => { @@ -232,6 +304,64 @@ describe('Tenant', () => { }).to.throw('"CreateTenantRequest.testPhoneNumbers" must be a non-null object.'); }); + it('should throw on null SmsRegionConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"SmsRegionConfig" must be a non-null object.'); + }); + + it('should throw on invalid SmsRegionConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig.invalidParameter = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"invalidParameter" is not a valid SmsRegionConfig parameter.'); + }); + + it('should throw on invalid allowlistOnly attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig = deepCopy(smsAllowlistOnly); + tenantOptionsClientRequest.smsRegionConfig.allowlistOnly.disallowedRegions = [ 'AC', 'AD' ]; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"disallowedRegions" is not a valid SmsRegionConfig.allowlistOnly parameter.'); + }); + + it('should throw on invalid allowByDefault attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig.allowByDefault.allowedRegions = [ 'AC', 'AD' ]; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"allowedRegions" is not a valid SmsRegionConfig.allowByDefault parameter.'); + }); + + it('should throw on non-array disallowedRegions attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig.allowByDefault.disallowedRegions = 'non-array'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"SmsRegionConfig.allowByDefault.disallowedRegions" must be an array of valid string.'); + }); + + it('should throw on non-array allowedRegions attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig = deepCopy(smsAllowlistOnly); + tenantOptionsClientRequest.smsRegionConfig.allowlistOnly.allowedRegions = 'non-array'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"SmsRegionConfig.allowlistOnly.allowedRegions" must be an array of valid string.'); + }); + + it('should throw when both allowlistOnly and allowByDefault attributes are presented', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.smsRegionConfig = { ...smsAllowByDefault, ...smsAllowlistOnly }; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameter.'); + }); + const nonObjects = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; nonObjects.forEach((request) => { it('should throw on invalid CreateTenantRequest:' + JSON.stringify(request), () => { @@ -314,6 +444,11 @@ describe('Tenant', () => { deepCopy(clientRequest.testPhoneNumbers)); }); + it('should set readonly property smsRegionConfig', () => { + expect(tenant.smsRegionConfig).to.deep.equal( + deepCopy(clientRequest.smsRegionConfig)); + }); + it('should throw when no tenant ID is provided', () => { const invalidOptions = deepCopy(serverRequest); // Use resource name that does not include a tenant ID. @@ -352,6 +487,7 @@ describe('Tenant', () => { anonymousSignInEnabled: false, multiFactorConfig: deepCopy(clientRequest.multiFactorConfig), testPhoneNumbers: deepCopy(clientRequest.testPhoneNumbers), + smsRegionConfig: deepCopy(clientRequest.smsRegionConfig), }); }); @@ -359,6 +495,7 @@ describe('Tenant', () => { const serverRequestCopyWithoutMfa: TenantServerResponse = deepCopy(serverRequest); delete serverRequestCopyWithoutMfa.mfaConfig; delete serverRequestCopyWithoutMfa.testPhoneNumbers; + delete serverRequestCopyWithoutMfa.smsRegionConfig; expect(new Tenant(serverRequestCopyWithoutMfa).toJSON()).to.deep.equal({ tenantId: 'TENANT-ID', From 013944f511f604ea2eae7e943fa54eb49f23574d Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Tue, 26 Apr 2022 16:10:28 -0700 Subject: [PATCH 3/8] fix lint issues --- etc/firebase-admin.auth.api.md | 24 ++++++++------ src/auth/auth-config.ts | 15 ++++++++- src/auth/index.ts | 2 ++ src/auth/project-config.ts | 4 +-- test/integration/auth.spec.ts | 4 +-- test/unit/auth/project-config-manager.spec.ts | 14 ++++---- test/unit/auth/project-config.spec.ts | 32 +++++++++---------- 7 files changed, 57 insertions(+), 38 deletions(-) diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index 0c5cd975ee..9e6d223a12 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -23,23 +23,27 @@ export interface ActionCodeSettings { url: string; } -// @public (undocumented) +// @public +export interface AllowByDefault { + disallowedRegions: string[]; +} + +// @public export interface AllowByDefaultWrap { - // Warning: (ae-forgotten-export) The symbol "AllowByDefault" needs to be exported by the entry point index.d.ts - // - // (undocumented) allowByDefault: AllowByDefault; // @alpha (undocumented) - allowlistOnly: never; + allowlistOnly?: never; } -// @public (undocumented) +// @public +export interface AllowlistOnly { + allowedRegions: string[]; +} + +// @public export interface AllowlistOnlyWrap { // @alpha (undocumented) - allowByDefault: never; - // Warning: (ae-forgotten-export) The symbol "AllowlistOnly" needs to be exported by the entry point index.d.ts - // - // (undocumented) + allowByDefault?: never; allowlistOnly: AllowlistOnly; } diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 7711f8df7c..21f27e32f4 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1459,13 +1459,26 @@ export class OIDCConfig implements OIDCAuthProviderConfig { */ export type SmsRegionConfig = AllowByDefaultWrap | AllowlistOnlyWrap; +/** + * Mutual exclusive SMS Region Config of AllowByDefault interface + */ export interface AllowByDefaultWrap { + /** + * Allowing every region by default. + */ allowByDefault: AllowByDefault; /** @alpha */ allowlistOnly?: never; } +/** + * Mutual exclusive SMS Region Config of AllowlistOnly interface + */ export interface AllowlistOnlyWrap { + /** + * Only allowing regions by explicitly adding them to an + * allowlist. + */ allowlistOnly: AllowlistOnly; /** @alpha */ allowByDefault?: never; @@ -1500,7 +1513,7 @@ export interface AllowlistOnly { } export class SmsRegionsAuthConfig { - public static validate(options: SmsRegionConfig) { + public static validate(options: SmsRegionConfig): void { if (!validator.isNonNullObject(options)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, diff --git a/src/auth/index.ts b/src/auth/index.ts index 43fa50ad0f..716b57cfb2 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -61,7 +61,9 @@ export { } from './auth'; export { + AllowByDefault, AllowByDefaultWrap, + AllowlistOnly, AllowlistOnlyWrap, AuthFactorType, AuthProviderConfig, diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts index a80a3c7ffd..9a2fba6020 100644 --- a/src/auth/project-config.ts +++ b/src/auth/project-config.ts @@ -92,11 +92,11 @@ export class ProjectConfig { * 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); + ProjectConfig.validate(configOptions); return configOptions as ProjectConfigClientRequest; } diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index a365c151a7..2681442616 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -1209,7 +1209,7 @@ describe('admin.auth', () => { }); }); - describe.only('Tenant management operations', () => { + describe('Tenant management operations', () => { let createdTenantId: string; const createdTenants: string[] = []; const tenantOptions: CreateTenantRequest = { @@ -1294,7 +1294,7 @@ describe('admin.auth', () => { // By default we skip multi-tenancy as it is a Google Cloud Identity Platform // feature only and requires to be enabled via the Cloud Console. console.log(chalk.yellow(' Skipping multi-tenancy tests.')); - //this.skip(); + this.skip(); } /* tslint:enable:no-console */ }); diff --git a/test/unit/auth/project-config-manager.spec.ts b/test/unit/auth/project-config-manager.spec.ts index f5b87f0c55..d06b24fa80 100644 --- a/test/unit/auth/project-config-manager.spec.ts +++ b/test/unit/auth/project-config-manager.spec.ts @@ -47,9 +47,9 @@ describe('ProjectConfigManager', () => { let rejectedPromiseAccessTokenProjectConfigManager: ProjectConfigManager; const GET_CONFIG_RESPONSE: ProjectConfigServerResponse = { smsRegionConfig: { - allowlistOnly: { - allowedRegions: [ 'AC', 'AD' ], - }, + allowlistOnly: { + allowedRegions: [ 'AC', 'AD' ], + }, }, }; @@ -126,11 +126,11 @@ describe('ProjectConfigManager', () => { describe('updateProjectConfig()', () => { const projectConfigOptions: UpdateProjectConfigRequest = { - smsRegionConfig: { - allowByDefault: { - disallowedRegions: [ 'AC', 'AD' ], - }, + smsRegionConfig: { + allowByDefault: { + disallowedRegions: [ 'AC', 'AD' ], }, + }, }; const expectedProjectConfig = new ProjectConfig(GET_CONFIG_RESPONSE); const expectedError = new FirebaseAuthError( diff --git a/test/unit/auth/project-config.spec.ts b/test/unit/auth/project-config.spec.ts index 921fcbe4e5..1b1d045c91 100644 --- a/test/unit/auth/project-config.spec.ts +++ b/test/unit/auth/project-config.spec.ts @@ -35,36 +35,36 @@ const expect = chai.expect; describe('ProjectConfig', () => { const serverResponse: ProjectConfigServerResponse = { smsRegionConfig: { - allowByDefault: { - disallowedRegions: [ 'AC', 'AD' ], - }, + allowByDefault: { + disallowedRegions: [ 'AC', 'AD' ], + }, }, }; const updateProjectConfigRequest1: UpdateProjectConfigRequest = { smsRegionConfig: { - allowByDefault: { - disallowedRegions: [ 'AC', 'AD' ], - }, + allowByDefault: { + disallowedRegions: [ 'AC', 'AD' ], + }, }, }; const updateProjectConfigRequest2: UpdateProjectConfigRequest = { smsRegionConfig: { - allowlistOnly: { - allowedRegions: [ 'AC', 'AD' ], - }, + allowlistOnly: { + allowedRegions: [ 'AC', 'AD' ], + }, }, }; const updateProjectConfigRequest3: any = { smsRegionConfig: { - allowlistOnly: { - allowedRegions: [ 'AC', 'AD' ], - }, - allowByDefault: { - disallowedRegions: ['AC', 'AD'], - }, + allowlistOnly: { + allowedRegions: [ 'AC', 'AD' ], + }, + allowByDefault: { + disallowedRegions: ['AC', 'AD'], + }, }, }; @@ -167,7 +167,7 @@ describe('ProjectConfig', () => { it('should set readonly property smsRegionConfig', () => { const expectedSmsRegionConfig = { allowByDefault: { - disallowedRegions: [ 'AC', 'AD' ], + disallowedRegions: [ 'AC', 'AD' ], }, }; expect(projectConfig.smsRegionConfig).to.deep.equal(expectedSmsRegionConfig); From e3bfba58af0644577e5e224f5a589bcd59fc7d1a Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Wed, 27 Apr 2022 10:21:02 -0700 Subject: [PATCH 4/8] remove reCAPTCHA related docs and variable names --- src/auth/auth-api-request.ts | 4 ++-- src/auth/project-config.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 2e9c22fc83..2abe399339 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -2100,9 +2100,9 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { * Update the current project's config. * @returns A promise that resolves with the project config information. */ - public updateProjectConfig(recaptchaOptions: UpdateProjectConfigRequest): Promise { + public updateProjectConfig(options: UpdateProjectConfigRequest): Promise { try { - const request = ProjectConfig.buildServerRequest(recaptchaOptions); + const request = ProjectConfig.buildServerRequest(options); const updateMask = utils.generateUpdateMask(request); return this.invokeRequestHandler( this.authResourceUrlBuilder, UPDATE_PROJECT_CONFIG, request, { updateMask: updateMask.join(',') }) diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts index 9a2fba6020..3dd9bb2e45 100644 --- a/src/auth/project-config.ts +++ b/src/auth/project-config.ts @@ -33,7 +33,7 @@ export interface UpdateProjectConfigRequest { /** * Response received from get/update project config. - * We are only exposing the recaptcha config for now. + * We are only exposing the SMS Region config for now. */ export interface ProjectConfigServerResponse { smsRegionConfig?: SmsRegionConfig; From 1f6398f925a420d5531df6cede5c999a477a43d4 Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Wed, 27 Apr 2022 16:12:33 -0700 Subject: [PATCH 5/8] Address PR feedbacks --- src/auth/auth-config.ts | 13 +++++++++---- src/auth/auth.ts | 2 +- src/auth/project-config-manager.ts | 2 +- src/auth/project-config.ts | 2 +- test/unit/auth/project-config.spec.ts | 6 +++--- test/unit/auth/tenant.spec.ts | 12 ++++++------ 6 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 21f27e32f4..b50621750e 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1512,6 +1512,11 @@ export interface AllowlistOnly { allowedRegions: string[]; } +/** + * Defines the SMSRegionConfig class used for validation. + * + * @internal + */ export class SmsRegionsAuthConfig { public static validate(options: SmsRegionConfig): void { if (!validator.isNonNullObject(options)) { @@ -1539,7 +1544,7 @@ export class SmsRegionsAuthConfig { if (typeof options.allowByDefault !== 'undefined' && typeof options.allowlistOnly !== 'undefined') { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, - 'SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameter.', + 'SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameters.', ); } // validation for allowByDefault type @@ -1560,7 +1565,7 @@ export class SmsRegionsAuthConfig { && !validator.isArray(options.allowByDefault.disallowedRegions)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, - '"SmsRegionConfig.allowByDefault.disallowedRegions" must be an array of valid string.', + '"SmsRegionConfig.allowByDefault.disallowedRegions" must be a valid string array.', ); } } @@ -1583,9 +1588,9 @@ export class SmsRegionsAuthConfig { && !validator.isArray(options.allowlistOnly.allowedRegions)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, - '"SmsRegionConfig.allowlistOnly.allowedRegions" must be an array of valid string.', + '"SmsRegionConfig.allowlistOnly.allowedRegions" must be a valid string array.', ); } } } -} \ No newline at end of file +} diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 67a64afef2..4808fbbdc0 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -69,4 +69,4 @@ export class Auth extends BaseAuth { public projectConfigManager(): ProjectConfigManager { return this.projectConfigManager_; } -} \ No newline at end of file +} diff --git a/src/auth/project-config-manager.ts b/src/auth/project-config-manager.ts index 0174d779fe..a969984516 100644 --- a/src/auth/project-config-manager.ts +++ b/src/auth/project-config-manager.ts @@ -64,4 +64,4 @@ export class ProjectConfigManager { return new ProjectConfig(response); }) } -} \ No newline at end of file +} diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts index 3dd9bb2e45..738b3b83a8 100644 --- a/src/auth/project-config.ts +++ b/src/auth/project-config.ts @@ -52,7 +52,7 @@ export interface ProjectConfigClientRequest { */ export class ProjectConfig { /** - * The SMS Regions Config to update on the project config. + * The SMS Regions Config for the project. * Configures the regions where users are allowed to send verification SMS. * This is based on the calling code of the destination phone number. */ diff --git a/test/unit/auth/project-config.spec.ts b/test/unit/auth/project-config.spec.ts index 1b1d045c91..19cc8f420d 100644 --- a/test/unit/auth/project-config.spec.ts +++ b/test/unit/auth/project-config.spec.ts @@ -108,7 +108,7 @@ describe('ProjectConfig', () => { configOptionsClientRequest.smsRegionConfig.allowByDefault.disallowedRegions = 'non-array'; expect(() => { ProjectConfig.buildServerRequest(configOptionsClientRequest); - }).to.throw('"SmsRegionConfig.allowByDefault.disallowedRegions" must be an array of valid string.'); + }).to.throw('"SmsRegionConfig.allowByDefault.disallowedRegions" must be a valid string array.'); }); it('should throw on non-array allowedRegions attribute', () => { @@ -116,14 +116,14 @@ describe('ProjectConfig', () => { configOptionsClientRequest.smsRegionConfig.allowlistOnly.allowedRegions = 'non-array'; expect(() => { ProjectConfig.buildServerRequest(configOptionsClientRequest); - }).to.throw('"SmsRegionConfig.allowlistOnly.allowedRegions" must be an array of valid string.'); + }).to.throw('"SmsRegionConfig.allowlistOnly.allowedRegions" must be a valid string array.'); }); it('should throw when both allowlistOnly and allowByDefault attributes are presented', () => { const configOptionsClientRequest = deepCopy(updateProjectConfigRequest3) as any; expect(() => { ProjectConfig.buildServerRequest(configOptionsClientRequest); - }).to.throw('SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameter.'); + }).to.throw('SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameters.'); }); it('should not throw on valid client request object', () => { diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 0a56cc51ea..4780cbb473 100644 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -193,7 +193,7 @@ describe('Tenant', () => { tenantOptionsClientRequest.smsRegionConfig.allowByDefault.disallowedRegions = 'non-array'; expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); - }).to.throw('"SmsRegionConfig.allowByDefault.disallowedRegions" must be an array of valid string.'); + }).to.throw('"SmsRegionConfig.allowByDefault.disallowedRegions" must be a valid string array.'); }); it('should throw on non-array allowedRegions attribute', () => { @@ -202,7 +202,7 @@ describe('Tenant', () => { tenantOptionsClientRequest.smsRegionConfig.allowlistOnly.allowedRegions = 'non-array'; expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); - }).to.throw('"SmsRegionConfig.allowlistOnly.allowedRegions" must be an array of valid string.'); + }).to.throw('"SmsRegionConfig.allowlistOnly.allowedRegions" must be a valid string array.'); }); it('should throw when both allowlistOnly and allowByDefault attributes are presented', () => { @@ -210,7 +210,7 @@ describe('Tenant', () => { tenantOptionsClientRequest.smsRegionConfig = { ...smsAllowByDefault, ...smsAllowlistOnly }; expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); - }).to.throw('SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameter.'); + }).to.throw('SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameters.'); }); it('should not throw on valid client request object', () => { @@ -342,7 +342,7 @@ describe('Tenant', () => { tenantOptionsClientRequest.smsRegionConfig.allowByDefault.disallowedRegions = 'non-array'; expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); - }).to.throw('"SmsRegionConfig.allowByDefault.disallowedRegions" must be an array of valid string.'); + }).to.throw('"SmsRegionConfig.allowByDefault.disallowedRegions" must be a valid string array.'); }); it('should throw on non-array allowedRegions attribute', () => { @@ -351,7 +351,7 @@ describe('Tenant', () => { tenantOptionsClientRequest.smsRegionConfig.allowlistOnly.allowedRegions = 'non-array'; expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); - }).to.throw('"SmsRegionConfig.allowlistOnly.allowedRegions" must be an array of valid string.'); + }).to.throw('"SmsRegionConfig.allowlistOnly.allowedRegions" must be a valid string array.'); }); it('should throw when both allowlistOnly and allowByDefault attributes are presented', () => { @@ -359,7 +359,7 @@ describe('Tenant', () => { tenantOptionsClientRequest.smsRegionConfig = { ...smsAllowByDefault, ...smsAllowlistOnly }; expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); - }).to.throw('SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameter.'); + }).to.throw('SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameters.'); }); const nonObjects = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; From 0d8a433faa351bff5da836c10d3117c89d4a2c3d Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Wed, 27 Apr 2022 16:35:55 -0700 Subject: [PATCH 6/8] removed the unnecessary optional tag --- src/auth/auth-config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index b50621750e..829c3801aa 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1561,7 +1561,7 @@ export class SmsRegionsAuthConfig { } } // disallowedRegion can be empty. - if (typeof options.allowByDefault?.disallowedRegions !== 'undefined' + if (typeof options.allowByDefault.disallowedRegions !== 'undefined' && !validator.isArray(options.allowByDefault.disallowedRegions)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, @@ -1584,7 +1584,7 @@ export class SmsRegionsAuthConfig { } // allowedRegions can be empty - if (typeof options.allowlistOnly?.allowedRegions !== 'undefined' + if (typeof options.allowlistOnly.allowedRegions !== 'undefined' && !validator.isArray(options.allowlistOnly.allowedRegions)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, From b7e756652b629f115b42ddff8cdaaa1251424ca3 Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Thu, 28 Apr 2022 09:40:48 -0700 Subject: [PATCH 7/8] updated the validation parameter type --- src/auth/project-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts index 738b3b83a8..ab125bc288 100644 --- a/src/auth/project-config.ts +++ b/src/auth/project-config.ts @@ -63,7 +63,7 @@ export class ProjectConfig { * * @param request - The project config options object to validate. */ - private static validate(request: any): void { + private static validate(request: ProjectConfigClientRequest): void { if (!validator.isNonNullObject(request)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, From 4adf98647539c4752b85b931a92031eb8d72c693 Mon Sep 17 00:00:00 2001 From: Liubin Jiang Date: Thu, 28 Apr 2022 13:22:25 -0700 Subject: [PATCH 8/8] update the doc --- src/auth/auth-config.ts | 4 ++-- src/auth/project-config-manager.ts | 2 +- src/auth/project-config.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 829c3801aa..45ca3ef2d0 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1464,7 +1464,7 @@ export type SmsRegionConfig = AllowByDefaultWrap | AllowlistOnlyWrap; */ export interface AllowByDefaultWrap { /** - * Allowing every region by default. + * Allow every region by default. */ allowByDefault: AllowByDefault; /** @alpha */ @@ -1472,7 +1472,7 @@ export interface AllowByDefaultWrap { } /** - * Mutual exclusive SMS Region Config of AllowlistOnly interface + * Mutually exclusive SMS Region Config of AllowlistOnly interface */ export interface AllowlistOnlyWrap { /** diff --git a/src/auth/project-config-manager.ts b/src/auth/project-config-manager.ts index a969984516..030b64a779 100644 --- a/src/auth/project-config-manager.ts +++ b/src/auth/project-config-manager.ts @@ -56,7 +56,7 @@ export class ProjectConfigManager { * * @param projectConfigOptions - The properties to update on the project. * - * @returns A promise fulfilled with the update project config. + * @returns A promise fulfilled with the updated project config. */ public updateProjectConfig(projectConfigOptions: UpdateProjectConfigRequest): Promise { return this.authRequestHandler.updateProjectConfig(projectConfigOptions) diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts index ab125bc288..54dcfb3b9c 100644 --- a/src/auth/project-config.ts +++ b/src/auth/project-config.ts @@ -32,8 +32,8 @@ export interface UpdateProjectConfigRequest { } /** - * Response received from get/update project config. - * We are only exposing the SMS Region config for now. + * Response received from getting or updating a project config. + * This object currently exposes only the SMS Region config. */ export interface ProjectConfigServerResponse { smsRegionConfig?: SmsRegionConfig; @@ -41,7 +41,7 @@ export interface ProjectConfigServerResponse { /** * Request sent to update project config. - * We are only updating the SMS Regions config for now. + * This object currently exposes only the SMS Region config. */ export interface ProjectConfigClientRequest { smsRegionConfig?: SmsRegionConfig;