[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"] "pathGroupsExcludedImportTypes": ["builtin"]
} }
], ],
"rxjs-angular/prefer-takeuntil": "error", "rxjs-angular/prefer-takeuntil": ["error", { "alias": ["takeUntilDestroyed"] }],
"rxjs/no-exposed-subjects": ["error", { "allowProtected": true }], "rxjs/no-exposed-subjects": ["error", { "allowProtected": true }],
"no-restricted-syntax": [ "no-restricted-syntax": [
"error", "error",

View File

@ -2386,12 +2386,6 @@
"message": "EU", "message": "EU",
"description": "European Union" "description": "European Union"
}, },
"usDomain": {
"message": "bitwarden.com"
},
"euDomain": {
"message": "bitwarden.eu"
},
"accessDenied": { "accessDenied": {
"message": "Access denied. You do not have permission to view this page." "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, name: account.name ?? account.email,
email: account.email, email: account.email,
id: id, id: id,
server: await this.environmentService.getHost(id), server: (await this.environmentService.getEnvironment(id))?.getHostname(),
status: account.status, status: account.status,
isActive: id === activeAccount?.id, isActive: id === activeAccount?.id,
avatarColor: await firstValueFrom( 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 } }); this.router.navigate(["login"], { queryParams: { email: this.formGroup.value.email } });
} }
get selfHostedDomain() {
return this.environmentService.hasBaseUrl() ? this.environmentService.getWebVaultUrl() : null;
}
setFormValues() { setFormValues() {
this.loginService.setEmail(this.formGroup.value.email); this.loginService.setEmail(this.formGroup.value.email);
this.loginService.setRememberEmail(this.formGroup.value.rememberEmail); this.loginService.setRememberEmail(this.formGroup.value.rememberEmail);

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,14 @@
import { mock } from "jest-mock-extended"; 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 { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsService } from "@bitwarden/common/autofill/services/user-notification-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 { 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 { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@ -1348,16 +1349,21 @@ describe("NotificationBackground", () => {
const message: NotificationBackgroundExtensionMessage = { const message: NotificationBackgroundExtensionMessage = {
command: "getWebVaultUrlForNotification", 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 const environmentServiceSpy = jest
.spyOn(environmentService, "getWebVaultUrl") .spyOn(environmentService as any, "environment$", "get")
.mockReturnValueOnce(webVaultUrl); .mockReturnValue(new BehaviorSubject(env).asObservable());
sendExtensionRuntimeMessage(message); sendExtensionRuntimeMessage(message);
await flushPromises(); await flushPromises();
expect(environmentServiceSpy).toHaveBeenCalled(); expect(environmentServiceSpy).toHaveBeenCalled();
expect(environmentServiceSpy).toHaveReturnedWith(webVaultUrl);
}); });
}); });
}); });

View File

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

View File

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

View File

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

View File

@ -613,6 +613,7 @@ export default class MainBackground {
this.authService, this.authService,
this.environmentService, this.environmentService,
this.logService, this.logService,
this.stateProvider,
true, true,
); );
@ -1032,10 +1033,6 @@ export default class MainBackground {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
setTimeout(async () => { 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) { if (!this.isPrivateMode) {
await this.refreshBadge(); await this.refreshBadge();
} }

View File

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

View File

@ -13,6 +13,7 @@ import {
} from "./environment-service.factory"; } from "./environment-service.factory";
import { FactoryOptions, CachedServices, factory } from "./factory-options"; import { FactoryOptions, CachedServices, factory } from "./factory-options";
import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory";
import { stateProviderFactory } from "./state-provider.factory";
import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory";
type ConfigServiceFactoryOptions = FactoryOptions & { type ConfigServiceFactoryOptions = FactoryOptions & {
@ -43,6 +44,7 @@ export function configServiceFactory(
await authServiceFactory(cache, opts), await authServiceFactory(cache, opts),
await environmentServiceFactory(cache, opts), await environmentServiceFactory(cache, opts),
await logServiceFactory(cache, opts), await logServiceFactory(cache, opts),
await stateProviderFactory(cache, opts),
opts.configServiceOptions?.subscribe ?? true, 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ConfigService } from "@bitwarden/common/platform/services/config/config.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"; import { browserSession, sessionSync } from "../decorators/session-sync-observable";
@ -21,8 +22,17 @@ export class BrowserConfigService extends ConfigService {
authService: AuthService, authService: AuthService,
environmentService: EnvironmentService, environmentService: EnvironmentService,
logService: LogService, logService: LogService,
stateProvider: StateProvider,
subscribe = false, 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 { 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 { 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 { StateProvider } from "@bitwarden/common/platform/state";
import { GroupPolicyEnvironment } from "../../admin-console/types/group-policy-environment"; import { GroupPolicyEnvironment } from "../../admin-console/types/group-policy-environment";
import { devFlagEnabled, devFlagValue } from "../flags"; import { devFlagEnabled, devFlagValue } from "../flags";
export class BrowserEnvironmentService extends EnvironmentService { export class BrowserEnvironmentService extends DefaultEnvironmentService {
constructor( constructor(
private logService: LogService, private logService: LogService,
stateProvider: StateProvider, stateProvider: StateProvider,
@ -29,16 +32,18 @@ export class BrowserEnvironmentService extends EnvironmentService {
return false; return false;
} }
const env = await this.getManagedEnvironment(); const managedEnv = await this.getManagedEnvironment();
const env = await firstValueFrom(this.environment$);
const urls = env.getUrls();
return ( return (
env.base != this.baseUrl || managedEnv.base != urls.base ||
env.webVault != this.webVaultUrl || managedEnv.webVault != urls.webVault ||
env.api != this.webVaultUrl || managedEnv.api != urls.api ||
env.identity != this.identityUrl || managedEnv.identity != urls.identity ||
env.icons != this.iconsUrl || managedEnv.icons != urls.icons ||
env.notifications != this.notificationsUrl || managedEnv.notifications != urls.notifications ||
env.events != this.eventsUrl managedEnv.events != urls.events
); );
} }
@ -62,7 +67,7 @@ export class BrowserEnvironmentService extends EnvironmentService {
async setUrlsToManagedEnvironment() { async setUrlsToManagedEnvironment() {
const env = await this.getManagedEnvironment(); const env = await this.getManagedEnvironment();
await this.setUrls({ await this.setEnvironment(Region.SelfHosted, {
base: env.base, base: env.base,
webVault: env.webVault, webVault: env.webVault,
api: env.api, api: env.api,

View File

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

View File

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

View File

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

View File

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

View File

@ -690,6 +690,8 @@ export class LoginCommand {
codeChallenge: string, codeChallenge: string,
state: string, state: string,
): Promise<{ ssoCode: string; orgIdentifier: string }> { ): Promise<{ ssoCode: string; orgIdentifier: string }> {
const env = await firstValueFrom(this.environmentService.environment$);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const callbackServer = http.createServer((req, res) => { const callbackServer = http.createServer((req, res) => {
const urlString = "http://localhost" + req.url; const urlString = "http://localhost" + req.url;
@ -724,7 +726,7 @@ export class LoginCommand {
} }
}); });
let foundPort = false; let foundPort = false;
const webUrl = this.environmentService.getWebVaultUrl(); const webUrl = env.getWebVaultUrl();
for (let port = 8065; port <= 8070; port++) { for (let port = 8065; port <= 8070; port++) {
try { try {
this.ssoRedirectUri = "http://localhost:" + port; 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 { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { ClientType } from "@bitwarden/common/enums"; import { ClientType } from "@bitwarden/common/enums";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; 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 { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { import {
BiometricStateService, BiometricStateService,
@ -62,7 +63,7 @@ import { ConfigApiService } from "@bitwarden/common/platform/services/config/con
import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { CryptoService } from "@bitwarden/common/platform/services/crypto.service"; import { CryptoService } from "@bitwarden/common/platform/services/crypto.service";
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; 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 { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
@ -312,7 +313,10 @@ export class Main {
this.derivedStateProvider, this.derivedStateProvider,
); );
this.environmentService = new EnvironmentService(this.stateProvider, this.accountService); this.environmentService = new DefaultEnvironmentService(
this.stateProvider,
this.accountService,
);
this.tokenService = new TokenService( this.tokenService = new TokenService(
this.singleUserStateProvider, this.singleUserStateProvider,
@ -504,6 +508,7 @@ export class Main {
this.authService, this.authService,
this.environmentService, this.environmentService,
this.logService, this.logService,
this.stateProvider,
true, true,
); );
@ -703,7 +708,6 @@ export class Main {
await this.storageService.init(); await this.storageService.init();
await this.stateService.init(); await this.stateService.init();
this.containerService.attachToGlobal(global); this.containerService.attachToGlobal(global);
await this.environmentService.setUrlsFromStorage();
await this.i18nService.init(); await this.i18nService.init();
this.twoFactorService.init(); this.twoFactorService.init();
this.configService.init(); this.configService.init();

View File

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

View File

@ -1,8 +1,12 @@
import * as inquirer from "inquirer"; import * as inquirer from "inquirer";
import { firstValueFrom } from "rxjs";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; 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 { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { Response } from "../models/response"; import { Response } from "../models/response";
@ -67,9 +71,10 @@ export class ConvertToKeyConnectorCommand {
await this.keyConnectorService.setUsesKeyConnector(true); await this.keyConnectorService.setUsesKeyConnector(true);
// Update environment URL - required for api key login // 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; urls.keyConnector = organization.keyConnectorUrl;
await this.environmentService.setUrls(urls); await this.environmentService.setEnvironment(Region.SelfHosted, urls);
return Response.success(); return Response.success();
} else if (answer.convert === "leave") { } 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 { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@ -17,7 +19,7 @@ export class StatusCommand {
async run(): Promise<Response> { async run(): Promise<Response> {
try { try {
const baseUrl = this.baseUrl(); const baseUrl = await this.baseUrl();
const status = await this.status(); const status = await this.status();
const lastSync = await this.syncService.getLastSync(); const lastSync = await this.syncService.getLastSync();
const userId = await this.stateService.getUserId(); const userId = await this.stateService.getUserId();
@ -37,8 +39,9 @@ export class StatusCommand {
} }
} }
private baseUrl(): string { private async baseUrl(): Promise<string> {
return this.envService.getUrls().base; const env = await firstValueFrom(this.envService.environment$);
return env.getUrls().base;
} }
private async status(): Promise<"unauthenticated" | "locked" | "unlocked"> { private async status(): Promise<"unauthenticated" | "locked" | "unlocked"> {

View File

@ -127,7 +127,8 @@ export class SendCreateCommand {
await this.sendApiService.save([encSend, fileData]); await this.sendApiService.save([encSend, fileData]);
const newSend = await this.sendService.getFromState(encSend.id); const newSend = await this.sendService.getFromState(encSend.id);
const decSend = await newSend.decrypt(); 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); return Response.success(res);
} catch (e) { } catch (e) {
return Response.error(e); return Response.error(e);

View File

@ -1,4 +1,5 @@
import { OptionValues } from "commander"; import { OptionValues } from "commander";
import { firstValueFrom } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
@ -32,7 +33,8 @@ export class SendGetCommand extends DownloadCommand {
return Response.notFound(); 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 filter = (s: SendView) => true;
let selector = async (s: SendView): Promise<Response> => let selector = async (s: SendView): Promise<Response> =>
Response.success(new SendResponse(s, webVaultUrl)); 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 { SearchService } from "@bitwarden/common/abstractions/search.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; 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); 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))); const res = new ListResponse(sends.map((s) => new SendResponse(s, webVaultUrl)));
return Response.success(res); return Response.success(res);
} }

View File

@ -1,5 +1,6 @@
import { OptionValues } from "commander"; import { OptionValues } from "commander";
import * as inquirer from "inquirer"; import * as inquirer from "inquirer";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; 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"); 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); const [id, key] = this.getIdAndKey(urlObject);
if (Utils.isNullOrWhitespace(id) || Utils.isNullOrWhitespace(key)) { if (Utils.isNullOrWhitespace(id) || Utils.isNullOrWhitespace(key)) {
@ -108,8 +109,9 @@ export class SendReceiveCommand extends DownloadCommand {
return [result[0], result[1]]; return [result[0], result[1]];
} }
private getApiUrl(url: URL) { private async getApiUrl(url: URL) {
const urls = this.environmentService.getUrls(); const env = await firstValueFrom(this.environmentService.environment$);
const urls = env.getUrls();
if (url.origin === "https://send.bitwarden.com") { if (url.origin === "https://send.bitwarden.com") {
return "https://api.bitwarden.com"; return "https://api.bitwarden.com";
} else if (url.origin === urls.api) { } 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 { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { SendService } from "@bitwarden/common/tools/send/services//send.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services//send.service.abstraction";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.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 updatedSend = await this.sendService.get(id);
const decSend = await updatedSend.decrypt(); 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); const res = new SendResponse(decSend, webVaultUrl);
return Response.success(res); return Response.success(res);
} catch (e) { } catch (e) {

View File

@ -105,7 +105,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
name: (await this.tokenService.getName()) ?? (await this.tokenService.getEmail()), name: (await this.tokenService.getName()) ?? (await this.tokenService.getEmail()),
email: await this.tokenService.getEmail(), email: await this.tokenService.getEmail(),
avatarColor: await firstValueFrom(this.avatarService.avatarColor$), avatarColor: await firstValueFrom(this.avatarService.avatarColor$),
server: await this.environmentService.getHost(), server: (await this.environmentService.getEnvironment())?.getHostname(),
}; };
} catch { } catch {
this.activeAccount = undefined; this.activeAccount = undefined;
@ -158,7 +158,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
email: baseAccounts[userId].profile.email, email: baseAccounts[userId].profile.email,
authenticationStatus: await this.authService.getAuthStatus(userId), authenticationStatus: await this.authService.getAuthStatus(userId),
avatarColor: await firstValueFrom(this.avatarService.getUserAvatarColor$(userId as 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 { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.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 { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.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 { export class InitService {
constructor( constructor(
@Inject(WINDOW) private win: Window, @Inject(WINDOW) private win: Window,
private environmentService: EnvironmentServiceAbstraction,
private syncService: SyncServiceAbstraction, private syncService: SyncServiceAbstraction,
private vaultTimeoutService: VaultTimeoutService, private vaultTimeoutService: VaultTimeoutService,
private i18nService: I18nServiceAbstraction, private i18nService: I18nServiceAbstraction,
@ -46,10 +44,6 @@ export class InitService {
return async () => { return async () => {
this.nativeMessagingService.init(); this.nativeMessagingService.init();
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process 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. // 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises
this.syncService.fullSync(true); this.syncService.fullSync(true);

View File

@ -49,10 +49,6 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
return this.formGroup.value.email; return this.formGroup.value.email;
} }
get selfHostedDomain() {
return this.environmentService.hasBaseUrl() ? this.environmentService.getWebVaultUrl() : null;
}
constructor( constructor(
devicesApiService: DevicesApiServiceAbstraction, devicesApiService: DevicesApiServiceAbstraction,
appIdService: AppIdService, appIdService: AppIdService,
@ -152,9 +148,6 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
// eslint-disable-next-line rxjs/no-async-subscribe // eslint-disable-next-line rxjs/no-async-subscribe
childComponent.onSaved.pipe(takeUntil(this.componentDestroyed$)).subscribe(async () => { childComponent.onSaved.pipe(takeUntil(this.componentDestroyed$)).subscribe(async () => {
modal.close(); 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); await this.getLoginWithDevice(this.loggedEmail);
}); });
} }

View File

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

View File

@ -2346,12 +2346,6 @@
"loggingInOn": { "loggingInOn": {
"message": "Logging in on" "message": "Logging in on"
}, },
"usDomain": {
"message": "bitwarden.com"
},
"euDomain": {
"message": "bitwarden.eu"
},
"selfHostedServer": { "selfHostedServer": {
"message": "self-hosted" "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 { DefaultBiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; 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 { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
@ -54,7 +54,7 @@ export class Main {
memoryStorageForStateProviders: MemoryStorageServiceForStateProviders; memoryStorageForStateProviders: MemoryStorageServiceForStateProviders;
messagingService: ElectronMainMessagingService; messagingService: ElectronMainMessagingService;
stateService: StateService; stateService: StateService;
environmentService: EnvironmentService; environmentService: DefaultEnvironmentService;
mainCryptoFunctionService: MainCryptoFunctionService; mainCryptoFunctionService: MainCryptoFunctionService;
desktopCredentialStorageListener: DesktopCredentialStorageListener; desktopCredentialStorageListener: DesktopCredentialStorageListener;
migrationRunner: MigrationRunner; migrationRunner: MigrationRunner;
@ -148,7 +148,7 @@ export class Main {
new DefaultDerivedStateProvider(this.memoryStorageForStateProviders), new DefaultDerivedStateProvider(this.memoryStorageForStateProviders),
); );
this.environmentService = new EnvironmentService(stateProvider, accountService); this.environmentService = new DefaultEnvironmentService(stateProvider, accountService);
this.tokenService = new TokenService( this.tokenService = new TokenService(
singleUserStateProvider, singleUserStateProvider,

View File

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

View File

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

View File

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

View File

@ -4,6 +4,22 @@
"notifications": "https://notifications.euqa.bitwarden.pw", "notifications": "https://notifications.euqa.bitwarden.pw",
"scim": "https://scim.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": { "flags": {
"secretsManager": true, "secretsManager": true,
"showPasswordless": true "showPasswordless": true

View File

@ -10,6 +10,22 @@
"proxyEvents": "https://events.qa.bitwarden.pw", "proxyEvents": "https://events.qa.bitwarden.pw",
"proxyNotifications": "https://notifications.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": { "flags": {
"secretsManager": true, "secretsManager": true,
"showPasswordless": true, "showPasswordless": true,

View File

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

View File

@ -44,11 +44,11 @@ export class PremiumComponent implements OnInit {
private billingAccountProfileStateService: BillingAccountProfileStateService, private billingAccountProfileStateService: BillingAccountProfileStateService,
) { ) {
this.selfHosted = platformUtilsService.isSelfHost(); this.selfHosted = platformUtilsService.isSelfHost();
this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl();
this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$; this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$;
} }
async ngOnInit() { async ngOnInit() {
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
if (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumPersonally$)) { 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. // 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises

View File

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

View File

@ -1,7 +1,7 @@
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms"; import { FormControl, FormGroup } from "@angular/forms";
import { ActivatedRoute } from "@angular/router"; 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 { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; 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 i18nService: I18nService,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private dialogService: DialogService, private dialogService: DialogService,
) { ) {}
this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl();
}
async ngOnInit() { async ngOnInit() {
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
this.route.params this.route.params
.pipe( .pipe(
concatMap(async (params) => { 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 { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request"; import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
export type PayPalConfig = {
businessId?: string;
buttonAction?: string;
};
@Component({ @Component({
selector: "app-add-credit", selector: "app-add-credit",
templateUrl: "add-credit.component.html", templateUrl: "add-credit.component.html",

View File

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

View File

@ -1,7 +1,10 @@
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { Router } from "@angular/router"; 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; 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 { export class EnvironmentSelectorComponent implements OnInit {
constructor( constructor(
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private environmentService: EnvironmentService,
private router: Router, private router: Router,
) {} ) {}
isEuServer: boolean; protected availableRegions = this.environmentService.availableRegions();
isUsServer: boolean; protected currentRegion?: RegionConfig;
showRegionSelector = false;
routeAndParams: string; protected showRegionSelector = false;
protected routeAndParams: string;
async ngOnInit() { 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.showRegionSelector = !this.platformUtilsService.isSelfHost();
this.routeAndParams = `/#${this.router.url}`; 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_MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE, OBSERVABLE_DISK_STORAGE,
OBSERVABLE_DISK_LOCAL_STORAGE, OBSERVABLE_DISK_LOCAL_STORAGE,
WINDOW,
} from "@bitwarden/angular/services/injection-tokens"; } from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service"; 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 as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service";
import { LoginService } from "@bitwarden/common/auth/services/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 { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.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 { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; 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 { 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"; import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service";
/* eslint-enable import/no-restricted-paths -- Implementation for memory storage */ /* eslint-enable import/no-restricted-paths -- Implementation for memory storage */
import { import {
@ -41,6 +44,7 @@ import {
import { PolicyListService } from "../admin-console/core/policy-list.service"; import { PolicyListService } from "../admin-console/core/policy-list.service";
import { HtmlStorageService } from "../core/html-storage.service"; import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service"; import { I18nService } from "../core/i18n.service";
import { WebEnvironmentService } from "../platform/web-environment.service";
import { WebMigrationRunner } from "../platform/web-migration-runner"; import { WebMigrationRunner } from "../platform/web-migration-runner";
import { WebStorageServiceProvider } from "../platform/web-storage-service.provider"; import { WebStorageServiceProvider } from "../platform/web-storage-service.provider";
import { WindowStorageService } from "../platform/window-storage.service"; import { WindowStorageService } from "../platform/window-storage.service";
@ -138,6 +142,11 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service";
OBSERVABLE_DISK_LOCAL_STORAGE, OBSERVABLE_DISK_LOCAL_STORAGE,
], ],
}, },
{
provide: EnvironmentService,
useClass: WebEnvironmentService,
deps: [WINDOW, StateProvider, AccountService],
},
{ {
provide: ThemeStateService, provide: ThemeStateService,
useFactory: (globalStateProvider: GlobalStateProvider) => 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 { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.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 { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { ConfigService } from "@bitwarden/common/platform/services/config/config.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 { export class InitService {
constructor( constructor(
@Inject(WINDOW) private win: Window, @Inject(WINDOW) private win: Window,
private environmentService: EnvironmentServiceAbstraction,
private notificationsService: NotificationsServiceAbstraction, private notificationsService: NotificationsServiceAbstraction,
private vaultTimeoutService: VaultTimeoutService, private vaultTimeoutService: VaultTimeoutService,
private i18nService: I18nServiceAbstraction, private i18nService: I18nServiceAbstraction,
@ -41,13 +36,6 @@ export class InitService {
return async () => { return async () => {
await this.stateService.init(); 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); setTimeout(() => this.notificationsService.init(), 3000);
await this.vaultTimeoutService.init(true); await this.vaultTimeoutService.init(true);
await this.i18nService.init(); 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": { "enforceOnLoginDesc": {
"message": "Require existing members to change their passwords" "message": "Require existing members to change their passwords"
}, },
"usDomain": {
"message": "bitwarden.com"
},
"euDomain": {
"message": "bitwarden.eu"
},
"smProjectDeleteAccessRestricted": { "smProjectDeleteAccessRestricted": {
"message": "You don't have permissions to delete this project", "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." "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"] ?? {}, PAYPAL_CONFIG: envConfig["paypal"] ?? {},
FLAGS: envConfig["flags"] ?? {}, FLAGS: envConfig["flags"] ?? {},
DEV_FLAGS: NODE_ENV === "development" ? envConfig["devFlags"] : {}, DEV_FLAGS: NODE_ENV === "development" ? envConfig["devFlags"] : {},
ADDITIONAL_REGIONS: envConfig["additionalRegions"] ?? [],
}), }),
new AngularWebpackPlugin({ new AngularWebpackPlugin({
tsConfigPath: "tsconfig.json", tsConfigPath: "tsconfig.json",

View File

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

View File

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

View File

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

View File

@ -1,13 +1,13 @@
import { animate, state, style, transition, trigger } from "@angular/animations"; import { animate, state, style, transition, trigger } from "@angular/animations";
import { ConnectedPosition } from "@angular/cdk/overlay"; 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 { 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 { import {
EnvironmentService as EnvironmentServiceAbstraction, EnvironmentService,
Region, Region,
RegionConfig,
} from "@bitwarden/common/platform/abstractions/environment.service"; } from "@bitwarden/common/platform/abstractions/environment.service";
@Component({ @Component({
@ -34,7 +34,7 @@ import {
]), ]),
], ],
}) })
export class EnvironmentSelectorComponent implements OnInit, OnDestroy { export class EnvironmentSelectorComponent {
@Output() onOpenSelfHostedSettings = new EventEmitter(); @Output() onOpenSelfHostedSettings = new EventEmitter();
isOpen = false; isOpen = false;
showingModal = false; showingModal = false;
@ -48,59 +48,34 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
overlayY: "top", 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( constructor(
protected environmentService: EnvironmentServiceAbstraction, protected environmentService: EnvironmentService,
protected configService: ConfigServiceAbstraction,
protected router: Router, 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) { async toggle(option: Region) {
this.isOpen = !this.isOpen; this.isOpen = !this.isOpen;
if (option === null) { if (option === null) {
return; 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) { if (option === Region.SelfHosted) {
this.onOpenSelfHostedSettings.emit(); this.onOpenSelfHostedSettings.emit();
return; return;
} }
await this.environmentService.setRegion(option); await this.environmentService.setEnvironment(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;
} }
close() { close() {
this.isOpen = false; 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 { Directive, EventEmitter, Output } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { import {
EnvironmentService, EnvironmentService,
@ -27,21 +28,29 @@ export class EnvironmentComponent {
protected i18nService: I18nService, protected i18nService: I18nService,
private modalService: ModalService, private modalService: ModalService,
) { ) {
const urls = this.environmentService.getUrls(); this.environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => {
if (this.environmentService.selectedRegion != Region.SelfHosted) { if (env.getRegion() !== Region.SelfHosted) {
return; this.baseUrl = "";
} this.webVaultUrl = "";
this.apiUrl = "";
this.identityUrl = "";
this.iconsUrl = "";
this.notificationsUrl = "";
return;
}
this.baseUrl = urls.base || ""; const urls = env.getUrls();
this.webVaultUrl = urls.webVault || ""; this.baseUrl = urls.base || "";
this.apiUrl = urls.api || ""; this.webVaultUrl = urls.webVault || "";
this.identityUrl = urls.identity || ""; this.apiUrl = urls.api || "";
this.iconsUrl = urls.icons || ""; this.identityUrl = urls.identity || "";
this.notificationsUrl = urls.notifications || ""; this.iconsUrl = urls.icons || "";
this.notificationsUrl = urls.notifications || "";
});
} }
async submit() { async submit() {
const resUrls = await this.environmentService.setUrls({ await this.environmentService.setEnvironment(Region.SelfHosted, {
base: this.baseUrl, base: this.baseUrl,
api: this.apiUrl, api: this.apiUrl,
identity: this.identityUrl, identity: this.identityUrl,
@ -50,14 +59,6 @@ export class EnvironmentComponent {
notifications: this.notificationsUrl, 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.platformUtilsService.showToast("success", null, this.i18nService.t("environmentSaved"));
this.saved(); this.saved();
} }

View File

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

View File

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

View File

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

View File

@ -116,7 +116,8 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
} }
if (this.win != null && this.webAuthnSupported) { 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.webAuthn = new WebAuthnIFrame(
this.win, this.win,
webVaultUrl, webVaultUrl,
@ -494,5 +495,5 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
} }
// implemented in clients // 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 { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.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 { 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 { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.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 { CryptoService } from "@bitwarden/common/platform/services/crypto.service";
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation";
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-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 { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
@ -363,7 +363,7 @@ const typesafeProviders: Array<SafeProvider> = [
MessagingServiceAbstraction, MessagingServiceAbstraction,
LogService, LogService,
KeyConnectorServiceAbstraction, KeyConnectorServiceAbstraction,
EnvironmentServiceAbstraction, EnvironmentService,
StateServiceAbstraction, StateServiceAbstraction,
TwoFactorServiceAbstraction, TwoFactorServiceAbstraction,
I18nServiceAbstraction, I18nServiceAbstraction,
@ -477,8 +477,8 @@ const typesafeProviders: Array<SafeProvider> = [
deps: [CryptoServiceAbstraction, I18nServiceAbstraction, StateProvider], deps: [CryptoServiceAbstraction, I18nServiceAbstraction, StateProvider],
}), }),
safeProvider({ safeProvider({
provide: EnvironmentServiceAbstraction, provide: EnvironmentService,
useClass: EnvironmentService, useClass: DefaultEnvironmentService,
deps: [StateProvider, AccountServiceAbstraction], deps: [StateProvider, AccountServiceAbstraction],
}), }),
safeProvider({ safeProvider({
@ -545,7 +545,7 @@ const typesafeProviders: Array<SafeProvider> = [
deps: [ deps: [
TokenServiceAbstraction, TokenServiceAbstraction,
PlatformUtilsServiceAbstraction, PlatformUtilsServiceAbstraction,
EnvironmentServiceAbstraction, EnvironmentService,
AppIdServiceAbstraction, AppIdServiceAbstraction,
StateServiceAbstraction, StateServiceAbstraction,
LOGOUT_CALLBACK, LOGOUT_CALLBACK,
@ -647,7 +647,7 @@ const typesafeProviders: Array<SafeProvider> = [
LogService, LogService,
STATE_FACTORY, STATE_FACTORY,
AccountServiceAbstraction, AccountServiceAbstraction,
EnvironmentServiceAbstraction, EnvironmentService,
TokenServiceAbstraction, TokenServiceAbstraction,
MigrationRunner, MigrationRunner,
STATE_SERVICE_USE_CACHE, STATE_SERVICE_USE_CACHE,
@ -711,7 +711,7 @@ const typesafeProviders: Array<SafeProvider> = [
SyncServiceAbstraction, SyncServiceAbstraction,
AppIdServiceAbstraction, AppIdServiceAbstraction,
ApiServiceAbstraction, ApiServiceAbstraction,
EnvironmentServiceAbstraction, EnvironmentService,
LOGOUT_CALLBACK, LOGOUT_CALLBACK,
StateServiceAbstraction, StateServiceAbstraction,
AuthServiceAbstraction, AuthServiceAbstraction,
@ -853,8 +853,9 @@ const typesafeProviders: Array<SafeProvider> = [
StateServiceAbstraction, StateServiceAbstraction,
ConfigApiServiceAbstraction, ConfigApiServiceAbstraction,
AuthServiceAbstraction, AuthServiceAbstraction,
EnvironmentServiceAbstraction, EnvironmentService,
LogService, LogService,
StateProvider,
], ],
}), }),
safeProvider({ safeProvider({
@ -869,7 +870,7 @@ const typesafeProviders: Array<SafeProvider> = [
safeProvider({ safeProvider({
provide: AnonymousHubServiceAbstraction, provide: AnonymousHubServiceAbstraction,
useClass: AnonymousHubService, useClass: AnonymousHubService,
deps: [EnvironmentServiceAbstraction, LoginStrategyServiceAbstraction, LogService], deps: [EnvironmentService, LoginStrategyServiceAbstraction, LogService],
}), }),
safeProvider({ safeProvider({
provide: ValidationServiceAbstraction, provide: ValidationServiceAbstraction,
@ -949,7 +950,7 @@ const typesafeProviders: Array<SafeProvider> = [
safeProvider({ safeProvider({
provide: WebAuthnLoginApiServiceAbstraction, provide: WebAuthnLoginApiServiceAbstraction,
useClass: WebAuthnLoginApiService, useClass: WebAuthnLoginApiService,
deps: [ApiServiceAbstraction, EnvironmentServiceAbstraction], deps: [ApiServiceAbstraction, EnvironmentService],
}), }),
safeProvider({ safeProvider({
provide: WebAuthnLoginServiceAbstraction, provide: WebAuthnLoginServiceAbstraction,

View File

@ -1,7 +1,7 @@
import { DatePipe } from "@angular/common"; import { DatePipe } from "@angular/common";
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms"; 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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums"; 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("sendTypeFile"), value: SendType.File, premium: true },
{ name: i18nService.t("sendTypeText"), value: SendType.Text, premium: false }, { name: i18nService.t("sendTypeText"), value: SendType.Text, premium: false },
]; ];
this.sendLinkBaseUrl = this.environmentService.getSendUrl();
} }
get link(): string { 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$ this.billingAccountProfileStateService.hasPremiumFromAnySource$
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe((hasPremiumFromAnySource) => { .subscribe((hasPremiumFromAnySource) => {

View File

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

View File

@ -39,11 +39,12 @@ export class IconComponent implements OnInit {
) {} ) {}
async ngOnInit() { async ngOnInit() {
const iconsUrl = this.environmentService.getIconsUrl();
this.data$ = combineLatest([ this.data$ = combineLatest([
this.environmentService.environment$.pipe(map((e) => e.getIconsUrl())),
this.domainSettingsService.showFavicons$.pipe(distinctUntilChanged()), this.domainSettingsService.showFavicons$.pipe(distinctUntilChanged()),
this.cipher$.pipe(filter((c) => c !== undefined)), 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 { OnInit, Directive } from "@angular/core";
import { Observable, Subject } from "rxjs"; import { firstValueFrom, Observable } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.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"; import { DialogService } from "@bitwarden/components";
@Directive() @Directive()
export class PremiumComponent { export class PremiumComponent implements OnInit {
isPremium$: Observable<boolean>; isPremium$: Observable<boolean>;
price = 10; price = 10;
refreshPromise: Promise<any>; refreshPromise: Promise<any>;
cloudWebVaultUrl: string; cloudWebVaultUrl: string;
private directiveIsDestroyed$ = new Subject<boolean>();
constructor( constructor(
protected i18nService: I18nService, protected i18nService: I18nService,
@ -25,13 +24,16 @@ export class PremiumComponent {
private logService: LogService, private logService: LogService,
protected stateService: StateService, protected stateService: StateService,
protected dialogService: DialogService, protected dialogService: DialogService,
environmentService: EnvironmentService, private environmentService: EnvironmentService,
billingAccountProfileStateService: BillingAccountProfileStateService, billingAccountProfileStateService: BillingAccountProfileStateService,
) { ) {
this.cloudWebVaultUrl = environmentService.getCloudWebVaultUrl();
this.isPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$; this.isPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$;
} }
async ngOnInit() {
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
}
async refresh() { async refresh() {
try { try {
this.refreshPromise = this.apiService.refreshIdentityToken(); this.refreshPromise = this.apiService.refreshIdentityToken();

View File

@ -1,4 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.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 { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -145,8 +149,11 @@ describe("UserApiLoginStrategy", () => {
const tokenResponse = identityTokenResponseFactory(); const tokenResponse = identityTokenResponseFactory();
tokenResponse.apiUseKeyConnector = true; tokenResponse.apiUseKeyConnector = true;
const env = mock<Environment>();
env.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
environmentService.environment$ = new BehaviorSubject(env);
apiService.postIdentityToken.mockResolvedValue(tokenResponse); apiService.postIdentityToken.mockResolvedValue(tokenResponse);
environmentService.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
await apiLogInStrategy.logIn(credentials); await apiLogInStrategy.logIn(credentials);
@ -160,8 +167,11 @@ describe("UserApiLoginStrategy", () => {
const tokenResponse = identityTokenResponseFactory(); const tokenResponse = identityTokenResponseFactory();
tokenResponse.apiUseKeyConnector = true; tokenResponse.apiUseKeyConnector = true;
const env = mock<Environment>();
env.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
environmentService.environment$ = new BehaviorSubject(env);
apiService.postIdentityToken.mockResolvedValue(tokenResponse); apiService.postIdentityToken.mockResolvedValue(tokenResponse);
environmentService.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
cryptoService.getMasterKey.mockResolvedValue(masterKey); cryptoService.getMasterKey.mockResolvedValue(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); 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 { Jsonify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
@ -85,7 +85,8 @@ export class UserApiLoginStrategy extends LoginStrategy {
protected override async setMasterKey(response: IdentityTokenResponse) { protected override async setMasterKey(response: IdentityTokenResponse) {
if (response.apiUseKeyConnector) { if (response.apiUseKeyConnector) {
const keyConnectorUrl = this.environmentService.getKeyConnectorUrl(); const env = await firstValueFrom(this.environmentService.environment$);
const keyConnectorUrl = env.getKeyConnectorUrl();
await this.keyConnectorService.setMasterKeyFromUrl(keyConnectorUrl); 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, IHubProtocol,
} from "@microsoft/signalr"; } from "@microsoft/signalr";
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack"; import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
import { firstValueFrom } from "rxjs";
import { LoginStrategyServiceAbstraction } from "../../../../auth/src/common/abstractions/login-strategy.service"; import { LoginStrategyServiceAbstraction } from "../../../../auth/src/common/abstractions/login-strategy.service";
import { import {
@ -26,7 +27,7 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction {
) {} ) {}
async createHubConnection(token: string) { async createHubConnection(token: string) {
this.url = this.environmentService.getNotificationsUrl(); this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
this.anonHubConnection = new HubConnectionBuilder() this.anonHubConnection = new HubConnectionBuilder()
.withUrl(this.url + "/anonymous-hub?Token=" + token, { .withUrl(this.url + "/anonymous-hub?Token=" + token, {

View File

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

View File

@ -14,64 +14,119 @@ export type Urls = {
scim?: string; scim?: string;
}; };
export type PayPalConfig = { /**
businessId?: string; * A subset of available regions, additional regions can be loaded through configuration.
buttonAction?: string; */
};
export enum Region { export enum Region {
US = "US", US = "US",
EU = "EU", EU = "EU",
SelfHosted = "Self-hosted", SelfHosted = "Self-hosted",
} }
export enum RegionDomain { /**
US = "bitwarden.com", * The possible cloud regions.
EU = "bitwarden.eu", */
USQA = "bitwarden.pw", 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 { export abstract class EnvironmentService {
urls: Observable<void>; abstract environment$: Observable<Environment>;
usUrls: Urls; abstract cloudWebVaultUrl$: Observable<string>;
euUrls: Urls;
selectedRegion?: Region;
initialized = true;
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. * This currently relies on compile time provided constants, and will not change at runtime.
* @remarks Use this method only in views exclusive to self-host instances. * 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. * 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>; abstract getEnvironment(userId?: string): Promise<Environment | undefined>;
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;
} }

View File

@ -1,11 +1,13 @@
import { MockProxy, mock } from "jest-mock-extended"; import { MockProxy, mock } from "jest-mock-extended";
import { ReplaySubject, skip, take } from "rxjs"; import { ReplaySubject, skip, take } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { UserId } from "../../../types/guid";
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
import { ServerConfig } from "../../abstractions/config/server-config"; 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 { LogService } from "../../abstractions/log.service";
import { StateService } from "../../abstractions/state.service"; import { StateService } from "../../abstractions/state.service";
import { ServerConfigData } from "../../models/data/server-config.data"; import { ServerConfigData } from "../../models/data/server-config.data";
@ -14,6 +16,7 @@ import {
ServerConfigResponse, ServerConfigResponse,
ThirdPartyServerConfigResponse, ThirdPartyServerConfigResponse,
} from "../../models/response/server-config.response"; } from "../../models/response/server-config.response";
import { StateProvider } from "../../state";
import { ConfigService } from "./config.service"; import { ConfigService } from "./config.service";
@ -23,6 +26,8 @@ describe("ConfigService", () => {
let authService: MockProxy<AuthService>; let authService: MockProxy<AuthService>;
let environmentService: MockProxy<EnvironmentService>; let environmentService: MockProxy<EnvironmentService>;
let logService: MockProxy<LogService>; let logService: MockProxy<LogService>;
let replaySubject: ReplaySubject<Environment>;
let stateProvider: StateProvider;
let serverResponseCount: number; // increments to track distinct responses received from server let serverResponseCount: number; // increments to track distinct responses received from server
@ -35,6 +40,7 @@ describe("ConfigService", () => {
authService, authService,
environmentService, environmentService,
logService, logService,
stateProvider,
); );
configService.init(); configService.init();
return configService; return configService;
@ -46,8 +52,11 @@ describe("ConfigService", () => {
authService = mock(); authService = mock();
environmentService = mock(); environmentService = mock();
logService = 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; serverResponseCount = 1;
configApiService.get.mockImplementation(() => configApiService.get.mockImplementation(() =>
@ -139,7 +148,7 @@ describe("ConfigService", () => {
} }
}); });
(environmentService.urls as ReplaySubject<void>).next(); replaySubject.next(null);
}); });
it("when triggerServerConfigFetch() is called", (done) => { 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 { LogService } from "../../abstractions/log.service";
import { StateService } from "../../abstractions/state.service"; import { StateService } from "../../abstractions/state.service";
import { ServerConfigData } from "../../models/data/server-config.data"; import { ServerConfigData } from "../../models/data/server-config.data";
import { StateProvider } from "../../state";
const ONE_HOUR_IN_MILLISECONDS = 1000 * 3600; const ONE_HOUR_IN_MILLISECONDS = 1000 * 3600;
@ -44,6 +45,7 @@ export class ConfigService implements ConfigServiceAbstraction {
private authService: AuthService, private authService: AuthService,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private logService: LogService, private logService: LogService,
private stateProvider: StateProvider,
// Used to avoid duplicate subscriptions, e.g. in browser between the background and popup // Used to avoid duplicate subscriptions, e.g. in browser between the background and popup
private subscribe = true, 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 // If you need to fetch a new config when an event occurs, add an observable that emits on that event here
merge( merge(
this.refreshTimer$, // an overridable interval 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 this._forceFetchConfig, // manual
) )
.pipe( .pipe(
@ -104,8 +106,9 @@ export class ConfigService implements ConfigServiceAbstraction {
return; return;
} }
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
await this.stateService.setServerConfig(data); 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 CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk"); export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk");
export const ENVIRONMENT_DISK = new StateDefinition("environment", "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 THEMING_DISK = new StateDefinition("theming", "disk", { web: "disk-local" });
export const TRANSLATION_DISK = new StateDefinition("translation", "disk"); 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 { ApiService as ApiServiceAbstraction } from "../abstractions/api.service";
import { OrganizationConnectionType } from "../admin-console/enums"; import { OrganizationConnectionType } from "../admin-console/enums";
import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request"; 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()
: request.toIdentityToken(this.platformUtilsService.getClientType()); : request.toIdentityToken(this.platformUtilsService.getClientType());
const env = await firstValueFrom(this.environmentService.environment$);
const response = await this.fetch( const response = await this.fetch(
new Request(this.environmentService.getIdentityUrl() + "/connect/token", { new Request(env.getIdentityUrl() + "/connect/token", {
body: this.qsStringify(identityToken), body: this.qsStringify(identityToken),
credentials: this.getCredentials(), credentials: await this.getCredentials(),
cache: "no-store", cache: "no-store",
headers: headers, headers: headers,
method: "POST", method: "POST",
@ -323,13 +327,14 @@ export class ApiService implements ApiServiceAbstraction {
} }
async postPrelogin(request: PreloginRequest): Promise<PreloginResponse> { async postPrelogin(request: PreloginRequest): Promise<PreloginResponse> {
const env = await firstValueFrom(this.environmentService.environment$);
const r = await this.send( const r = await this.send(
"POST", "POST",
"/accounts/prelogin", "/accounts/prelogin",
request, request,
false, false,
true, true,
this.environmentService.getIdentityUrl(), env.getIdentityUrl(),
); );
return new PreloginResponse(r); return new PreloginResponse(r);
} }
@ -368,13 +373,14 @@ export class ApiService implements ApiServiceAbstraction {
} }
async postRegister(request: RegisterRequest): Promise<RegisterResponse> { async postRegister(request: RegisterRequest): Promise<RegisterResponse> {
const env = await firstValueFrom(this.environmentService.environment$);
const r = await this.send( const r = await this.send(
"POST", "POST",
"/accounts/register", "/accounts/register",
request, request,
false, false,
true, true,
this.environmentService.getIdentityUrl(), env.getIdentityUrl(),
); );
return new RegisterResponse(r); return new RegisterResponse(r);
} }
@ -1457,10 +1463,11 @@ export class ApiService implements ApiServiceAbstraction {
if (this.customUserAgent != null) { if (this.customUserAgent != null) {
headers.set("User-Agent", this.customUserAgent); headers.set("User-Agent", this.customUserAgent);
} }
const env = await firstValueFrom(this.environmentService.environment$);
const response = await this.fetch( const response = await this.fetch(
new Request(this.environmentService.getEventsUrl() + "/collect", { new Request(env.getEventsUrl() + "/collect", {
cache: "no-store", cache: "no-store",
credentials: this.getCredentials(), credentials: await this.getCredentials(),
method: "POST", method: "POST",
body: JSON.stringify(request), body: JSON.stringify(request),
headers: headers, headers: headers,
@ -1617,11 +1624,12 @@ export class ApiService implements ApiServiceAbstraction {
headers.set("User-Agent", this.customUserAgent); headers.set("User-Agent", this.customUserAgent);
} }
const env = await firstValueFrom(this.environmentService.environment$);
const path = `/sso/prevalidate?domainHint=${encodeURIComponent(identifier)}`; const path = `/sso/prevalidate?domainHint=${encodeURIComponent(identifier)}`;
const response = await this.fetch( const response = await this.fetch(
new Request(this.environmentService.getIdentityUrl() + path, { new Request(env.getIdentityUrl() + path, {
cache: "no-store", cache: "no-store",
credentials: this.getCredentials(), credentials: await this.getCredentials(),
headers: headers, headers: headers,
method: "GET", method: "GET",
}), }),
@ -1751,16 +1759,17 @@ export class ApiService implements ApiServiceAbstraction {
headers.set("User-Agent", this.customUserAgent); headers.set("User-Agent", this.customUserAgent);
} }
const env = await firstValueFrom(this.environmentService.environment$);
const decodedToken = await this.tokenService.decodeAccessToken(); const decodedToken = await this.tokenService.decodeAccessToken();
const response = await this.fetch( const response = await this.fetch(
new Request(this.environmentService.getIdentityUrl() + "/connect/token", { new Request(env.getIdentityUrl() + "/connect/token", {
body: this.qsStringify({ body: this.qsStringify({
grant_type: "refresh_token", grant_type: "refresh_token",
client_id: decodedToken.client_id, client_id: decodedToken.client_id,
refresh_token: refreshToken, refresh_token: refreshToken,
}), }),
cache: "no-store", cache: "no-store",
credentials: this.getCredentials(), credentials: await this.getCredentials(),
headers: headers, headers: headers,
method: "POST", method: "POST",
}), }),
@ -1822,7 +1831,8 @@ export class ApiService implements ApiServiceAbstraction {
apiUrl?: string, apiUrl?: string,
alterHeaders?: (headers: Headers) => void, alterHeaders?: (headers: Headers) => void,
): Promise<any> { ): 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 // Prevent directory traversal from malicious paths
const pathParts = path.split("?"); const pathParts = path.split("?");
@ -1838,7 +1848,7 @@ export class ApiService implements ApiServiceAbstraction {
const requestInit: RequestInit = { const requestInit: RequestInit = {
cache: "no-store", cache: "no-store",
credentials: this.getCredentials(), credentials: await this.getCredentials(),
method: method, method: method,
}; };
@ -1917,8 +1927,9 @@ export class ApiService implements ApiServiceAbstraction {
.join("&"); .join("&");
} }
private getCredentials(): RequestCredentials { private async getCredentials(): Promise<RequestCredentials> {
if (!this.isWebClient || this.environmentService.hasBaseUrl()) { const env = await firstValueFrom(this.environmentService.environment$);
if (!this.isWebClient || env.hasBaseUrl()) {
return "include"; return "include";
} }
return undefined; return undefined;

View File

@ -1,5 +1,6 @@
import * as signalR from "@microsoft/signalr"; import * as signalR from "@microsoft/signalr";
import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack"; import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack";
import { firstValueFrom } from "rxjs";
import { ApiService } from "../abstractions/api.service"; import { ApiService } from "../abstractions/api.service";
import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service"; import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service";
@ -38,7 +39,7 @@ export class NotificationsService implements NotificationsServiceAbstraction {
private authService: AuthService, private authService: AuthService,
private messagingService: MessagingService, private messagingService: MessagingService,
) { ) {
this.environmentService.urls.subscribe(() => { this.environmentService.environment$.subscribe(() => {
if (!this.inited) { if (!this.inited) {
return; return;
} }
@ -51,7 +52,7 @@ export class NotificationsService implements NotificationsServiceAbstraction {
async init(): Promise<void> { async init(): Promise<void> {
this.inited = false; 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 // Set notifications server URL to `https://-` to effectively disable communication
// with the notifications server from the client app // 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 { 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 { 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 { 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 { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; 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"; import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3; export const MIN_VERSION = 3;
export const CURRENT_VERSION = 44; export const CURRENT_VERSION = 45;
export type MinVersion = typeof MIN_VERSION; export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() { export function createMigrationBuilder() {
@ -94,7 +96,8 @@ export function createMigrationBuilder() {
.with(EventCollectionMigrator, 40, 41) .with(EventCollectionMigrator, 40, 41)
.with(EnableFaviconMigrator, 41, 42) .with(EnableFaviconMigrator, 41, 42)
.with(AutoConfirmFingerPrintsMigrator, 42, 43) .with(AutoConfirmFingerPrintsMigrator, 42, 43)
.with(UserDecryptionOptionsMigrator, 43, CURRENT_VERSION); .with(UserDecryptionOptionsMigrator, 43, 44)
.with(MergeEnvironmentState, 44, CURRENT_VERSION);
} }
export async function currentVersion( 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({ this.oidcClient = new OidcClient({
authority: this.vault.userType.openIDConnectAuthorityBase, authority: this.vault.userType.openIDConnectAuthorityBase,
client_id: this.vault.userType.openIDConnectClientId, client_id: this.vault.userType.openIDConnectClientId,
redirect_uri: this.getOidcRedirectUrl(), redirect_uri: await this.getOidcRedirectUrl(),
response_type: "code", response_type: "code",
scope: this.vault.userType.oidcScope, scope: this.vault.userType.oidcScope,
response_mode: "query", response_mode: "query",
@ -151,12 +151,13 @@ export class LastPassDirectImportService {
return redirectUri + "&" + params; return redirectUri + "&" + params;
} }
private getOidcRedirectUrl() { private async getOidcRedirectUrl() {
const clientType = this.platformUtilsService.getClientType(); const clientType = this.platformUtilsService.getClientType();
if (clientType === ClientType.Desktop) { if (clientType === ClientType.Desktop) {
return "bitwarden://import-callback-lp"; 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"; return webUrl + "/sso-connector.html?lp=1";
} }