diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index fd513f8792d7..d3da7ba0ba55 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -80,6 +80,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service"; import { Fido2ActiveRequestManager as Fido2ActiveRequestManagerAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-active-request-manager.abstraction"; import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction"; import { Fido2ClientService as Fido2ClientServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction"; @@ -570,6 +571,7 @@ export default class MainBackground { this.logService, this.stateProvider, this.accountService, + process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[], ); this.biometricStateService = new DefaultBiometricStateService(this.stateProvider); diff --git a/apps/browser/src/platform/services/browser-environment.service.ts b/apps/browser/src/platform/services/browser-environment.service.ts index d7e22cf747eb..89f05579c880 100644 --- a/apps/browser/src/platform/services/browser-environment.service.ts +++ b/apps/browser/src/platform/services/browser-environment.service.ts @@ -1,7 +1,7 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { Region } from "@bitwarden/common/platform/abstractions/environment.service"; +import { Region, RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -14,8 +14,9 @@ export class BrowserEnvironmentService extends DefaultEnvironmentService { private logService: LogService, stateProvider: StateProvider, accountService: AccountService, + additionalRegionConfigs: RegionConfig[] = [], ) { - super(stateProvider, accountService); + super(stateProvider, accountService, additionalRegionConfigs); } async hasManagedEnvironment(): Promise { diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 024b4f46315a..411eed380de3 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -14,6 +14,7 @@ import { DEFAULT_VAULT_TIMEOUT, INTRAPROCESS_MESSAGING_SUBJECT, CLIENT_TYPE, + ENV_ADDITIONAL_REGIONS, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { AnonLayoutWrapperDataService, LockComponentService } from "@bitwarden/auth/angular"; @@ -197,7 +198,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: BrowserEnvironmentService, useClass: BrowserEnvironmentService, - deps: [LogService, StateProvider, AccountServiceAbstraction], + deps: [LogService, StateProvider, AccountServiceAbstraction, ENV_ADDITIONAL_REGIONS], }), safeProvider({ provide: I18nServiceAbstraction, diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index c58672819721..280891edf36b 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -59,7 +59,10 @@ import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/bill import { ClientType } from "@bitwarden/common/enums"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { + EnvironmentService, + RegionConfig, +} from "@bitwarden/common/platform/abstractions/environment.service"; import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { KeySuffixOptions, LogLevelType } from "@bitwarden/common/platform/enums"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; @@ -346,6 +349,7 @@ export class ServiceContainer { this.environmentService = new DefaultEnvironmentService( this.stateProvider, this.accountService, + process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[], ); this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 723b410f19b0..a1b03509c702 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,6 +5,7 @@ import { Subject, firstValueFrom } from "rxjs"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { ClientType } from "@bitwarden/common/enums"; +import { RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service"; import { Message, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- For dependency creation import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; @@ -152,7 +153,11 @@ export class Main { new DefaultDerivedStateProvider(), ); - this.environmentService = new DefaultEnvironmentService(stateProvider, accountService); + this.environmentService = new DefaultEnvironmentService( + stateProvider, + accountService, + process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[], + ); this.migrationRunner = new MigrationRunner( this.storageService, diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 842d31bb105e..ceff2682172d 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -11,6 +11,7 @@ import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/sa import { CLIENT_TYPE, DEFAULT_VAULT_TIMEOUT, + ENV_ADDITIONAL_REGIONS, LOCALES_DIRECTORY, MEMORY_STORAGE, OBSERVABLE_DISK_LOCAL_STORAGE, @@ -42,7 +43,10 @@ import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.ser import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { + EnvironmentService, + Urls, +} from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -84,6 +88,7 @@ import { WebStorageServiceProvider } from "../platform/web-storage-service.provi import { EventService } from "./event.service"; import { InitService } from "./init.service"; +import { ENV_URLS } from "./injection-tokens"; import { ModalService } from "./modal.service"; import { RouterService } from "./router.service"; import { WebFileDownloadService } from "./web-file-download.service"; @@ -173,10 +178,14 @@ const safeProviders: SafeProvider[] = [ useClass: WebMigrationRunner, deps: [AbstractStorageService, LogService, MigrationBuilderService, WindowStorageService], }), + safeProvider({ + provide: ENV_URLS, + useValue: process.env.URLS as Urls, + }), safeProvider({ provide: EnvironmentService, useClass: WebEnvironmentService, - deps: [WINDOW, StateProvider, AccountService, Router], + deps: [WINDOW, StateProvider, AccountService, ENV_ADDITIONAL_REGIONS, Router, ENV_URLS], }), safeProvider({ provide: BiometricsService, diff --git a/apps/web/src/app/core/injection-tokens.ts b/apps/web/src/app/core/injection-tokens.ts new file mode 100644 index 000000000000..9d74a0deb852 --- /dev/null +++ b/apps/web/src/app/core/injection-tokens.ts @@ -0,0 +1,10 @@ +// Put web specific injection tokens here +import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; +import { Urls } from "@bitwarden/common/platform/abstractions/environment.service"; + +/** + * Injection token for injecting the NodeJS process.env urls into services. + * Using an injection token allows services to be tested without needing to + * mock the process.env. + */ +export const ENV_URLS = new SafeInjectionToken("ENV_URLS"); diff --git a/apps/web/src/app/platform/web-environment.service.spec.ts b/apps/web/src/app/platform/web-environment.service.spec.ts new file mode 100644 index 000000000000..14b5e7dcaa0e --- /dev/null +++ b/apps/web/src/app/platform/web-environment.service.spec.ts @@ -0,0 +1,457 @@ +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { Region, Urls } from "@bitwarden/common/platform/abstractions/environment.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { PRODUCTION_REGIONS } from "@bitwarden/common/platform/services/default-environment.service"; +import { + FakeAccountService, + FakeStateProvider, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { + WebCloudEnvironment, + WebEnvironmentService, + WebRegionConfig, +} from "./web-environment.service"; + +describe("WebEnvironmentService", () => { + let service: WebEnvironmentService; + + let window: MockProxy; + + let stateProvider: FakeStateProvider; + let accountService: FakeAccountService; + let router: MockProxy; + + const mockUserId = Utils.newGuid() as UserId; + + describe("Production Environment", () => { + describe("US Region", () => { + const mockInitialProdUSUrls = { + base: null, + api: "https://api.bitwarden.com", + identity: "https://identity.bitwarden.com", + icons: "https://icons.bitwarden.net", + webVault: "https://vault.bitwarden.com", + notifications: "https://notifications.bitwarden.com", + events: "https://events.bitwarden.com", + scim: "https://scim.bitwarden.com", + } as Urls; + + const mockProdUSBaseUrl = "https://vault.bitwarden.com"; + + const expectedProdUSUrls: Urls = { + ...mockInitialProdUSUrls, + base: mockProdUSBaseUrl, + }; + + const expectedModifiedScimUrl = expectedProdUSUrls.scim + "/v2"; + const expectedSendUrl = "https://send.bitwarden.com/#"; + + const PROD_US_REGION = PRODUCTION_REGIONS.find((r) => r.key === Region.US); + + const prodUSEnv = new WebCloudEnvironment(PROD_US_REGION, expectedProdUSUrls); + + beforeEach(() => { + window = mock(); + + window.location = { + origin: mockProdUSBaseUrl, + href: mockProdUSBaseUrl + "/#/example", + } as Location; + + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + router = mock(); + + (router as any).url = ""; + + service = new WebEnvironmentService( + window, + stateProvider, + accountService, + [], // no additional region configs required for prod envs + router, + mockInitialProdUSUrls, + ); + }); + + it("initializes the environment with the US production urls", async () => { + const env = await firstValueFrom(service.environment$); + + expect(env).toEqual(prodUSEnv); + + expect(env.getRegion()).toEqual(Region.US); + expect(env.getUrls()).toEqual(expectedProdUSUrls); + expect(env.isCloud()).toBeTruthy(); + + expect(env.getApiUrl()).toEqual(expectedProdUSUrls.api); + expect(env.getIdentityUrl()).toEqual(expectedProdUSUrls.identity); + expect(env.getIconsUrl()).toEqual(expectedProdUSUrls.icons); + expect(env.getWebVaultUrl()).toEqual(expectedProdUSUrls.webVault); + expect(env.getNotificationsUrl()).toEqual(expectedProdUSUrls.notifications); + expect(env.getEventsUrl()).toEqual(expectedProdUSUrls.events); + + expect(env.getScimUrl()).toEqual(expectedModifiedScimUrl); + expect(env.getSendUrl()).toEqual(expectedSendUrl); + + expect(env.getHostname()).toEqual(PROD_US_REGION.domain); + }); + + describe("setEnvironment", () => { + it("throws an error when trying to set the environment to self-hosted", async () => { + await expect(service.setEnvironment(Region.SelfHosted)).rejects.toThrow( + "setEnvironment does not work in web for self-hosted.", + ); + }); + + it("only returns the current env's urls when trying to set the environment to the current region", async () => { + const urls = await service.setEnvironment(Region.US); + expect(urls).toEqual(expectedProdUSUrls); + }); + + it("errors if the selected region is unknown", async () => { + await expect(service.setEnvironment("unknown" as Region)).rejects.toThrow( + "The selected region is not known as an available region.", + ); + }); + + it("sets the window location to a new region's web vault url and preserves any query params", async () => { + const routeAndQueryParams = "/signup?example=1&another=2"; + (router as any).url = routeAndQueryParams; + + const newRegion = Region.EU; + const newRegionConfig = PRODUCTION_REGIONS.find((r) => r.key === newRegion); + + await service.setEnvironment(newRegion); + + expect(window.location.href).toEqual( + newRegionConfig.urls.webVault + "/#" + routeAndQueryParams, + ); + }); + }); + }); + + describe("EU Region", () => { + const mockInitialProdEUUrls = { + base: null, + api: "https://api.bitwarden.eu", + identity: "https://identity.bitwarden.eu", + icons: "https://icons.bitwarden.eu", + webVault: "https://vault.bitwarden.eu", + notifications: "https://notifications.bitwarden.eu", + events: "https://events.bitwarden.eu", + scim: "https://scim.bitwarden.eu", + } as Urls; + + const mockProdEUBaseUrl = "https://vault.bitwarden.eu"; + + const expectedProdEUUrls: Urls = { + ...mockInitialProdEUUrls, + base: mockProdEUBaseUrl, + }; + + const expectedModifiedScimUrl = expectedProdEUUrls.scim + "/v2"; + const expectedSendUrl = expectedProdEUUrls.webVault + "/#/send/"; + + const prodEURegionConfig = PRODUCTION_REGIONS.find((r) => r.key === Region.EU); + + const prodEUEnv = new WebCloudEnvironment(prodEURegionConfig, expectedProdEUUrls); + + beforeEach(() => { + window = mock(); + + window.location = { + origin: mockProdEUBaseUrl, + href: mockProdEUBaseUrl + "/#/example", + } as Location; + + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + router = mock(); + + service = new WebEnvironmentService( + window, + stateProvider, + accountService, + [], // no additional region configs required for prod envs + router, + mockInitialProdEUUrls, + ); + }); + + it("initializes the environment to be the prod EU environment", async () => { + const env = await firstValueFrom(service.environment$); + + expect(env).toEqual(prodEUEnv); + expect(env.getRegion()).toEqual(Region.EU); + expect(env.getUrls()).toEqual(expectedProdEUUrls); + expect(env.isCloud()).toBeTruthy(); + + expect(env.getApiUrl()).toEqual(expectedProdEUUrls.api); + expect(env.getIdentityUrl()).toEqual(expectedProdEUUrls.identity); + expect(env.getIconsUrl()).toEqual(expectedProdEUUrls.icons); + expect(env.getWebVaultUrl()).toEqual(expectedProdEUUrls.webVault); + expect(env.getNotificationsUrl()).toEqual(expectedProdEUUrls.notifications); + expect(env.getEventsUrl()).toEqual(expectedProdEUUrls.events); + + expect(env.getScimUrl()).toEqual(expectedModifiedScimUrl); + expect(env.getSendUrl()).toEqual(expectedSendUrl); + + expect(env.getHostname()).toEqual(prodEURegionConfig.domain); + }); + + describe("setEnvironment", () => { + it("throws an error when trying to set the environment to self-hosted", async () => { + await expect(service.setEnvironment(Region.SelfHosted)).rejects.toThrow( + "setEnvironment does not work in web for self-hosted.", + ); + }); + + it("only returns the current env's urls when trying to set the environment to the current region", async () => { + const urls = await service.setEnvironment(Region.EU); + expect(urls).toEqual(expectedProdEUUrls); + }); + + it("errors if the selected region is unknown", async () => { + await expect(service.setEnvironment("unknown" as Region)).rejects.toThrow( + "The selected region is not known as an available region.", + ); + }); + + it("sets the window location to a new region's web vault url and preserves any query params", async () => { + const routeAndQueryParams = "/signup?example=1&another=2"; + (router as any).url = routeAndQueryParams; + + const newRegion = Region.US; + const newRegionConfig = PRODUCTION_REGIONS.find((r) => r.key === newRegion); + + await service.setEnvironment(newRegion); + + expect(window.location.href).toEqual( + newRegionConfig.urls.webVault + "/#" + routeAndQueryParams, + ); + }); + }); + }); + }); + + describe("QA Environment", () => { + const QA_US_REGION_KEY = "USQA"; + const QA_US_WEB_REGION_CONFIG = { + key: QA_US_REGION_KEY, + domain: "qa.bitwarden.pw", + urls: { + webVault: "https://vault.qa.bitwarden.pw", + }, + } as WebRegionConfig; + + const QA_EU_REGION_KEY = "EUQA"; + const QA_EU_WEB_REGION_CONFIG = { + key: QA_EU_REGION_KEY, + domain: "euqa.bitwarden.pw", + urls: { + webVault: "https://vault.euqa.bitwarden.pw", + }, + } as WebRegionConfig; + + const additionalRegionConfigs: WebRegionConfig[] = [ + QA_US_WEB_REGION_CONFIG, + QA_EU_WEB_REGION_CONFIG, + ]; + + describe("US Region", () => { + const initial_QA_US_Urls = { + icons: "https://icons.qa.bitwarden.pw", + notifications: "https://notifications.qa.bitwarden.pw", + scim: "https://scim.qa.bitwarden.pw", + } as Urls; + + const mock_QA_US_BaseUrl = "https://vault.qa.bitwarden.pw"; + + const expected_QA_US_Urls: Urls = { + ...initial_QA_US_Urls, + base: mock_QA_US_BaseUrl, + }; + + const expectedModifiedScimUrl = expected_QA_US_Urls.scim + "/v2"; + + const expectedSendUrl = QA_US_WEB_REGION_CONFIG.urls.webVault + "/#/send/"; + + const QA_US_Env = new WebCloudEnvironment(QA_US_WEB_REGION_CONFIG, expected_QA_US_Urls); + + beforeEach(() => { + window = mock(); + window.location = { + origin: mock_QA_US_BaseUrl, + href: mock_QA_US_BaseUrl + "/#/example", + } as Location; + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + router = mock(); + (router as any).url = ""; + service = new WebEnvironmentService( + window, + stateProvider, + accountService, + additionalRegionConfigs, + router, + initial_QA_US_Urls, + ); + }); + + it("initializes the environment to be the QA US environment", async () => { + const env = await firstValueFrom(service.environment$); + + expect(env).toEqual(QA_US_Env); + expect(env.getRegion()).toEqual(QA_US_REGION_KEY); + expect(env.getUrls()).toEqual(expected_QA_US_Urls); + expect(env.isCloud()).toBeTruthy(); + + expect(env.getApiUrl()).toEqual(expected_QA_US_Urls.base + "/api"); + expect(env.getIdentityUrl()).toEqual(expected_QA_US_Urls.base + "/identity"); + expect(env.getIconsUrl()).toEqual(expected_QA_US_Urls.icons); + + expect(env.getWebVaultUrl()).toEqual(QA_US_WEB_REGION_CONFIG.urls.webVault); + + expect(env.getNotificationsUrl()).toEqual(expected_QA_US_Urls.notifications); + expect(env.getEventsUrl()).toEqual(expected_QA_US_Urls.base + "/events"); + + expect(env.getScimUrl()).toEqual(expectedModifiedScimUrl); + + expect(env.getSendUrl()).toEqual(expectedSendUrl); + + expect(env.getHostname()).toEqual(QA_US_WEB_REGION_CONFIG.domain); + }); + + describe("setEnvironment", () => { + it("throws an error when trying to set the environment to self-hosted", async () => { + await expect(service.setEnvironment(Region.SelfHosted)).rejects.toThrow( + "setEnvironment does not work in web for self-hosted.", + ); + }); + + it("only returns the current env's urls when trying to set the environment to the current region", async () => { + const urls = await service.setEnvironment(QA_US_REGION_KEY); + expect(urls).toEqual(expected_QA_US_Urls); + }); + + it("errors if the selected region is unknown", async () => { + await expect(service.setEnvironment("unknown" as Region)).rejects.toThrow( + "The selected region is not known as an available region.", + ); + }); + + it("sets the window location to a new region's web vault url and preserves any query params", async () => { + const routeAndQueryParams = "/signup?example=1&another=2"; + (router as any).url = routeAndQueryParams; + + await service.setEnvironment(QA_EU_REGION_KEY); + + expect(window.location.href).toEqual( + QA_EU_WEB_REGION_CONFIG.urls.webVault + "/#" + routeAndQueryParams, + ); + }); + }); + }); + + describe("EU Region", () => { + const initial_QA_EU_Urls = { + icons: "https://icons.euqa.bitwarden.pw", + notifications: "https://notifications.euqa.bitwarden.pw", + scim: "https://scim.euqa.bitwarden.pw", + } as Urls; + + const mock_QA_EU_BaseUrl = "https://vault.euqa.bitwarden.pw"; + + const expected_QA_EU_Urls: Urls = { + ...initial_QA_EU_Urls, + base: mock_QA_EU_BaseUrl, + }; + + const expectedModifiedScimUrl = expected_QA_EU_Urls.scim + "/v2"; + + const expectedSendUrl = QA_EU_WEB_REGION_CONFIG.urls.webVault + "/#/send/"; + + const QA_EU_Env = new WebCloudEnvironment(QA_EU_WEB_REGION_CONFIG, expected_QA_EU_Urls); + + beforeEach(() => { + window = mock(); + window.location = { + origin: mock_QA_EU_BaseUrl, + href: mock_QA_EU_BaseUrl + "/#/example", + } as Location; + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + router = mock(); + (router as any).url = ""; + service = new WebEnvironmentService( + window, + stateProvider, + accountService, + additionalRegionConfigs, + router, + initial_QA_EU_Urls, + ); + }); + + it("initializes the environment to be the QA US environment", async () => { + const env = await firstValueFrom(service.environment$); + + expect(env).toEqual(QA_EU_Env); + expect(env.getRegion()).toEqual(QA_EU_REGION_KEY); + expect(env.getUrls()).toEqual(expected_QA_EU_Urls); + expect(env.isCloud()).toBeTruthy(); + + expect(env.getApiUrl()).toEqual(expected_QA_EU_Urls.base + "/api"); + expect(env.getIdentityUrl()).toEqual(expected_QA_EU_Urls.base + "/identity"); + expect(env.getIconsUrl()).toEqual(expected_QA_EU_Urls.icons); + + expect(env.getWebVaultUrl()).toEqual(QA_EU_WEB_REGION_CONFIG.urls.webVault); + + expect(env.getNotificationsUrl()).toEqual(expected_QA_EU_Urls.notifications); + expect(env.getEventsUrl()).toEqual(expected_QA_EU_Urls.base + "/events"); + + expect(env.getScimUrl()).toEqual(expectedModifiedScimUrl); + + expect(env.getSendUrl()).toEqual(expectedSendUrl); + + expect(env.getHostname()).toEqual(QA_EU_WEB_REGION_CONFIG.domain); + }); + + describe("setEnvironment", () => { + it("throws an error when trying to set the environment to self-hosted", async () => { + await expect(service.setEnvironment(Region.SelfHosted)).rejects.toThrow( + "setEnvironment does not work in web for self-hosted.", + ); + }); + + it("only returns the current env's urls when trying to set the environment to the current region", async () => { + const urls = await service.setEnvironment(QA_EU_REGION_KEY); + expect(urls).toEqual(expected_QA_EU_Urls); + }); + + it("errors if the selected region is unknown", async () => { + await expect(service.setEnvironment("unknown" as Region)).rejects.toThrow( + "The selected region is not known as an available region.", + ); + }); + + it("sets the window location to a new region's web vault url and preserves any query params", async () => { + const routeAndQueryParams = "/signup?example=1&another=2"; + (router as any).url = routeAndQueryParams; + + await service.setEnvironment(QA_US_REGION_KEY); + + expect(window.location.href).toEqual( + QA_US_WEB_REGION_CONFIG.urls.webVault + "/#" + routeAndQueryParams, + ); + }); + }); + }); + }); +}); diff --git a/apps/web/src/app/platform/web-environment.service.ts b/apps/web/src/app/platform/web-environment.service.ts index 9c2afff4a1c2..ebddc7491ba6 100644 --- a/apps/web/src/app/platform/web-environment.service.ts +++ b/apps/web/src/app/platform/web-environment.service.ts @@ -1,5 +1,5 @@ import { Router } from "@angular/router"; -import { ReplaySubject } from "rxjs"; +import { firstValueFrom, ReplaySubject } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { @@ -8,7 +8,6 @@ import { RegionConfig, Urls, } from "@bitwarden/common/platform/abstractions/environment.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CloudEnvironment, DefaultEnvironmentService, @@ -16,6 +15,12 @@ import { } from "@bitwarden/common/platform/services/default-environment.service"; import { StateProvider } from "@bitwarden/common/platform/state"; +export type WebRegionConfig = RegionConfig & { + key: Region | string; // strings are used for custom environments + domain: string; + urls: Urls; +}; + /** * Web specific environment service. Ensures that the urls are set from the window location. */ @@ -24,23 +29,30 @@ export class WebEnvironmentService extends DefaultEnvironmentService { private win: Window, stateProvider: StateProvider, accountService: AccountService, + additionalRegionConfigs: WebRegionConfig[] = [], private router: Router, + private envUrls: Urls, ) { - super(stateProvider, accountService); + super(stateProvider, accountService, additionalRegionConfigs); // The web vault always uses the current location as the base url - const urls = process.env.URLS as Urls; - urls.base ??= this.win.location.origin; + envUrls.base ??= this.win.location.origin; // Find the region - const domain = Utils.getDomain(this.win.location.href); - const region = this.availableRegions().find((r) => Utils.getDomain(r.urls.webVault) === domain); + const currentHostname = new URL(this.win.location.href).hostname; + const availableRegions = this.availableRegions(); + const region = availableRegions.find((r) => { + // We must use hostname as our QA envs use the same + // domain (bitwarden.pw) but different subdomains (qa and euqa) + const webVaultHostname = new URL(r.urls.webVault).hostname; + return webVaultHostname === currentHostname; + }); let environment: Environment; if (region) { - environment = new WebCloudEnvironment(region, urls); + environment = new WebCloudEnvironment(region, envUrls); } else { - environment = new SelfHostedEnvironment(urls); + environment = new SelfHostedEnvironment(envUrls); } // Override the environment observable with a replay subject @@ -50,37 +62,45 @@ export class WebEnvironmentService extends DefaultEnvironmentService { } // Web setting env means navigating to a new location - setEnvironment(region: Region, urls?: Urls): Promise { + async setEnvironment(region: Region | string, urls?: Urls): Promise { if (region === Region.SelfHosted) { throw new Error("setEnvironment does not work in web for self-hosted."); } - const currentDomain = Utils.getDomain(this.win.location.href); - const currentRegion = this.availableRegions().find( - (r) => Utils.getDomain(r.urls.webVault) === currentDomain, - ); + // Find the region + const currentHostname = new URL(this.win.location.href).hostname; + const availableRegions = this.availableRegions(); + const currentRegionConfig = availableRegions.find((r) => { + // We must use hostname as our QA envs use the same + // domain (bitwarden.pw) but different subdomains (qa and euqa) + const webVaultHostname = new URL(r.urls.webVault).hostname; + return webVaultHostname === currentHostname; + }); - if (currentRegion.key === region) { - // They have selected the current region, nothing to do - return Promise.resolve(currentRegion.urls); + if (currentRegionConfig.key === region) { + // They have selected the current region, return the current env urls + // We can't return the region urls because the env base url is modified + // in the constructor to match the current window.location.origin. + const currentEnv = await firstValueFrom(this.environment$); + return currentEnv.getUrls(); } - const chosenRegion = this.availableRegions().find((r) => r.key === region); + const chosenRegionConfig = this.availableRegions().find((r) => r.key === region); - if (chosenRegion == null) { + if (chosenRegionConfig == null) { throw new Error("The selected region is not known as an available region."); } // Preserve the current in app route + params in the new location const routeAndParams = `/#${this.router.url}`; - this.win.location.href = chosenRegion.urls.webVault + routeAndParams; + this.win.location.href = chosenRegionConfig.urls.webVault + routeAndParams; // This return shouldn't matter as we are about to leave the current window - return Promise.resolve(chosenRegion.urls); + return chosenRegionConfig.urls; } } -class WebCloudEnvironment extends CloudEnvironment { +export class WebCloudEnvironment extends CloudEnvironment { constructor(config: RegionConfig, urls: Urls) { super(config); // We override the urls to avoid CORS issues diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 40405b062c61..572d26ffc036 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -3,6 +3,7 @@ import { Observable, Subject } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; import { ClientType } from "@bitwarden/common/enums"; +import { RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service"; import { AbstractStorageService, ObservableStorageService, @@ -58,3 +59,12 @@ export const CLIENT_TYPE = new SafeInjectionToken("CLIENT_TYPE"); export const REFRESH_ACCESS_TOKEN_ERROR_CALLBACK = new SafeInjectionToken<() => void>( "REFRESH_ACCESS_TOKEN_ERROR_CALLBACK", ); + +/** + * Injection token for injecting the NodeJS process.env additional regions into services. + * Using an injection token allows services to be tested without needing to + * mock the process.env. + */ +export const ENV_ADDITIONAL_REGIONS = new SafeInjectionToken( + "ENV_ADDITIONAL_REGIONS", +); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 512f0730f8f9..934649ebfc83 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -141,7 +141,10 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { + EnvironmentService, + RegionConfig, +} from "@bitwarden/common/platform/abstractions/environment.service"; import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; @@ -298,6 +301,7 @@ import { INTRAPROCESS_MESSAGING_SUBJECT, CLIENT_TYPE, REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, + ENV_ADDITIONAL_REGIONS, } from "./injection-tokens"; import { ModalService } from "./modal.service"; @@ -530,10 +534,14 @@ const safeProviders: SafeProvider[] = [ useClass: CollectionService, deps: [CryptoServiceAbstraction, EncryptService, I18nServiceAbstraction, StateProvider], }), + safeProvider({ + provide: ENV_ADDITIONAL_REGIONS, + useValue: process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[], + }), safeProvider({ provide: EnvironmentService, useClass: DefaultEnvironmentService, - deps: [StateProvider, AccountServiceAbstraction], + deps: [StateProvider, AccountServiceAbstraction, ENV_ADDITIONAL_REGIONS], }), safeProvider({ provide: InternalUserDecryptionOptionsServiceAbstraction, diff --git a/libs/common/src/platform/services/default-environment.service.ts b/libs/common/src/platform/services/default-environment.service.ts index 97f084d80f31..8ed673d066ec 100644 --- a/libs/common/src/platform/services/default-environment.service.ts +++ b/libs/common/src/platform/services/default-environment.service.ts @@ -136,6 +136,7 @@ export class DefaultEnvironmentService implements EnvironmentService { constructor( private stateProvider: StateProvider, private accountService: AccountService, + private additionalRegionConfigs: RegionConfig[] = [], ) { this.globalState = this.stateProvider.getGlobal(GLOBAL_ENVIRONMENT_KEY); this.globalCloudRegionState = this.stateProvider.getGlobal(GLOBAL_CLOUD_REGION_KEY); @@ -177,8 +178,7 @@ export class DefaultEnvironmentService implements EnvironmentService { } availableRegions(): RegionConfig[] { - const additionalRegions = (process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[]) ?? []; - return PRODUCTION_REGIONS.concat(additionalRegions); + return PRODUCTION_REGIONS.concat(this.additionalRegionConfigs); } /**