1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00

[PM-5979] Refactor EnvironmentService (#8040)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
import { Jsonify } from "type-fest";
export class EnvironmentUrls {
base: string = null;
api: string = null;
identity: string = null;
icons: string = null;
notifications: string = null;
events: string = null;
webVault: string = null;
keyConnector: string = null;
static fromJSON(obj: Jsonify<EnvironmentUrls>): EnvironmentUrls {
return Object.assign(new EnvironmentUrls(), obj);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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