1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[PM-5979] Refactor EnvironmentService (#8040)

Refactor environment service to emit a single observable. This required significant changes to how the environment service behaves and tackles much of the tech debt planned for it.
This commit is contained in:
Oscar Hinton
2024-03-21 17:09:44 +01:00
committed by GitHub
parent 7a42b4ebc6
commit e767295c86
88 changed files with 1710 additions and 1379 deletions

View File

@@ -14,64 +14,119 @@ export type Urls = {
scim?: string;
};
export type PayPalConfig = {
businessId?: string;
buttonAction?: string;
};
/**
* A subset of available regions, additional regions can be loaded through configuration.
*/
export enum Region {
US = "US",
EU = "EU",
SelfHosted = "Self-hosted",
}
export enum RegionDomain {
US = "bitwarden.com",
EU = "bitwarden.eu",
USQA = "bitwarden.pw",
/**
* The possible cloud regions.
*/
export type CloudRegion = Exclude<Region, Region.SelfHosted>;
export type RegionConfig = {
// Beware this isn't completely true, it's actually a string for custom environments,
// which are currently only supported in web where it doesn't matter.
key: Region;
domain: string;
urls: Urls;
};
/**
* The Environment interface represents a server environment.
*
* It provides methods to retrieve the URLs of the different services.
*/
export interface Environment {
/**
* Retrieve the current region.
*/
getRegion(): Region;
/**
* Retrieve the urls, should only be used when configuring the environment.
*/
getUrls(): Urls;
/**
* Identify if the region is a cloud environment.
*
* @returns true if the environment is a cloud environment, false otherwise.
*/
isCloud(): boolean;
getApiUrl(): string;
getEventsUrl(): string;
getIconsUrl(): string;
getIdentityUrl(): string;
/**
* @deprecated This is currently only used by the CLI. This functionality should be extracted since
* the CLI relies on changing environment mid-login.
*
* @remarks
* Expect this to be null unless the CLI has explicitly set it during the login flow.
*/
getKeyConnectorUrl(): string | null;
getNotificationsUrl(): string;
getScimUrl(): string;
getSendUrl(): string;
getWebVaultUrl(): string;
/**
* Get a friendly hostname for the environment.
*
* - For self-hosted this is the web vault url without protocol prefix.
* - For cloud environments it's the domain key.
*/
getHostname(): string;
// Not sure why we provide this, evaluate if we can remove it.
hasBaseUrl(): boolean;
}
/**
* The environment service. Provides access to set the current environment urls and region.
*/
export abstract class EnvironmentService {
urls: Observable<void>;
usUrls: Urls;
euUrls: Urls;
selectedRegion?: Region;
initialized = true;
abstract environment$: Observable<Environment>;
abstract cloudWebVaultUrl$: Observable<string>;
hasBaseUrl: () => boolean;
getNotificationsUrl: () => string;
getWebVaultUrl: () => string;
/**
* Retrieves the URL of the cloud web vault app.
* Retrieve all the available regions for environment selectors.
*
* @returns {string} The URL of the cloud web vault app.
* @remarks Use this method only in views exclusive to self-host instances.
* This currently relies on compile time provided constants, and will not change at runtime.
* Expect all builds to include production environments, QA builds to also include QA
* environments and dev builds to include localhost.
*/
getCloudWebVaultUrl: () => string;
abstract availableRegions(): RegionConfig[];
/**
* Set the global environment.
*/
abstract setEnvironment(region: Region, urls?: Urls): Promise<Urls>;
/**
* Seed the environment state for a given user based on the global environment.
*
* @remarks
* Expected to be called only by the StateService when adding a new account.
*/
abstract seedUserEnvironment(userId: UserId): Promise<void>;
/**
* Sets the URL of the cloud web vault app based on the region parameter.
*
* @param {Region} region - The region of the cloud web vault app.
* @param userId - The user id to set the cloud web vault app URL for. If null or undefined the global environment is set.
* @param region - The region of the cloud web vault app.
*/
setCloudWebVaultUrl: (region: Region) => void;
abstract setCloudRegion(userId: UserId, region: Region): Promise<void>;
/**
* Seed the environment for a given user based on the globally set defaults.
* Get the environment from state. Useful if you need to get the environment for another user.
*/
seedUserEnvironment: (userId: UserId) => Promise<void>;
getSendUrl: () => string;
getIconsUrl: () => string;
getApiUrl: () => string;
getIdentityUrl: () => string;
getEventsUrl: () => string;
getKeyConnectorUrl: () => string;
getScimUrl: () => string;
setUrlsFromStorage: () => Promise<void>;
setUrls: (urls: Urls) => Promise<Urls>;
getHost: (userId?: string) => Promise<string>;
setRegion: (region: Region) => Promise<void>;
getUrls: () => Urls;
isCloud: () => boolean;
isEmpty: () => boolean;
abstract getEnvironment(userId?: string): Promise<Environment | undefined>;
}

View File

@@ -1,11 +1,13 @@
import { MockProxy, mock } from "jest-mock-extended";
import { ReplaySubject, skip, take } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { UserId } from "../../../types/guid";
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
import { ServerConfig } from "../../abstractions/config/server-config";
import { EnvironmentService } from "../../abstractions/environment.service";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { LogService } from "../../abstractions/log.service";
import { StateService } from "../../abstractions/state.service";
import { ServerConfigData } from "../../models/data/server-config.data";
@@ -14,6 +16,7 @@ import {
ServerConfigResponse,
ThirdPartyServerConfigResponse,
} from "../../models/response/server-config.response";
import { StateProvider } from "../../state";
import { ConfigService } from "./config.service";
@@ -23,6 +26,8 @@ describe("ConfigService", () => {
let authService: MockProxy<AuthService>;
let environmentService: MockProxy<EnvironmentService>;
let logService: MockProxy<LogService>;
let replaySubject: ReplaySubject<Environment>;
let stateProvider: StateProvider;
let serverResponseCount: number; // increments to track distinct responses received from server
@@ -35,6 +40,7 @@ describe("ConfigService", () => {
authService,
environmentService,
logService,
stateProvider,
);
configService.init();
return configService;
@@ -46,8 +52,11 @@ describe("ConfigService", () => {
authService = mock();
environmentService = mock();
logService = mock();
replaySubject = new ReplaySubject<Environment>(1);
const accountService = mockAccountServiceWith("0" as UserId);
stateProvider = new FakeStateProvider(accountService);
environmentService.urls = new ReplaySubject<void>(1);
environmentService.environment$ = replaySubject.asObservable();
serverResponseCount = 1;
configApiService.get.mockImplementation(() =>
@@ -139,7 +148,7 @@ describe("ConfigService", () => {
}
});
(environmentService.urls as ReplaySubject<void>).next();
replaySubject.next(null);
});
it("when triggerServerConfigFetch() is called", (done) => {

View File

@@ -22,6 +22,7 @@ import { EnvironmentService, Region } from "../../abstractions/environment.servi
import { LogService } from "../../abstractions/log.service";
import { StateService } from "../../abstractions/state.service";
import { ServerConfigData } from "../../models/data/server-config.data";
import { StateProvider } from "../../state";
const ONE_HOUR_IN_MILLISECONDS = 1000 * 3600;
@@ -44,6 +45,7 @@ export class ConfigService implements ConfigServiceAbstraction {
private authService: AuthService,
private environmentService: EnvironmentService,
private logService: LogService,
private stateProvider: StateProvider,
// Used to avoid duplicate subscriptions, e.g. in browser between the background and popup
private subscribe = true,
@@ -67,7 +69,7 @@ export class ConfigService implements ConfigServiceAbstraction {
// If you need to fetch a new config when an event occurs, add an observable that emits on that event here
merge(
this.refreshTimer$, // an overridable interval
this.environmentService.urls, // when environment URLs change (including when app is started)
this.environmentService.environment$, // when environment URLs change (including when app is started)
this._forceFetchConfig, // manual
)
.pipe(
@@ -104,8 +106,9 @@ export class ConfigService implements ConfigServiceAbstraction {
return;
}
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
await this.stateService.setServerConfig(data);
this.environmentService.setCloudWebVaultUrl(data.environment?.cloudRegion);
await this.environmentService.setCloudRegion(userId, data.environment?.cloudRegion);
}
/**

View File

@@ -0,0 +1,418 @@
import { firstValueFrom } from "rxjs";
import { FakeStateProvider, awaitAsync } from "../../../spec";
import { FakeAccountService } from "../../../spec/fake-account-service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { UserId } from "../../types/guid";
import { CloudRegion, Region } from "../abstractions/environment.service";
import {
ENVIRONMENT_KEY,
DefaultEnvironmentService,
EnvironmentUrls,
} from "./default-environment.service";
// There are a few main states EnvironmentService could be in when first used
// 1. Not initialized, no active user. Hopefully not to likely but possible
// 2. Not initialized, with active user. Not likely
// 3. Initialized, no active user.
// 4. Initialized, with active user.
describe("EnvironmentService", () => {
let accountService: FakeAccountService;
let stateProvider: FakeStateProvider;
let sut: DefaultEnvironmentService;
const testUser = "00000000-0000-1000-a000-000000000001" as UserId;
const alternateTestUser = "00000000-0000-1000-a000-000000000002" as UserId;
beforeEach(async () => {
accountService = new FakeAccountService({
[testUser]: {
name: "name",
email: "email",
status: AuthenticationStatus.Locked,
},
[alternateTestUser]: {
name: "name",
email: "email",
status: AuthenticationStatus.Locked,
},
});
stateProvider = new FakeStateProvider(accountService);
sut = new DefaultEnvironmentService(stateProvider, accountService);
});
const switchUser = async (userId: UserId) => {
accountService.activeAccountSubject.next({
id: userId,
email: "test@example.com",
name: `Test Name ${userId}`,
status: AuthenticationStatus.Unlocked,
});
await awaitAsync();
};
const setGlobalData = (region: Region, environmentUrls: EnvironmentUrls) => {
stateProvider.global.getFake(ENVIRONMENT_KEY).stateSubject.next({
region: region,
urls: environmentUrls,
});
};
const setUserData = (
region: Region,
environmentUrls: EnvironmentUrls,
userId: UserId = testUser,
) => {
stateProvider.singleUser.getFake(userId, ENVIRONMENT_KEY).nextState({
region: region,
urls: environmentUrls,
});
};
const REGION_SETUP = [
{
region: Region.US,
expectedUrls: {
webVault: "https://vault.bitwarden.com",
identity: "https://identity.bitwarden.com",
api: "https://api.bitwarden.com",
icons: "https://icons.bitwarden.net",
notifications: "https://notifications.bitwarden.com",
events: "https://events.bitwarden.com",
scim: "https://scim.bitwarden.com/v2",
send: "https://send.bitwarden.com/#",
},
},
{
region: Region.EU,
expectedUrls: {
webVault: "https://vault.bitwarden.eu",
identity: "https://identity.bitwarden.eu",
api: "https://api.bitwarden.eu",
icons: "https://icons.bitwarden.eu",
notifications: "https://notifications.bitwarden.eu",
events: "https://events.bitwarden.eu",
scim: "https://scim.bitwarden.eu/v2",
send: "https://vault.bitwarden.eu/#/send/",
},
},
];
describe("with user", () => {
it.each(REGION_SETUP)(
"sets correct urls for each region %s",
async ({ region, expectedUrls }) => {
setUserData(region, new EnvironmentUrls());
await switchUser(testUser);
const env = await firstValueFrom(sut.environment$);
expect(env.hasBaseUrl()).toBe(false);
expect(env.getWebVaultUrl()).toBe(expectedUrls.webVault);
expect(env.getIdentityUrl()).toBe(expectedUrls.identity);
expect(env.getApiUrl()).toBe(expectedUrls.api);
expect(env.getIconsUrl()).toBe(expectedUrls.icons);
expect(env.getNotificationsUrl()).toBe(expectedUrls.notifications);
expect(env.getEventsUrl()).toBe(expectedUrls.events);
expect(env.getScimUrl()).toBe(expectedUrls.scim);
expect(env.getSendUrl()).toBe(expectedUrls.send);
expect(env.getKeyConnectorUrl()).toBe(undefined);
expect(env.isCloud()).toBe(true);
expect(env.getUrls()).toEqual({
base: null,
cloudWebVault: undefined,
webVault: expectedUrls.webVault,
identity: expectedUrls.identity,
api: expectedUrls.api,
icons: expectedUrls.icons,
notifications: expectedUrls.notifications,
events: expectedUrls.events,
scim: expectedUrls.scim.replace("/v2", ""),
keyConnector: undefined,
});
},
);
it("returns user data", async () => {
const globalEnvironmentUrls = new EnvironmentUrls();
globalEnvironmentUrls.base = "https://global-url.example.com";
setGlobalData(Region.SelfHosted, globalEnvironmentUrls);
const userEnvironmentUrls = new EnvironmentUrls();
userEnvironmentUrls.base = "https://user-url.example.com";
setUserData(Region.SelfHosted, userEnvironmentUrls);
await switchUser(testUser);
const env = await firstValueFrom(sut.environment$);
expect(env.getWebVaultUrl()).toBe("https://user-url.example.com");
expect(env.getIdentityUrl()).toBe("https://user-url.example.com/identity");
expect(env.getApiUrl()).toBe("https://user-url.example.com/api");
expect(env.getIconsUrl()).toBe("https://user-url.example.com/icons");
expect(env.getNotificationsUrl()).toBe("https://user-url.example.com/notifications");
expect(env.getEventsUrl()).toBe("https://user-url.example.com/events");
expect(env.getScimUrl()).toBe("https://user-url.example.com/scim/v2");
expect(env.getSendUrl()).toBe("https://user-url.example.com/#/send/");
expect(env.isCloud()).toBe(false);
expect(env.getUrls()).toEqual({
base: "https://user-url.example.com",
api: null,
cloudWebVault: undefined,
events: null,
icons: null,
identity: null,
keyConnector: null,
notifications: null,
scim: null,
webVault: null,
});
});
});
describe("without user", () => {
it.each(REGION_SETUP)("gets default urls %s", async ({ region, expectedUrls }) => {
setGlobalData(region, new EnvironmentUrls());
const env = await firstValueFrom(sut.environment$);
expect(env.hasBaseUrl()).toBe(false);
expect(env.getWebVaultUrl()).toBe(expectedUrls.webVault);
expect(env.getIdentityUrl()).toBe(expectedUrls.identity);
expect(env.getApiUrl()).toBe(expectedUrls.api);
expect(env.getIconsUrl()).toBe(expectedUrls.icons);
expect(env.getNotificationsUrl()).toBe(expectedUrls.notifications);
expect(env.getEventsUrl()).toBe(expectedUrls.events);
expect(env.getScimUrl()).toBe(expectedUrls.scim);
expect(env.getSendUrl()).toBe(expectedUrls.send);
expect(env.getKeyConnectorUrl()).toBe(undefined);
expect(env.isCloud()).toBe(true);
expect(env.getUrls()).toEqual({
base: null,
cloudWebVault: undefined,
webVault: expectedUrls.webVault,
identity: expectedUrls.identity,
api: expectedUrls.api,
icons: expectedUrls.icons,
notifications: expectedUrls.notifications,
events: expectedUrls.events,
scim: expectedUrls.scim.replace("/v2", ""),
keyConnector: undefined,
});
});
it("gets global data", async () => {
const globalEnvironmentUrls = new EnvironmentUrls();
globalEnvironmentUrls.base = "https://global-url.example.com";
globalEnvironmentUrls.keyConnector = "https://global-key-connector.example.com";
setGlobalData(Region.SelfHosted, globalEnvironmentUrls);
const userEnvironmentUrls = new EnvironmentUrls();
userEnvironmentUrls.base = "https://user-url.example.com";
userEnvironmentUrls.keyConnector = "https://user-key-connector.example.com";
setUserData(Region.SelfHosted, userEnvironmentUrls);
const env = await firstValueFrom(sut.environment$);
expect(env.getWebVaultUrl()).toBe("https://global-url.example.com");
expect(env.getIdentityUrl()).toBe("https://global-url.example.com/identity");
expect(env.getApiUrl()).toBe("https://global-url.example.com/api");
expect(env.getIconsUrl()).toBe("https://global-url.example.com/icons");
expect(env.getNotificationsUrl()).toBe("https://global-url.example.com/notifications");
expect(env.getEventsUrl()).toBe("https://global-url.example.com/events");
expect(env.getScimUrl()).toBe("https://global-url.example.com/scim/v2");
expect(env.getSendUrl()).toBe("https://global-url.example.com/#/send/");
expect(env.getKeyConnectorUrl()).toBe("https://global-key-connector.example.com");
expect(env.isCloud()).toBe(false);
expect(env.getUrls()).toEqual({
api: null,
base: "https://global-url.example.com",
cloudWebVault: undefined,
webVault: null,
events: null,
icons: null,
identity: null,
keyConnector: "https://global-key-connector.example.com",
notifications: null,
scim: null,
});
});
});
describe("setEnvironment", () => {
it("self-hosted with base-url", async () => {
await sut.setEnvironment(Region.SelfHosted, {
base: "base.example.com",
});
await awaitAsync();
const env = await firstValueFrom(sut.environment$);
expect(env.getRegion()).toBe(Region.SelfHosted);
expect(env.getUrls()).toEqual({
base: "https://base.example.com",
api: null,
identity: null,
webVault: null,
icons: null,
notifications: null,
scim: null,
events: null,
keyConnector: null,
});
});
it("self-hosted and sets all urls", async () => {
let env = await firstValueFrom(sut.environment$);
expect(env.getScimUrl()).toBe("https://scim.bitwarden.com/v2");
await sut.setEnvironment(Region.SelfHosted, {
base: "base.example.com",
api: "api.example.com",
identity: "identity.example.com",
webVault: "vault.example.com",
icons: "icons.example.com",
notifications: "notifications.example.com",
scim: "scim.example.com",
});
env = await firstValueFrom(sut.environment$);
expect(env.getRegion()).toBe(Region.SelfHosted);
expect(env.getUrls()).toEqual({
base: "https://base.example.com",
api: "https://api.example.com",
identity: "https://identity.example.com",
webVault: "https://vault.example.com",
icons: "https://icons.example.com",
notifications: "https://notifications.example.com",
scim: null,
events: null,
keyConnector: null,
});
expect(env.getScimUrl()).toBe("https://vault.example.com/scim/v2");
});
it("sets the region", async () => {
await sut.setEnvironment(Region.US);
const data = await firstValueFrom(sut.environment$);
expect(data.getRegion()).toBe(Region.US);
});
});
describe("getEnvironment", () => {
it.each([
{ region: Region.US, expectedHost: "bitwarden.com" },
{ region: Region.EU, expectedHost: "bitwarden.eu" },
])("gets it from user data if there is an active user", async ({ region, expectedHost }) => {
setGlobalData(Region.US, new EnvironmentUrls());
setUserData(region, new EnvironmentUrls());
await switchUser(testUser);
const env = await sut.getEnvironment();
expect(env.getHostname()).toBe(expectedHost);
});
it.each([
{ region: Region.US, expectedHost: "bitwarden.com" },
{ region: Region.EU, expectedHost: "bitwarden.eu" },
])("gets it from global data if there is no active user", async ({ region, expectedHost }) => {
setGlobalData(region, new EnvironmentUrls());
setUserData(Region.US, new EnvironmentUrls());
const env = await sut.getEnvironment();
expect(env.getHostname()).toBe(expectedHost);
});
it.each([
{ region: Region.US, expectedHost: "bitwarden.com" },
{ region: Region.EU, expectedHost: "bitwarden.eu" },
])(
"gets it from global state if there is no active user even if a user id is passed in.",
async ({ region, expectedHost }) => {
setGlobalData(region, new EnvironmentUrls());
setUserData(Region.US, new EnvironmentUrls());
const env = await sut.getEnvironment(testUser);
expect(env.getHostname()).toBe(expectedHost);
},
);
it.each([
{ region: Region.US, expectedHost: "bitwarden.com" },
{ region: Region.EU, expectedHost: "bitwarden.eu" },
])(
"gets it from the passed in userId if there is any active user: %s",
async ({ region, expectedHost }) => {
setGlobalData(Region.US, new EnvironmentUrls());
setUserData(Region.US, new EnvironmentUrls());
setUserData(region, new EnvironmentUrls(), alternateTestUser);
await switchUser(testUser);
const env = await sut.getEnvironment(alternateTestUser);
expect(env.getHostname()).toBe(expectedHost);
},
);
it("gets it from base url saved in self host config", async () => {
const globalSelfHostUrls = new EnvironmentUrls();
globalSelfHostUrls.base = "https://base.example.com";
setGlobalData(Region.SelfHosted, globalSelfHostUrls);
setUserData(Region.EU, new EnvironmentUrls());
const env = await sut.getEnvironment();
expect(env.getHostname()).toBe("base.example.com");
});
it("gets it from webVault url saved in self host config", async () => {
const globalSelfHostUrls = new EnvironmentUrls();
globalSelfHostUrls.webVault = "https://vault.example.com";
globalSelfHostUrls.base = "https://base.example.com";
setGlobalData(Region.SelfHosted, globalSelfHostUrls);
setUserData(Region.EU, new EnvironmentUrls());
const env = await sut.getEnvironment();
expect(env.getHostname()).toBe("vault.example.com");
});
it("gets it from saved self host config from passed in user when there is an active user", async () => {
setGlobalData(Region.US, new EnvironmentUrls());
setUserData(Region.EU, new EnvironmentUrls());
const selfHostUserUrls = new EnvironmentUrls();
selfHostUserUrls.base = "https://base.example.com";
setUserData(Region.SelfHosted, selfHostUserUrls, alternateTestUser);
await switchUser(testUser);
const env = await sut.getEnvironment(alternateTestUser);
expect(env.getHostname()).toBe("base.example.com");
});
});
describe("cloudWebVaultUrl$", () => {
it("no extra initialization, returns US vault", async () => {
expect(await firstValueFrom(sut.cloudWebVaultUrl$)).toBe("https://vault.bitwarden.com");
});
it.each([
{ region: Region.US, expectedVault: "https://vault.bitwarden.com" },
{ region: Region.EU, expectedVault: "https://vault.bitwarden.eu" },
{ region: Region.SelfHosted, expectedVault: "https://vault.bitwarden.com" },
])(
"no extra initialization, returns expected host for each region %s",
async ({ region, expectedVault }) => {
await switchUser(testUser);
expect(await sut.setCloudRegion(testUser, region as CloudRegion));
expect(await firstValueFrom(sut.cloudWebVaultUrl$)).toBe(expectedVault);
},
);
});
});

View File

@@ -0,0 +1,433 @@
import { distinctUntilChanged, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { Jsonify } from "type-fest";
import { AccountService } from "../../auth/abstractions/account.service";
import { UserId } from "../../types/guid";
import {
EnvironmentService,
Environment,
Region,
RegionConfig,
Urls,
CloudRegion,
} from "../abstractions/environment.service";
import { Utils } from "../misc/utils";
import {
ENVIRONMENT_DISK,
ENVIRONMENT_MEMORY,
GlobalState,
KeyDefinition,
StateProvider,
} from "../state";
export class EnvironmentUrls {
base: string = null;
api: string = null;
identity: string = null;
icons: string = null;
notifications: string = null;
events: string = null;
webVault: string = null;
keyConnector: string = null;
}
class EnvironmentState {
region: Region;
urls: EnvironmentUrls;
static fromJSON(obj: Jsonify<EnvironmentState>): EnvironmentState {
return Object.assign(new EnvironmentState(), obj);
}
}
export const ENVIRONMENT_KEY = new KeyDefinition<EnvironmentState>(
ENVIRONMENT_DISK,
"environment",
{
deserializer: EnvironmentState.fromJSON,
},
);
export const CLOUD_REGION_KEY = new KeyDefinition<CloudRegion>(ENVIRONMENT_MEMORY, "cloudRegion", {
deserializer: (b) => b,
});
/**
* The production regions available for selection.
*
* In the future we desire to load these urls from the config endpoint.
*/
export const PRODUCTION_REGIONS: RegionConfig[] = [
{
key: Region.US,
domain: "bitwarden.com",
urls: {
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",
},
},
{
key: Region.EU,
domain: "bitwarden.eu",
urls: {
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",
},
},
];
/**
* The default region when starting the app.
*/
const DEFAULT_REGION = Region.US;
/**
* The default region configuration.
*/
const DEFAULT_REGION_CONFIG = PRODUCTION_REGIONS.find((r) => r.key === DEFAULT_REGION);
export class DefaultEnvironmentService implements EnvironmentService {
private globalState: GlobalState<EnvironmentState | null>;
private globalCloudRegionState: GlobalState<CloudRegion | null>;
// We intentionally don't want the helper on account service, we want the null back if there is no active user
private activeAccountId$: Observable<UserId | null> = this.accountService.activeAccount$.pipe(
map((a) => a?.id),
);
environment$: Observable<Environment>;
cloudWebVaultUrl$: Observable<string>;
constructor(
private stateProvider: StateProvider,
private accountService: AccountService,
) {
this.globalState = this.stateProvider.getGlobal(ENVIRONMENT_KEY);
this.globalCloudRegionState = this.stateProvider.getGlobal(CLOUD_REGION_KEY);
const account$ = this.activeAccountId$.pipe(
// Use == here to not trigger on undefined -> null transition
distinctUntilChanged((oldUserId: UserId, newUserId: UserId) => oldUserId == newUserId),
);
this.environment$ = account$.pipe(
switchMap((userId) => {
const t = userId
? this.stateProvider.getUser(userId, ENVIRONMENT_KEY).state$
: this.stateProvider.getGlobal(ENVIRONMENT_KEY).state$;
return t;
}),
map((state) => {
return this.buildEnvironment(state?.region, state?.urls);
}),
);
this.cloudWebVaultUrl$ = account$.pipe(
switchMap((userId) => {
const t = userId
? this.stateProvider.getUser(userId, CLOUD_REGION_KEY).state$
: this.stateProvider.getGlobal(CLOUD_REGION_KEY).state$;
return t;
}),
map((region) => {
if (region != null) {
const config = this.getRegionConfig(region);
if (config != null) {
return config.urls.webVault;
}
}
return DEFAULT_REGION_CONFIG.urls.webVault;
}),
);
}
availableRegions(): RegionConfig[] {
const additionalRegions = (process.env.ADDITIONAL_REGIONS as unknown as RegionConfig[]) ?? [];
return PRODUCTION_REGIONS.concat(additionalRegions);
}
/**
* Get the region configuration for the given region.
*/
private getRegionConfig(region: Region): RegionConfig | undefined {
return this.availableRegions().find((r) => r.key === region);
}
async setEnvironment(region: Region, urls?: Urls): Promise<Urls> {
// Unknown regions are treated as self-hosted
if (this.getRegionConfig(region) == null) {
region = Region.SelfHosted;
}
// If self-hosted ensure urls are valid else fallback to default region
if (region == Region.SelfHosted && isEmpty(urls)) {
region = DEFAULT_REGION;
}
if (region != Region.SelfHosted) {
await this.globalState.update(() => ({
region: region,
urls: null,
}));
return null;
} else {
// Clean the urls
urls.base = formatUrl(urls.base);
urls.webVault = formatUrl(urls.webVault);
urls.api = formatUrl(urls.api);
urls.identity = formatUrl(urls.identity);
urls.icons = formatUrl(urls.icons);
urls.notifications = formatUrl(urls.notifications);
urls.events = formatUrl(urls.events);
urls.keyConnector = formatUrl(urls.keyConnector);
urls.scim = null;
await this.globalState.update(() => ({
region: region,
urls: {
base: urls.base,
api: urls.api,
identity: urls.identity,
webVault: urls.webVault,
icons: urls.icons,
notifications: urls.notifications,
events: urls.events,
keyConnector: urls.keyConnector,
},
}));
return urls;
}
}
/**
* Helper for building the environment from state. Performs some general sanitization to avoid invalid regions and urls.
*/
protected buildEnvironment(region: Region, urls: Urls) {
// Unknown regions are treated as self-hosted
if (this.getRegionConfig(region) == null) {
region = Region.SelfHosted;
}
// If self-hosted ensure urls are valid else fallback to default region
if (region == Region.SelfHosted && isEmpty(urls)) {
region = DEFAULT_REGION;
}
// Load urls from region config
if (region != Region.SelfHosted) {
const regionConfig = this.getRegionConfig(region);
if (regionConfig != null) {
return new CloudEnvironment(regionConfig);
}
}
return new SelfHostedEnvironment(urls);
}
async setCloudRegion(userId: UserId, region: CloudRegion) {
if (userId == null) {
await this.globalCloudRegionState.update(() => region);
} else {
await this.stateProvider.getUser(userId, CLOUD_REGION_KEY).update(() => region);
}
}
async getEnvironment(userId?: UserId) {
if (userId == null) {
return await firstValueFrom(this.environment$);
}
const state = await this.getEnvironmentState(userId);
return this.buildEnvironment(state.region, state.urls);
}
private async getEnvironmentState(userId: UserId | null) {
// Previous rules dictated that we only get from user scoped state if there is an active user.
const activeUserId = await firstValueFrom(this.activeAccountId$);
return activeUserId == null
? await firstValueFrom(this.globalState.state$)
: await firstValueFrom(
this.stateProvider.getUser(userId ?? activeUserId, ENVIRONMENT_KEY).state$,
);
}
async seedUserEnvironment(userId: UserId) {
const global = await firstValueFrom(this.globalState.state$);
await this.stateProvider.getUser(userId, ENVIRONMENT_KEY).update(() => global);
}
}
function formatUrl(url: string): string {
if (url == null || url === "") {
return null;
}
url = url.replace(/\/+$/g, "");
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://" + url;
}
return url.trim();
}
function isEmpty(u?: Urls): boolean {
if (u == null) {
return true;
}
return (
u.base == null &&
u.webVault == null &&
u.api == null &&
u.identity == null &&
u.icons == null &&
u.notifications == null &&
u.events == null
);
}
abstract class UrlEnvironment implements Environment {
constructor(
protected region: Region,
protected urls: Urls,
) {
// Scim is always null for self-hosted
if (region == Region.SelfHosted) {
this.urls.scim = null;
}
}
abstract getHostname(): string;
getRegion() {
return this.region;
}
getUrls() {
return {
base: this.urls.base,
webVault: this.urls.webVault,
api: this.urls.api,
identity: this.urls.identity,
icons: this.urls.icons,
notifications: this.urls.notifications,
events: this.urls.events,
keyConnector: this.urls.keyConnector,
scim: this.urls.scim,
};
}
hasBaseUrl() {
return this.urls.base != null;
}
getWebVaultUrl() {
return this.getUrl("webVault", "");
}
getApiUrl() {
return this.getUrl("api", "/api");
}
getEventsUrl() {
return this.getUrl("events", "/events");
}
getIconsUrl() {
return this.getUrl("icons", "/icons");
}
getIdentityUrl() {
return this.getUrl("identity", "/identity");
}
getKeyConnectorUrl() {
return this.urls.keyConnector;
}
getNotificationsUrl() {
return this.getUrl("notifications", "/notifications");
}
getScimUrl() {
if (this.urls.scim != null) {
return this.urls.scim + "/v2";
}
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
? "https://scim.bitwarden.com/v2"
: this.getWebVaultUrl() + "/scim/v2";
}
getSendUrl() {
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
? "https://send.bitwarden.com/#"
: this.getWebVaultUrl() + "/#/send/";
}
/**
* Presume that if the region is not self-hosted, it is cloud.
*/
isCloud(): boolean {
return this.region !== Region.SelfHosted;
}
/**
* Helper for getting an URL.
*
* @param key Key of the URL to get from URLs
* @param baseSuffix Suffix to append to the base URL if the url is not set
* @returns
*/
private getUrl(key: keyof Urls, baseSuffix: string) {
if (this.urls[key] != null) {
return this.urls[key];
}
if (this.urls.base) {
return this.urls.base + baseSuffix;
}
return DEFAULT_REGION_CONFIG.urls[key];
}
}
/**
* Denote a cloud environment.
*/
export class CloudEnvironment extends UrlEnvironment {
constructor(private config: RegionConfig) {
super(config.key, config.urls);
}
/**
* Cloud always returns nice urls, i.e. bitwarden.com instead of vault.bitwarden.com.
*/
getHostname() {
return this.config.domain;
}
}
export class SelfHostedEnvironment extends UrlEnvironment {
constructor(urls: Urls) {
super(Region.SelfHosted, urls);
}
getHostname() {
return Utils.getHost(this.getWebVaultUrl());
}
}

View File

@@ -1,535 +0,0 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, timeout } from "rxjs";
import { awaitAsync } from "../../../spec";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeStorageService } from "../../../spec/fake-storage.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
import { UserId } from "../../types/guid";
import { Region } from "../abstractions/environment.service";
import { StateProvider } from "../state";
/* eslint-disable import/no-restricted-paths -- Rare testing need */
import { DefaultActiveUserStateProvider } from "../state/implementations/default-active-user-state.provider";
import { DefaultDerivedStateProvider } from "../state/implementations/default-derived-state.provider";
import { DefaultGlobalStateProvider } from "../state/implementations/default-global-state.provider";
import { DefaultSingleUserStateProvider } from "../state/implementations/default-single-user-state.provider";
import { DefaultStateProvider } from "../state/implementations/default-state.provider";
import { StateEventRegistrarService } from "../state/state-event-registrar.service";
/* eslint-enable import/no-restricted-paths */
import { EnvironmentService } from "./environment.service";
import { StorageServiceProvider } from "./storage-service.provider";
// There are a few main states EnvironmentService could be in when first used
// 1. Not initialized, no active user. Hopefully not to likely but possible
// 2. Not initialized, with active user. Not likely
// 3. Initialized, no active user.
// 4. Initialized, with active user.
describe("EnvironmentService", () => {
let diskStorageService: FakeStorageService;
let memoryStorageService: FakeStorageService;
let storageServiceProvider: StorageServiceProvider;
const stateEventRegistrarService = mock<StateEventRegistrarService>();
let accountService: FakeAccountService;
let stateProvider: StateProvider;
let sut: EnvironmentService;
const testUser = "00000000-0000-1000-a000-000000000001" as UserId;
const alternateTestUser = "00000000-0000-1000-a000-000000000002" as UserId;
beforeEach(async () => {
diskStorageService = new FakeStorageService();
memoryStorageService = new FakeStorageService();
storageServiceProvider = new StorageServiceProvider(diskStorageService, memoryStorageService);
accountService = mockAccountServiceWith(undefined);
const singleUserStateProvider = new DefaultSingleUserStateProvider(
storageServiceProvider,
stateEventRegistrarService,
);
stateProvider = new DefaultStateProvider(
new DefaultActiveUserStateProvider(accountService, singleUserStateProvider),
singleUserStateProvider,
new DefaultGlobalStateProvider(storageServiceProvider),
new DefaultDerivedStateProvider(memoryStorageService),
);
sut = new EnvironmentService(stateProvider, accountService);
});
const switchUser = async (userId: UserId) => {
accountService.activeAccountSubject.next({
id: userId,
email: "test@example.com",
name: `Test Name ${userId}`,
status: AuthenticationStatus.Unlocked,
});
await awaitAsync();
};
const setGlobalData = (region: Region, environmentUrls: EnvironmentUrls) => {
const data = diskStorageService.internalStore;
data["global_environment_region"] = region;
data["global_environment_urls"] = environmentUrls;
diskStorageService.internalUpdateStore(data);
};
const getGlobalData = () => {
const storage = diskStorageService.internalStore;
return {
region: storage?.["global_environment_region"],
urls: storage?.["global_environment_urls"],
};
};
const setUserData = (
region: Region,
environmentUrls: EnvironmentUrls,
userId: UserId = testUser,
) => {
const data = diskStorageService.internalStore;
data[`user_${userId}_environment_region`] = region;
data[`user_${userId}_environment_urls`] = environmentUrls;
diskStorageService.internalUpdateStore(data);
};
// END: CAN CHANGE
const initialize = async (options: { switchUser: boolean }) => {
await sut.setUrlsFromStorage();
sut.initialized = true;
if (options.switchUser) {
await switchUser(testUser);
}
};
const REGION_SETUP = [
{
region: Region.US,
expectedUrls: {
webVault: "https://vault.bitwarden.com",
identity: "https://identity.bitwarden.com",
api: "https://api.bitwarden.com",
icons: "https://icons.bitwarden.net",
notifications: "https://notifications.bitwarden.com",
events: "https://events.bitwarden.com",
scim: "https://scim.bitwarden.com/v2",
send: "https://send.bitwarden.com/#",
},
},
{
region: Region.EU,
expectedUrls: {
webVault: "https://vault.bitwarden.eu",
identity: "https://identity.bitwarden.eu",
api: "https://api.bitwarden.eu",
icons: "https://icons.bitwarden.eu",
notifications: "https://notifications.bitwarden.eu",
events: "https://events.bitwarden.eu",
scim: "https://scim.bitwarden.eu/v2",
send: "https://vault.bitwarden.eu/#/send/",
},
},
];
describe("with user", () => {
it.each(REGION_SETUP)(
"sets correct urls for each region %s",
async ({ region, expectedUrls }) => {
setUserData(region, new EnvironmentUrls());
await initialize({ switchUser: true });
expect(sut.hasBaseUrl()).toBe(false);
expect(sut.getWebVaultUrl()).toBe(expectedUrls.webVault);
expect(sut.getIdentityUrl()).toBe(expectedUrls.identity);
expect(sut.getApiUrl()).toBe(expectedUrls.api);
expect(sut.getIconsUrl()).toBe(expectedUrls.icons);
expect(sut.getNotificationsUrl()).toBe(expectedUrls.notifications);
expect(sut.getEventsUrl()).toBe(expectedUrls.events);
expect(sut.getScimUrl()).toBe(expectedUrls.scim);
expect(sut.getSendUrl()).toBe(expectedUrls.send);
expect(sut.getKeyConnectorUrl()).toBe(null);
expect(sut.isCloud()).toBe(true);
expect(sut.getUrls()).toEqual({
base: null,
cloudWebVault: undefined,
webVault: expectedUrls.webVault,
identity: expectedUrls.identity,
api: expectedUrls.api,
icons: expectedUrls.icons,
notifications: expectedUrls.notifications,
events: expectedUrls.events,
scim: expectedUrls.scim.replace("/v2", ""),
keyConnector: null,
});
},
);
it("returns user data", async () => {
const globalEnvironmentUrls = new EnvironmentUrls();
globalEnvironmentUrls.base = "https://global-url.example.com";
setGlobalData(Region.SelfHosted, globalEnvironmentUrls);
const userEnvironmentUrls = new EnvironmentUrls();
userEnvironmentUrls.base = "https://user-url.example.com";
setUserData(Region.SelfHosted, userEnvironmentUrls);
await initialize({ switchUser: true });
expect(sut.getWebVaultUrl()).toBe("https://user-url.example.com");
expect(sut.getIdentityUrl()).toBe("https://user-url.example.com/identity");
expect(sut.getApiUrl()).toBe("https://user-url.example.com/api");
expect(sut.getIconsUrl()).toBe("https://user-url.example.com/icons");
expect(sut.getNotificationsUrl()).toBe("https://user-url.example.com/notifications");
expect(sut.getEventsUrl()).toBe("https://user-url.example.com/events");
expect(sut.getScimUrl()).toBe("https://user-url.example.com/scim/v2");
expect(sut.getSendUrl()).toBe("https://user-url.example.com/#/send/");
expect(sut.isCloud()).toBe(false);
expect(sut.getUrls()).toEqual({
base: "https://user-url.example.com",
api: null,
cloudWebVault: undefined,
events: null,
icons: null,
identity: null,
keyConnector: null,
notifications: null,
scim: null,
webVault: null,
});
});
});
describe("without user", () => {
it.each(REGION_SETUP)("gets default urls %s", async ({ region, expectedUrls }) => {
setGlobalData(region, new EnvironmentUrls());
await initialize({ switchUser: false });
expect(sut.hasBaseUrl()).toBe(false);
expect(sut.getWebVaultUrl()).toBe(expectedUrls.webVault);
expect(sut.getIdentityUrl()).toBe(expectedUrls.identity);
expect(sut.getApiUrl()).toBe(expectedUrls.api);
expect(sut.getIconsUrl()).toBe(expectedUrls.icons);
expect(sut.getNotificationsUrl()).toBe(expectedUrls.notifications);
expect(sut.getEventsUrl()).toBe(expectedUrls.events);
expect(sut.getScimUrl()).toBe(expectedUrls.scim);
expect(sut.getSendUrl()).toBe(expectedUrls.send);
expect(sut.getKeyConnectorUrl()).toBe(null);
expect(sut.isCloud()).toBe(true);
expect(sut.getUrls()).toEqual({
base: null,
cloudWebVault: undefined,
webVault: expectedUrls.webVault,
identity: expectedUrls.identity,
api: expectedUrls.api,
icons: expectedUrls.icons,
notifications: expectedUrls.notifications,
events: expectedUrls.events,
scim: expectedUrls.scim.replace("/v2", ""),
keyConnector: null,
});
});
it("gets global data", async () => {
const globalEnvironmentUrls = new EnvironmentUrls();
globalEnvironmentUrls.base = "https://global-url.example.com";
globalEnvironmentUrls.keyConnector = "https://global-key-connector.example.com";
setGlobalData(Region.SelfHosted, globalEnvironmentUrls);
const userEnvironmentUrls = new EnvironmentUrls();
userEnvironmentUrls.base = "https://user-url.example.com";
userEnvironmentUrls.keyConnector = "https://user-key-connector.example.com";
setUserData(Region.SelfHosted, userEnvironmentUrls);
await initialize({ switchUser: false });
expect(sut.getWebVaultUrl()).toBe("https://global-url.example.com");
expect(sut.getIdentityUrl()).toBe("https://global-url.example.com/identity");
expect(sut.getApiUrl()).toBe("https://global-url.example.com/api");
expect(sut.getIconsUrl()).toBe("https://global-url.example.com/icons");
expect(sut.getNotificationsUrl()).toBe("https://global-url.example.com/notifications");
expect(sut.getEventsUrl()).toBe("https://global-url.example.com/events");
expect(sut.getScimUrl()).toBe("https://global-url.example.com/scim/v2");
expect(sut.getSendUrl()).toBe("https://global-url.example.com/#/send/");
expect(sut.getKeyConnectorUrl()).toBe("https://global-key-connector.example.com");
expect(sut.isCloud()).toBe(false);
expect(sut.getUrls()).toEqual({
api: null,
base: "https://global-url.example.com",
cloudWebVault: undefined,
webVault: null,
events: null,
icons: null,
identity: null,
keyConnector: "https://global-key-connector.example.com",
notifications: null,
scim: null,
});
});
});
it("returns US defaults when not initialized", async () => {
setGlobalData(Region.EU, new EnvironmentUrls());
setUserData(Region.EU, new EnvironmentUrls());
expect(sut.initialized).toBe(false);
expect(sut.hasBaseUrl()).toBe(false);
expect(sut.getWebVaultUrl()).toBe("https://vault.bitwarden.com");
expect(sut.getIdentityUrl()).toBe("https://identity.bitwarden.com");
expect(sut.getApiUrl()).toBe("https://api.bitwarden.com");
expect(sut.getIconsUrl()).toBe("https://icons.bitwarden.net");
expect(sut.getNotificationsUrl()).toBe("https://notifications.bitwarden.com");
expect(sut.getEventsUrl()).toBe("https://events.bitwarden.com");
expect(sut.getScimUrl()).toBe("https://scim.bitwarden.com/v2");
expect(sut.getKeyConnectorUrl()).toBe(undefined);
expect(sut.isCloud()).toBe(true);
});
describe("setUrls", () => {
it("set just a base url", async () => {
await initialize({ switchUser: true });
await sut.setUrls({
base: "base.example.com",
});
const globalData = getGlobalData();
expect(globalData.region).toBe(Region.SelfHosted);
expect(globalData.urls).toEqual({
base: "https://base.example.com",
api: null,
identity: null,
webVault: null,
icons: null,
notifications: null,
events: null,
keyConnector: null,
});
});
it("sets all urls", async () => {
await initialize({ switchUser: true });
expect(sut.getScimUrl()).toBe("https://scim.bitwarden.com/v2");
await sut.setUrls({
base: "base.example.com",
api: "api.example.com",
identity: "identity.example.com",
webVault: "vault.example.com",
icons: "icons.example.com",
notifications: "notifications.example.com",
scim: "scim.example.com",
});
const globalData = getGlobalData();
expect(globalData.region).toBe(Region.SelfHosted);
expect(globalData.urls).toEqual({
base: "https://base.example.com",
api: "https://api.example.com",
identity: "https://identity.example.com",
webVault: "https://vault.example.com",
icons: "https://icons.example.com",
notifications: "https://notifications.example.com",
events: null,
keyConnector: null,
});
expect(sut.getScimUrl()).toBe("https://scim.example.com/v2");
});
});
describe("setRegion", () => {
it("sets the region on the global object even if there is a user.", async () => {
setGlobalData(Region.EU, new EnvironmentUrls());
setUserData(Region.EU, new EnvironmentUrls());
await initialize({ switchUser: true });
await sut.setRegion(Region.US);
const globalData = getGlobalData();
expect(globalData.region).toBe(Region.US);
});
});
describe("getHost", () => {
it.each([
{ region: Region.US, expectedHost: "bitwarden.com" },
{ region: Region.EU, expectedHost: "bitwarden.eu" },
])("gets it from user data if there is an active user", async ({ region, expectedHost }) => {
setGlobalData(Region.US, new EnvironmentUrls());
setUserData(region, new EnvironmentUrls());
await initialize({ switchUser: true });
const host = await sut.getHost();
expect(host).toBe(expectedHost);
});
it.each([
{ region: Region.US, expectedHost: "bitwarden.com" },
{ region: Region.EU, expectedHost: "bitwarden.eu" },
])("gets it from global data if there is no active user", async ({ region, expectedHost }) => {
setGlobalData(region, new EnvironmentUrls());
setUserData(Region.US, new EnvironmentUrls());
await initialize({ switchUser: false });
const host = await sut.getHost();
expect(host).toBe(expectedHost);
});
it.each([
{ region: Region.US, expectedHost: "bitwarden.com" },
{ region: Region.EU, expectedHost: "bitwarden.eu" },
])(
"gets it from global state if there is no active user even if a user id is passed in.",
async ({ region, expectedHost }) => {
setGlobalData(region, new EnvironmentUrls());
setUserData(Region.US, new EnvironmentUrls());
await initialize({ switchUser: false });
const host = await sut.getHost(testUser);
expect(host).toBe(expectedHost);
},
);
it.each([
{ region: Region.US, expectedHost: "bitwarden.com" },
{ region: Region.EU, expectedHost: "bitwarden.eu" },
])(
"gets it from the passed in userId if there is any active user: %s",
async ({ region, expectedHost }) => {
setGlobalData(Region.US, new EnvironmentUrls());
setUserData(Region.US, new EnvironmentUrls());
setUserData(region, new EnvironmentUrls(), alternateTestUser);
await initialize({ switchUser: true });
const host = await sut.getHost(alternateTestUser);
expect(host).toBe(expectedHost);
},
);
it("gets it from base url saved in self host config", async () => {
const globalSelfHostUrls = new EnvironmentUrls();
globalSelfHostUrls.base = "https://base.example.com";
setGlobalData(Region.SelfHosted, globalSelfHostUrls);
setUserData(Region.EU, new EnvironmentUrls());
await initialize({ switchUser: false });
const host = await sut.getHost();
expect(host).toBe("base.example.com");
});
it("gets it from webVault url saved in self host config", async () => {
const globalSelfHostUrls = new EnvironmentUrls();
globalSelfHostUrls.webVault = "https://vault.example.com";
globalSelfHostUrls.base = "https://base.example.com";
setGlobalData(Region.SelfHosted, globalSelfHostUrls);
setUserData(Region.EU, new EnvironmentUrls());
await initialize({ switchUser: false });
const host = await sut.getHost();
expect(host).toBe("vault.example.com");
});
it("gets it from saved self host config from passed in user when there is an active user", async () => {
setGlobalData(Region.US, new EnvironmentUrls());
setUserData(Region.EU, new EnvironmentUrls());
const selfHostUserUrls = new EnvironmentUrls();
selfHostUserUrls.base = "https://base.example.com";
setUserData(Region.SelfHosted, selfHostUserUrls, alternateTestUser);
await initialize({ switchUser: true });
const host = await sut.getHost(alternateTestUser);
expect(host).toBe("base.example.com");
});
});
describe("setUrlsFromStorage", () => {
it("will set the global data to Region US if no existing data", async () => {
await sut.setUrlsFromStorage();
expect(sut.getWebVaultUrl()).toBe("https://vault.bitwarden.com");
const globalData = getGlobalData();
expect(globalData.region).toBe(Region.US);
});
it("will set the urls to whatever is in global", async () => {
setGlobalData(Region.EU, new EnvironmentUrls());
await sut.setUrlsFromStorage();
expect(sut.getWebVaultUrl()).toBe("https://vault.bitwarden.eu");
});
it("recovers from previous bug", async () => {
const buggedEnvironmentUrls = new EnvironmentUrls();
buggedEnvironmentUrls.base = "https://vault.bitwarden.com";
buggedEnvironmentUrls.notifications = null;
setGlobalData(null, buggedEnvironmentUrls);
const urlEmission = firstValueFrom(sut.urls.pipe(timeout(100)));
await sut.setUrlsFromStorage();
await urlEmission;
const globalData = getGlobalData();
expect(globalData.region).toBe(Region.US);
expect(globalData.urls).toEqual({
base: null,
api: null,
identity: null,
events: null,
icons: null,
notifications: null,
keyConnector: null,
webVault: null,
});
});
it("will get urls from signed in user", async () => {
await switchUser(testUser);
const userUrls = new EnvironmentUrls();
userUrls.base = "base.example.com";
setUserData(Region.SelfHosted, userUrls);
await sut.setUrlsFromStorage();
expect(sut.getWebVaultUrl()).toBe("base.example.com");
});
});
describe("getCloudWebVaultUrl", () => {
it("no extra initialization, returns US vault", () => {
expect(sut.getCloudWebVaultUrl()).toBe("https://vault.bitwarden.com");
});
it.each([
{ region: Region.US, expectedVault: "https://vault.bitwarden.com" },
{ region: Region.EU, expectedVault: "https://vault.bitwarden.eu" },
{ region: Region.SelfHosted, expectedVault: "https://vault.bitwarden.com" },
])(
"no extra initialization, returns expected host for each region %s",
({ region, expectedVault }) => {
expect(sut.setCloudWebVaultUrl(region));
expect(sut.getCloudWebVaultUrl()).toBe(expectedVault);
},
);
});
});

View File

@@ -1,418 +0,0 @@
import {
concatMap,
distinctUntilChanged,
firstValueFrom,
map,
Observable,
ReplaySubject,
} from "rxjs";
import { AccountService } from "../../auth/abstractions/account.service";
import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
import { UserId } from "../../types/guid";
import {
EnvironmentService as EnvironmentServiceAbstraction,
Region,
RegionDomain,
Urls,
} from "../abstractions/environment.service";
import { Utils } from "../misc/utils";
import { ENVIRONMENT_DISK, GlobalState, KeyDefinition, StateProvider } from "../state";
const REGION_KEY = new KeyDefinition<Region>(ENVIRONMENT_DISK, "region", {
deserializer: (s) => s,
});
const URLS_KEY = new KeyDefinition<EnvironmentUrls>(ENVIRONMENT_DISK, "urls", {
deserializer: EnvironmentUrls.fromJSON,
});
export class EnvironmentService implements EnvironmentServiceAbstraction {
private readonly urlsSubject = new ReplaySubject<void>(1);
urls: Observable<void> = this.urlsSubject.asObservable();
selectedRegion?: Region;
initialized = false;
protected baseUrl: string;
protected webVaultUrl: string;
protected apiUrl: string;
protected identityUrl: string;
protected iconsUrl: string;
protected notificationsUrl: string;
protected eventsUrl: string;
private keyConnectorUrl: string;
private scimUrl: string = null;
private cloudWebVaultUrl: string;
private regionGlobalState: GlobalState<Region | null>;
private urlsGlobalState: GlobalState<EnvironmentUrls | null>;
private activeAccountId$: Observable<UserId | null>;
readonly usUrls: Urls = {
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",
};
readonly euUrls: Urls = {
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",
};
constructor(
private stateProvider: StateProvider,
private accountService: AccountService,
) {
// We intentionally don't want the helper on account service, we want the null back if there is no active user
this.activeAccountId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id));
// TODO: Get rid of early subscription during EnvironmentService refactor
this.activeAccountId$
.pipe(
// Use == here to not trigger on undefined -> null transition
distinctUntilChanged((oldUserId: string, newUserId: string) => oldUserId == newUserId),
concatMap(async () => {
if (!this.initialized) {
return;
}
await this.setUrlsFromStorage();
}),
)
.subscribe();
this.regionGlobalState = this.stateProvider.getGlobal(REGION_KEY);
this.urlsGlobalState = this.stateProvider.getGlobal(URLS_KEY);
}
hasBaseUrl() {
return this.baseUrl != null;
}
getNotificationsUrl() {
if (this.notificationsUrl != null) {
return this.notificationsUrl;
}
if (this.baseUrl != null) {
return this.baseUrl + "/notifications";
}
return "https://notifications.bitwarden.com";
}
getWebVaultUrl() {
if (this.webVaultUrl != null) {
return this.webVaultUrl;
}
if (this.baseUrl) {
return this.baseUrl;
}
return "https://vault.bitwarden.com";
}
getCloudWebVaultUrl() {
if (this.cloudWebVaultUrl != null) {
return this.cloudWebVaultUrl;
}
return this.usUrls.webVault;
}
setCloudWebVaultUrl(region: Region) {
switch (region) {
case Region.EU:
this.cloudWebVaultUrl = this.euUrls.webVault;
break;
case Region.US:
default:
this.cloudWebVaultUrl = this.usUrls.webVault;
break;
}
}
getSendUrl() {
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
? "https://send.bitwarden.com/#"
: this.getWebVaultUrl() + "/#/send/";
}
getIconsUrl() {
if (this.iconsUrl != null) {
return this.iconsUrl;
}
if (this.baseUrl) {
return this.baseUrl + "/icons";
}
return "https://icons.bitwarden.net";
}
getApiUrl() {
if (this.apiUrl != null) {
return this.apiUrl;
}
if (this.baseUrl) {
return this.baseUrl + "/api";
}
return "https://api.bitwarden.com";
}
getIdentityUrl() {
if (this.identityUrl != null) {
return this.identityUrl;
}
if (this.baseUrl) {
return this.baseUrl + "/identity";
}
return "https://identity.bitwarden.com";
}
getEventsUrl() {
if (this.eventsUrl != null) {
return this.eventsUrl;
}
if (this.baseUrl) {
return this.baseUrl + "/events";
}
return "https://events.bitwarden.com";
}
getKeyConnectorUrl() {
return this.keyConnectorUrl;
}
getScimUrl() {
if (this.scimUrl != null) {
return this.scimUrl + "/v2";
}
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
? "https://scim.bitwarden.com/v2"
: this.getWebVaultUrl() + "/scim/v2";
}
async setUrlsFromStorage(): Promise<void> {
const activeUserId = await firstValueFrom(this.activeAccountId$);
const region = await this.getRegion(activeUserId);
const savedUrls = await this.getEnvironmentUrls(activeUserId);
const envUrls = new EnvironmentUrls();
// In release `2023.5.0`, we set the `base` property of the environment URLs to the US web vault URL when a user clicked the "US" region.
// This check will detect these cases and convert them to the proper region instead.
// We are detecting this by checking for the presence of the web vault URL in the `base` and the absence of the `notifications` property.
// This is because the `notifications` will not be `null` in the web vault, and we don't want to migrate the URLs in that case.
if (savedUrls.base === "https://vault.bitwarden.com" && savedUrls.notifications == null) {
await this.setRegion(Region.US);
return;
}
switch (region) {
case Region.EU:
await this.setRegion(Region.EU);
return;
case Region.US:
await this.setRegion(Region.US);
return;
case Region.SelfHosted:
case null:
default:
this.baseUrl = envUrls.base = savedUrls.base;
this.webVaultUrl = savedUrls.webVault;
this.apiUrl = envUrls.api = savedUrls.api;
this.identityUrl = envUrls.identity = savedUrls.identity;
this.iconsUrl = savedUrls.icons;
this.notificationsUrl = savedUrls.notifications;
this.eventsUrl = envUrls.events = savedUrls.events;
this.keyConnectorUrl = savedUrls.keyConnector;
await this.setRegion(Region.SelfHosted);
// scimUrl is not saved to storage
this.urlsSubject.next();
break;
}
}
async setUrls(urls: Urls): Promise<Urls> {
urls.base = this.formatUrl(urls.base);
urls.webVault = this.formatUrl(urls.webVault);
urls.api = this.formatUrl(urls.api);
urls.identity = this.formatUrl(urls.identity);
urls.icons = this.formatUrl(urls.icons);
urls.notifications = this.formatUrl(urls.notifications);
urls.events = this.formatUrl(urls.events);
urls.keyConnector = this.formatUrl(urls.keyConnector);
// scimUrl cannot be cleared
urls.scim = this.formatUrl(urls.scim) ?? this.scimUrl;
// Don't save scim url
await this.urlsGlobalState.update(() => ({
base: urls.base,
api: urls.api,
identity: urls.identity,
webVault: urls.webVault,
icons: urls.icons,
notifications: urls.notifications,
events: urls.events,
keyConnector: urls.keyConnector,
}));
this.baseUrl = urls.base;
this.webVaultUrl = urls.webVault;
this.apiUrl = urls.api;
this.identityUrl = urls.identity;
this.iconsUrl = urls.icons;
this.notificationsUrl = urls.notifications;
this.eventsUrl = urls.events;
this.keyConnectorUrl = urls.keyConnector;
this.scimUrl = urls.scim;
await this.setRegion(Region.SelfHosted);
this.urlsSubject.next();
return urls;
}
getUrls() {
return {
base: this.baseUrl,
webVault: this.webVaultUrl,
cloudWebVault: this.cloudWebVaultUrl,
api: this.apiUrl,
identity: this.identityUrl,
icons: this.iconsUrl,
notifications: this.notificationsUrl,
events: this.eventsUrl,
keyConnector: this.keyConnectorUrl,
scim: this.scimUrl,
};
}
isEmpty(): boolean {
return (
this.baseUrl == null &&
this.webVaultUrl == null &&
this.apiUrl == null &&
this.identityUrl == null &&
this.iconsUrl == null &&
this.notificationsUrl == null &&
this.eventsUrl == null
);
}
async getHost(userId?: UserId) {
const region = await this.getRegion(userId);
switch (region) {
case Region.US:
return RegionDomain.US;
case Region.EU:
return RegionDomain.EU;
default: {
// Environment is self-hosted
const envUrls = await this.getEnvironmentUrls(userId);
return Utils.getHost(envUrls.webVault || envUrls.base);
}
}
}
private async getRegion(userId: UserId | null) {
// Previous rules dictated that we only get from user scoped state if there is an active user.
const activeUserId = await firstValueFrom(this.activeAccountId$);
return activeUserId == null
? await firstValueFrom(this.regionGlobalState.state$)
: await firstValueFrom(this.stateProvider.getUser(userId ?? activeUserId, REGION_KEY).state$);
}
private async getEnvironmentUrls(userId: UserId | null) {
return userId == null
? (await firstValueFrom(this.urlsGlobalState.state$)) ?? new EnvironmentUrls()
: (await firstValueFrom(this.stateProvider.getUser(userId, URLS_KEY).state$)) ??
new EnvironmentUrls();
}
async setRegion(region: Region) {
this.selectedRegion = region;
await this.regionGlobalState.update(() => region);
if (region === Region.SelfHosted) {
// If user saves a self-hosted region with empty fields, default to US
if (this.isEmpty()) {
await this.setRegion(Region.US);
}
} else {
// If we are setting the region to EU or US, clear the self-hosted URLs
await this.urlsGlobalState.update(() => new EnvironmentUrls());
if (region === Region.EU) {
this.setUrlsInternal(this.euUrls);
} else if (region === Region.US) {
this.setUrlsInternal(this.usUrls);
}
}
}
async seedUserEnvironment(userId: UserId) {
const globalRegion = await firstValueFrom(this.regionGlobalState.state$);
const globalUrls = await firstValueFrom(this.urlsGlobalState.state$);
await this.stateProvider.getUser(userId, REGION_KEY).update(() => globalRegion);
await this.stateProvider.getUser(userId, URLS_KEY).update(() => globalUrls);
}
private setUrlsInternal(urls: Urls) {
this.baseUrl = this.formatUrl(urls.base);
this.webVaultUrl = this.formatUrl(urls.webVault);
this.apiUrl = this.formatUrl(urls.api);
this.identityUrl = this.formatUrl(urls.identity);
this.iconsUrl = this.formatUrl(urls.icons);
this.notificationsUrl = this.formatUrl(urls.notifications);
this.eventsUrl = this.formatUrl(urls.events);
this.keyConnectorUrl = this.formatUrl(urls.keyConnector);
// scimUrl cannot be cleared
this.scimUrl = this.formatUrl(urls.scim) ?? this.scimUrl;
this.urlsSubject.next();
}
private formatUrl(url: string): string {
if (url == null || url === "") {
return null;
}
url = url.replace(/\/+$/g, "");
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://" + url;
}
return url.trim();
}
isCloud(): boolean {
return [
"https://api.bitwarden.com",
"https://vault.bitwarden.com/api",
"https://api.bitwarden.eu",
"https://vault.bitwarden.eu/api",
].includes(this.getApiUrl());
}
}

View File

@@ -77,6 +77,7 @@ export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk");
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory");
export const THEMING_DISK = new StateDefinition("theming", "disk", { web: "disk-local" });
export const TRANSLATION_DISK = new StateDefinition("translation", "disk");