diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index 2986869ef2..c7090af304 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -23,10 +23,35 @@ export interface ActionCodeSettings { url: string; } +// @public +export interface AllowByDefault { + disallowedRegions: string[]; +} + +// @public +export interface AllowByDefaultWrap { + allowByDefault: AllowByDefault; + // @alpha (undocumented) + allowlistOnly?: never; +} + +// @public +export interface AllowlistOnly { + allowedRegions: string[]; +} + +// @public +export interface AllowlistOnlyWrap { + // @alpha (undocumented) + allowByDefault?: never; + 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; } @@ -309,6 +334,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) @@ -342,6 +379,9 @@ export interface SessionCookieOptions { expiresIn: number; } +// @public +export type SmsRegionConfig = AllowByDefaultWrap | AllowlistOnlyWrap; + // @public export class Tenant { // (undocumented) @@ -349,6 +389,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; @@ -391,6 +432,11 @@ export interface UpdatePhoneMultiFactorInfoRequest extends BaseUpdateMultiFactor phoneNumber: string; } +// @public +export interface UpdateProjectConfigRequest { + smsRegionConfig?: SmsRegionConfig; +} + // @public export interface UpdateRequest { disabled?: boolean; @@ -411,6 +457,7 @@ export interface UpdateTenantRequest { displayName?: string; emailSignInConfig?: EmailSignInProviderConfig; multiFactorConfig?: MultiFactorConfig; + smsRegionConfig?: SmsRegionConfig; testPhoneNumbers?: { [phoneNumber: string]: string; } | null; diff --git a/package-lock.json b/package-lock.json index 24d1405289..7dbafb2014 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10188,4 +10188,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index a962a4f719..2893d49a9d 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; @@ -1981,6 +1981,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') @@ -2049,13 +2072,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. @@ -2065,7 +2088,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { */ constructor(app: App) { super(app); - this.tenantMgmtResourceBuilder = new AuthResourceUrlBuilder(app, 'v2'); + this.authResourceUrlBuilder = new AuthResourceUrlBuilder(app, 'v2'); } /** @@ -2082,6 +2105,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(options: UpdateProjectConfigRequest): Promise { + try { + const request = ProjectConfig.buildServerRequest(options); + 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. * @@ -2092,7 +2144,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; }); @@ -2122,7 +2174,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 = []; @@ -2142,7 +2194,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. }); @@ -2158,7 +2210,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; }); @@ -2184,7 +2236,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 ce45713f97..45ca3ef2d0 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1451,3 +1451,146 @@ export class OIDCConfig implements OIDCAuthProviderConfig { }; } } + +/** + * 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 type SmsRegionConfig = AllowByDefaultWrap | AllowlistOnlyWrap; + +/** + * Mutual exclusive SMS Region Config of AllowByDefault interface + */ +export interface AllowByDefaultWrap { + /** + * Allow every region by default. + */ + allowByDefault: AllowByDefault; + /** @alpha */ + allowlistOnly?: never; +} + +/** + * Mutually 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; +} + +/** + * 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[]; +} + +/** + * Defines the SMSRegionConfig class used for validation. + * + * @internal + */ +export class SmsRegionsAuthConfig { + public static validate(options: SmsRegionConfig): void { + 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" parameters.', + ); + } + // 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 a valid string array.', + ); + } + } + + 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 a valid string array.', + ); + } + } + } +} 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 5a7e668244..7dec658473 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -61,6 +61,10 @@ export { } from './auth'; export { + AllowByDefault, + AllowByDefaultWrap, + AllowlistOnly, + AllowlistOnlyWrap, AuthFactorType, AuthProviderConfig, AuthProviderConfigFilter, @@ -81,6 +85,7 @@ export { OIDCUpdateAuthProviderRequest, SAMLAuthProviderConfig, SAMLUpdateAuthProviderRequest, + SmsRegionConfig, UserProvider, UpdateAuthProviderRequest, UpdateMultiFactorInfoRequest, @@ -116,6 +121,15 @@ export { TenantManager, } from './tenant-manager'; +export { + UpdateProjectConfigRequest, + ProjectConfig, +} from './project-config'; + +export { + ProjectConfigManager, +} from './project-config-manager'; + export { DecodedIdToken, DecodedAuthBlockingToken diff --git a/src/auth/project-config-manager.ts b/src/auth/project-config-manager.ts new file mode 100644 index 0000000000..030b64a779 --- /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 updated 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..54dcfb3b9c --- /dev/null +++ b/src/auth/project-config.ts @@ -0,0 +1,131 @@ +/*! + * 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 { + 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 SMS configuration to update on the project. + */ + smsRegionConfig?: SmsRegionConfig; +} + +/** + * Response received from getting or updating a project config. + * This object currently exposes only the SMS Region config. + */ +export interface ProjectConfigServerResponse { + smsRegionConfig?: SmsRegionConfig; +} + +/** + * Request sent to update project config. + * This object currently exposes only the SMS Region config. + */ +export interface ProjectConfigClientRequest { + smsRegionConfig?: SmsRegionConfig; +} + +/** +* Represents a project configuration. +*/ +export class ProjectConfig { + /** + * 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. + */ + public 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: ProjectConfigClientRequest): 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.`, + ); + } + } + // Validate SMS Regions Config if provided. + if (typeof request.smsRegionConfig !== 'undefined') { + SmsRegionsAuthConfig.validate(request.smsRegionConfig); + } + } + + /** + * 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 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.smsRegionConfig !== 'undefined') { + this.smsRegionConfig = response.smsRegionConfig; + } + } + /** + * Returns a JSON-serializable representation of this object. + * + * @returns A JSON-serializable representation of this object. + */ + public toJSON(): object { + // JSON serialization + const json = { + smsRegionConfig: deepCopy(this.smsRegionConfig), + }; + if (typeof json.smsRegionConfig === 'undefined') { + delete json.smsRegionConfig; + } + return json; + } +} + diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index e489fa3b09..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, + MultiFactorAuthConfig, SmsRegionConfig, SmsRegionsAuthConfig } 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; } /** @@ -68,6 +73,7 @@ export interface TenantOptionsServerRequest extends EmailSignInConfigServerReque enableAnonymousUser?: boolean; mfaConfig?: MultiFactorAuthServerConfig; testPhoneNumbers?: {[key: string]: string}; + smsRegionConfig?: SmsRegionConfig; } /** The tenant server response interface. */ @@ -79,6 +85,7 @@ export interface TenantServerResponse { enableAnonymousUser?: boolean; mfaConfig?: MultiFactorAuthServerConfig; testPhoneNumbers?: {[key: string]: string}; + smsRegionConfig?: SmsRegionConfig; } /** @@ -123,6 +130,13 @@ export class Tenant { private readonly emailSignInConfig_?: EmailSignInConfig; private readonly multiFactorConfig_?: MultiFactorAuthConfig; + /** + * 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. * @@ -152,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; } @@ -185,6 +202,7 @@ export class Tenant { anonymousSignInEnabled: true, multiFactorConfig: true, testPhoneNumbers: true, + smsRegionConfig: true, }; const label = createRequest ? 'CreateTenantRequest' : 'UpdateTenantRequest'; if (!validator.isNonNullObject(request)) { @@ -231,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); + } } /** @@ -265,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); + } } /** @@ -294,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; @@ -301,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 78cfb62701..0184d47b4e 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import url = require('url'); +import * as url from 'url'; import * as crypto from 'crypto'; import * as bcrypt from 'bcrypt'; import * as chai from 'chai'; @@ -31,7 +31,7 @@ import { deepExtend, deepCopy } from '../../src/utils/deep-copy'; import { AuthProviderConfig, CreateTenantRequest, DeleteUsersResult, PhoneMultiFactorInfo, TenantAwareAuth, UpdatePhoneMultiFactorInfoRequest, UpdateTenantRequest, UserImportOptions, - UserImportRecord, UserMetadata, UserRecord, getAuth, + UserImportRecord, UserRecord, getAuth, UpdateProjectConfigRequest, UserMetadata, } from '../../lib/auth/index'; const chalk = require('chalk'); // eslint-disable-line @typescript-eslint/no-var-requires @@ -57,7 +57,7 @@ const testPhoneNumber2 = '+16505550101'; const nonexistentPhoneNumber = '+18888888888'; const updatedEmail = generateRandomString(20).toLowerCase() + '@example.com'; const updatedPhone = '+16505550102'; -const customClaims: {[key: string]: any} = { +const customClaims: { [key: string]: any } = { admin: true, groupId: '1234', }; @@ -433,15 +433,15 @@ describe('admin.auth', () => { * and UserImportRecord instances. */ function mapUserRecordsToUidEmailPhones( - values: Array<{ uid: string; email?: string; phoneNumber?: string}> - ): Array<{ uid: string; email?: string; phoneNumber?: string}> { + values: Array<{ uid: string; email?: string; phoneNumber?: string }> + ): Array<{ uid: string; email?: string; phoneNumber?: string }> { return values.map((ur) => ({ uid: ur.uid, email: ur.email, phoneNumber: ur.phoneNumber })); } const testUser1 = { uid: 'uid1', email: 'user1@example.com', phoneNumber: '+15555550001' }; const testUser2 = { uid: 'uid2', email: 'user2@example.com', phoneNumber: '+15555550002' }; const testUser3 = { uid: 'uid3', email: 'user3@example.com', phoneNumber: '+15555550003' }; - const usersToCreate = [ testUser1, testUser2, testUser3 ]; + const usersToCreate = [testUser1, testUser2, testUser3]; // Also create a user with a provider config. (You can't create a user with // a provider config. But you *can* import one.) @@ -551,11 +551,11 @@ describe('admin.auth', () => { // each attempt). Occassionally, this call retrieves the user data // without the lastLoginTime/lastRefreshTime set; possibly because // it's hitting a different server than the login request uses. - let userRecord = null; + let userRecord: UserRecord | null = null; for (let i = 0; i < 3; i++) { userRecord = await getAuth().getUser('lastRefreshTimeUser'); - if (userRecord.metadata.lastRefreshTime) { + if (userRecord!['metadata']['lastRefreshTime']) { break; } @@ -564,11 +564,11 @@ describe('admin.auth', () => { }); } - const metadata = userRecord!.metadata; - expect(metadata.lastRefreshTime).to.exist; - expect(isUTCString(metadata.lastRefreshTime!)).to.be.true; - const creationTime = new Date(metadata.creationTime).getTime(); - const lastRefreshTime = new Date(metadata.lastRefreshTime!).getTime(); + const metadata = userRecord!['metadata']; + expect(metadata['lastRefreshTime']).to.exist; + expect(isUTCString(metadata['lastRefreshTime']!)).to.be.true; + const creationTime = new Date(metadata['creationTime']).getTime(); + const lastRefreshTime = new Date(metadata['lastRefreshTime']!).getTime(); expect(creationTime).lte(lastRefreshTime); expect(lastRefreshTime).lte(creationTime + 3600 * 1000); }); @@ -604,16 +604,16 @@ describe('admin.auth', () => { expect( listUsersResult.users[0].passwordHash, 'Missing passwordHash field. A common cause would be forgetting to ' - + 'add the "Firebase Authentication Admin" permission. See ' - + 'instructions in CONTRIBUTING.md', + + 'add the "Firebase Authentication Admin" permission. See ' + + 'instructions in CONTRIBUTING.md', ).to.be.ok; expect(listUsersResult.users[0].passwordHash!.length).greaterThan(0); expect( listUsersResult.users[0].passwordSalt, 'Missing passwordSalt field. A common cause would be forgetting to ' - + 'add the "Firebase Authentication Admin" permission. See ' - + 'instructions in CONTRIBUTING.md', + + 'add the "Firebase Authentication Admin" permission. See ' + + 'instructions in CONTRIBUTING.md', ).to.be.ok; expect(listUsersResult.users[0].passwordSalt!.length).greaterThan(0); @@ -704,7 +704,7 @@ describe('admin.auth', () => { // Verify ID token contents. return getAuth().verifyIdToken(idToken); }) - .then((decodedIdToken: {[key: string]: any}) => { + .then((decodedIdToken: { [key: string]: any }) => { // Confirm expected claims set on the user's ID token. for (const key in customClaims) { if (Object.prototype.hasOwnProperty.call(customClaims, key)) { @@ -728,7 +728,7 @@ describe('admin.auth', () => { // Verify ID token contents. return getAuth().verifyIdToken(idToken); }) - .then((decodedIdToken: {[key: string]: any}) => { + .then((decodedIdToken: { [key: string]: any }) => { // Confirm all custom claims are cleared. for (const key in customClaims) { if (Object.prototype.hasOwnProperty.call(customClaims, key)) { @@ -811,7 +811,7 @@ describe('admin.auth', () => { }) .then((userRecord) => { // Confirm second factors added to user. - const actualUserRecord: {[key: string]: any} = userRecord.toJSON(); + const actualUserRecord: { [key: string]: any } = userRecord.toJSON(); expect(actualUserRecord.multiFactor.enrolledFactors.length).to.equal(2); expect(actualUserRecord.multiFactor.enrolledFactors).to.deep.equal(enrolledFactors); // Update list of second factors. @@ -823,7 +823,7 @@ describe('admin.auth', () => { }) .then((userRecord) => { expect(userRecord.multiFactor!.enrolledFactors.length).to.equal(1); - const actualUserRecord: {[key: string]: any} = userRecord.toJSON(); + const actualUserRecord: { [key: string]: any } = userRecord.toJSON(); expect(actualUserRecord.multiFactor.enrolledFactors[0]).to.deep.equal(enrolledFactors[0]); // Remove all second factors. return getAuth().updateUser(updateUser.uid, { @@ -919,7 +919,7 @@ describe('admin.auth', () => { it('A user with user record disabled is unable to sign in', async () => { const password = 'password'; const email = 'updatedEmail@example.com'; - return getAuth().updateUser(updateUser.uid, { disabled : true , password, email }) + return getAuth().updateUser(updateUser.uid, { disabled: true, password, email }) .then(() => { return clientAuth().signInWithEmailAndPassword(email, password); }) @@ -1154,7 +1154,7 @@ describe('admin.auth', () => { }); }); - it('generateVerifyAndChangeEmailLink() should return a verification link', function() { + it('generateVerifyAndChangeEmailLink() should return a verification link', function () { if (authEmulatorHost) { return this.skip(); // Not yet supported in Auth Emulator. } @@ -1180,6 +1180,61 @@ describe('admin.auth', () => { }); }); + 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('Tenant management operations', () => { let createdTenantId: string; const createdTenants: string[] = []; @@ -1248,12 +1303,17 @@ describe('admin.auth', () => { state: 'ENABLED', factorIds: ['phone'], }, + smsRegionConfig: { + allowByDefault: { + disallowedRegions: ['AC', 'AD'], + } + }, }; // https://mochajs.org/ // Passing arrow functions (aka "lambdas") to Mocha is discouraged. // Lambdas lexically bind this and cannot access the Mocha context. - before(function() { + before(function () { /* tslint:disable:no-console */ if (!cmdArgs.testMultiTenancy) { // To enable, run: npm run test:integration -- --testMultiTenancy @@ -1271,7 +1331,7 @@ describe('admin.auth', () => { createdTenants.forEach((tenantId) => { promises.push( getAuth().tenantManager().deleteTenant(tenantId) - .catch(() => {/** Ignore. */})); + .catch(() => {/** Ignore. */ })); }); return Promise.all(promises); }); @@ -1285,7 +1345,7 @@ describe('admin.auth', () => { const actualTenantObj = actualTenant.toJSON(); if (authEmulatorHost) { // Not supported in Auth Emulator - delete (actualTenantObj as {testPhoneNumbers?: Record}).testPhoneNumbers; + delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; delete expectedCreatedTenant.testPhoneNumbers; } expect(actualTenantObj).to.deep.equal(expectedCreatedTenant); @@ -1324,7 +1384,7 @@ describe('admin.auth', () => { const rawPassword = 'password'; const rawSalt = 'NaCl'; - before(function() { + before(function () { if (!createdTenantId) { this.skip(); } else { @@ -1432,10 +1492,10 @@ describe('admin.auth', () => { // Confirm expected user returned in the list and all users returned // belong to the expected tenant. const allUsersBelongToTenant = - listUsersResult.users.every((user) => user.tenantId === createdTenantId); + listUsersResult.users.every((user) => user.tenantId === createdTenantId); expect(allUsersBelongToTenant).to.be.true; const knownUserInTenant = - listUsersResult.users.some((user) => user.uid === createdUserUid); + listUsersResult.users.some((user) => user.uid === createdUserUid); expect(knownUserInTenant).to.be.true; }); }); @@ -1451,7 +1511,7 @@ describe('admin.auth', () => { it('importUsers() should upload a user to the specified tenant', () => { const currentHashKey = importOptions.hash.key.toString('utf8'); const passwordHash = - crypto.createHmac('sha256', currentHashKey).update(rawPassword + rawSalt).digest(); + crypto.createHmac('sha256', currentHashKey).update(rawPassword + rawSalt).digest(); const importUserRecord: any = { uid: createdUserUid, email: createdUserUid + '@example.com', @@ -1512,7 +1572,7 @@ describe('admin.auth', () => { enableRequestSigning: false, }; - before(function() { + before(function () { if (!createdTenantId) { this.skip(); } else { @@ -1593,7 +1653,7 @@ describe('admin.auth', () => { }, }; - before(function() { + before(function () { if (!createdTenantId) { this.skip(); } else { @@ -1610,7 +1670,7 @@ describe('admin.auth', () => { }); } }); - + it('should support CRUD operations', function () { // TODO(lisajian): Unskip once auth emulator supports OIDC/SAML if (authEmulatorHost) { @@ -1643,7 +1703,7 @@ describe('admin.auth', () => { const actualTenantObj = actualTenant.toJSON(); if (authEmulatorHost) { // Not supported in Auth Emulator - delete (actualTenantObj as {testPhoneNumbers?: Record}).testPhoneNumbers; + delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; delete expectedCreatedTenant.testPhoneNumbers; } expect(actualTenantObj).to.deep.equal(expectedCreatedTenant); @@ -1669,13 +1729,14 @@ 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) .then((actualTenant) => { const actualTenantObj = actualTenant.toJSON(); // Not supported in Auth Emulator - delete (actualTenantObj as {testPhoneNumbers?: Record}).testPhoneNumbers; + delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; delete expectedUpdatedTenant.testPhoneNumbers; expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant); return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2); @@ -1683,7 +1744,7 @@ describe('admin.auth', () => { .then((actualTenant) => { const actualTenantObj = actualTenant.toJSON(); // Not supported in Auth Emulator - delete (actualTenantObj as {testPhoneNumbers?: Record}).testPhoneNumbers; + delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; delete expectedUpdatedTenant2.testPhoneNumbers; expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant2); }); @@ -1698,6 +1759,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({ @@ -1799,8 +1882,8 @@ describe('admin.auth', () => { const removeTempConfigs = (): Promise => { return Promise.all([ - getAuth().deleteProviderConfig(authProviderConfig1.providerId).catch(() => {/* empty */}), - getAuth().deleteProviderConfig(authProviderConfig2.providerId).catch(() => {/* empty */}), + getAuth().deleteProviderConfig(authProviderConfig1.providerId).catch(() => {/* empty */ }), + getAuth().deleteProviderConfig(authProviderConfig2.providerId).catch(() => {/* empty */ }), ]); }; @@ -1941,8 +2024,8 @@ describe('admin.auth', () => { const removeTempConfigs = (): Promise => { return Promise.all([ - getAuth().deleteProviderConfig(authProviderConfig1.providerId).catch(() => {/* empty */}), - getAuth().deleteProviderConfig(authProviderConfig2.providerId).catch(() => {/* empty */}), + getAuth().deleteProviderConfig(authProviderConfig1.providerId).catch(() => {/* empty */ }), + getAuth().deleteProviderConfig(authProviderConfig2.providerId).catch(() => {/* empty */ }), ]); }; @@ -2249,7 +2332,7 @@ describe('admin.auth', () => { const decodedIdToken = await getAuth().verifySessionCookie(sessionCookie, true); expect(decodedIdToken.uid).to.equal(uid4); - const userRecord = await getAuth().updateUser(uid4, { disabled : true }); + const userRecord = await getAuth().updateUser(uid4, { disabled: true }); // Ensure disabled field has been updated. expect(userRecord.uid).to.equal(uid4); expect(userRecord.disabled).to.equal(true); @@ -2293,7 +2376,7 @@ describe('admin.auth', () => { let decodedIdToken = await getAuth().verifySessionCookie(sessionCookie, true); expect(decodedIdToken.uid).to.equal(uid); - const userRecord = await getAuth().updateUser(uid, { disabled : true }); + const userRecord = await getAuth().updateUser(uid, { disabled: true }); // Ensure disabled field has been updated. expect(userRecord.uid).to.equal(uid); expect(userRecord.disabled).to.equal(true); @@ -2326,9 +2409,9 @@ describe('admin.auth', () => { // Simulate a user stored using SCRYPT being migrated to Firebase Auth via importUsers. // Obtained from https://github.com/firebase/scrypt. const scryptHashKey = 'jxspr8Ki0RYycVU8zykbdLGjFQ3McFUH0uiiTvC8pVMXAn210wjLNmdZ' + - 'JzxUECKbm0QsEmYUSDzZvpjeJ9WmXA=='; + 'JzxUECKbm0QsEmYUSDzZvpjeJ9WmXA=='; const scryptPasswordHash = 'V358E8LdWJXAO7muq0CufVpEOXaj8aFiC7T/rcaGieN04q/ZPJ0' + - '8WhJEHGjj9lz/2TT+/86N5VjVoc5DdBhBiw=='; + '8WhJEHGjj9lz/2TT+/86N5VjVoc5DdBhBiw=='; const scryptHashOptions = { hash: { algorithm: 'SCRYPT', @@ -2555,7 +2638,7 @@ describe('admin.auth', () => { -readonly [k in keyof UserMetadata]: UserMetadata[k]; }; (importUserRecord.metadata as Writable).lastRefreshTime = null; - const actualUserRecord: {[key: string]: any} = userRecord.toJSON(); + const actualUserRecord: { [key: string]: any } = userRecord.toJSON(); for (const key of Object.keys(importUserRecord)) { expect(JSON.stringify(actualUserRecord[key])) .to.be.equal(JSON.stringify((importUserRecord as any)[key])); @@ -2577,7 +2660,7 @@ describe('admin.auth', () => { displayName: 'Work phone number', factorId: 'phone', enrollmentTime: now, - } , + }, { uid: 'mfaUid2', phoneNumber: '+16505550002', @@ -2619,7 +2702,7 @@ describe('admin.auth', () => { return getAuth().getUser(uid); }).then((userRecord) => { // Confirm second factors added to user. - const actualUserRecord: {[key: string]: any} = userRecord.toJSON(); + const actualUserRecord: { [key: string]: any } = userRecord.toJSON(); expect(actualUserRecord.multiFactor.enrolledFactors.length).to.equal(2); expect(actualUserRecord.multiFactor.enrolledFactors) .to.deep.equal(importUserRecord.multiFactor?.enrolledFactors); @@ -2832,7 +2915,7 @@ async function deleteUsersWithDelay(uids: string[]): Promise * @param expected object. * @param actual object. */ -function assertDeepEqualUnordered(expected: {[key: string]: any}, actual: {[key: string]: any}): void { +function assertDeepEqualUnordered(expected: { [key: string]: any }, actual: { [key: string]: any }): void { for (const key in expected) { if (Object.prototype.hasOwnProperty.call(expected, key)) { expect(actual[key]) 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..d06b24fa80 --- /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..19cc8f420d --- /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 a valid string array.'); + }); + + 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 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" parameters.'); + }); + + 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 3843a41bb7..44885ecafa 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 a valid string array.'); + }); + + 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 a valid string array.'); + }); + + 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" parameters.'); + }); + 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 a valid string array.'); + }); + + 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 a valid string array.'); + }); + + 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" parameters.'); + }); + 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',