[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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
88 changed files with 1710 additions and 1379 deletions

View File

@ -67,7 +67,7 @@
"pathGroupsExcludedImportTypes": ["builtin"]
}
],
"rxjs-angular/prefer-takeuntil": "error",
"rxjs-angular/prefer-takeuntil": ["error", { "alias": ["takeUntilDestroyed"] }],
"rxjs/no-exposed-subjects": ["error", { "allowProtected": true }],
"no-restricted-syntax": [
"error",

View File

@ -2386,12 +2386,6 @@
"message": "EU",
"description": "European Union"
},
"usDomain": {
"message": "bitwarden.com"
},
"euDomain": {
"message": "bitwarden.eu"
},
"accessDenied": {
"message": "Access denied. You do not have permission to view this page."
},

View File

@ -65,7 +65,7 @@ export class AccountSwitcherService {
name: account.name ?? account.email,
email: account.email,
id: id,
server: await this.environmentService.getHost(id),
server: (await this.environmentService.getEnvironment(id))?.getHostname(),
status: account.status,
isActive: id === activeAccount?.id,
avatarColor: await firstValueFrom(

View File

@ -94,10 +94,6 @@ export class HomeComponent implements OnInit, OnDestroy {
this.router.navigate(["login"], { queryParams: { email: this.formGroup.value.email } });
}
get selfHostedDomain() {
return this.environmentService.hasBaseUrl() ? this.environmentService.getWebVaultUrl() : null;
}
setFormValues() {
this.loginService.setEmail(this.formGroup.value.email);
this.loginService.setRememberEmail(this.formGroup.value.rememberEmail);

View File

@ -1,6 +1,7 @@
import { Component, NgZone } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/auth/components/login.component";
import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service";
@ -114,7 +115,8 @@ export class LoginComponent extends BaseLoginComponent {
await this.ssoLoginService.setCodeVerifier(codeVerifier);
await this.ssoLoginService.setSsoState(state);
let url = this.environmentService.getWebVaultUrl();
const env = await firstValueFrom(this.environmentService.environment$);
let url = env.getWebVaultUrl();
if (url == null) {
url = "https://vault.bitwarden.com";
}

View File

@ -1,4 +1,5 @@
import { Component, Inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { SsoComponent as BaseSsoComponent } from "@bitwarden/angular/auth/components/sso.component";
@ -64,9 +65,9 @@ export class SsoComponent extends BaseSsoComponent {
configService,
);
const url = this.environmentService.getWebVaultUrl();
this.redirectUri = url + "/sso-connector.html";
environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => {
this.redirectUri = env.getWebVaultUrl() + "/sso-connector.html";
});
this.clientId = "browser";
super.onSuccessfulLogin = async () => {

View File

@ -1,6 +1,6 @@
import { Component, Inject } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, Subscription } from "rxjs";
import { Subject, Subscription, firstValueFrom } from "rxjs";
import { filter, first, takeUntil } from "rxjs/operators";
import { TwoFactorComponent as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor.component";
@ -225,7 +225,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
}
}
override launchDuoFrameless() {
override async launchDuoFrameless() {
const duoHandOffMessage = {
title: this.i18nService.t("youSuccessfullyLoggedIn"),
message: this.i18nService.t("youMayCloseThisWindow"),
@ -234,8 +234,9 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
// we're using the connector here as a way to set a cookie with translations
// before continuing to the duo frameless url
const env = await firstValueFrom(this.environmentService.environment$);
const launchUrl =
this.environmentService.getWebVaultUrl() +
env.getWebVaultUrl() +
"/duo-redirect-connector.html" +
"?duoFramelessUrl=" +
encodeURIComponent(this.duoFramelessUrl) +

View File

@ -113,7 +113,7 @@ type NotificationBackgroundExtensionMessageHandlers = {
bgGetEnableChangedPasswordPrompt: () => Promise<boolean>;
bgGetEnableAddedLoginPrompt: () => Promise<boolean>;
bgGetExcludedDomains: () => Promise<NeverDomains>;
getWebVaultUrlForNotification: () => string;
getWebVaultUrlForNotification: () => Promise<string>;
};
export {

View File

@ -1,13 +1,14 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsService } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@ -1348,16 +1349,21 @@ describe("NotificationBackground", () => {
const message: NotificationBackgroundExtensionMessage = {
command: "getWebVaultUrlForNotification",
};
const webVaultUrl = "https://example.com";
const env = new SelfHostedEnvironment({ webVault: "https://example.com" });
Object.defineProperty(environmentService, "environment$", {
configurable: true,
get: () => null,
});
const environmentServiceSpy = jest
.spyOn(environmentService, "getWebVaultUrl")
.mockReturnValueOnce(webVaultUrl);
.spyOn(environmentService as any, "environment$", "get")
.mockReturnValue(new BehaviorSubject(env).asObservable());
sendExtensionRuntimeMessage(message);
await flushPromises();
expect(environmentServiceSpy).toHaveBeenCalled();
expect(environmentServiceSpy).toHaveReturnedWith(webVaultUrl);
});
});
});

View File

@ -165,6 +165,7 @@ export default class NotificationBackground {
notificationQueueMessage: NotificationQueueMessageItem,
) {
const notificationType = notificationQueueMessage.type;
const typeData: Record<string, any> = {
isVaultLocked: notificationQueueMessage.wasVaultLocked,
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
@ -655,8 +656,9 @@ export default class NotificationBackground {
return await firstValueFrom(this.folderService.folderViews$);
}
private getWebVaultUrl(): string {
return this.environmentService.getWebVaultUrl();
private async getWebVaultUrl(): Promise<string> {
const env = await firstValueFrom(this.environmentService.environment$);
return env.getWebVaultUrl();
}
private async removeIndividualVault(): Promise<boolean> {

View File

@ -1,5 +1,5 @@
import { mock, mockReset } from "jest-mock-extended";
import { of } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
@ -12,9 +12,13 @@ import {
DefaultDomainSettingsService,
DomainSettingsService,
} from "@bitwarden/common/autofill/services/domain-settings.service";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { CloudEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
import { I18nService } from "@bitwarden/common/platform/services/i18n.service";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import {
@ -48,8 +52,6 @@ import {
import OverlayBackground from "./overlay.background";
const iconServerUrl = "https://icons.bitwarden.com/";
describe("OverlayBackground", () => {
const mockUserId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
@ -61,9 +63,15 @@ describe("OverlayBackground", () => {
const cipherService = mock<CipherService>();
const autofillService = mock<AutofillService>();
const authService = mock<AuthService>();
const environmentService = mock<EnvironmentService>({
getIconsUrl: () => iconServerUrl,
});
const environmentService = mock<EnvironmentService>();
environmentService.environment$ = new BehaviorSubject(
new CloudEnvironment({
key: Region.US,
domain: "bitwarden.com",
urls: { icons: "https://icons.bitwarden.com/" },
}),
);
const stateService = mock<BrowserStateService>();
const autofillSettingsService = mock<AutofillSettingsService>();
const i18nService = mock<I18nService>();

View File

@ -53,7 +53,7 @@ class OverlayBackground implements OverlayBackgroundInterface {
private overlayListPort: chrome.runtime.Port;
private focusedFieldData: FocusedFieldData;
private overlayPageTranslations: Record<string, string>;
private readonly iconsServerUrl: string;
private iconsServerUrl: string;
private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = {
openAutofillOverlay: () => this.openOverlay(false),
autofillOverlayElementClosed: ({ message }) => this.overlayElementClosed(message),
@ -98,9 +98,7 @@ class OverlayBackground implements OverlayBackgroundInterface {
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private themeStateService: ThemeStateService,
) {
this.iconsServerUrl = this.environmentService.getIconsUrl();
}
) {}
/**
* Removes cached page details for a tab
@ -118,6 +116,8 @@ class OverlayBackground implements OverlayBackgroundInterface {
*/
async init() {
this.setupExtensionMessageListeners();
const env = await firstValueFrom(this.environmentService.environment$);
this.iconsServerUrl = env.getIconsUrl();
await this.getOverlayVisibility();
await this.getAuthStatus();
}

View File

@ -613,6 +613,7 @@ export default class MainBackground {
this.authService,
this.environmentService,
this.logService,
this.stateProvider,
true,
);
@ -1032,10 +1033,6 @@ export default class MainBackground {
return new Promise<void>((resolve) => {
setTimeout(async () => {
await this.environmentService.setUrlsFromStorage();
// Workaround to ignore stateService.activeAccount until URLs are set
// TODO: Remove this when implementing ticket PM-2637
this.environmentService.initialized = true;
if (!this.isPrivateMode) {
await this.refreshBadge();
}

View File

@ -1,3 +1,5 @@
import { firstValueFrom } from "rxjs";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
@ -220,7 +222,8 @@ export default class RuntimeBackground {
}
break;
case "authResult": {
const vaultUrl = this.environmentService.getWebVaultUrl();
const env = await firstValueFrom(this.environmentService.environment$);
const vaultUrl = env.getWebVaultUrl();
if (msg.referrer == null || Utils.getHostname(vaultUrl) !== msg.referrer) {
return;
@ -241,7 +244,8 @@ export default class RuntimeBackground {
break;
}
case "webAuthnResult": {
const vaultUrl = this.environmentService.getWebVaultUrl();
const env = await firstValueFrom(this.environmentService.environment$);
const vaultUrl = env.getWebVaultUrl();
if (msg.referrer == null || Utils.getHostname(vaultUrl) !== msg.referrer) {
return;
@ -364,7 +368,8 @@ export default class RuntimeBackground {
async sendBwInstalledMessageToVault() {
try {
const vaultUrl = this.environmentService.getWebVaultUrl();
const env = await firstValueFrom(this.environmentService.environment$);
const vaultUrl = env.getWebVaultUrl();
const urlObj = new URL(vaultUrl);
const tabs = await BrowserApi.tabsQuery({ url: `${urlObj.href}*` });

View File

@ -13,6 +13,7 @@ import {
} from "./environment-service.factory";
import { FactoryOptions, CachedServices, factory } from "./factory-options";
import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory";
import { stateProviderFactory } from "./state-provider.factory";
import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory";
type ConfigServiceFactoryOptions = FactoryOptions & {
@ -43,6 +44,7 @@ export function configServiceFactory(
await authServiceFactory(cache, opts),
await environmentServiceFactory(cache, opts),
await logServiceFactory(cache, opts),
await stateProviderFactory(cache, opts),
opts.configServiceOptions?.subscribe ?? true,
),
);

View File

@ -7,6 +7,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ConfigService } from "@bitwarden/common/platform/services/config/config.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { browserSession, sessionSync } from "../decorators/session-sync-observable";
@ -21,8 +22,17 @@ export class BrowserConfigService extends ConfigService {
authService: AuthService,
environmentService: EnvironmentService,
logService: LogService,
stateProvider: StateProvider,
subscribe = false,
) {
super(stateService, configApiService, authService, environmentService, logService, subscribe);
super(
stateService,
configApiService,
authService,
environmentService,
logService,
stateProvider,
subscribe,
);
}
}

View File

@ -1,12 +1,15 @@
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { Region } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { GroupPolicyEnvironment } from "../../admin-console/types/group-policy-environment";
import { devFlagEnabled, devFlagValue } from "../flags";
export class BrowserEnvironmentService extends EnvironmentService {
export class BrowserEnvironmentService extends DefaultEnvironmentService {
constructor(
private logService: LogService,
stateProvider: StateProvider,
@ -29,16 +32,18 @@ export class BrowserEnvironmentService extends EnvironmentService {
return false;
}
const env = await this.getManagedEnvironment();
const managedEnv = await this.getManagedEnvironment();
const env = await firstValueFrom(this.environment$);
const urls = env.getUrls();
return (
env.base != this.baseUrl ||
env.webVault != this.webVaultUrl ||
env.api != this.webVaultUrl ||
env.identity != this.identityUrl ||
env.icons != this.iconsUrl ||
env.notifications != this.notificationsUrl ||
env.events != this.eventsUrl
managedEnv.base != urls.base ||
managedEnv.webVault != urls.webVault ||
managedEnv.api != urls.api ||
managedEnv.identity != urls.identity ||
managedEnv.icons != urls.icons ||
managedEnv.notifications != urls.notifications ||
managedEnv.events != urls.events
);
}
@ -62,7 +67,7 @@ export class BrowserEnvironmentService extends EnvironmentService {
async setUrlsToManagedEnvironment() {
const env = await this.getManagedEnvironment();
await this.setUrls({
await this.setEnvironment(Region.SelfHosted, {
base: env.base,
webVault: env.webVault,
api: env.api,

View File

@ -222,12 +222,12 @@ function getBgService<T>(service: keyof MainBackground) {
},
{
provide: BrowserEnvironmentService,
useExisting: EnvironmentService,
useClass: BrowserEnvironmentService,
deps: [LogService, StateProvider, AccountServiceAbstraction],
},
{
provide: EnvironmentService,
useFactory: getBgService<EnvironmentService>("environmentService"),
deps: [],
useExisting: BrowserEnvironmentService,
},
{ provide: TotpService, useFactory: getBgService<TotpService>("totpService"), deps: [] },
{
@ -480,6 +480,7 @@ function getBgService<T>(service: keyof MainBackground) {
ConfigApiServiceAbstraction,
AuthServiceAbstraction,
EnvironmentService,
StateProvider,
LogService,
],
},

View File

@ -6,33 +6,33 @@
<div bitDialogContent>
<p>&copy; Bitwarden Inc. 2015-{{ year }}</p>
<p>{{ "version" | i18n }}: {{ version }}</p>
<ng-container *ngIf="serverConfig$ | async as serverConfig">
<p *ngIf="isCloud">
{{ "serverVersion" | i18n }}: {{ this.serverConfig?.version }}
<span *ngIf="!serverConfig.isValid()">
({{ "lastSeenOn" | i18n: (serverConfig.utcDate | date: "mediumDate") }})
<ng-container *ngIf="data$ | async as data">
<p *ngIf="data.isCloud">
{{ "serverVersion" | i18n }}: {{ data.serverConfig?.version }}
<span *ngIf="!data.serverConfig.isValid()">
({{ "lastSeenOn" | i18n: (data.serverConfig.utcDate | date: "mediumDate") }})
</span>
</p>
<ng-container *ngIf="!isCloud">
<ng-container *ngIf="serverConfig.server">
<ng-container *ngIf="!data.isCloud">
<ng-container *ngIf="data.serverConfig.server">
<p>
{{ "serverVersion" | i18n }} <small>({{ "thirdParty" | i18n }})</small>:
{{ this.serverConfig?.version }}
<span *ngIf="!serverConfig.isValid()">
({{ "lastSeenOn" | i18n: (serverConfig.utcDate | date: "mediumDate") }})
{{ data.serverConfig?.version }}
<span *ngIf="!data.serverConfig.isValid()">
({{ "lastSeenOn" | i18n: (data.serverConfig.utcDate | date: "mediumDate") }})
</span>
</p>
<div>
<small>{{ "thirdPartyServerMessage" | i18n: serverConfig.server?.name }}</small>
<small>{{ "thirdPartyServerMessage" | i18n: data.serverConfig.server?.name }}</small>
</div>
</ng-container>
<p *ngIf="!serverConfig.server">
<p *ngIf="!data.serverConfig.server">
{{ "serverVersion" | i18n }} <small>({{ "selfHostedServer" | i18n }})</small>:
{{ this.serverConfig?.version }}
<span *ngIf="!serverConfig.isValid()">
({{ "lastSeenOn" | i18n: (serverConfig.utcDate | date: "mediumDate") }})
{{ data.serverConfig?.version }}
<span *ngIf="!data.serverConfig.isValid()">
({{ "lastSeenOn" | i18n: (data.serverConfig.utcDate | date: "mediumDate") }})
</span>
</p>
</ng-container>

View File

@ -1,10 +1,9 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { Observable } from "rxjs";
import { combineLatest, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { ButtonModule, DialogModule } from "@bitwarden/components";
@ -16,11 +15,13 @@ import { BrowserApi } from "../../platform/browser/browser-api";
imports: [CommonModule, JslibModule, DialogModule, ButtonModule],
})
export class AboutComponent {
protected serverConfig$: Observable<ServerConfig> = this.configService.serverConfig$;
protected year = new Date().getFullYear();
protected version = BrowserApi.getApplicationVersion();
protected isCloud = this.environmentService.isCloud();
protected data$ = combineLatest([
this.configService.serverConfig$,
this.environmentService.environment$.pipe(map((env) => env.isCloud())),
]).pipe(map(([serverConfig, isCloud]) => ({ serverConfig, isCloud })));
constructor(
private configService: ConfigServiceAbstraction,

View File

@ -446,9 +446,8 @@ export class SettingsComponent implements OnInit {
type: "info",
});
if (confirmed) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
BrowserApi.createNewTab(this.environmentService.getWebVaultUrl());
const env = await firstValueFrom(this.environmentService.environment$);
await BrowserApi.createNewTab(env.getWebVaultUrl());
}
}
@ -479,10 +478,9 @@ export class SettingsComponent implements OnInit {
}
async webVault() {
const url = this.environmentService.getWebVaultUrl();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
BrowserApi.createNewTab(url);
const env = await firstValueFrom(this.environmentService.environment$);
const url = env.getWebVaultUrl();
await BrowserApi.createNewTab(url);
}
async import() {

View File

@ -690,6 +690,8 @@ export class LoginCommand {
codeChallenge: string,
state: string,
): Promise<{ ssoCode: string; orgIdentifier: string }> {
const env = await firstValueFrom(this.environmentService.environment$);
return new Promise((resolve, reject) => {
const callbackServer = http.createServer((req, res) => {
const urlString = "http://localhost" + req.url;
@ -724,7 +726,7 @@ export class LoginCommand {
}
});
let foundPort = false;
const webUrl = this.environmentService.getWebVaultUrl();
const webUrl = env.getWebVaultUrl();
for (let port = 8065; port <= 8070; port++) {
try {
this.ssoRedirectUri = "http://localhost:" + port;

View File

@ -47,6 +47,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { ClientType } from "@bitwarden/common/enums";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service";
import {
BiometricStateService,
@ -62,7 +63,7 @@ import { ConfigApiService } from "@bitwarden/common/platform/services/config/con
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { CryptoService } from "@bitwarden/common/platform/services/crypto.service";
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service";
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
@ -312,7 +313,10 @@ export class Main {
this.derivedStateProvider,
);
this.environmentService = new EnvironmentService(this.stateProvider, this.accountService);
this.environmentService = new DefaultEnvironmentService(
this.stateProvider,
this.accountService,
);
this.tokenService = new TokenService(
this.singleUserStateProvider,
@ -504,6 +508,7 @@ export class Main {
this.authService,
this.environmentService,
this.logService,
this.stateProvider,
true,
);
@ -703,7 +708,6 @@ export class Main {
await this.storageService.init();
await this.stateService.init();
this.containerService.attachToGlobal(global);
await this.environmentService.setUrlsFromStorage();
await this.i18nService.init();
this.twoFactorService.init();
this.configService.init();

View File

@ -1,6 +1,10 @@
import { OptionValues } from "commander";
import { firstValueFrom } from "rxjs";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { Response } from "../models/response";
import { MessageResponse } from "../models/response/message.response";
@ -29,16 +33,15 @@ export class ConfigCommand {
!options.notifications &&
!options.events
) {
const env = await firstValueFrom(this.environmentService.environment$);
const stringRes = new StringResponse(
this.environmentService.hasBaseUrl()
? this.environmentService.getUrls().base
: "https://bitwarden.com",
env.hasBaseUrl() ? env.getUrls().base : "https://bitwarden.com",
);
return Response.success(stringRes);
}
url = url === "null" || url === "bitwarden.com" || url === "https://bitwarden.com" ? null : url;
await this.environmentService.setUrls({
await this.environmentService.setEnvironment(Region.SelfHosted, {
base: url,
webVault: options.webVault || null,
api: options.api || null,

View File

@ -1,8 +1,12 @@
import * as inquirer from "inquirer";
import { firstValueFrom } from "rxjs";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { Response } from "../models/response";
@ -67,9 +71,10 @@ export class ConvertToKeyConnectorCommand {
await this.keyConnectorService.setUsesKeyConnector(true);
// Update environment URL - required for api key login
const urls = this.environmentService.getUrls();
const env = await firstValueFrom(this.environmentService.environment$);
const urls = env.getUrls();
urls.keyConnector = organization.keyConnectorUrl;
await this.environmentService.setUrls(urls);
await this.environmentService.setEnvironment(Region.SelfHosted, urls);
return Response.success();
} else if (answer.convert === "leave") {

View File

@ -1,3 +1,5 @@
import { firstValueFrom } from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@ -17,7 +19,7 @@ export class StatusCommand {
async run(): Promise<Response> {
try {
const baseUrl = this.baseUrl();
const baseUrl = await this.baseUrl();
const status = await this.status();
const lastSync = await this.syncService.getLastSync();
const userId = await this.stateService.getUserId();
@ -37,8 +39,9 @@ export class StatusCommand {
}
}
private baseUrl(): string {
return this.envService.getUrls().base;
private async baseUrl(): Promise<string> {
const env = await firstValueFrom(this.envService.environment$);
return env.getUrls().base;
}
private async status(): Promise<"unauthenticated" | "locked" | "unlocked"> {

View File

@ -127,7 +127,8 @@ export class SendCreateCommand {
await this.sendApiService.save([encSend, fileData]);
const newSend = await this.sendService.getFromState(encSend.id);
const decSend = await newSend.decrypt();
const res = new SendResponse(decSend, this.environmentService.getWebVaultUrl());
const env = await firstValueFrom(this.environmentService.environment$);
const res = new SendResponse(decSend, env.getWebVaultUrl());
return Response.success(res);
} catch (e) {
return Response.error(e);

View File

@ -1,4 +1,5 @@
import { OptionValues } from "commander";
import { firstValueFrom } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
@ -32,7 +33,8 @@ export class SendGetCommand extends DownloadCommand {
return Response.notFound();
}
const webVaultUrl = this.environmentService.getWebVaultUrl();
const env = await firstValueFrom(this.environmentService.environment$);
const webVaultUrl = env.getWebVaultUrl();
let filter = (s: SendView) => true;
let selector = async (s: SendView): Promise<Response> =>
Response.success(new SendResponse(s, webVaultUrl));

View File

@ -1,3 +1,5 @@
import { firstValueFrom } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
@ -21,7 +23,8 @@ export class SendListCommand {
sends = this.searchService.searchSends(sends, normalizedOptions.search);
}
const webVaultUrl = this.environmentService.getWebVaultUrl();
const env = await firstValueFrom(this.environmentService.environment$);
const webVaultUrl = env.getWebVaultUrl();
const res = new ListResponse(sends.map((s) => new SendResponse(s, webVaultUrl)));
return Response.success(res);
}

View File

@ -1,5 +1,6 @@
import { OptionValues } from "commander";
import * as inquirer from "inquirer";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
@ -46,7 +47,7 @@ export class SendReceiveCommand extends DownloadCommand {
return Response.badRequest("Failed to parse the provided Send url");
}
const apiUrl = this.getApiUrl(urlObject);
const apiUrl = await this.getApiUrl(urlObject);
const [id, key] = this.getIdAndKey(urlObject);
if (Utils.isNullOrWhitespace(id) || Utils.isNullOrWhitespace(key)) {
@ -108,8 +109,9 @@ export class SendReceiveCommand extends DownloadCommand {
return [result[0], result[1]];
}
private getApiUrl(url: URL) {
const urls = this.environmentService.getUrls();
private async getApiUrl(url: URL) {
const env = await firstValueFrom(this.environmentService.environment$);
const urls = env.getUrls();
if (url.origin === "https://send.bitwarden.com") {
return "https://api.bitwarden.com";
} else if (url.origin === urls.api) {

View File

@ -1,3 +1,5 @@
import { firstValueFrom } from "rxjs";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { SendService } from "@bitwarden/common/tools/send/services//send.service.abstraction";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
@ -18,7 +20,8 @@ export class SendRemovePasswordCommand {
const updatedSend = await this.sendService.get(id);
const decSend = await updatedSend.decrypt();
const webVaultUrl = this.environmentService.getWebVaultUrl();
const env = await firstValueFrom(this.environmentService.environment$);
const webVaultUrl = env.getWebVaultUrl();
const res = new SendResponse(decSend, webVaultUrl);
return Response.success(res);
} catch (e) {

View File

@ -105,7 +105,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
name: (await this.tokenService.getName()) ?? (await this.tokenService.getEmail()),
email: await this.tokenService.getEmail(),
avatarColor: await firstValueFrom(this.avatarService.avatarColor$),
server: await this.environmentService.getHost(),
server: (await this.environmentService.getEnvironment())?.getHostname(),
};
} catch {
this.activeAccount = undefined;
@ -158,7 +158,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
email: baseAccounts[userId].profile.email,
authenticationStatus: await this.authService.getAuthStatus(userId),
avatarColor: await firstValueFrom(this.avatarService.getUserAvatarColor$(userId as UserId)),
server: await this.environmentService.getHost(userId),
server: (await this.environmentService.getEnvironment(userId))?.getHostname(),
};
}

View File

@ -8,7 +8,6 @@ import { NotificationsService as NotificationsServiceAbstraction } from "@bitwar
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EnvironmentService as EnvironmentServiceAbstraction } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
@ -25,7 +24,6 @@ import { NativeMessagingService } from "../../services/native-messaging.service"
export class InitService {
constructor(
@Inject(WINDOW) private win: Window,
private environmentService: EnvironmentServiceAbstraction,
private syncService: SyncServiceAbstraction,
private vaultTimeoutService: VaultTimeoutService,
private i18nService: I18nServiceAbstraction,
@ -46,10 +44,6 @@ export class InitService {
return async () => {
this.nativeMessagingService.init();
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process
await this.environmentService.setUrlsFromStorage();
// Workaround to ignore stateService.activeAccount until URLs are set
// TODO: Remove this when implementing ticket PM-2637
this.environmentService.initialized = true;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.syncService.fullSync(true);

View File

@ -49,10 +49,6 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
return this.formGroup.value.email;
}
get selfHostedDomain() {
return this.environmentService.hasBaseUrl() ? this.environmentService.getWebVaultUrl() : null;
}
constructor(
devicesApiService: DevicesApiServiceAbstraction,
appIdService: AppIdService,
@ -152,9 +148,6 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
// eslint-disable-next-line rxjs/no-async-subscribe
childComponent.onSaved.pipe(takeUntil(this.componentDestroyed$)).subscribe(async () => {
modal.close();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.environmentSelector.updateEnvironmentInfo();
await this.getLoginWithDevice(this.loggedEmail);
});
}

View File

@ -1,5 +1,6 @@
import { Component, Inject, NgZone, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { TwoFactorComponent as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor.component";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
@ -141,7 +142,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
}
}
override launchDuoFrameless() {
override async launchDuoFrameless() {
const duoHandOffMessage = {
title: this.i18nService.t("youSuccessfullyLoggedIn"),
message: this.i18nService.t("youMayCloseThisWindow"),
@ -150,8 +151,9 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
// we're using the connector here as a way to set a cookie with translations
// before continuing to the duo frameless url
const env = await firstValueFrom(this.environmentService.environment$);
const launchUrl =
this.environmentService.getWebVaultUrl() +
env.getWebVaultUrl() +
"/duo-redirect-connector.html" +
"?duoFramelessUrl=" +
encodeURIComponent(this.duoFramelessUrl) +

View File

@ -2346,12 +2346,6 @@
"loggingInOn": {
"message": "Logging in on"
},
"usDomain": {
"message": "bitwarden.com"
},
"euDomain": {
"message": "bitwarden.eu"
},
"selfHostedServer": {
"message": "self-hosted"
},

View File

@ -10,7 +10,7 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { DefaultBiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
@ -54,7 +54,7 @@ export class Main {
memoryStorageForStateProviders: MemoryStorageServiceForStateProviders;
messagingService: ElectronMainMessagingService;
stateService: StateService;
environmentService: EnvironmentService;
environmentService: DefaultEnvironmentService;
mainCryptoFunctionService: MainCryptoFunctionService;
desktopCredentialStorageListener: DesktopCredentialStorageListener;
migrationRunner: MigrationRunner;
@ -148,7 +148,7 @@ export class Main {
new DefaultDerivedStateProvider(this.memoryStorageForStateProviders),
);
this.environmentService = new EnvironmentService(stateProvider, accountService);
this.environmentService = new DefaultEnvironmentService(stateProvider, accountService);
this.tokenService = new TokenService(
singleUserStateProvider,

View File

@ -1,4 +1,5 @@
import { app, Menu } from "electron";
import { firstValueFrom } from "rxjs";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -45,7 +46,8 @@ export class MenuMain {
}
private async getWebVaultUrl() {
return this.environmentService.getWebVaultUrl() ?? cloudWebVaultUrl;
const env = await firstValueFrom(this.environmentService.environment$);
return env.getWebVaultUrl() ?? cloudWebVaultUrl;
}
private initContextMenu() {

View File

@ -177,6 +177,7 @@ const renderer = {
ENV: ENV,
FLAGS: envConfig.flags,
DEV_FLAGS: NODE_ENV === "development" ? envConfig.devFlags : {},
ADDITIONAL_REGIONS: envConfig.additionalRegions ?? [],
}),
],
};

View File

@ -10,6 +10,15 @@
"proxyNotifications": "http://localhost:61840",
"wsConnectSrc": "ws://localhost:61840"
},
"additionalRegions": [
{
"key": "LOCAL",
"domain": "localhost",
"urls": {
"webVault": "https://localhost:8080"
}
}
],
"flags": {
"secretsManager": true,
"showPasswordless": true,

View File

@ -4,6 +4,22 @@
"notifications": "https://notifications.euqa.bitwarden.pw",
"scim": "https://scim.euqa.bitwarden.pw"
},
"additionalRegions": [
{
"key": "USQA",
"domain": "qa.bitwarden.pw",
"urls": {
"webVault": "https://vault.qa.bitwarden.pw"
}
},
{
"key": "EUQA",
"domain": "euqa.bitwarden.pw",
"urls": {
"webVault": "https://vault.euqa.bitwarden.pw"
}
}
],
"flags": {
"secretsManager": true,
"showPasswordless": true

View File

@ -10,6 +10,22 @@
"proxyEvents": "https://events.qa.bitwarden.pw",
"proxyNotifications": "https://notifications.qa.bitwarden.pw"
},
"additionalRegions": [
{
"key": "USQA",
"domain": "qa.bitwarden.pw",
"urls": {
"webVault": "https://vault.qa.bitwarden.pw"
}
},
{
"key": "EUQA",
"domain": "euqa.bitwarden.pw",
"urls": {
"webVault": "https://vault.euqa.bitwarden.pw"
}
}
],
"flags": {
"secretsManager": true,
"showPasswordless": true,

View File

@ -127,7 +127,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest
await this.submit();
};
override launchDuoFrameless() {
override async launchDuoFrameless() {
const duoHandOffMessage = {
title: this.i18nService.t("youSuccessfullyLoggedIn"),
message: this.i18nService.t("thisWindowWillCloseIn5Seconds"),

View File

@ -44,11 +44,11 @@ export class PremiumComponent implements OnInit {
private billingAccountProfileStateService: BillingAccountProfileStateService,
) {
this.selfHosted = platformUtilsService.isSelfHost();
this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl();
this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$;
}
async ngOnInit() {
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
if (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumPersonally$)) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises

View File

@ -49,10 +49,10 @@ export class UserSubscriptionComponent implements OnInit {
private billingAccountProfileStateService: BillingAccountProfileStateService,
) {
this.selfHosted = platformUtilsService.isSelfHost();
this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl();
}
async ngOnInit() {
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
this.presentUserWithOffboardingSurvey$ = this.configService.getFeatureFlag$<boolean>(
FeatureFlag.AC1607_PresentUserOffboardingSurvey,
);

View File

@ -1,7 +1,7 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { concatMap, Subject, takeUntil } from "rxjs";
import { concatMap, firstValueFrom, Subject, takeUntil } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
@ -82,11 +82,11 @@ export class OrganizationSubscriptionSelfhostComponent implements OnInit, OnDest
private i18nService: I18nService,
private environmentService: EnvironmentService,
private dialogService: DialogService,
) {
this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl();
}
) {}
async ngOnInit() {
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
this.route.params
.pipe(
concatMap(async (params) => {

View File

@ -14,11 +14,15 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { PayPalConfig } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
export type PayPalConfig = {
businessId?: string;
buttonAction?: string;
};
@Component({
selector: "app-add-credit",
templateUrl: "add-credit.component.html",

View File

@ -1,38 +1,25 @@
<div class="tw-mb-1" *ngIf="showRegionSelector">
<bit-menu #environmentOptions>
<a
*ngFor="let region of availableRegions"
bitMenuItem
[attr.href]="
isUsServer ? 'javascript:void(0)' : 'https://vault.bitwarden.com' + routeAndParams
region == currentRegion ? 'javascript:void(0)' : region.urls.webVault + routeAndParams
"
class="pr-4"
>
<i
class="bwi bwi-fw bwi-sm bwi-check pb-1"
aria-hidden="true"
[style.visibility]="isUsServer ? 'visible' : 'hidden'"
[style.visibility]="region == currentRegion ? 'visible' : 'hidden'"
></i>
{{ "usDomain" | i18n }}
</a>
<a
bitMenuItem
[attr.href]="
isEuServer ? 'javascript:void(0)' : 'https://vault.bitwarden.eu' + routeAndParams
"
class="pr-4"
>
<i
class="bwi bwi-fw bwi-sm bwi-check pb-1"
aria-hidden="true"
[style.visibility]="isEuServer ? 'visible' : 'hidden'"
></i>
{{ "euDomain" | i18n }}
{{ region.domain }}
</a>
</bit-menu>
<div>
{{ "server" | i18n }}:
<a [routerLink]="[]" [bitMenuTriggerFor]="environmentOptions">
<b>{{ isEuServer ? ("euDomain" | i18n) : ("usDomain" | i18n) }}</b
<b>{{ currentRegion?.domain }}</b
><i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</a>
</div>

View File

@ -1,7 +1,10 @@
import { Component, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { RegionDomain } from "@bitwarden/common/platform/abstractions/environment.service";
import {
EnvironmentService,
RegionConfig,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@ -12,19 +15,21 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
export class EnvironmentSelectorComponent implements OnInit {
constructor(
private platformUtilsService: PlatformUtilsService,
private environmentService: EnvironmentService,
private router: Router,
) {}
isEuServer: boolean;
isUsServer: boolean;
showRegionSelector = false;
routeAndParams: string;
protected availableRegions = this.environmentService.availableRegions();
protected currentRegion?: RegionConfig;
protected showRegionSelector = false;
protected routeAndParams: string;
async ngOnInit() {
const domain = Utils.getDomain(window.location.href);
this.isEuServer = domain.includes(RegionDomain.EU);
this.isUsServer = domain.includes(RegionDomain.US) || domain.includes(RegionDomain.USQA);
this.showRegionSelector = !this.platformUtilsService.isSelfHost();
this.routeAndParams = `/#${this.router.url}`;
const host = Utils.getHost(window.location.href);
this.currentRegion = this.availableRegions.find((r) => Utils.getHost(r.urls.webVault) === host);
}
}

View File

@ -11,11 +11,14 @@ import {
OBSERVABLE_MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_DISK_LOCAL_STORAGE,
WINDOW,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service";
import { LoginService } from "@bitwarden/common/auth/services/login.service";
import { EnvironmentService } 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";
@ -28,9 +31,9 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
/* eslint-disable import/no-restricted-paths -- Implementation for memory storage */
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
/* eslint-disable import/no-restricted-paths -- Implementation for memory storage */
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service";
/* eslint-enable import/no-restricted-paths -- Implementation for memory storage */
import {
@ -41,6 +44,7 @@ import {
import { PolicyListService } from "../admin-console/core/policy-list.service";
import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service";
import { WebEnvironmentService } from "../platform/web-environment.service";
import { WebMigrationRunner } from "../platform/web-migration-runner";
import { WebStorageServiceProvider } from "../platform/web-storage-service.provider";
import { WindowStorageService } from "../platform/window-storage.service";
@ -138,6 +142,11 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service";
OBSERVABLE_DISK_LOCAL_STORAGE,
],
},
{
provide: EnvironmentService,
useClass: WebEnvironmentService,
deps: [WINDOW, StateProvider, AccountService],
},
{
provide: ThemeStateService,
useFactory: (globalStateProvider: GlobalStateProvider) =>

View File

@ -8,10 +8,6 @@ import { NotificationsService as NotificationsServiceAbstraction } from "@bitwar
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import {
EnvironmentService as EnvironmentServiceAbstraction,
Urls,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { ConfigService } from "@bitwarden/common/platform/services/config/config.service";
@ -23,7 +19,6 @@ import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/va
export class InitService {
constructor(
@Inject(WINDOW) private win: Window,
private environmentService: EnvironmentServiceAbstraction,
private notificationsService: NotificationsServiceAbstraction,
private vaultTimeoutService: VaultTimeoutService,
private i18nService: I18nServiceAbstraction,
@ -41,13 +36,6 @@ export class InitService {
return async () => {
await this.stateService.init();
const urls = process.env.URLS as Urls;
urls.base ??= this.win.location.origin;
await this.environmentService.setUrls(urls);
// Workaround to ignore stateService.activeAccount until process.env.URLS are set
// TODO: Remove this when implementing ticket PM-2637
this.environmentService.initialized = true;
setTimeout(() => this.notificationsService.init(), 3000);
await this.vaultTimeoutService.init(true);
await this.i18nService.init();

View File

@ -0,0 +1,62 @@
import { ReplaySubject } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
Environment,
Region,
RegionConfig,
Urls,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
CloudEnvironment,
DefaultEnvironmentService,
SelfHostedEnvironment,
} from "@bitwarden/common/platform/services/default-environment.service";
import { StateProvider } from "@bitwarden/common/platform/state";
/**
* Web specific environment service. Ensures that the urls are set from the window location.
*/
export class WebEnvironmentService extends DefaultEnvironmentService {
constructor(
private win: Window,
stateProvider: StateProvider,
accountService: AccountService,
) {
super(stateProvider, accountService);
// 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;
// Find the region
const domain = Utils.getDomain(this.win.location.href);
const region = this.availableRegions().find((r) => Utils.getDomain(r.urls.webVault) === domain);
let environment: Environment;
if (region) {
environment = new WebCloudEnvironment(region, urls);
} else {
environment = new SelfHostedEnvironment(urls);
}
// Override the environment observable with a replay subject
const subject = new ReplaySubject<Environment>(1);
subject.next(environment);
this.environment$ = subject.asObservable();
}
// Web cannot set environment
async setEnvironment(region: Region, urls?: Urls): Promise<Urls> {
return;
}
}
class WebCloudEnvironment extends CloudEnvironment {
constructor(config: RegionConfig, urls: Urls) {
super(config);
// We override the urls to avoid CORS issues
this.urls = urls;
}
}

View File

@ -7063,12 +7063,6 @@
"enforceOnLoginDesc": {
"message": "Require existing members to change their passwords"
},
"usDomain": {
"message": "bitwarden.com"
},
"euDomain": {
"message": "bitwarden.eu"
},
"smProjectDeleteAccessRestricted": {
"message": "You don't have permissions to delete this project",
"description": "The individual description shown to the user when the user doesn't have access to delete a project."

View File

@ -171,6 +171,7 @@ const plugins = [
PAYPAL_CONFIG: envConfig["paypal"] ?? {},
FLAGS: envConfig["flags"] ?? {},
DEV_FLAGS: NODE_ENV === "development" ? envConfig["devFlags"] : {},
ADDITIONAL_REGIONS: envConfig["additionalRegions"] ?? [],
}),
new AngularWebpackPlugin({
tsConfigPath: "tsconfig.json",

View File

@ -1,6 +1,7 @@
import { Component, OnInit } from "@angular/core";
import { UntypedFormBuilder, FormControl } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
@ -76,13 +77,13 @@ export class ScimComponent implements OnInit {
apiKeyRequest,
);
this.formData.setValue({
endpointUrl: this.getScimEndpointUrl(),
endpointUrl: await this.getScimEndpointUrl(),
clientSecret: apiKeyResponse.apiKey,
});
}
async copyScimUrl() {
this.platformUtilsService.copyToClipboard(this.getScimEndpointUrl());
this.platformUtilsService.copyToClipboard(await this.getScimEndpointUrl());
}
async rotateScimKey() {
@ -148,8 +149,9 @@ export class ScimComponent implements OnInit {
this.formPromise = null;
}
getScimEndpointUrl() {
return this.environmentService.getScimUrl() + "/" + this.organizationId;
async getScimEndpointUrl() {
const env = await firstValueFrom(this.environmentService.environment$);
return env.getScimUrl() + "/" + this.organizationId;
}
toggleScimKey() {
@ -163,7 +165,7 @@ export class ScimComponent implements OnInit {
this.showScimSettings = true;
this.enabled.setValue(true);
this.formData.setValue({
endpointUrl: this.getScimEndpointUrl(),
endpointUrl: await this.getScimEndpointUrl(),
clientSecret: "",
});
await this.loadApiKey();

View File

@ -1,4 +1,5 @@
import { Directive, Input } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { CaptchaIFrame } from "@bitwarden/common/auth/captcha-iframe";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@ -19,7 +20,8 @@ export abstract class CaptchaProtectedComponent {
) {}
async setupCaptcha() {
const webVaultUrl = this.environmentService.getWebVaultUrl();
const env = await firstValueFrom(this.environmentService.environment$);
const webVaultUrl = env.getWebVaultUrl();
this.captcha = new CaptchaIFrame(
window,

View File

@ -7,17 +7,15 @@
#trigger="cdkOverlayOrigin"
aria-haspopup="dialog"
aria-controls="cdk-overlay-container"
[ngSwitch]="selectedEnvironment"
>
<span *ngSwitchCase="ServerEnvironmentType.US" class="text-primary">{{
"usDomain" | i18n
}}</span>
<span *ngSwitchCase="ServerEnvironmentType.EU" class="text-primary">{{
"euDomain" | i18n
}}</span>
<span *ngSwitchCase="ServerEnvironmentType.SelfHosted" class="text-primary">{{
"selfHostedServer" | i18n
}}</span>
<span class="text-primary">
<ng-container *ngIf="selectedRegion$ | async as selectedRegion; else fallback">
{{ selectedRegion.domain }}
</ng-container>
<ng-template #fallback>
{{ "selfHostedServer" | i18n }}
</ng-template>
</span>
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</button>
</div>
@ -41,40 +39,23 @@
role="dialog"
aria-modal="true"
>
<button
type="button"
class="environment-selector-dialog-item"
(click)="toggle(ServerEnvironmentType.US)"
[attr.aria-pressed]="selectedEnvironment === ServerEnvironmentType.US ? 'true' : 'false'"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
style="padding-bottom: 1px"
aria-hidden="true"
[style.visibility]="
selectedEnvironment === ServerEnvironmentType.US ? 'visible' : 'hidden'
"
></i>
<span>{{ "usDomain" | i18n }}</span>
</button>
<br />
<button
type="button"
class="environment-selector-dialog-item"
(click)="toggle(ServerEnvironmentType.EU)"
[attr.aria-pressed]="selectedEnvironment === ServerEnvironmentType.EU ? 'true' : 'false'"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
style="padding-bottom: 1px"
aria-hidden="true"
[style.visibility]="
selectedEnvironment === ServerEnvironmentType.EU ? 'visible' : 'hidden'
"
></i>
<span>{{ "euDomain" | i18n }}</span>
</button>
<br />
<ng-container *ngFor="let region of availableRegions">
<button
type="button"
class="environment-selector-dialog-item"
(click)="toggle(region.key)"
[attr.aria-pressed]="selectedEnvironment === region.key ? 'true' : 'false'"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
style="padding-bottom: 1px"
aria-hidden="true"
[style.visibility]="selectedEnvironment === region.key ? 'visible' : 'hidden'"
></i>
<span>{{ region.domain }}</span>
</button>
<br />
</ng-container>
<button
type="button"
class="environment-selector-dialog-item"

View File

@ -1,13 +1,13 @@
import { animate, state, style, transition, trigger } from "@angular/animations";
import { ConnectedPosition } from "@angular/cdk/overlay";
import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core";
import { Component, EventEmitter, Output } from "@angular/core";
import { Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { Observable, map } from "rxjs";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import {
EnvironmentService as EnvironmentServiceAbstraction,
EnvironmentService,
Region,
RegionConfig,
} from "@bitwarden/common/platform/abstractions/environment.service";
@Component({
@ -34,7 +34,7 @@ import {
]),
],
})
export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
export class EnvironmentSelectorComponent {
@Output() onOpenSelfHostedSettings = new EventEmitter();
isOpen = false;
showingModal = false;
@ -48,59 +48,34 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
overlayY: "top",
},
];
protected componentDestroyed$: Subject<void> = new Subject();
protected availableRegions = this.environmentService.availableRegions();
protected selectedRegion$: Observable<RegionConfig | undefined> =
this.environmentService.environment$.pipe(
map((e) => e.getRegion()),
map((r) => this.availableRegions.find((ar) => ar.key === r)),
);
constructor(
protected environmentService: EnvironmentServiceAbstraction,
protected configService: ConfigServiceAbstraction,
protected environmentService: EnvironmentService,
protected router: Router,
) {}
async ngOnInit() {
this.configService.serverConfig$.pipe(takeUntil(this.componentDestroyed$)).subscribe(() => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateEnvironmentInfo();
});
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateEnvironmentInfo();
}
ngOnDestroy(): void {
this.componentDestroyed$.next();
this.componentDestroyed$.complete();
}
async toggle(option: Region) {
this.isOpen = !this.isOpen;
if (option === null) {
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateEnvironmentInfo();
if (option === Region.SelfHosted) {
this.onOpenSelfHostedSettings.emit();
return;
}
await this.environmentService.setRegion(option);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateEnvironmentInfo();
}
async updateEnvironmentInfo() {
this.selectedEnvironment = this.environmentService.selectedRegion;
await this.environmentService.setEnvironment(option);
}
close() {
this.isOpen = false;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateEnvironmentInfo();
}
}

View File

@ -1,4 +1,5 @@
import { Directive, EventEmitter, Output } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
EnvironmentService,
@ -27,21 +28,29 @@ export class EnvironmentComponent {
protected i18nService: I18nService,
private modalService: ModalService,
) {
const urls = this.environmentService.getUrls();
if (this.environmentService.selectedRegion != Region.SelfHosted) {
return;
}
this.environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => {
if (env.getRegion() !== Region.SelfHosted) {
this.baseUrl = "";
this.webVaultUrl = "";
this.apiUrl = "";
this.identityUrl = "";
this.iconsUrl = "";
this.notificationsUrl = "";
return;
}
this.baseUrl = urls.base || "";
this.webVaultUrl = urls.webVault || "";
this.apiUrl = urls.api || "";
this.identityUrl = urls.identity || "";
this.iconsUrl = urls.icons || "";
this.notificationsUrl = urls.notifications || "";
const urls = env.getUrls();
this.baseUrl = urls.base || "";
this.webVaultUrl = urls.webVault || "";
this.apiUrl = urls.api || "";
this.identityUrl = urls.identity || "";
this.iconsUrl = urls.icons || "";
this.notificationsUrl = urls.notifications || "";
});
}
async submit() {
const resUrls = await this.environmentService.setUrls({
await this.environmentService.setEnvironment(Region.SelfHosted, {
base: this.baseUrl,
api: this.apiUrl,
identity: this.identityUrl,
@ -50,14 +59,6 @@ export class EnvironmentComponent {
notifications: this.notificationsUrl,
});
// re-set urls since service can change them, ex: prefixing https://
this.baseUrl = resUrls.base;
this.apiUrl = resUrls.api;
this.identityUrl = resUrls.identity;
this.webVaultUrl = resUrls.webVault;
this.iconsUrl = resUrls.icons;
this.notificationsUrl = resUrls.notifications;
this.platformUtilsService.showToast("success", null, this.i18nService.t("environmentSaved"));
this.saved();
}

View File

@ -346,7 +346,7 @@ export class LockComponent implements OnInit, OnDestroy {
!this.platformUtilsService.supportsSecureStorage());
this.email = await this.stateService.getEmail();
this.webVaultHostname = await this.environmentService.getHost();
this.webVaultHostname = (await this.environmentService.getEnvironment()).getHostname();
}
/**

View File

@ -1,7 +1,7 @@
import { Directive, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject } from "rxjs";
import { Subject, firstValueFrom } from "rxjs";
import { take, takeUntil } from "rxjs/operators";
import { LoginStrategyServiceAbstraction, PasswordLoginCredentials } from "@bitwarden/auth/common";
@ -84,10 +84,6 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
super(environmentService, i18nService, platformUtilsService);
}
get selfHostedDomain() {
return this.environmentService.hasBaseUrl() ? this.environmentService.getWebVaultUrl() : null;
}
async ngOnInit() {
this.route?.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
if (!params) {
@ -245,7 +241,8 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
await this.ssoLoginService.setCodeVerifier(ssoCodeVerifier);
// Build URI
const webUrl = this.environmentService.getWebVaultUrl();
const env = await firstValueFrom(this.environmentService.environment$);
const webUrl = env.getWebVaultUrl();
// Launch browser
this.platformUtilsService.launchUri(

View File

@ -157,8 +157,10 @@ export class SsoComponent {
// Save state (regardless of new or existing)
await this.ssoLoginService.setSsoState(state);
const env = await firstValueFrom(this.environmentService.environment$);
let authorizeUrl =
this.environmentService.getIdentityUrl() +
env.getIdentityUrl() +
"/connect/authorize?" +
"client_id=" +
this.clientId +

View File

@ -1,5 +1,6 @@
import { Directive, EventEmitter, OnInit, Output } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
@ -31,8 +32,9 @@ export class TwoFactorOptionsComponent implements OnInit {
this.onProviderSelected.emit(p.type);
}
recover() {
const webVault = this.environmentService.getWebVaultUrl();
async recover() {
const env = await firstValueFrom(this.environmentService.environment$);
const webVault = env.getWebVaultUrl();
this.platformUtilsService.launchUri(webVault + "/#/recover-2fa");
this.onRecoverSelected.emit();
}

View File

@ -116,7 +116,8 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
}
if (this.win != null && this.webAuthnSupported) {
const webVaultUrl = this.environmentService.getWebVaultUrl();
const env = await firstValueFrom(this.environmentService.environment$);
const webVaultUrl = env.getWebVaultUrl();
this.webAuthn = new WebAuthnIFrame(
this.win,
webVaultUrl,
@ -494,5 +495,5 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
}
// implemented in clients
launchDuoFrameless() {}
async launchDuoFrameless() {}
}

View File

@ -115,7 +115,7 @@ import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstraction
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 as EnvironmentServiceAbstraction } from "@bitwarden/common/platform/abstractions/environment.service";
import { EnvironmentService } 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";
@ -140,7 +140,7 @@ import { ConsoleLogService } from "@bitwarden/common/platform/services/console-l
import { CryptoService } from "@bitwarden/common/platform/services/crypto.service";
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation";
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service";
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
@ -363,7 +363,7 @@ const typesafeProviders: Array<SafeProvider> = [
MessagingServiceAbstraction,
LogService,
KeyConnectorServiceAbstraction,
EnvironmentServiceAbstraction,
EnvironmentService,
StateServiceAbstraction,
TwoFactorServiceAbstraction,
I18nServiceAbstraction,
@ -477,8 +477,8 @@ const typesafeProviders: Array<SafeProvider> = [
deps: [CryptoServiceAbstraction, I18nServiceAbstraction, StateProvider],
}),
safeProvider({
provide: EnvironmentServiceAbstraction,
useClass: EnvironmentService,
provide: EnvironmentService,
useClass: DefaultEnvironmentService,
deps: [StateProvider, AccountServiceAbstraction],
}),
safeProvider({
@ -545,7 +545,7 @@ const typesafeProviders: Array<SafeProvider> = [
deps: [
TokenServiceAbstraction,
PlatformUtilsServiceAbstraction,
EnvironmentServiceAbstraction,
EnvironmentService,
AppIdServiceAbstraction,
StateServiceAbstraction,
LOGOUT_CALLBACK,
@ -647,7 +647,7 @@ const typesafeProviders: Array<SafeProvider> = [
LogService,
STATE_FACTORY,
AccountServiceAbstraction,
EnvironmentServiceAbstraction,
EnvironmentService,
TokenServiceAbstraction,
MigrationRunner,
STATE_SERVICE_USE_CACHE,
@ -711,7 +711,7 @@ const typesafeProviders: Array<SafeProvider> = [
SyncServiceAbstraction,
AppIdServiceAbstraction,
ApiServiceAbstraction,
EnvironmentServiceAbstraction,
EnvironmentService,
LOGOUT_CALLBACK,
StateServiceAbstraction,
AuthServiceAbstraction,
@ -853,8 +853,9 @@ const typesafeProviders: Array<SafeProvider> = [
StateServiceAbstraction,
ConfigApiServiceAbstraction,
AuthServiceAbstraction,
EnvironmentServiceAbstraction,
EnvironmentService,
LogService,
StateProvider,
],
}),
safeProvider({
@ -869,7 +870,7 @@ const typesafeProviders: Array<SafeProvider> = [
safeProvider({
provide: AnonymousHubServiceAbstraction,
useClass: AnonymousHubService,
deps: [EnvironmentServiceAbstraction, LoginStrategyServiceAbstraction, LogService],
deps: [EnvironmentService, LoginStrategyServiceAbstraction, LogService],
}),
safeProvider({
provide: ValidationServiceAbstraction,
@ -949,7 +950,7 @@ const typesafeProviders: Array<SafeProvider> = [
safeProvider({
provide: WebAuthnLoginApiServiceAbstraction,
useClass: WebAuthnLoginApiService,
deps: [ApiServiceAbstraction, EnvironmentServiceAbstraction],
deps: [ApiServiceAbstraction, EnvironmentService],
}),
safeProvider({
provide: WebAuthnLoginServiceAbstraction,

View File

@ -1,7 +1,7 @@
import { DatePipe } from "@angular/common";
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { BehaviorSubject, Subject, concatMap, firstValueFrom, map, takeUntil } from "rxjs";
import { Subject, firstValueFrom, takeUntil, map, BehaviorSubject, concatMap } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@ -123,7 +123,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
{ name: i18nService.t("sendTypeText"), value: SendType.Text, premium: false },
];
this.sendLinkBaseUrl = this.environmentService.getSendUrl();
}
get link(): string {
@ -190,6 +189,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
});
const env = await firstValueFrom(this.environmentService.environment$);
this.sendLinkBaseUrl = env.getSendUrl();
this.billingAccountProfileStateService.hasPremiumFromAnySource$
.pipe(takeUntil(this.destroy$))
.subscribe((hasPremiumFromAnySource) => {

View File

@ -1,5 +1,5 @@
import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { Subject, firstValueFrom, takeUntil } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@ -198,9 +198,9 @@ export class SendComponent implements OnInit, OnDestroy {
return true;
}
copy(s: SendView) {
const sendLinkBaseUrl = this.environmentService.getSendUrl();
const link = sendLinkBaseUrl + s.accessId + "/" + s.urlB64Key;
async copy(s: SendView) {
const env = await firstValueFrom(this.environmentService.environment$);
const link = env.getSendUrl() + s.accessId + "/" + s.urlB64Key;
this.platformUtilsService.copyToClipboard(link);
this.platformUtilsService.showToast(
"success",

View File

@ -39,11 +39,12 @@ export class IconComponent implements OnInit {
) {}
async ngOnInit() {
const iconsUrl = this.environmentService.getIconsUrl();
this.data$ = combineLatest([
this.environmentService.environment$.pipe(map((e) => e.getIconsUrl())),
this.domainSettingsService.showFavicons$.pipe(distinctUntilChanged()),
this.cipher$.pipe(filter((c) => c !== undefined)),
]).pipe(map(([showFavicon, cipher]) => buildCipherIcon(iconsUrl, cipher, showFavicon)));
]).pipe(
map(([iconsUrl, showFavicon, cipher]) => buildCipherIcon(iconsUrl, cipher, showFavicon)),
);
}
}

View File

@ -1,5 +1,5 @@
import { Directive } from "@angular/core";
import { Observable, Subject } from "rxjs";
import { OnInit, Directive } from "@angular/core";
import { firstValueFrom, Observable } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
@ -11,12 +11,11 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { DialogService } from "@bitwarden/components";
@Directive()
export class PremiumComponent {
export class PremiumComponent implements OnInit {
isPremium$: Observable<boolean>;
price = 10;
refreshPromise: Promise<any>;
cloudWebVaultUrl: string;
private directiveIsDestroyed$ = new Subject<boolean>();
constructor(
protected i18nService: I18nService,
@ -25,13 +24,16 @@ export class PremiumComponent {
private logService: LogService,
protected stateService: StateService,
protected dialogService: DialogService,
environmentService: EnvironmentService,
private environmentService: EnvironmentService,
billingAccountProfileStateService: BillingAccountProfileStateService,
) {
this.cloudWebVaultUrl = environmentService.getCloudWebVaultUrl();
this.isPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$;
}
async ngOnInit() {
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
}
async refresh() {
try {
this.refreshPromise = this.apiService.refreshIdentityToken();

View File

@ -1,4 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
@ -8,7 +9,10 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import {
Environment,
EnvironmentService,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -145,8 +149,11 @@ describe("UserApiLoginStrategy", () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.apiUseKeyConnector = true;
const env = mock<Environment>();
env.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
environmentService.environment$ = new BehaviorSubject(env);
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
environmentService.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
await apiLogInStrategy.logIn(credentials);
@ -160,8 +167,11 @@ describe("UserApiLoginStrategy", () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.apiUseKeyConnector = true;
const env = mock<Environment>();
env.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
environmentService.environment$ = new BehaviorSubject(env);
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
environmentService.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
cryptoService.getMasterKey.mockResolvedValue(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);

View File

@ -1,4 +1,4 @@
import { BehaviorSubject } from "rxjs";
import { firstValueFrom, BehaviorSubject } from "rxjs";
import { Jsonify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@ -85,7 +85,8 @@ export class UserApiLoginStrategy extends LoginStrategy {
protected override async setMasterKey(response: IdentityTokenResponse) {
if (response.apiUseKeyConnector) {
const keyConnectorUrl = this.environmentService.getKeyConnectorUrl();
const env = await firstValueFrom(this.environmentService.environment$);
const keyConnectorUrl = env.getKeyConnectorUrl();
await this.keyConnectorService.setMasterKeyFromUrl(keyConnectorUrl);
}
}

View File

@ -1,16 +0,0 @@
import { Jsonify } from "type-fest";
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;
static fromJSON(obj: Jsonify<EnvironmentUrls>): EnvironmentUrls {
return Object.assign(new EnvironmentUrls(), obj);
}
}

View File

@ -5,6 +5,7 @@ import {
IHubProtocol,
} from "@microsoft/signalr";
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
import { firstValueFrom } from "rxjs";
import { LoginStrategyServiceAbstraction } from "../../../../auth/src/common/abstractions/login-strategy.service";
import {
@ -26,7 +27,7 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction {
) {}
async createHubConnection(token: string) {
this.url = this.environmentService.getNotificationsUrl();
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
this.anonHubConnection = new HubConnectionBuilder()
.withUrl(this.url + "/anonymous-hub?Token=" + token, {

View File

@ -1,3 +1,5 @@
import { firstValueFrom } from "rxjs";
import { ApiService } from "../../../abstractions/api.service";
import { EnvironmentService } from "../../../platform/abstractions/environment.service";
import { WebAuthnLoginApiServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-api.service.abstraction";
@ -11,13 +13,14 @@ export class WebAuthnLoginApiService implements WebAuthnLoginApiServiceAbstracti
) {}
async getCredentialAssertionOptions(): Promise<CredentialAssertionOptionsResponse> {
const env = await firstValueFrom(this.environmentService.environment$);
const response = await this.apiService.send(
"GET",
`/accounts/webauthn/assertion-options`,
null,
false,
true,
this.environmentService.getIdentityUrl(),
env.getIdentityUrl(),
);
return new CredentialAssertionOptionsResponse(response);
}

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");

View File

@ -1,3 +1,5 @@
import { firstValueFrom } from "rxjs";
import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service";
import { OrganizationConnectionType } from "../admin-console/enums";
import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request";
@ -204,10 +206,12 @@ export class ApiService implements ApiServiceAbstraction {
? request.toIdentityToken()
: request.toIdentityToken(this.platformUtilsService.getClientType());
const env = await firstValueFrom(this.environmentService.environment$);
const response = await this.fetch(
new Request(this.environmentService.getIdentityUrl() + "/connect/token", {
new Request(env.getIdentityUrl() + "/connect/token", {
body: this.qsStringify(identityToken),
credentials: this.getCredentials(),
credentials: await this.getCredentials(),
cache: "no-store",
headers: headers,
method: "POST",
@ -323,13 +327,14 @@ export class ApiService implements ApiServiceAbstraction {
}
async postPrelogin(request: PreloginRequest): Promise<PreloginResponse> {
const env = await firstValueFrom(this.environmentService.environment$);
const r = await this.send(
"POST",
"/accounts/prelogin",
request,
false,
true,
this.environmentService.getIdentityUrl(),
env.getIdentityUrl(),
);
return new PreloginResponse(r);
}
@ -368,13 +373,14 @@ export class ApiService implements ApiServiceAbstraction {
}
async postRegister(request: RegisterRequest): Promise<RegisterResponse> {
const env = await firstValueFrom(this.environmentService.environment$);
const r = await this.send(
"POST",
"/accounts/register",
request,
false,
true,
this.environmentService.getIdentityUrl(),
env.getIdentityUrl(),
);
return new RegisterResponse(r);
}
@ -1457,10 +1463,11 @@ export class ApiService implements ApiServiceAbstraction {
if (this.customUserAgent != null) {
headers.set("User-Agent", this.customUserAgent);
}
const env = await firstValueFrom(this.environmentService.environment$);
const response = await this.fetch(
new Request(this.environmentService.getEventsUrl() + "/collect", {
new Request(env.getEventsUrl() + "/collect", {
cache: "no-store",
credentials: this.getCredentials(),
credentials: await this.getCredentials(),
method: "POST",
body: JSON.stringify(request),
headers: headers,
@ -1617,11 +1624,12 @@ export class ApiService implements ApiServiceAbstraction {
headers.set("User-Agent", this.customUserAgent);
}
const env = await firstValueFrom(this.environmentService.environment$);
const path = `/sso/prevalidate?domainHint=${encodeURIComponent(identifier)}`;
const response = await this.fetch(
new Request(this.environmentService.getIdentityUrl() + path, {
new Request(env.getIdentityUrl() + path, {
cache: "no-store",
credentials: this.getCredentials(),
credentials: await this.getCredentials(),
headers: headers,
method: "GET",
}),
@ -1751,16 +1759,17 @@ export class ApiService implements ApiServiceAbstraction {
headers.set("User-Agent", this.customUserAgent);
}
const env = await firstValueFrom(this.environmentService.environment$);
const decodedToken = await this.tokenService.decodeAccessToken();
const response = await this.fetch(
new Request(this.environmentService.getIdentityUrl() + "/connect/token", {
new Request(env.getIdentityUrl() + "/connect/token", {
body: this.qsStringify({
grant_type: "refresh_token",
client_id: decodedToken.client_id,
refresh_token: refreshToken,
}),
cache: "no-store",
credentials: this.getCredentials(),
credentials: await this.getCredentials(),
headers: headers,
method: "POST",
}),
@ -1822,7 +1831,8 @@ export class ApiService implements ApiServiceAbstraction {
apiUrl?: string,
alterHeaders?: (headers: Headers) => void,
): Promise<any> {
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? this.environmentService.getApiUrl() : apiUrl;
const env = await firstValueFrom(this.environmentService.environment$);
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl;
// Prevent directory traversal from malicious paths
const pathParts = path.split("?");
@ -1838,7 +1848,7 @@ export class ApiService implements ApiServiceAbstraction {
const requestInit: RequestInit = {
cache: "no-store",
credentials: this.getCredentials(),
credentials: await this.getCredentials(),
method: method,
};
@ -1917,8 +1927,9 @@ export class ApiService implements ApiServiceAbstraction {
.join("&");
}
private getCredentials(): RequestCredentials {
if (!this.isWebClient || this.environmentService.hasBaseUrl()) {
private async getCredentials(): Promise<RequestCredentials> {
const env = await firstValueFrom(this.environmentService.environment$);
if (!this.isWebClient || env.hasBaseUrl()) {
return "include";
}
return undefined;

View File

@ -1,5 +1,6 @@
import * as signalR from "@microsoft/signalr";
import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack";
import { firstValueFrom } from "rxjs";
import { ApiService } from "../abstractions/api.service";
import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service";
@ -38,7 +39,7 @@ export class NotificationsService implements NotificationsServiceAbstraction {
private authService: AuthService,
private messagingService: MessagingService,
) {
this.environmentService.urls.subscribe(() => {
this.environmentService.environment$.subscribe(() => {
if (!this.inited) {
return;
}
@ -51,7 +52,7 @@ export class NotificationsService implements NotificationsServiceAbstraction {
async init(): Promise<void> {
this.inited = false;
this.url = this.environmentService.getNotificationsUrl();
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
// Set notifications server URL to `https://-` to effectively disable communication
// with the notifications server from the client app

View File

@ -40,6 +40,7 @@ import { EventCollectionMigrator } from "./migrations/41-move-event-collection-t
import { EnableFaviconMigrator } from "./migrations/42-move-enable-favicon-to-domain-settings-state-provider";
import { AutoConfirmFingerPrintsMigrator } from "./migrations/43-move-auto-confirm-finger-prints-to-state-provider";
import { UserDecryptionOptionsMigrator } from "./migrations/44-move-user-decryption-options-to-state-provider";
import { MergeEnvironmentState } from "./migrations/45-merge-environment-state";
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
@ -48,7 +49,8 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 44;
export const CURRENT_VERSION = 45;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@ -94,7 +96,8 @@ export function createMigrationBuilder() {
.with(EventCollectionMigrator, 40, 41)
.with(EnableFaviconMigrator, 41, 42)
.with(AutoConfirmFingerPrintsMigrator, 42, 43)
.with(UserDecryptionOptionsMigrator, 43, CURRENT_VERSION);
.with(UserDecryptionOptionsMigrator, 43, 44)
.with(MergeEnvironmentState, 44, CURRENT_VERSION);
}
export async function currentVersion(

View File

@ -0,0 +1,164 @@
import { runMigrator } from "../migration-helper.spec";
import { MergeEnvironmentState } from "./45-merge-environment-state";
describe("MergeEnvironmentState", () => {
const migrator = new MergeEnvironmentState(44, 45);
it("can migrate all data", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: ["user1", "user2"],
global: {
extra: "data",
},
global_environment_region: "US",
global_environment_urls: {
base: "example.com",
},
user1: {
extra: "data",
settings: {
extra: "data",
},
},
user2: {
extra: "data",
settings: {
extra: "data",
},
},
extra: "data",
user_user1_environment_region: "US",
user_user2_environment_region: "EU",
user_user1_environment_urls: {
base: "example.com",
},
user_user2_environment_urls: {
base: "other.example.com",
},
});
expect(output).toEqual({
authenticatedAccounts: ["user1", "user2"],
global: {
extra: "data",
},
global_environment_environment: {
region: "US",
urls: {
base: "example.com",
},
},
user1: {
extra: "data",
settings: {
extra: "data",
},
},
user2: {
extra: "data",
settings: {
extra: "data",
},
},
extra: "data",
user_user1_environment_environment: {
region: "US",
urls: {
base: "example.com",
},
},
user_user2_environment_environment: {
region: "EU",
urls: {
base: "other.example.com",
},
},
});
});
it("handles missing parts", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: ["user1", "user2"],
global: {
extra: "data",
},
user1: {
extra: "data",
settings: {
extra: "data",
},
},
user2: null,
});
expect(output).toEqual({
authenticatedAccounts: ["user1", "user2"],
global: {
extra: "data",
},
user1: {
extra: "data",
settings: {
extra: "data",
},
},
user2: null,
});
});
it("can migrate only global data", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: [],
global_environment_region: "Self-Hosted",
global: {},
});
expect(output).toEqual({
authenticatedAccounts: [],
global_environment_environment: {
region: "Self-Hosted",
urls: undefined,
},
global: {},
});
});
it("can migrate only user state", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: ["user1"] as const,
global: null,
user1: { settings: {} },
user_user1_environment_region: "Self-Hosted",
user_user1_environment_urls: {
base: "some-base-url",
api: "some-api-url",
identity: "some-identity-url",
icons: "some-icons-url",
notifications: "some-notifications-url",
events: "some-events-url",
webVault: "some-webVault-url",
keyConnector: "some-keyConnector-url",
},
});
expect(output).toEqual({
authenticatedAccounts: ["user1"] as const,
global: null,
user1: { settings: {} },
user_user1_environment_environment: {
region: "Self-Hosted",
urls: {
base: "some-base-url",
api: "some-api-url",
identity: "some-identity-url",
icons: "some-icons-url",
notifications: "some-notifications-url",
events: "some-events-url",
webVault: "some-webVault-url",
keyConnector: "some-keyConnector-url",
},
},
});
});
});

View File

@ -0,0 +1,83 @@
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
import { Migrator } from "../migrator";
const ENVIRONMENT_STATE: StateDefinitionLike = { name: "environment" };
const ENVIRONMENT_REGION: KeyDefinitionLike = {
key: "region",
stateDefinition: ENVIRONMENT_STATE,
};
const ENVIRONMENT_URLS: KeyDefinitionLike = {
key: "urls",
stateDefinition: ENVIRONMENT_STATE,
};
const ENVIRONMENT_ENVIRONMENT: KeyDefinitionLike = {
key: "environment",
stateDefinition: ENVIRONMENT_STATE,
};
export class MergeEnvironmentState extends Migrator<44, 45> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<unknown>();
async function migrateAccount(userId: string, account: unknown): Promise<void> {
const region = await helper.getFromUser(userId, ENVIRONMENT_REGION);
const urls = await helper.getFromUser(userId, ENVIRONMENT_URLS);
if (region == null && urls == null) {
return;
}
await helper.setToUser(userId, ENVIRONMENT_ENVIRONMENT, {
region,
urls,
});
await helper.removeFromUser(userId, ENVIRONMENT_REGION);
await helper.removeFromUser(userId, ENVIRONMENT_URLS);
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
const region = await helper.getFromGlobal(ENVIRONMENT_REGION);
const urls = await helper.getFromGlobal(ENVIRONMENT_URLS);
if (region == null && urls == null) {
return;
}
await helper.setToGlobal(ENVIRONMENT_ENVIRONMENT, {
region,
urls,
});
await helper.removeFromGlobal(ENVIRONMENT_REGION);
await helper.removeFromGlobal(ENVIRONMENT_URLS);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<unknown>();
async function rollbackAccount(userId: string, account: unknown): Promise<void> {
const state = (await helper.getFromUser(userId, ENVIRONMENT_ENVIRONMENT)) as {
region: string;
urls: string;
} | null;
await helper.setToUser(userId, ENVIRONMENT_REGION, state?.region);
await helper.setToUser(userId, ENVIRONMENT_URLS, state?.urls);
await helper.removeFromUser(userId, ENVIRONMENT_ENVIRONMENT);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
const state = (await helper.getFromGlobal(ENVIRONMENT_ENVIRONMENT)) as {
region: string;
urls: string;
} | null;
await helper.setToGlobal(ENVIRONMENT_REGION, state?.region);
await helper.setToGlobal(ENVIRONMENT_URLS, state?.urls);
await helper.removeFromGlobal(ENVIRONMENT_ENVIRONMENT);
}
}

View File

@ -121,7 +121,7 @@ export class LastPassDirectImportService {
this.oidcClient = new OidcClient({
authority: this.vault.userType.openIDConnectAuthorityBase,
client_id: this.vault.userType.openIDConnectClientId,
redirect_uri: this.getOidcRedirectUrl(),
redirect_uri: await this.getOidcRedirectUrl(),
response_type: "code",
scope: this.vault.userType.oidcScope,
response_mode: "query",
@ -151,12 +151,13 @@ export class LastPassDirectImportService {
return redirectUri + "&" + params;
}
private getOidcRedirectUrl() {
private async getOidcRedirectUrl() {
const clientType = this.platformUtilsService.getClientType();
if (clientType === ClientType.Desktop) {
return "bitwarden://import-callback-lp";
}
const webUrl = this.environmentService.getWebVaultUrl();
const env = await firstValueFrom(this.environmentService.environment$);
const webUrl = env.getWebVaultUrl();
return webUrl + "/sso-connector.html?lp=1";
}