From 0ef5cfcd109bf95137c1d5639f141f5e32071d5a Mon Sep 17 00:00:00 2001 From: pragatimodi <110490169+pragatimodi@users.noreply.github.com> Date: Wed, 29 Mar 2023 14:17:30 -0700 Subject: [PATCH] feat(auth): Add TOTP support in Project and Tenant config (#1989) * Sync with master (#1986) * build(deps): bump @types/node from 18.7.23 to 18.11.9 (#1983) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 18.7.23 to 18.11.9. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump @firebase/auth-types from 0.11.0 to 0.11.1 (#1985) * build(deps-dev): bump sinon from 14.0.1 to 14.0.2 (#1984) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Adding TOTP support for MFA * Merge with master (#1987) * build(deps): bump @types/node from 18.7.23 to 18.11.9 (#1983) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 18.7.23 to 18.11.9. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump @firebase/auth-types from 0.11.0 to 0.11.1 (#1985) * build(deps-dev): bump sinon from 14.0.1 to 14.0.2 (#1984) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Revert "Merge with master (#1987)" (#1988) This reverts commit 5ebf34bba20e4c389d1cbab36570fcc7a0f33d36. * Revert "Sync with master (#1986)" This reverts commit 329094325ff15fb8de695606389e2d21da283951. * Update auth-api-request.ts * Update auth-api-request.ts * Addressing comments, adding tests and cleaning up * Auto generated after running `$ npm run api-extractor:local` * Linter fixes * Resolving review comments * Sync MFA field with backend * Reviewed changes * Formatting fix * Reverting packagelock.json auto changes * Project server config updates * Import fix * Fix lint errors * Unit tests fix * api extractor fix * Import fix * Import fix * API extractor changes * Adding documentation * `npm run api-extractor:local` changes * Undo whitespace changes * Review fixes * Variable names fix * Removing whitespace changes from package-lock.json * Lint error * Minor updates * Minor updates * Adding some more validators and unit test * Minor fixes * Minor fixes * Minor fixes * Fix lint errors * Minor Fixes * Minor fixes * Minor fix * Removing whitespace only changes * Improvements on comments --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- etc/firebase-admin.auth.api.md | 14 +++ src/auth/auth-config.ts | 132 +++++++++++++++++++++- src/auth/index.ts | 2 + src/auth/project-config.ts | 47 +++++++- test/integration/auth.spec.ts | 153 +++++++++++++++++++++++++- test/unit/auth/auth-config.spec.ts | 152 +++++++++++++++++++++++++ test/unit/auth/project-config.spec.ts | 31 +++++- test/unit/auth/tenant.spec.ts | 28 ++++- 8 files changed, 549 insertions(+), 10 deletions(-) diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index c7090af304..c6c3f01c1b 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -267,6 +267,7 @@ export interface ListUsersResult { // @public export interface MultiFactorConfig { factorIds?: AuthFactorType[]; + providerConfigs?: MultiFactorProviderConfig[]; state: MultiFactorConfigState; } @@ -287,6 +288,12 @@ export abstract class MultiFactorInfo { readonly uid: string; } +// @public +export interface MultiFactorProviderConfig { + state: MultiFactorConfigState; + totpProviderConfig?: TotpMultiFactorProviderConfig; +} + // @public export class MultiFactorSettings { enrolledFactors: MultiFactorInfo[]; @@ -336,6 +343,7 @@ export class PhoneMultiFactorInfo extends MultiFactorInfo { // @public export class ProjectConfig { + get multiFactorConfig(): MultiFactorConfig | undefined; readonly smsRegionConfig?: SmsRegionConfig; toJSON(): object; } @@ -415,6 +423,11 @@ export class TenantManager { updateTenant(tenantId: string, tenantOptions: UpdateTenantRequest): Promise; } +// @public +export interface TotpMultiFactorProviderConfig { + adjacentIntervals?: number; +} + // @public export interface UidIdentifier { // (undocumented) @@ -434,6 +447,7 @@ export interface UpdatePhoneMultiFactorInfoRequest extends BaseUpdateMultiFactor // @public export interface UpdateProjectConfigRequest { + multiFactorConfig?: MultiFactorConfig; smsRegionConfig?: SmsRegionConfig; } diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 45ca3ef2d0..3f8f387f84 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -478,6 +478,7 @@ const AUTH_FACTOR_SERVER_TO_CLIENT_TYPE: {[key: string]: AuthFactorType} = export interface MultiFactorAuthServerConfig { state?: MultiFactorConfigState; enabledProviders?: AuthFactorServerType[]; + providerConfigs?: MultiFactorProviderConfig[]; } /** @@ -506,16 +507,58 @@ export interface MultiFactorConfig { * Currently only ‘phone’ is supported. */ factorIds?: AuthFactorType[]; + + /** + * A list of multi-factor provider configurations. + * MFA providers (except phone) indicate whether they're enabled through this field. */ + providerConfigs?: MultiFactorProviderConfig[]; +} + +/** + * Interface representing a multi-factor auth provider configuration. + * This interface is used for second factor auth providers other than SMS. + * Currently, only TOTP is supported. + */export interface MultiFactorProviderConfig { + /** + * Indicates whether this multi-factor provider is enabled or disabled. */ + state: MultiFactorConfigState; + /** + * TOTP multi-factor provider config. */ + totpProviderConfig?: TotpMultiFactorProviderConfig; +} + +/** + * Interface representing configuration settings for TOTP second factor auth. + */ +export interface TotpMultiFactorProviderConfig { + /** + * The allowed number of adjacent intervals that will be used for verification + * to compensate for clock skew. */ + adjacentIntervals?: number; } /** * Defines the multi-factor config class used to convert client side MultiFactorConfig * to a format that is understood by the Auth server. + * + * @internal */ export class MultiFactorAuthConfig implements MultiFactorConfig { + /** + * The multi-factor config state. + */ public readonly state: MultiFactorConfigState; + /** + * The list of identifiers for enabled second factors. + * Currently only ‘phone’ is supported. + */ public readonly factorIds: AuthFactorType[]; + /** + * A list of multi-factor provider specific config. + * New MFA providers (except phone) will indicate enablement/disablement through this field. + */ + public readonly providerConfigs: MultiFactorProviderConfig[]; /** * Static method to convert a client side request to a MultiFactorAuthServerConfig. @@ -543,6 +586,9 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { request.enabledProviders = []; } } + if (Object.prototype.hasOwnProperty.call(options, 'providerConfigs')) { + request.providerConfigs = options.providerConfigs; + } return request; } @@ -551,10 +597,11 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { * * @param options - The options object to validate. */ - private static validate(options: MultiFactorConfig): void { + public static validate(options: MultiFactorConfig): void { const validKeys = { state: true, factorIds: true, + providerConfigs: true, }; if (!validator.isNonNullObject(options)) { throw new FirebaseAuthError( @@ -599,6 +646,71 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { } }); } + + if (typeof options.providerConfigs !== 'undefined') { + if (!validator.isArray(options.providerConfigs)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.providerConfigs" must be an array of valid "MultiFactorProviderConfig."', + ); + } + //Validate content of array. + options.providerConfigs.forEach((multiFactorProviderConfig) => { + if (typeof multiFactorProviderConfig === 'undefined' || !validator.isObject(multiFactorProviderConfig)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${multiFactorProviderConfig}" is not a valid "MultiFactorProviderConfig" type.` + ) + } + const validProviderConfigKeys = { + state: true, + totpProviderConfig: true, + }; + for (const key in multiFactorProviderConfig) { + if (!(key in validProviderConfigKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid ProviderConfig parameter.`, + ); + } + } + if (typeof multiFactorProviderConfig.state === 'undefined' || + (multiFactorProviderConfig.state !== 'ENABLED' && + multiFactorProviderConfig.state !== 'DISABLED')) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.providerConfigs.state" must be either "ENABLED" or "DISABLED".', + ) + } + // Since TOTP is the only provider config available right now, not defining it will lead into an error + if (typeof multiFactorProviderConfig.totpProviderConfig === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.providerConfigs.totpProviderConfig" must be defined.' + ) + } + const validTotpProviderConfigKeys = { + adjacentIntervals: true, + }; + for (const key in multiFactorProviderConfig.totpProviderConfig) { + if (!(key in validTotpProviderConfigKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid TotpProviderConfig parameter.`, + ); + } + } + const adjIntervals = multiFactorProviderConfig.totpProviderConfig.adjacentIntervals + if (typeof adjIntervals !== 'undefined' && + (!Number.isInteger(adjIntervals) || adjIntervals < 0 || adjIntervals > 10)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"MultiFactorConfig.providerConfigs.totpProviderConfig.adjacentIntervals" must' + + ' be a valid number between 0 and 10 (both inclusive).' + ) + } + }); + } } /** @@ -624,13 +736,29 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { this.factorIds.push(AUTH_FACTOR_SERVER_TO_CLIENT_TYPE[enabledProvider]); } }) + this.providerConfigs = []; + (response.providerConfigs || []).forEach((providerConfig) => { + if (typeof providerConfig !== 'undefined') { + if (typeof providerConfig.state === 'undefined' || + typeof providerConfig.totpProviderConfig === 'undefined' || + (typeof providerConfig.totpProviderConfig.adjacentIntervals !== 'undefined' && + typeof providerConfig.totpProviderConfig.adjacentIntervals !== 'number')) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor configuration response'); + } + this.providerConfigs.push(providerConfig); + } + }) } - /** @returns The plain object representation of the multi-factor config instance. */ + /** Converts MultiFactorConfig to JSON object + * @returns The plain object representation of the multi-factor config instance. */ public toJSON(): object { return { state: this.state, factorIds: this.factorIds, + providerConfigs: this.providerConfigs, }; } } diff --git a/src/auth/index.ts b/src/auth/index.ts index 7dec658473..d91c46f083 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -80,6 +80,7 @@ export { MultiFactorConfigState, MultiFactorCreateSettings, MultiFactorUpdateSettings, + MultiFactorProviderConfig, OAuthResponseType, OIDCAuthProviderConfig, OIDCUpdateAuthProviderRequest, @@ -91,6 +92,7 @@ export { UpdateMultiFactorInfoRequest, UpdatePhoneMultiFactorInfoRequest, UpdateRequest, + TotpMultiFactorProviderConfig, } from './auth-config'; export { diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts index 54dcfb3b9c..4abcce9b3e 100644 --- a/src/auth/project-config.ts +++ b/src/auth/project-config.ts @@ -18,6 +18,9 @@ import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; import { SmsRegionsAuthConfig, SmsRegionConfig, + MultiFactorConfig, + MultiFactorAuthConfig, + MultiFactorAuthServerConfig, } from './auth-config'; import { deepCopy } from '../utils/deep-copy'; @@ -29,6 +32,10 @@ export interface UpdateProjectConfigRequest { * The SMS configuration to update on the project. */ smsRegionConfig?: SmsRegionConfig; + /** + * The multi-factor auth configuration to update on the project. + */ + multiFactorConfig?: MultiFactorConfig; } /** @@ -37,6 +44,7 @@ export interface UpdateProjectConfigRequest { */ export interface ProjectConfigServerResponse { smsRegionConfig?: SmsRegionConfig; + mfa?: MultiFactorAuthServerConfig; } /** @@ -45,6 +53,7 @@ export interface ProjectConfigServerResponse { */ export interface ProjectConfigClientRequest { smsRegionConfig?: SmsRegionConfig; + mfa?: MultiFactorAuthServerConfig; } /** @@ -57,13 +66,23 @@ export class ProjectConfig { * This is based on the calling code of the destination phone number. */ public readonly smsRegionConfig?: SmsRegionConfig; + /** + * The project's multi-factor auth configuration. + * Supports only phone and TOTP. + */ private readonly multiFactorConfig_?: MultiFactorConfig; + /** + * The multi-factor auth configuration. + */ + get multiFactorConfig(): MultiFactorConfig | undefined { + return this.multiFactorConfig_; + } /** * 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 { + private static validate(request: UpdateProjectConfigRequest): void { if (!validator.isNonNullObject(request)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, @@ -72,6 +91,7 @@ export class ProjectConfig { } const validKeys = { smsRegionConfig: true, + multiFactorConfig: true, } // Check for unsupported top level attributes. for (const key in request) { @@ -86,6 +106,11 @@ export class ProjectConfig { if (typeof request.smsRegionConfig !== 'undefined') { SmsRegionsAuthConfig.validate(request.smsRegionConfig); } + + // Validate Multi Factor Config if provided + if (typeof request.multiFactorConfig !== 'undefined') { + MultiFactorAuthConfig.validate(request.multiFactorConfig); + } } /** @@ -97,7 +122,16 @@ export class ProjectConfig { */ public static buildServerRequest(configOptions: UpdateProjectConfigRequest): ProjectConfigClientRequest { ProjectConfig.validate(configOptions); - return configOptions as ProjectConfigClientRequest; + const request = configOptions as any; + if (configOptions.multiFactorConfig !== undefined) { + request.mfa = MultiFactorAuthConfig.buildServerRequest(configOptions.multiFactorConfig); + } + // Backend API returns "mfa" in case of project config and "mfaConfig" in case of tenant config. + // The SDK exposes it as multiFactorConfig always. + // See https://cloud.google.com/identity-platform/docs/reference/rest/v2/projects.tenants#resource:-tenant + // and https://cloud.google.com/identity-platform/docs/reference/rest/v2/Config + delete request.multiFactorConfig; + return request as ProjectConfigClientRequest; } /** @@ -111,6 +145,11 @@ export class ProjectConfig { if (typeof response.smsRegionConfig !== 'undefined') { this.smsRegionConfig = response.smsRegionConfig; } + //Backend API returns "mfa" in case of project config and "mfaConfig" in case of tenant config. + //The SDK exposes it as multiFactorConfig always. + if (typeof response.mfa !== 'undefined') { + this.multiFactorConfig_ = new MultiFactorAuthConfig(response.mfa); + } } /** * Returns a JSON-serializable representation of this object. @@ -121,10 +160,14 @@ export class ProjectConfig { // JSON serialization const json = { smsRegionConfig: deepCopy(this.smsRegionConfig), + multiFactorConfig: deepCopy(this.multiFactorConfig), }; if (typeof json.smsRegionConfig === 'undefined') { delete json.smsRegionConfig; } + if (typeof json.multiFactorConfig === 'undefined') { + delete json.multiFactorConfig; + } return json; } } diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 68e66aaf06..e1a29ee48a 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, UpdateProjectConfigRequest, UserMetadata, + UserImportRecord, UserRecord, getAuth, UpdateProjectConfigRequest, UserMetadata, MultiFactorConfig, } from '../../lib/auth/index'; import * as sinon from 'sinon'; import * as sinonChai from 'sinon-chai'; @@ -1206,12 +1206,25 @@ describe('admin.auth', () => { this.skip(); // getConfig is not supported in Auth Emulator } }); + const mfaConfig: MultiFactorConfig = { + state: 'ENABLED', + factorIds: ['phone'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], + }; const projectConfigOption1: UpdateProjectConfigRequest = { smsRegionConfig: { allowByDefault: { disallowedRegions: ['AC', 'AD'], } }, + multiFactorConfig: mfaConfig, }; const projectConfigOption2: UpdateProjectConfigRequest = { smsRegionConfig: { @@ -1220,12 +1233,25 @@ describe('admin.auth', () => { } }, }; + const projectConfigOptionSmsEnabledTotpDisabled: UpdateProjectConfigRequest = { + multiFactorConfig: { + state: 'ENABLED', + factorIds: ['phone'], + providerConfigs: [ + { + state: 'DISABLED', + totpProviderConfig: {}, + }, + ], + }, + }; const expectedProjectConfig1: any = { smsRegionConfig: { allowByDefault: { disallowedRegions: ['AC', 'AD'], } }, + multiFactorConfig: mfaConfig, }; const expectedProjectConfig2: any = { smsRegionConfig: { @@ -1233,6 +1259,20 @@ describe('admin.auth', () => { allowedRegions: ['AC', 'AD'], } }, + multiFactorConfig: mfaConfig, + }; + const expectedProjectConfigSmsEnabledTotpDisabled: any = { + smsRegionConfig: expectedProjectConfig2.smsRegionConfig, + multiFactorConfig: { + state: 'ENABLED', + factorIds: ['phone'], + providerConfigs: [ + { + state: 'DISABLED', + totpProviderConfig: {}, + } + ], + }, }; it('updateProjectConfig() should resolve with the updated project config', () => { @@ -1243,6 +1283,9 @@ describe('admin.auth', () => { }) .then((actualProjectConfig) => { expect(actualProjectConfig.toJSON()).to.deep.equal(expectedProjectConfig2); + return getAuth().projectConfigManager().updateProjectConfig(projectConfigOptionSmsEnabledTotpDisabled); + }).then((actualProjectConfig) => { + expect(actualProjectConfig.toJSON()).to.deep.equal(expectedProjectConfigSmsEnabledTotpDisabled); }); }); @@ -1250,7 +1293,7 @@ describe('admin.auth', () => { return getAuth().projectConfigManager().getProjectConfig() .then((actualConfig) => { const actualConfigObj = actualConfig.toJSON(); - expect(actualConfigObj).to.deep.equal(expectedProjectConfig2); + expect(actualConfigObj).to.deep.equal(expectedProjectConfigSmsEnabledTotpDisabled); }); }); }); @@ -1267,6 +1310,14 @@ describe('admin.auth', () => { multiFactorConfig: { state: 'ENABLED', factorIds: ['phone'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }, // Add random phone number / code pairs. testPhoneNumbers: { @@ -1284,6 +1335,14 @@ describe('admin.auth', () => { multiFactorConfig: { state: 'ENABLED', factorIds: ['phone'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }, // These test phone numbers will not be checked when running integration // tests against the emulator suite and are ignored in auth emulator @@ -1304,6 +1363,14 @@ describe('admin.auth', () => { multiFactorConfig: { state: 'DISABLED', factorIds: [], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }, // Test phone numbers will not be checked when running integration tests // against emulator suite. For more information, please refer to: @@ -1322,6 +1389,35 @@ describe('admin.auth', () => { multiFactorConfig: { state: 'ENABLED', factorIds: ['phone'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: {}, + }, + ], + }, + smsRegionConfig: { + allowByDefault: { + disallowedRegions: ['AC', 'AD'], + } + }, + }; + const expectedUpdatedTenantSmsEnabledTotpDisabled: any = { + displayName: 'testTenantUpdated', + emailSignInConfig: { + enabled: true, + passwordRequired: false, + }, + anonymousSignInEnabled: false, + multiFactorConfig: { + state: 'ENABLED', + factorIds: ['phone'], + providerConfigs: [ + { + state: 'DISABLED', + totpProviderConfig: {}, + }, + ], }, smsRegionConfig: { allowByDefault: { @@ -1801,6 +1897,59 @@ describe('admin.auth', () => { }); }); + it('updateTenant() should not update MFA-related config of tenant when MultiFactorConfig is undefined', () => { + expectedUpdatedTenant.tenantId = createdTenantId; + const updateRequestNoMfaConfig: UpdateTenantRequest = { + displayName: expectedUpdatedTenant2.displayName, + multiFactorConfig: undefined, + }; + if (authEmulatorHost) { + return getAuth().tenantManager().updateTenant(createdTenantId, updateRequestNoMfaConfig) + .then((actualTenant) => { + const actualTenantObj = actualTenant.toJSON(); + // Configuring test phone numbers are 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, updateRequestNoMfaConfig) + .then((actualTenant) => { + expect(actualTenant.toJSON()).to.deep.equal(expectedUpdatedTenant2); + }); + }); + + it('updateTenant() should not disable SMS MFA when TOTP is disabled', () => { + expectedUpdatedTenantSmsEnabledTotpDisabled.tenantId = createdTenantId; + const updateRequestSMSEnabledTOTPDisabled: UpdateTenantRequest = { + displayName: expectedUpdatedTenant2.displayName, + multiFactorConfig: { + state: 'ENABLED', + factorIds: ['phone'], + providerConfigs: [ + { + state: 'DISABLED', + totpProviderConfig: {} + }, + ], + }, + }; + if (authEmulatorHost) { + return getAuth().tenantManager().updateTenant(createdTenantId, updateRequestSMSEnabledTOTPDisabled) + .then((actualTenant) => { + const actualTenantObj = actualTenant.toJSON(); + // Configuring test phone numbers are not supported in Auth Emulator + delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; + delete expectedUpdatedTenantSmsEnabledTotpDisabled.testPhoneNumbers; + expect(actualTenantObj).to.deep.equal(expectedUpdatedTenantSmsEnabledTotpDisabled); + }); + } + return getAuth().tenantManager().updateTenant(createdTenantId, updateRequestSMSEnabledTOTPDisabled) + .then((actualTenant) => { + expect(actualTenant.toJSON()).to.deep.equal(expectedUpdatedTenantSmsEnabledTotpDisabled); + }); + }); + 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/auth-config.spec.ts b/test/unit/auth/auth-config.spec.ts index 62e7d4e3f6..79c8609134 100644 --- a/test/unit/auth/auth-config.spec.ts +++ b/test/unit/auth/auth-config.spec.ts @@ -206,10 +206,26 @@ describe('MultiFactorAuthConfig', () => { const config = new MultiFactorAuthConfig({ state: 'ENABLED', enabledProviders: ['PHONE_SMS'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }); expect(config.toJSON()).to.deep.equal({ state: 'ENABLED', factorIds: ['phone'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }); }); }); @@ -296,6 +312,142 @@ describe('MultiFactorAuthConfig', () => { }).to.throw(`"${factorId}" is not a valid "AuthFactorType".`); }); }); + + const totpBaseConfig = { + state: 'DISABLED', + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: {}, + }, + ], + } as any; + const expectedTotpConfig = deepCopy(totpBaseConfig); + it('should build server request with TOTP enabled', () => { + expect(MultiFactorAuthConfig.buildServerRequest(totpBaseConfig)).to.deep.equal(expectedTotpConfig); + }); + + it('should build server request with TOTP disabled', () => { + totpBaseConfig.providerConfigs[0].state = 'DISABLED'; + const expectedTotpConfig = deepCopy(totpBaseConfig); + expect(MultiFactorAuthConfig.buildServerRequest(totpBaseConfig)).to.deep.equal(expectedTotpConfig); + }); + + it('should return expected server request on valid TOTP provider config', () => { + totpBaseConfig.providerConfigs[0].totpProviderConfig.adjacentIntervals = 5; + const expectedTotpConfig = deepCopy(totpBaseConfig); + expect(MultiFactorAuthConfig.buildServerRequest(totpBaseConfig)).to.deep.equal(expectedTotpConfig); + }); + + it('should return empty enabledProviders when an empty "options.factorIds" is provided', () => { + expect(MultiFactorAuthConfig.buildServerRequest({ + state: 'DISABLED', + factorIds: [], + })).to.deep.equal({ + state: 'DISABLED', + enabledProviders: [], + }); + }); + + const invalidProviderConfigs = [NaN, 0, 1, '', 'a', {}, { a: 1 }, _.noop, true, false,] + invalidProviderConfigs.forEach((config) => { + it('should throw an error for invalid providerConfigs type:' + JSON.stringify(config), () => { + expect(() => { + MultiFactorAuthConfig.buildServerRequest({ + state: 'DISABLED', + providerConfigs: config, + } as any); + }).to.throw('"MultiFactorConfig.providerConfigs" must be an array of valid "MultiFactorProviderConfig."') + }); + }); + it('should throw on providerConfig with unsupported attribute', () => { + expect(() => { + MultiFactorAuthConfig.buildServerRequest({ + state: 'DISABLED', + providerConfigs: [ + { + unsupported: true, + state: 'ENABLED', + totpProviderConfig: {}, + } + ], + } as any); + }).to.throw('"unsupported" is not a valid ProviderConfig parameter.'); + }); + const invalidProviderConfigObjects = [undefined, NaN, 0, 1, 'a', [], true, false,] + invalidProviderConfigObjects.forEach((object) => { + it('should throw an error for invalid providerConfig type:' + JSON.stringify(object), () => { + expect(() => { + MultiFactorAuthConfig.buildServerRequest({ + state: 'DISABLED', + providerConfigs: [ + object + ], + } as any); + }).to.throw(`"${object}" is not a valid "MultiFactorProviderConfig" type.`) + }); + }); + invalidState.forEach((state) => { + it('should throw an error for invalid providerConfig.state type', () => { + expect(() => { + MultiFactorAuthConfig.buildServerRequest({ + state: 'DISABLED', + providerConfigs: [ + { + state, + totpProviderConfig: {}, + }, + ], + } as any); + }).to.throw('"MultiFactorConfig.providerConfigs.state" must be either "ENABLED" or "DISABLED".') + }); + }); + it('should throw on totpProviderConfig with unsupported attribute', () => { + expect(() => { + MultiFactorAuthConfig.buildServerRequest({ + state: 'DISABLED', + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + unsupported: true, + }, + } + ], + } as any); + }).to.throw('"unsupported" is not a valid TotpProviderConfig parameter.'); + }); + it('should throw an error for undefined totpProviderConfig', () => { + expect(() => { + MultiFactorAuthConfig.buildServerRequest({ + state: 'DISABLED', + providerConfigs: [ + { + state: 'ENABLED', + }, + ], + } as any); + }).to.throw('"MultiFactorConfig.providerConfigs.totpProviderConfig" must be defined.') + }); + const invalidAdjacentIntervals = [null, NaN, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop, true, false, -1, 11, 1.1] + invalidAdjacentIntervals.forEach((interval) => { + it('should throw an error for invalid adjacentIntervals type: ' + JSON.stringify(interval), () => { + expect(() => { + MultiFactorAuthConfig.buildServerRequest({ + state: 'DISABLED', + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: interval, + }, + }, + ], + } as any); + }).to.throw('"MultiFactorConfig.providerConfigs.totpProviderConfig.adjacentIntervals" must' + + ' be a valid number between 0 and 10 (both inclusive).') + }); + }); }); }); diff --git a/test/unit/auth/project-config.spec.ts b/test/unit/auth/project-config.spec.ts index 19cc8f420d..c4ff9d63c8 100644 --- a/test/unit/auth/project-config.spec.ts +++ b/test/unit/auth/project-config.spec.ts @@ -39,6 +39,17 @@ describe('ProjectConfig', () => { disallowedRegions: [ 'AC', 'AD' ], }, }, + mfa: { + state: 'DISABLED', + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], + }, }; const updateProjectConfigRequest1: UpdateProjectConfigRequest = { @@ -172,20 +183,36 @@ describe('ProjectConfig', () => { }; expect(projectConfig.smsRegionConfig).to.deep.equal(expectedSmsRegionConfig); }); + + it('should set readonly property multiFactorConfig', () => { + const expectedMultiFactorConfig = { + state: 'DISABLED', + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], + }; + expect(projectConfig.multiFactorConfig).to.deep.equal(expectedMultiFactorConfig); + }); }); 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) + smsRegionConfig: deepCopy(serverResponse.smsRegionConfig), + multiFactorConfig: deepCopy(serverResponse.mfa) }); }); it('should not populate optional fields if not available', () => { const serverResponseOptionalCopy: ProjectConfigServerResponse = deepCopy(serverResponse); delete serverResponseOptionalCopy.smsRegionConfig; - + delete serverResponseOptionalCopy.mfa; expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({}); }); }); diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 44885ecafa..74c008172a 100644 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -53,6 +53,14 @@ describe('Tenant', () => { mfaConfig: { state: 'ENABLED', enabledProviders: ['PHONE_SMS'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }, testPhoneNumbers: { '+16505551234': '019287', @@ -70,6 +78,14 @@ describe('Tenant', () => { multiFactorConfig: { state: 'ENABLED', factorIds: ['phone'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }, testPhoneNumbers: { '+16505551234': '019287', @@ -105,7 +121,7 @@ describe('Tenant', () => { .to.deep.equal(tenantOptionsServerRequest); }); - it('should return the expected server request with multi-factor and phone config', () => { + it('should return the expected server request with multi-factor (SMS, TOTP) and testPhoneNumber config', () => { const tenantOptionsClientRequest = deepCopy(clientRequest); const tenantOptionsServerRequest = deepCopy(serverRequest); delete (tenantOptionsServerRequest as any).name; @@ -276,7 +292,7 @@ describe('Tenant', () => { .to.throw('"EmailSignInConfig" must be a non-null object.'); }); - it('should throw on invalid MultiFactorConfig attribute', () => { + it('should throw on invalid MultiFactorConfig.factorIds attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.multiFactorConfig.factorIds = ['invalid']; expect(() => { @@ -435,6 +451,14 @@ describe('Tenant', () => { const expectedMultiFactorConfig = new MultiFactorAuthConfig({ state: 'ENABLED', enabledProviders: ['PHONE_SMS'], + providerConfigs: [ + { + state: 'ENABLED', + totpProviderConfig: { + adjacentIntervals: 5, + }, + }, + ], }); expect(tenant.multiFactorConfig).to.deep.equal(expectedMultiFactorConfig); });