1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-20 18:23:31 +00:00

Merge branch 'main' into vault/pm-5273

# Conflicts:
#	libs/common/src/platform/abstractions/state.service.ts
#	libs/common/src/platform/services/state.service.ts
#	libs/common/src/state-migrations/migrate.ts
This commit is contained in:
Carlos Gonçalves
2024-03-25 18:38:10 +00:00
364 changed files with 9254 additions and 2586 deletions

View File

@@ -149,6 +149,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
this.captchaToken,
null,
);
this.formPromise = this.loginStrategyService.logIn(credentials);
const response = await this.formPromise;
this.setFormValues();
@@ -302,6 +303,9 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
async saveEmailSettings() {
this.setFormValues();
await this.loginService.saveEmailSettings();
// Save off email for SSO
await this.ssoLoginService.setSsoEmail(this.formGroup.value.email);
}
// Legacy accounts used the master key to encrypt data. Migration is required

View File

@@ -182,11 +182,14 @@ export class SsoComponent {
private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise<void> {
this.loggingIn = true;
try {
const email = await this.ssoLoginService.getSsoEmail();
const credentials = new SsoLoginCredentials(
code,
codeVerifier,
this.redirectUri,
orgSsoIdentifier,
email,
);
this.formPromise = this.loginStrategyService.logIn(credentials);
const authResult = await this.formPromise;

View File

@@ -3,6 +3,7 @@ import { Router } from "@angular/router";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -19,6 +20,7 @@ export class TwoFactorOptionsComponent implements OnInit {
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected win: Window,
protected environmentService: EnvironmentService,
) {}
ngOnInit() {
@@ -30,7 +32,8 @@ export class TwoFactorOptionsComponent implements OnInit {
}
recover() {
this.platformUtilsService.launchUri("https://vault.bitwarden.com/#/recover-2fa");
const webVault = this.environmentService.getWebVaultUrl();
this.platformUtilsService.launchUri(webVault + "/#/recover-2fa");
this.onRecoverSelected.emit();
}
}

View File

@@ -1,6 +1,7 @@
import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
/**
* Hides the element if the user has premium.
@@ -12,11 +13,13 @@ export class NotPremiumDirective implements OnInit {
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private stateService: StateService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
) {}
async ngOnInit(): Promise<void> {
const premium = await this.stateService.getCanAccessPremium();
const premium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$,
);
if (premium) {
this.viewContainer.clear();

View File

@@ -1,6 +1,7 @@
import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
import { Directive, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
/**
* Only shows the element if the user has premium.
@@ -8,20 +9,29 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
@Directive({
selector: "[appPremium]",
})
export class PremiumDirective implements OnInit {
export class PremiumDirective implements OnInit, OnDestroy {
private directiveIsDestroyed$ = new Subject<boolean>();
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private stateService: StateService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
) {}
async ngOnInit(): Promise<void> {
const premium = await this.stateService.getCanAccessPremium();
this.billingAccountProfileStateService.hasPremiumFromAnySource$
.pipe(takeUntil(this.directiveIsDestroyed$))
.subscribe((premium: boolean) => {
if (premium) {
this.viewContainer.clear();
} else {
this.viewContainer.createEmbeddedView(this.templateRef);
}
});
}
if (premium) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
ngOnDestroy() {
this.directiveIsDestroyed$.next(true);
this.directiveIsDestroyed$.complete();
}
}

View File

@@ -42,6 +42,7 @@ export const LOGOUT_CALLBACK = new SafeInjectionToken<
export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promise<void>>(
"LOCKED_CALLBACK",
);
export const SUPPORTS_SECURE_STORAGE = new SafeInjectionToken<boolean>("SUPPORTS_SECURE_STORAGE");
export const LOCALES_DIRECTORY = new SafeInjectionToken<string>("LOCALES_DIRECTORY");
export const SYSTEM_LANGUAGE = new SafeInjectionToken<string>("SYSTEM_LANGUAGE");
export const LOG_MAC_FAILURES = new SafeInjectionToken<boolean>("LOG_MAC_FAILURES");

View File

@@ -9,14 +9,12 @@ import {
LoginStrategyServiceAbstraction,
LoginStrategyService,
} from "@bitwarden/auth/common";
import { AvatarUpdateService as AccountUpdateServiceAbstraction } from "@bitwarden/common/abstractions/account/avatar-update.service";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
import { SettingsService as SettingsServiceAbstraction } from "@bitwarden/common/abstractions/settings.service";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
@@ -29,6 +27,7 @@ import {
OrgDomainInternalServiceAbstraction,
OrgDomainServiceAbstraction,
} from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain.service.abstraction";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import {
@@ -40,6 +39,7 @@ import { OrganizationApiService } from "@bitwarden/common/admin-console/services
import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service";
import { OrgDomainApiService } from "@bitwarden/common/admin-console/services/organization-domain/org-domain-api.service";
import { OrgDomainService } from "@bitwarden/common/admin-console/services/organization-domain/org-domain.service";
import { DefaultOrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/services/organization-management-preferences/default-organization-management-preferences.service";
import { OrganizationUserServiceImplementation } from "@bitwarden/common/admin-console/services/organization-user/organization-user.service.implementation";
import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
@@ -51,6 +51,7 @@ import {
} from "@bitwarden/common/auth/abstractions/account.service";
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
@@ -69,6 +70,7 @@ import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation";
import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation";
import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation";
@@ -95,9 +97,11 @@ import {
DomainSettingsService,
DefaultDomainSettingsService,
} from "@bitwarden/common/autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PaymentMethodWarningsServiceAbstraction } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { PaymentMethodWarningsService } from "@bitwarden/common/billing/services/payment-method-warnings.service";
@@ -163,14 +167,12 @@ import {
DefaultThemeStateService,
ThemeStateService,
} from "@bitwarden/common/platform/theming/theme-state.service";
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
import { ApiService } from "@bitwarden/common/services/api.service";
import { AuditService } from "@bitwarden/common/services/audit.service";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/services/notifications.service";
import { SearchService } from "@bitwarden/common/services/search.service";
import { SettingsService } from "@bitwarden/common/services/settings.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
import {
@@ -250,6 +252,7 @@ import {
SECURE_STORAGE,
STATE_FACTORY,
STATE_SERVICE_USE_CACHE,
SUPPORTS_SECURE_STORAGE,
SYSTEM_LANGUAGE,
SYSTEM_THEME_OBSERVABLE,
WINDOW,
@@ -272,6 +275,12 @@ const typesafeProviders: Array<SafeProvider> = [
useFactory: (i18nService: I18nServiceAbstraction) => i18nService.translationLocale,
deps: [I18nServiceAbstraction],
}),
safeProvider({
provide: SUPPORTS_SECURE_STORAGE,
useFactory: (platformUtilsService: PlatformUtilsServiceAbstraction) =>
platformUtilsService.supportsSecureStorage(),
deps: [PlatformUtilsServiceAbstraction],
}),
safeProvider({
provide: LOCALES_DIRECTORY,
useValue: "./locales",
@@ -361,6 +370,7 @@ const typesafeProviders: Array<SafeProvider> = [
DeviceTrustCryptoServiceAbstraction,
AuthRequestServiceAbstraction,
GlobalStateProvider,
BillingAccountProfileStateService,
],
}),
safeProvider({
@@ -454,9 +464,9 @@ const typesafeProviders: Array<SafeProvider> = [
useExisting: InternalAccountService,
}),
safeProvider({
provide: AccountUpdateServiceAbstraction,
useClass: AvatarUpdateService,
deps: [ApiServiceAbstraction, StateServiceAbstraction],
provide: AvatarServiceAbstraction,
useClass: AvatarService,
deps: [ApiServiceAbstraction, StateProvider],
}),
safeProvider({ provide: LogService, useFactory: () => new ConsoleLogService(false), deps: [] }),
safeProvider({
@@ -477,7 +487,12 @@ const typesafeProviders: Array<SafeProvider> = [
safeProvider({
provide: TokenServiceAbstraction,
useClass: TokenService,
deps: [StateServiceAbstraction],
deps: [
SingleUserStateProvider,
GlobalStateProvider,
SUPPORTS_SECURE_STORAGE,
AbstractStorageService,
],
}),
safeProvider({
provide: KeyGenerationServiceAbstraction,
@@ -521,6 +536,7 @@ const typesafeProviders: Array<SafeProvider> = [
PlatformUtilsServiceAbstraction,
EnvironmentServiceAbstraction,
AppIdServiceAbstraction,
StateServiceAbstraction,
LOGOUT_CALLBACK,
],
}),
@@ -563,15 +579,12 @@ const typesafeProviders: Array<SafeProvider> = [
FolderApiServiceAbstraction,
InternalOrganizationServiceAbstraction,
SendApiServiceAbstraction,
AvatarServiceAbstraction,
LOGOUT_CALLBACK,
BillingAccountProfileStateService,
],
}),
safeProvider({ provide: BroadcasterServiceAbstraction, useClass: BroadcasterService, deps: [] }),
safeProvider({
provide: SettingsServiceAbstraction,
useClass: SettingsService,
deps: [StateServiceAbstraction],
}),
safeProvider({
provide: VaultTimeoutSettingsServiceAbstraction,
useClass: VaultTimeoutSettingsService,
@@ -622,6 +635,7 @@ const typesafeProviders: Array<SafeProvider> = [
STATE_FACTORY,
AccountServiceAbstraction,
EnvironmentServiceAbstraction,
TokenServiceAbstraction,
MigrationRunner,
STATE_SERVICE_USE_CACHE,
],
@@ -704,16 +718,17 @@ const typesafeProviders: Array<SafeProvider> = [
safeProvider({
provide: EventUploadServiceAbstraction,
useClass: EventUploadService,
deps: [ApiServiceAbstraction, StateServiceAbstraction, LogService],
deps: [ApiServiceAbstraction, StateProvider, LogService, AccountServiceAbstraction],
}),
safeProvider({
provide: EventCollectionServiceAbstraction,
useClass: EventCollectionService,
deps: [
CipherServiceAbstraction,
StateServiceAbstraction,
StateProvider,
OrganizationServiceAbstraction,
EventUploadServiceAbstraction,
AccountServiceAbstraction,
],
}),
safeProvider({
@@ -761,7 +776,7 @@ const typesafeProviders: Array<SafeProvider> = [
safeProvider({
provide: InternalOrganizationServiceAbstraction,
useClass: OrganizationService,
deps: [StateServiceAbstraction, StateProvider],
deps: [StateProvider],
}),
safeProvider({
provide: OrganizationServiceAbstraction,
@@ -955,7 +970,7 @@ const typesafeProviders: Array<SafeProvider> = [
safeProvider({
provide: ActiveUserStateProvider,
useClass: DefaultActiveUserStateProvider,
deps: [AccountServiceAbstraction, StorageServiceProvider, StateEventRegistrarService],
deps: [AccountServiceAbstraction, SingleUserStateProvider],
}),
safeProvider({
provide: SingleUserStateProvider,
@@ -1032,6 +1047,16 @@ const typesafeProviders: Array<SafeProvider> = [
useClass: PaymentMethodWarningsService,
deps: [BillingApiServiceAbstraction, StateProvider],
}),
safeProvider({
provide: BillingAccountProfileStateService,
useClass: DefaultBillingAccountProfileStateService,
deps: [ActiveUserStateProvider],
}),
safeProvider({
provide: OrganizationManagementPreferencesService,
useClass: DefaultOrganizationManagementPreferencesService,
deps: [StateProvider],
}),
];
function encryptServiceFactory(

View File

@@ -34,7 +34,7 @@ export class ExportScopeCalloutComponent implements OnInit {
) {}
async ngOnInit(): Promise<void> {
if (!this.organizationService.hasOrganizations()) {
if (!(await this.organizationService.hasOrganizations())) {
return;
}
@@ -48,7 +48,7 @@ export class ExportScopeCalloutComponent implements OnInit {
? {
title: "exportingOrganizationVaultTitle",
description: "exportingOrganizationVaultDesc",
scopeIdentifier: this.organizationService.get(organizationId).name,
scopeIdentifier: (await this.organizationService.get(organizationId)).name,
}
: {
title: "exportingPersonalVaultTitle",

View File

@@ -25,8 +25,10 @@ export class ExportComponent implements OnInit, OnDestroy {
@Output() onSaved = new EventEmitter();
@ViewChild(PasswordStrengthComponent) passwordStrengthComponent: PasswordStrengthComponent;
encryptedExportType = EncryptedExportType;
protected showFilePassword: boolean;
filePasswordValue: string = null;
formPromise: Promise<string>;
private _disabledByPolicy = false;
protected organizationId: string = null;
@@ -126,10 +128,23 @@ export class ExportComponent implements OnInit, OnDestroy {
return this.format === "encrypted_json";
}
get isFileEncryptedExport() {
return (
this.format === "encrypted_json" &&
this.fileEncryptionType === EncryptedExportType.FileEncrypted
);
}
get isAccountEncryptedExport() {
return (
this.format === "encrypted_json" &&
this.fileEncryptionType === EncryptedExportType.AccountEncrypted
);
}
protected async doExport() {
try {
this.formPromise = this.getExportData();
const data = await this.formPromise;
const data = await this.getExportData();
this.downloadFile(data);
this.saved();
await this.collectEvent();

View File

@@ -5,6 +5,7 @@ import { BehaviorSubject, Subject, concatMap, firstValueFrom, map, takeUntil } f
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -116,6 +117,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected sendApiService: SendApiService,
protected dialogService: DialogService,
protected formBuilder: FormBuilder,
protected billingAccountProfileStateService: BillingAccountProfileStateService,
) {
this.typeOptions = [
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
@@ -188,6 +190,12 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
});
this.billingAccountProfileStateService.hasPremiumFromAnySource$
.pipe(takeUntil(this.destroy$))
.subscribe((hasPremiumFromAnySource) => {
this.canAccessPremium = hasPremiumFromAnySource;
});
await this.load();
}
@@ -205,7 +213,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
async load() {
this.canAccessPremium = await this.stateService.getCanAccessPremium();
this.emailVerified = await this.stateService.getEmailVerified();
this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File;

View File

@@ -1,6 +1,8 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
@@ -42,6 +44,7 @@ export class AttachmentsComponent implements OnInit {
protected stateService: StateService,
protected fileDownloadService: FileDownloadService,
protected dialogService: DialogService,
protected billingAccountProfileStateService: BillingAccountProfileStateService,
) {}
async ngOnInit() {
@@ -185,7 +188,9 @@ export class AttachmentsComponent implements OnInit {
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain),
);
const canAccessPremium = await this.stateService.getCanAccessPremium();
const canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$,
);
this.canAccessAttachments = canAccessPremium || this.cipher.organizationId != null;
if (!this.canAccessAttachments) {

View File

@@ -8,7 +8,7 @@ import {
Observable,
} from "rxjs";
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -35,15 +35,15 @@ export class IconComponent implements OnInit {
constructor(
private environmentService: EnvironmentService,
private settingsService: SettingsService,
private domainSettingsService: DomainSettingsService,
) {}
async ngOnInit() {
const iconsUrl = this.environmentService.getIconsUrl();
this.data$ = combineLatest([
this.settingsService.disableFavicon$.pipe(distinctUntilChanged()),
this.domainSettingsService.showFavicons$.pipe(distinctUntilChanged()),
this.cipher$.pipe(filter((c) => c !== undefined)),
]).pipe(map(([disableFavicon, cipher]) => buildCipherIcon(iconsUrl, cipher, disableFavicon)));
]).pipe(map(([showFavicon, cipher]) => buildCipherIcon(iconsUrl, cipher, showFavicon)));
}
}

View File

@@ -1,6 +1,8 @@
import { Directive, OnInit } from "@angular/core";
import { Directive } from "@angular/core";
import { Observable, Subject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -9,11 +11,12 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { DialogService } from "@bitwarden/components";
@Directive()
export class PremiumComponent implements OnInit {
isPremium = false;
export class PremiumComponent {
isPremium$: Observable<boolean>;
price = 10;
refreshPromise: Promise<any>;
cloudWebVaultUrl: string;
private directiveIsDestroyed$ = new Subject<boolean>();
constructor(
protected i18nService: I18nService,
@@ -22,13 +25,11 @@ export class PremiumComponent implements OnInit {
private logService: LogService,
protected stateService: StateService,
protected dialogService: DialogService,
private environmentService: EnvironmentService,
environmentService: EnvironmentService,
billingAccountProfileStateService: BillingAccountProfileStateService,
) {
this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl();
}
async ngOnInit() {
this.isPremium = await this.stateService.getCanAccessPremium();
this.cloudWebVaultUrl = environmentService.getCloudWebVaultUrl();
this.isPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$;
}
async refresh() {
@@ -36,7 +37,6 @@ export class PremiumComponent implements OnInit {
this.refreshPromise = this.apiService.refreshIdentityToken();
await this.refreshPromise;
this.platformUtilsService.showToast("success", null, this.i18nService.t("refreshComplete"));
this.isPremium = await this.stateService.getCanAccessPremium();
} catch (e) {
this.logService.error(e);
}

View File

@@ -9,12 +9,13 @@ import {
OnInit,
Output,
} from "@angular/core";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, Subject, takeUntil } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EventType } from "@bitwarden/common/enums";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
@@ -68,6 +69,7 @@ export class ViewComponent implements OnDestroy, OnInit {
private totpInterval: any;
private previousCipherId: string;
private passwordReprompted = false;
private directiveIsDestroyed$ = new Subject<boolean>();
get fido2CredentialCreationDateValue(): string {
const dateCreated = this.i18nService.t("dateCreated");
@@ -99,6 +101,7 @@ export class ViewComponent implements OnDestroy, OnInit {
protected fileDownloadService: FileDownloadService,
protected dialogService: DialogService,
protected datePipe: DatePipe,
private billingAccountProfileStateService: BillingAccountProfileStateService,
) {}
ngOnInit() {
@@ -116,11 +119,19 @@ export class ViewComponent implements OnDestroy, OnInit {
}
});
});
this.billingAccountProfileStateService.hasPremiumFromAnySource$
.pipe(takeUntil(this.directiveIsDestroyed$))
.subscribe((canAccessPremium: boolean) => {
this.canAccessPremium = canAccessPremium;
});
}
ngOnDestroy() {
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
this.cleanUp();
this.directiveIsDestroyed$.next(true);
this.directiveIsDestroyed$.complete();
}
async load() {
@@ -130,7 +141,6 @@ export class ViewComponent implements OnDestroy, OnInit {
this.cipher = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher),
);
this.canAccessPremium = await this.stateService.getCanAccessPremium();
this.showPremiumRequiredTotp =
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;

View File

@@ -4,3 +4,4 @@
export * from "./abstractions";
export * from "./models";
export * from "./services";
export * from "./utilities";

View File

@@ -5,6 +5,7 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -37,6 +38,7 @@ describe("AuthRequestLoginStrategy", () => {
let stateService: MockProxy<StateService>;
let twoFactorService: MockProxy<TwoFactorService>;
let deviceTrustCryptoService: MockProxy<DeviceTrustCryptoServiceAbstraction>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let authRequestLoginStrategy: AuthRequestLoginStrategy;
let credentials: AuthRequestLoginCredentials;
@@ -64,10 +66,11 @@ describe("AuthRequestLoginStrategy", () => {
stateService = mock<StateService>();
twoFactorService = mock<TwoFactorService>();
deviceTrustCryptoService = mock<DeviceTrustCryptoServiceAbstraction>();
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
tokenService.getTwoFactorToken.mockResolvedValue(null);
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeToken.mockResolvedValue({});
tokenService.decodeAccessToken.mockResolvedValue({});
authRequestLoginStrategy = new AuthRequestLoginStrategy(
cache,
@@ -81,6 +84,7 @@ describe("AuthRequestLoginStrategy", () => {
stateService,
twoFactorService,
deviceTrustCryptoService,
billingAccountProfileStateService,
);
tokenResponse = identityTokenResponseFactory();

View File

@@ -9,6 +9,7 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -54,6 +55,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
stateService: StateService,
twoFactorService: TwoFactorService,
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
billingAccountProfileStateService: BillingAccountProfileStateService,
) {
super(
cryptoService,
@@ -65,6 +67,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
logService,
stateService,
twoFactorService,
billingAccountProfileStateService,
);
this.cache = new BehaviorSubject(data);
@@ -79,7 +82,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
credentials.email,
credentials.accessCode,
null,
await this.buildTwoFactor(credentials.twoFactor),
await this.buildTwoFactor(credentials.twoFactor, credentials.email),
await this.buildDeviceRequest(),
);
data.tokenRequest.setAuthRequestAccessCode(credentials.authRequestId);

View File

@@ -14,6 +14,8 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -108,6 +110,7 @@ describe("LoginStrategy", () => {
let twoFactorService: MockProxy<TwoFactorService>;
let policyService: MockProxy<PolicyService>;
let passwordStrengthService: MockProxy<PasswordStrengthServiceAbstraction>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let passwordLoginStrategy: PasswordLoginStrategy;
let credentials: PasswordLoginCredentials;
@@ -123,11 +126,13 @@ describe("LoginStrategy", () => {
logService = mock<LogService>();
stateService = mock<StateService>();
twoFactorService = mock<TwoFactorService>();
policyService = mock<PolicyService>();
passwordStrengthService = mock<PasswordStrengthService>();
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeToken.calledWith(accessToken).mockResolvedValue(decodedToken);
tokenService.decodeAccessToken.calledWith(accessToken).mockResolvedValue(decodedToken);
// The base class is abstract so we test it via PasswordLoginStrategy
passwordLoginStrategy = new PasswordLoginStrategy(
@@ -144,6 +149,7 @@ describe("LoginStrategy", () => {
passwordStrengthService,
policyService,
loginStrategyService,
billingAccountProfileStateService,
);
credentials = new PasswordLoginCredentials(email, masterPassword);
});
@@ -167,8 +173,21 @@ describe("LoginStrategy", () => {
const idTokenResponse = identityTokenResponseFactory();
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
const mockVaultTimeoutAction = VaultTimeoutAction.Lock;
const mockVaultTimeout = 1000;
stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction);
stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout);
await passwordLoginStrategy.logIn(credentials);
expect(tokenService.setTokens).toHaveBeenCalledWith(
accessToken,
refreshToken,
mockVaultTimeoutAction,
mockVaultTimeout,
);
expect(stateService.addAccount).toHaveBeenCalledWith(
new Account({
profile: {
@@ -177,17 +196,12 @@ describe("LoginStrategy", () => {
userId: userId,
name: name,
email: email,
hasPremiumPersonally: false,
kdfIterations: kdfIterations,
kdfType: kdf,
},
},
tokens: {
...new AccountTokens(),
...{
accessToken: accessToken,
refreshToken: refreshToken,
},
},
keys: new AccountKeys(),
decryptionOptions: AccountDecryptionOptions.fromResponse(idTokenResponse),
@@ -299,6 +313,7 @@ describe("LoginStrategy", () => {
expect(stateService.addAccount).not.toHaveBeenCalled();
expect(messagingService.send).not.toHaveBeenCalled();
expect(tokenService.clearTwoFactorToken).toHaveBeenCalled();
const expected = new AuthResult();
expected.twoFactorProviders = new Map<TwoFactorProviderType, { [key: string]: string }>();
@@ -397,6 +412,7 @@ describe("LoginStrategy", () => {
passwordStrengthService,
policyService,
loginStrategyService,
billingAccountProfileStateService,
);
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());

View File

@@ -15,7 +15,9 @@ import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request
import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ClientType } from "@bitwarden/common/enums";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
@@ -49,6 +51,9 @@ export abstract class LoginStrategyData {
| SsoTokenRequest
| WebAuthnLoginTokenRequest;
captchaBypassToken?: string;
/** User's entered email obtained pre-login. */
abstract userEnteredEmail?: string;
}
export abstract class LoginStrategy {
@@ -64,6 +69,7 @@ export abstract class LoginStrategy {
protected logService: LogService,
protected stateService: StateService,
protected twoFactorService: TwoFactorService,
protected billingAccountProfileStateService: BillingAccountProfileStateService,
) {}
abstract exportCache(): CacheData;
@@ -110,21 +116,47 @@ export abstract class LoginStrategy {
return new DeviceRequest(appId, this.platformUtilsService);
}
protected async buildTwoFactor(userProvidedTwoFactor?: TokenTwoFactorRequest) {
/**
* Builds the TokenTwoFactorRequest to be used within other login strategies token requests
* to the server.
* If the user provided a 2FA token in an already created TokenTwoFactorRequest, it will be used.
* If not, and the user has previously remembered a 2FA token, it will be used.
* If neither of these are true, an empty TokenTwoFactorRequest will be returned.
* @param userProvidedTwoFactor - optional - The 2FA token request provided by the caller
* @param email - optional - ensure that email is provided for any login strategies that support remember 2FA functionality
* @returns a promise which resolves to a TokenTwoFactorRequest to be sent to the server
*/
protected async buildTwoFactor(
userProvidedTwoFactor?: TokenTwoFactorRequest,
email?: string,
): Promise<TokenTwoFactorRequest> {
if (userProvidedTwoFactor != null) {
return userProvidedTwoFactor;
}
const storedTwoFactorToken = await this.tokenService.getTwoFactorToken();
if (storedTwoFactorToken != null) {
return new TokenTwoFactorRequest(TwoFactorProviderType.Remember, storedTwoFactorToken, false);
if (email) {
const storedTwoFactorToken = await this.tokenService.getTwoFactorToken(email);
if (storedTwoFactorToken != null) {
return new TokenTwoFactorRequest(
TwoFactorProviderType.Remember,
storedTwoFactorToken,
false,
);
}
}
return new TokenTwoFactorRequest();
}
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) {
const accountInformation = await this.tokenService.decodeToken(tokenResponse.accessToken);
/**
* Initializes the account with information from the IdTokenResponse after successful login.
* It also sets the access token and refresh token in the token service.
*
* @param {IdentityTokenResponse} tokenResponse - The response from the server containing the identity token.
* @returns {Promise<void>} - A promise that resolves when the account information has been successfully saved.
*/
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise<void> {
const accountInformation = await this.tokenService.decodeAccessToken(tokenResponse.accessToken);
// Must persist existing device key if it exists for trusted device decryption to work
// However, we must provide a user id so that the device key can be retrieved
@@ -141,6 +173,18 @@ export abstract class LoginStrategy {
// If you don't persist existing admin auth requests on login, they will get deleted.
const adminAuthRequest = await this.stateService.getAdminAuthRequest({ userId });
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction();
const vaultTimeout = await this.stateService.getVaultTimeout();
// set access token and refresh token before account initialization so authN status can be accurate
// User id will be derived from the access token.
await this.tokenService.setTokens(
tokenResponse.accessToken,
tokenResponse.refreshToken,
vaultTimeoutAction as VaultTimeoutAction,
vaultTimeout,
);
await this.stateService.addAccount(
new Account({
profile: {
@@ -149,7 +193,6 @@ export abstract class LoginStrategy {
userId,
name: accountInformation.name,
email: accountInformation.email,
hasPremiumPersonally: accountInformation.premium,
kdfIterations: tokenResponse.kdfIterations,
kdfMemory: tokenResponse.kdfMemory,
kdfParallelism: tokenResponse.kdfParallelism,
@@ -158,16 +201,14 @@ export abstract class LoginStrategy {
},
tokens: {
...new AccountTokens(),
...{
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken,
},
},
keys: accountKeys,
decryptionOptions: AccountDecryptionOptions.fromResponse(tokenResponse),
adminAuthRequest: adminAuthRequest?.toJSON(),
}),
);
await this.billingAccountProfileStateService.setHasPremium(accountInformation.premium, false);
}
protected async processTokenResponse(response: IdentityTokenResponse): Promise<AuthResult> {
@@ -193,7 +234,10 @@ export abstract class LoginStrategy {
await this.saveAccountInformation(response);
if (response.twoFactorToken != null) {
await this.tokenService.setTwoFactorToken(response);
// note: we can read email from access token b/c it was saved in saveAccountInformation
const userEmail = await this.tokenService.getEmail();
await this.tokenService.setTwoFactorToken(userEmail, response.twoFactorToken);
}
await this.setMasterKey(response);
@@ -226,7 +270,18 @@ export abstract class LoginStrategy {
}
}
/**
* Handles the response from the server when a 2FA is required.
* It clears any existing 2FA token, as it's no longer valid, and sets up the necessary data for the 2FA process.
*
* @param {IdentityTwoFactorResponse} response - The response from the server indicating that 2FA is required.
* @returns {Promise<AuthResult>} - A promise that resolves to an AuthResult object
*/
private async processTwoFactorResponse(response: IdentityTwoFactorResponse): Promise<AuthResult> {
// If we get a 2FA required response, then we should clear the 2FA token
// just in case as it is no longer valid.
await this.clearTwoFactorToken();
const result = new AuthResult();
result.twoFactorProviders = response.twoFactorProviders2;
@@ -237,6 +292,16 @@ export abstract class LoginStrategy {
return result;
}
/**
* Clears the 2FA token from the token service using the user's email if it exists
*/
private async clearTwoFactorToken() {
const email = this.cache.value.userEnteredEmail;
if (email) {
await this.tokenService.clearTwoFactorToken(email);
}
}
private async processCaptchaResponse(response: IdentityCaptchaResponse): Promise<AuthResult> {
const result = new AuthResult();
result.captchaSiteKey = response.siteKey;

View File

@@ -9,6 +9,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -61,6 +62,7 @@ describe("PasswordLoginStrategy", () => {
let twoFactorService: MockProxy<TwoFactorService>;
let policyService: MockProxy<PolicyService>;
let passwordStrengthService: MockProxy<PasswordStrengthServiceAbstraction>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let passwordLoginStrategy: PasswordLoginStrategy;
let credentials: PasswordLoginCredentials;
@@ -79,9 +81,10 @@ describe("PasswordLoginStrategy", () => {
twoFactorService = mock<TwoFactorService>();
policyService = mock<PolicyService>();
passwordStrengthService = mock<PasswordStrengthService>();
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeToken.mockResolvedValue({});
tokenService.decodeAccessToken.mockResolvedValue({});
loginStrategyService.makePreloginKey.mockResolvedValue(masterKey);
@@ -108,6 +111,7 @@ describe("PasswordLoginStrategy", () => {
passwordStrengthService,
policyService,
loginStrategyService,
billingAccountProfileStateService,
);
credentials = new PasswordLoginCredentials(email, masterPassword);
tokenResponse = identityTokenResponseFactory(masterPasswordPolicy);

View File

@@ -13,6 +13,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -32,6 +33,10 @@ import { LoginStrategy, LoginStrategyData } from "./login.strategy";
export class PasswordLoginStrategyData implements LoginStrategyData {
tokenRequest: PasswordTokenRequest;
/** User's entered email obtained pre-login. Always present in MP login. */
userEnteredEmail: string;
captchaBypassToken?: string;
/**
* The local version of the user's master key hash
@@ -82,6 +87,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
private passwordStrengthService: PasswordStrengthServiceAbstraction,
private policyService: PolicyService,
private loginStrategyService: LoginStrategyServiceAbstraction,
billingAccountProfileStateService: BillingAccountProfileStateService,
) {
super(
cryptoService,
@@ -93,6 +99,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
logService,
stateService,
twoFactorService,
billingAccountProfileStateService,
);
this.cache = new BehaviorSubject(data);
@@ -105,6 +112,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
const data = new PasswordLoginStrategyData();
data.masterKey = await this.loginStrategyService.makePreloginKey(masterPassword, email);
data.userEnteredEmail = email;
// Hash the password early (before authentication) so we don't persist it in memory in plaintext
data.localMasterKeyHash = await this.cryptoService.hashMasterKey(
@@ -118,7 +126,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
email,
masterKeyHash,
captchaToken,
await this.buildTwoFactor(twoFactor),
await this.buildTwoFactor(twoFactor, email),
await this.buildDeviceRequest(),
);

View File

@@ -9,6 +9,7 @@ import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/a
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
@@ -42,6 +43,7 @@ describe("SsoLoginStrategy", () => {
let deviceTrustCryptoService: MockProxy<DeviceTrustCryptoServiceAbstraction>;
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
let i18nService: MockProxy<I18nService>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let ssoLoginStrategy: SsoLoginStrategy;
let credentials: SsoLoginCredentials;
@@ -68,10 +70,11 @@ describe("SsoLoginStrategy", () => {
deviceTrustCryptoService = mock<DeviceTrustCryptoServiceAbstraction>();
authRequestService = mock<AuthRequestServiceAbstraction>();
i18nService = mock<I18nService>();
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
tokenService.getTwoFactorToken.mockResolvedValue(null);
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeToken.mockResolvedValue({});
tokenService.decodeAccessToken.mockResolvedValue({});
ssoLoginStrategy = new SsoLoginStrategy(
null,
@@ -88,6 +91,7 @@ describe("SsoLoginStrategy", () => {
deviceTrustCryptoService,
authRequestService,
i18nService,
billingAccountProfileStateService,
);
credentials = new SsoLoginCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId);
});

View File

@@ -10,6 +10,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for
import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/sso-token.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { HttpStatusCode } from "@bitwarden/common/enums";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
@@ -29,6 +30,10 @@ import { LoginStrategyData, LoginStrategy } from "./login.strategy";
export class SsoLoginStrategyData implements LoginStrategyData {
captchaBypassToken: string;
tokenRequest: SsoTokenRequest;
/**
* User's entered email obtained pre-login. Present in most SSO flows, but not CLI + SSO Flow.
*/
userEnteredEmail?: string;
/**
* User email address. Only available after authentication.
*/
@@ -83,6 +88,7 @@ export class SsoLoginStrategy extends LoginStrategy {
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
private authRequestService: AuthRequestServiceAbstraction,
private i18nService: I18nService,
billingAccountProfileStateService: BillingAccountProfileStateService,
) {
super(
cryptoService,
@@ -94,6 +100,7 @@ export class SsoLoginStrategy extends LoginStrategy {
logService,
stateService,
twoFactorService,
billingAccountProfileStateService,
);
this.cache = new BehaviorSubject(data);
@@ -105,11 +112,14 @@ export class SsoLoginStrategy extends LoginStrategy {
async logIn(credentials: SsoLoginCredentials) {
const data = new SsoLoginStrategyData();
data.orgId = credentials.orgId;
data.userEnteredEmail = credentials.email;
data.tokenRequest = new SsoTokenRequest(
credentials.code,
credentials.codeVerifier,
credentials.redirectUrl,
await this.buildTwoFactor(credentials.twoFactor),
await this.buildTwoFactor(credentials.twoFactor, credentials.email),
await this.buildDeviceRequest(),
);

View File

@@ -4,6 +4,8 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
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";
@@ -35,6 +37,7 @@ describe("UserApiLoginStrategy", () => {
let twoFactorService: MockProxy<TwoFactorService>;
let keyConnectorService: MockProxy<KeyConnectorService>;
let environmentService: MockProxy<EnvironmentService>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let apiLogInStrategy: UserApiLoginStrategy;
let credentials: UserApiLoginCredentials;
@@ -56,10 +59,11 @@ describe("UserApiLoginStrategy", () => {
twoFactorService = mock<TwoFactorService>();
keyConnectorService = mock<KeyConnectorService>();
environmentService = mock<EnvironmentService>();
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.getTwoFactorToken.mockResolvedValue(null);
tokenService.decodeToken.mockResolvedValue({});
tokenService.decodeAccessToken.mockResolvedValue({});
apiLogInStrategy = new UserApiLoginStrategy(
cache,
@@ -74,6 +78,7 @@ describe("UserApiLoginStrategy", () => {
twoFactorService,
environmentService,
keyConnectorService,
billingAccountProfileStateService,
);
credentials = new UserApiLoginCredentials(apiClientId, apiClientSecret);
@@ -101,10 +106,23 @@ describe("UserApiLoginStrategy", () => {
it("sets the local environment after a successful login", async () => {
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
const mockVaultTimeoutAction = VaultTimeoutAction.Lock;
const mockVaultTimeout = 60;
stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction);
stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout);
await apiLogInStrategy.logIn(credentials);
expect(stateService.setApiKeyClientId).toHaveBeenCalledWith(apiClientId);
expect(stateService.setApiKeyClientSecret).toHaveBeenCalledWith(apiClientSecret);
expect(tokenService.setClientId).toHaveBeenCalledWith(
apiClientId,
mockVaultTimeoutAction,
mockVaultTimeout,
);
expect(tokenService.setClientSecret).toHaveBeenCalledWith(
apiClientSecret,
mockVaultTimeoutAction,
mockVaultTimeout,
);
expect(stateService.addAccount).toHaveBeenCalled();
});

View File

@@ -7,6 +7,8 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
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";
@@ -47,6 +49,7 @@ export class UserApiLoginStrategy extends LoginStrategy {
twoFactorService: TwoFactorService,
private environmentService: EnvironmentService,
private keyConnectorService: KeyConnectorService,
billingAccountProfileStateService: BillingAccountProfileStateService,
) {
super(
cryptoService,
@@ -58,6 +61,7 @@ export class UserApiLoginStrategy extends LoginStrategy {
logService,
stateService,
twoFactorService,
billingAccountProfileStateService,
);
this.cache = new BehaviorSubject(data);
}
@@ -104,9 +108,21 @@ export class UserApiLoginStrategy extends LoginStrategy {
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) {
await super.saveAccountInformation(tokenResponse);
const vaultTimeout = await this.stateService.getVaultTimeout();
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction();
const tokenRequest = this.cache.value.tokenRequest;
await this.stateService.setApiKeyClientId(tokenRequest.clientId);
await this.stateService.setApiKeyClientSecret(tokenRequest.clientSecret);
await this.tokenService.setClientId(
tokenRequest.clientId,
vaultTimeoutAction as VaultTimeoutAction,
vaultTimeout,
);
await this.tokenService.setClientSecret(
tokenRequest.clientSecret,
vaultTimeoutAction as VaultTimeoutAction,
vaultTimeout,
);
}
exportCache(): CacheData {

View File

@@ -7,6 +7,7 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -34,6 +35,7 @@ describe("WebAuthnLoginStrategy", () => {
let logService!: MockProxy<LogService>;
let stateService!: MockProxy<StateService>;
let twoFactorService!: MockProxy<TwoFactorService>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let webAuthnLoginStrategy!: WebAuthnLoginStrategy;
@@ -68,10 +70,11 @@ describe("WebAuthnLoginStrategy", () => {
logService = mock<LogService>();
stateService = mock<StateService>();
twoFactorService = mock<TwoFactorService>();
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
tokenService.getTwoFactorToken.mockResolvedValue(null);
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeToken.mockResolvedValue({});
tokenService.decodeAccessToken.mockResolvedValue({});
webAuthnLoginStrategy = new WebAuthnLoginStrategy(
cache,
@@ -84,6 +87,7 @@ describe("WebAuthnLoginStrategy", () => {
logService,
stateService,
twoFactorService,
billingAccountProfileStateService,
);
// Create credentials

View File

@@ -7,6 +7,7 @@ import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -48,6 +49,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
logService: LogService,
stateService: StateService,
twoFactorService: TwoFactorService,
billingAccountProfileStateService: BillingAccountProfileStateService,
) {
super(
cryptoService,
@@ -59,6 +61,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
logService,
stateService,
twoFactorService,
billingAccountProfileStateService,
);
this.cache = new BehaviorSubject(data);

View File

@@ -25,6 +25,11 @@ export class SsoLoginCredentials {
public codeVerifier: string,
public redirectUrl: string,
public orgId: string,
/**
* Optional email address for SSO login.
* Used for looking up 2FA token on clients that support remembering 2FA token.
*/
public email?: string,
public twoFactor?: TokenTwoFactorRequest,
) {}
}

View File

@@ -11,6 +11,7 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
@@ -50,6 +51,7 @@ describe("LoginStrategyService", () => {
let policyService: MockProxy<PolicyService>;
let deviceTrustCryptoService: MockProxy<DeviceTrustCryptoServiceAbstraction>;
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let stateProvider: FakeGlobalStateProvider;
let loginStrategyCacheExpirationState: FakeGlobalState<Date | null>;
@@ -72,6 +74,7 @@ describe("LoginStrategyService", () => {
policyService = mock<PolicyService>();
deviceTrustCryptoService = mock<DeviceTrustCryptoServiceAbstraction>();
authRequestService = mock<AuthRequestServiceAbstraction>();
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
stateProvider = new FakeGlobalStateProvider();
sut = new LoginStrategyService(
@@ -93,6 +96,7 @@ describe("LoginStrategyService", () => {
deviceTrustCryptoService,
authRequestService,
stateProvider,
billingAccountProfileStateService,
);
loginStrategyCacheExpirationState = stateProvider.getFake(CACHE_EXPIRATION_KEY);
@@ -114,7 +118,7 @@ describe("LoginStrategyService", () => {
token_type: "Bearer",
}),
);
tokenService.decodeToken.calledWith("ACCESS_TOKEN").mockResolvedValue({
tokenService.decodeAccessToken.calledWith("ACCESS_TOKEN").mockResolvedValue({
sub: "USER_ID",
name: "NAME",
email: "EMAIL",
@@ -161,7 +165,7 @@ describe("LoginStrategyService", () => {
}),
);
tokenService.decodeToken.calledWith("ACCESS_TOKEN").mockResolvedValue({
tokenService.decodeAccessToken.calledWith("ACCESS_TOKEN").mockResolvedValue({
sub: "USER_ID",
name: "NAME",
email: "EMAIL",

View File

@@ -20,6 +20,7 @@ import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { PasswordlessAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { PreloginRequest } from "@bitwarden/common/models/request/prelogin.request";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
@@ -101,6 +102,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
protected authRequestService: AuthRequestServiceAbstraction,
protected stateProvider: GlobalStateProvider,
protected billingAccountProfileStateService: BillingAccountProfileStateService,
) {
this.currentAuthnTypeState = this.stateProvider.get(CURRENT_LOGIN_STRATEGY_KEY);
this.loginStrategyCacheState = this.stateProvider.get(CACHE_KEY);
@@ -355,6 +357,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
this.passwordStrengthService,
this.policyService,
this,
this.billingAccountProfileStateService,
);
case AuthenticationType.Sso:
return new SsoLoginStrategy(
@@ -372,6 +375,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
this.deviceTrustCryptoService,
this.authRequestService,
this.i18nService,
this.billingAccountProfileStateService,
);
case AuthenticationType.UserApiKey:
return new UserApiLoginStrategy(
@@ -387,6 +391,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
this.twoFactorService,
this.environmentService,
this.keyConnectorService,
this.billingAccountProfileStateService,
);
case AuthenticationType.AuthRequest:
return new AuthRequestLoginStrategy(
@@ -401,6 +406,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
this.stateService,
this.twoFactorService,
this.deviceTrustCryptoService,
this.billingAccountProfileStateService,
);
case AuthenticationType.WebAuthn:
return new WebAuthnLoginStrategy(
@@ -414,6 +420,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
this.logService,
this.stateService,
this.twoFactorService,
this.billingAccountProfileStateService,
);
}
}),

View File

@@ -0,0 +1,90 @@
import { DecodedAccessToken } from "@bitwarden/common/auth/services/token.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { decodeJwtTokenToJson } from "./decode-jwt-token-to-json.utility";
describe("decodeJwtTokenToJson", () => {
const accessTokenJwt =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0IiwibmJmIjoxNzA5MzI0MTExLCJpYXQiOjE3MDkzMjQxMTEsImV4cCI6MTcwOTMyNzcxMSwic2NvcGUiOlsiYXBpIiwib2ZmbGluZV9hY2Nlc3MiXSwiYW1yIjpbIkFwcGxpY2F0aW9uIl0sImNsaWVudF9pZCI6IndlYiIsInN1YiI6ImVjZTcwYTEzLTcyMTYtNDNjNC05OTc3LWIxMDMwMTQ2ZTFlNyIsImF1dGhfdGltZSI6MTcwOTMyNDEwNCwiaWRwIjoiYml0d2FyZGVuIiwicHJlbWl1bSI6ZmFsc2UsImVtYWlsIjoiZXhhbXBsZUBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJzc3RhbXAiOiJHWTdKQU82NENLS1RLQkI2WkVBVVlMMldPUVU3QVNUMiIsIm5hbWUiOiJUZXN0IFVzZXIiLCJvcmdvd25lciI6WyI5MmI0OTkwOC1iNTE0LTQ1YTgtYmFkYi1iMTAzMDE0OGZlNTMiLCIzOGVkZTMyMi1iNGI0LTRiZDgtOWUwOS1iMTA3MDExMmRjMTEiLCJiMmQwNzAyOC1hNTgzLTRjM2UtOGQ2MC1iMTA3MDExOThjMjkiLCJiZjkzNGJhMi0wZmQ0LTQ5ZjItYTk1ZS1iMTA3MDExZmM5ZTYiLCJjMGI3Zjc1ZC0wMTVmLTQyYzktYjNhNi1iMTA4MDE3NjA3Y2EiXSwiZGV2aWNlIjoiNGI4NzIzNjctMGRhNi00MWEwLWFkY2ItNzdmMmZlZWZjNGY0IiwianRpIjoiNzUxNjFCRTQxMzFGRjVBMkRFNTExQjhDNEUyRkY4OUEifQ.n7roP8sSbfwcYdvRxZNZds27IK32TW6anorE6BORx_Q";
const accessTokenDecoded: DecodedAccessToken = {
iss: "http://localhost",
nbf: 1709324111,
iat: 1709324111,
exp: 1709327711,
scope: ["api", "offline_access"],
amr: ["Application"],
client_id: "web",
sub: "ece70a13-7216-43c4-9977-b1030146e1e7", // user id
auth_time: 1709324104,
idp: "bitwarden",
premium: false,
email: "example@bitwarden.com",
email_verified: false,
sstamp: "GY7JAO64CKKTKBB6ZEAUYL2WOQU7AST2",
name: "Test User",
orgowner: [
"92b49908-b514-45a8-badb-b1030148fe53",
"38ede322-b4b4-4bd8-9e09-b1070112dc11",
"b2d07028-a583-4c3e-8d60-b10701198c29",
"bf934ba2-0fd4-49f2-a95e-b107011fc9e6",
"c0b7f75d-015f-42c9-b3a6-b108017607ca",
],
device: "4b872367-0da6-41a0-adcb-77f2feefc4f4",
jti: "75161BE4131FF5A2DE511B8C4E2FF89A",
};
it("should decode the JWT token", () => {
// Act
const result = decodeJwtTokenToJson(accessTokenJwt);
// Assert
expect(result).toEqual(accessTokenDecoded);
});
it("should throw an error if the JWT token is null", () => {
// Act && Assert
expect(() => decodeJwtTokenToJson(null)).toThrow("JWT token not found");
});
it("should throw an error if the JWT token is missing 3 parts", () => {
// Act && Assert
expect(() => decodeJwtTokenToJson("invalidToken")).toThrow("JWT must have 3 parts");
});
it("should throw an error if the JWT token payload contains invalid JSON", () => {
// Arrange: Create a token with a valid format but with a payload that's valid Base64 but not valid JSON
const header = btoa(JSON.stringify({ alg: "none" }));
// Create a Base64-encoded string which fails to parse as JSON
const payload = btoa("invalid JSON");
const signature = "signature";
const malformedToken = `${header}.${payload}.${signature}`;
// Act & Assert
expect(() => decodeJwtTokenToJson(malformedToken)).toThrow(
"Cannot parse the token's payload into JSON",
);
});
it("should throw an error if the JWT token cannot be decoded", () => {
// Arrange: Create a token with a valid format
const header = btoa(JSON.stringify({ alg: "none" }));
const payload = "invalidPayloadBecauseWeWillMockTheFailure";
const signature = "signature";
const malformedToken = `${header}.${payload}.${signature}`;
// Mock Utils.fromUrlB64ToUtf8 to throw an error for this specific payload
jest.spyOn(Utils, "fromUrlB64ToUtf8").mockImplementation((input) => {
if (input === payload) {
throw new Error("Mock error");
}
return input; // Default behavior for other inputs
});
// Act & Assert
expect(() => decodeJwtTokenToJson(malformedToken)).toThrow("Cannot decode the token");
// Restore original function so other tests are not affected
jest.restoreAllMocks();
});
});

View File

@@ -0,0 +1,32 @@
import { Utils } from "@bitwarden/common/platform/misc/utils";
export function decodeJwtTokenToJson(jwtToken: string): any {
if (jwtToken == null) {
throw new Error("JWT token not found");
}
const parts = jwtToken.split(".");
if (parts.length !== 3) {
throw new Error("JWT must have 3 parts");
}
// JWT has 3 parts: header, payload, signature separated by '.'
// So, grab the payload to decode
const encodedPayload = parts[1];
let decodedPayloadJSON: string;
try {
// Attempt to decode from URL-safe Base64 to UTF-8
decodedPayloadJSON = Utils.fromUrlB64ToUtf8(encodedPayload);
} catch (decodingError) {
throw new Error("Cannot decode the token");
}
try {
// Attempt to parse the JSON payload
const decodedToken = JSON.parse(decodedPayloadJSON);
return decodedToken;
} catch (jsonError) {
throw new Error("Cannot parse the token's payload into JSON");
}
}

View File

@@ -0,0 +1 @@
export * from "./decode-jwt-token-to-json.utility";

View File

@@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended";
import { Observable, map } from "rxjs";
import { Observable, map, of, switchMap, take } from "rxjs";
import {
GlobalState,
@@ -119,7 +119,7 @@ export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
states: Map<string, FakeActiveUserState<unknown>> = new Map();
constructor(public accountService: FakeAccountService) {
this.activeUserId$ = accountService.activeAccountSubject.asObservable().pipe(map((a) => a.id));
this.activeUserId$ = accountService.activeAccountSubject.asObservable().pipe(map((a) => a?.id));
}
get<T>(keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>): ActiveUserState<T> {
@@ -171,7 +171,30 @@ export class FakeStateProvider implements StateProvider {
if (userId) {
return this.getUser<T>(userId, keyDefinition).state$;
}
return this.getActive<T>(keyDefinition).state$;
return this.getActive(keyDefinition).state$;
}
getUserStateOrDefault$<T>(
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
config: { userId: UserId | undefined; defaultValue?: T },
): Observable<T> {
const { userId, defaultValue = null } = config;
if (isUserKeyDefinition(keyDefinition)) {
this.mock.getUserStateOrDefault$(keyDefinition, config);
} else {
this.mock.getUserStateOrDefault$(keyDefinition, config);
}
if (userId) {
return this.getUser<T>(userId, keyDefinition).state$;
}
return this.activeUserId$.pipe(
take(1),
switchMap((userId) =>
userId != null ? this.getUser(userId, keyDefinition).state$ : of(defaultValue),
),
);
}
async setUserState<T>(

View File

@@ -1,8 +0,0 @@
import { Observable } from "rxjs";
import { ProfileResponse } from "../../models/response/profile.response";
export abstract class AvatarUpdateService {
avatarUpdate$ = new Observable<string | null>();
abstract pushUpdate(color: string): Promise<ProfileResponse | void>;
abstract loadColorFromState(): Promise<string | null>;
}

View File

@@ -1,3 +1,5 @@
import { UserId } from "../../types/guid";
export abstract class EventUploadService {
uploadEvents: (userId?: string) => Promise<void>;
uploadEvents: (userId?: UserId) => Promise<void>;
}

View File

@@ -1,8 +0,0 @@
import { Observable } from "rxjs";
export abstract class SettingsService {
disableFavicon$: Observable<boolean>;
setDisableFavicon: (value: boolean) => Promise<any>;
getDisableFavicon: () => boolean;
}

View File

@@ -0,0 +1,22 @@
import { Observable } from "rxjs";
/**
* Manages the state of a single organization management preference.
* Can be used to subscribe to or update a given property.
*/
export class OrganizationManagementPreference<T> {
state$: Observable<T>;
set: (value: T) => Promise<void>;
constructor(state$: Observable<T>, setFn: (value: T) => Promise<void>) {
this.state$ = state$;
this.set = setFn;
}
}
/**
* Publishes state of a given user's personal settings relating to the user experience of managing an organization.
*/
export abstract class OrganizationManagementPreferencesService {
autoConfirmFingerPrints: OrganizationManagementPreference<boolean>;
}

View File

@@ -8,7 +8,6 @@ import {
OrganizationUserInviteRequest,
OrganizationUserResetPasswordEnrollmentRequest,
OrganizationUserResetPasswordRequest,
OrganizationUserUpdateGroupsRequest,
OrganizationUserUpdateRequest,
} from "./requests";
import {
@@ -165,18 +164,6 @@ export abstract class OrganizationUserService {
request: OrganizationUserUpdateRequest,
): Promise<void>;
/**
* Update an organization user's groups
* @param organizationId - Identifier for the organization the user belongs to
* @param id - Organization user identifier
* @param groupIds - List of group ids to associate the user with
*/
abstract putOrganizationUserGroups(
organizationId: string,
id: string,
groupIds: OrganizationUserUpdateGroupsRequest,
): Promise<void>;
/**
* Update an organization user's reset password enrollment
* @param organizationId - Identifier for the organization the user belongs to

View File

@@ -6,4 +6,3 @@ export * from "./organization-user-invite.request";
export * from "./organization-user-reset-password.request";
export * from "./organization-user-reset-password-enrollment.request";
export * from "./organization-user-update.request";
export * from "./organization-user-update-groups.request";

View File

@@ -1,3 +0,0 @@
export class OrganizationUserUpdateGroupsRequest {
groupIds: string[] = [];
}

View File

@@ -2,6 +2,7 @@ import { map, Observable } from "rxjs";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { Utils } from "../../../platform/misc/utils";
import { UserId } from "../../../types/guid";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
@@ -86,34 +87,67 @@ export function canAccessImport(i18nService: I18nService) {
/**
* Returns `true` if a user is a member of an organization (rather than only being a ProviderUser)
* @deprecated Use organizationService.memberOrganizations$ instead
* @deprecated Use organizationService.organizations$ with a filter instead
*/
export function isMember(org: Organization): boolean {
return org.isMember;
}
/**
* Publishes an observable stream of organizations. This service is meant to
* be used widely across Bitwarden as the primary way of fetching organizations.
* Risky operations like updates are isolated to the
* internal extension `InternalOrganizationServiceAbstraction`.
*/
export abstract class OrganizationService {
/**
* Publishes state for all organizations under the active user.
* @returns An observable list of organizations
*/
organizations$: Observable<Organization[]>;
/**
* Organizations that the user is a member of (excludes organizations that they only have access to via a provider)
*/
// @todo Clean these up. Continuing to expand them is not recommended.
// @see https://bitwarden.atlassian.net/browse/AC-2252
memberOrganizations$: Observable<Organization[]>;
get$: (id: string) => Observable<Organization | undefined>;
get: (id: string) => Organization;
getByIdentifier: (identifier: string) => Organization;
getAll: (userId?: string) => Promise<Organization[]>;
/**
* @deprecated For the CLI only
* @param id id of the organization
* @deprecated This is currently only used in the CLI, and should not be
* used in any new calls. Use get$ instead for the time being, and we'll be
* removing this method soon. See Jira for details:
* https://bitwarden.atlassian.net/browse/AC-2252.
*/
getFromState: (id: string) => Promise<Organization>;
canManageSponsorships: () => Promise<boolean>;
hasOrganizations: () => boolean;
hasOrganizations: () => Promise<boolean>;
get$: (id: string) => Observable<Organization | undefined>;
get: (id: string) => Promise<Organization>;
getAll: (userId?: string) => Promise<Organization[]>;
//
}
/**
* Big scary buttons that **update** organization state. These should only be
* called from within admin-console scoped code. Extends the base
* `OrganizationService` for easy access to `get` calls.
* @internal
*/
export abstract class InternalOrganizationServiceAbstraction extends OrganizationService {
replace: (organizations: { [id: string]: OrganizationData }) => Promise<void>;
upsert: (OrganizationData: OrganizationData | OrganizationData[]) => Promise<void>;
/**
* Replaces state for the provided organization, or creates it if not found.
* @param organization The organization state being saved.
* @param userId The userId to replace state for. Defaults to the active
* user.
*/
upsert: (OrganizationData: OrganizationData) => Promise<void>;
/**
* Replaces state for the entire registered organization list for the active user.
* You probably don't want this unless you're calling from a full sync
* operation or a logout. See `upsert` for creating & updating a single
* organization in the state.
* @param organizations A complete list of all organization state for the active
* user.
* @param userId The userId to replace state for. Defaults to the active
* user.
*/
replace: (organizations: { [id: string]: OrganizationData }, userId?: UserId) => Promise<void>;
}

View File

@@ -1,13 +1,11 @@
import { Observable } from "rxjs";
import { ListResponse } from "../../../models/response/list.response";
import { UserId } from "../../../types/guid";
import { PolicyType } from "../../enums";
import { PolicyData } from "../../models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
import { Policy } from "../../models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
import { PolicyResponse } from "../../models/response/policy.response";
export abstract class PolicyService {
/**
@@ -75,18 +73,6 @@ export abstract class PolicyService {
policies: Policy[],
orgId: string,
) => [ResetPasswordPolicyOptions, boolean];
// Helpers
/**
* Instantiates {@link Policy} objects from {@link PolicyResponse} objects.
*/
mapPolicyFromResponse: (policyResponse: PolicyResponse) => Policy;
/**
* Instantiates {@link Policy} objects from {@link ListResponse<PolicyResponse>} objects.
*/
mapPoliciesFromToken: (policiesResponse: ListResponse<PolicyResponse>) => Policy[];
}
export abstract class InternalPolicyService extends PolicyService {

View File

@@ -320,6 +320,10 @@ export class Organization {
return !this.useTotp;
}
get canManageSponsorships() {
return this.familySponsorshipAvailable || this.familySponsorshipFriendlyName !== null;
}
static fromJSON(json: Jsonify<Organization>) {
if (json == null) {
return null;

View File

@@ -1,7 +1,9 @@
import { ListResponse } from "../../../models/response/list.response";
import Domain from "../../../platform/models/domain/domain-base";
import { PolicyId } from "../../../types/guid";
import { PolicyType } from "../../enums";
import { PolicyData } from "../data/policy.data";
import { PolicyResponse } from "../response/policy.response";
export class Policy extends Domain {
id: PolicyId;
@@ -27,4 +29,12 @@ export class Policy extends Domain {
this.data = obj.data;
this.enabled = obj.enabled;
}
static fromResponse(response: PolicyResponse): Policy {
return new Policy(new PolicyData(response));
}
static fromListResponse(response: ListResponse<PolicyResponse>): Policy[] | undefined {
return response.data?.map((d) => Policy.fromResponse(d)) ?? undefined;
}
}

View File

@@ -0,0 +1,44 @@
import { MockProxy } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { UserId } from "../../../types/guid";
import { DefaultOrganizationManagementPreferencesService } from "./default-organization-management-preferences.service";
describe("OrganizationManagementPreferencesService", () => {
let stateProvider: FakeStateProvider;
let organizationManagementPreferencesService: MockProxy<DefaultOrganizationManagementPreferencesService>;
beforeEach(() => {
const accountService = mockAccountServiceWith("userId" as UserId);
stateProvider = new FakeStateProvider(accountService);
organizationManagementPreferencesService = new DefaultOrganizationManagementPreferencesService(
stateProvider,
);
});
describe("autoConfirmFingerPrints", () => {
it("returns false by default", async () => {
const value = await firstValueFrom(
organizationManagementPreferencesService.autoConfirmFingerPrints.state$,
);
expect(value).toEqual(false);
});
it("returns true if set", async () => {
await organizationManagementPreferencesService.autoConfirmFingerPrints.set(true);
const value = await firstValueFrom(
organizationManagementPreferencesService.autoConfirmFingerPrints.state$,
);
expect(value).toEqual(true);
});
it("can be unset", async () => {
await organizationManagementPreferencesService.autoConfirmFingerPrints.set(true);
await organizationManagementPreferencesService.autoConfirmFingerPrints.set(false);
const value = await firstValueFrom(
organizationManagementPreferencesService.autoConfirmFingerPrints.state$,
);
expect(value).toEqual(false);
});
});
});

View File

@@ -0,0 +1,71 @@
import { map } from "rxjs";
import { Jsonify } from "type-fest";
import {
ORGANIZATION_MANAGEMENT_PREFERENCES_DISK,
StateProvider,
UserKeyDefinition,
} from "../../../platform/state";
import {
OrganizationManagementPreference,
OrganizationManagementPreferencesService,
} from "../../abstractions/organization-management-preferences/organization-management-preferences.service";
/**
* This helper function can be used to quickly create `KeyDefinitions` that
* target the `ORGANIZATION_MANAGEMENT_PREFERENCES_DISK` `StateDefinition`
* and that have the default deserializer and `clearOn` options. Any
* contenders for options to add to this service will likely use these same
* options.
*/
function buildKeyDefinition<T>(key: string): UserKeyDefinition<T> {
return new UserKeyDefinition<T>(ORGANIZATION_MANAGEMENT_PREFERENCES_DISK, key, {
deserializer: (obj: Jsonify<T>) => obj as T,
clearOn: ["logout"],
});
}
export const AUTO_CONFIRM_FINGERPRINTS = buildKeyDefinition<boolean>("autoConfirmFingerPrints");
export class DefaultOrganizationManagementPreferencesService
implements OrganizationManagementPreferencesService
{
constructor(private stateProvider: StateProvider) {}
autoConfirmFingerPrints = this.buildOrganizationManagementPreference(
AUTO_CONFIRM_FINGERPRINTS,
false,
);
/**
* Returns an `OrganizationManagementPreference` object for the provided
* `KeyDefinition`. This object can then be used by callers to subscribe to
* a given key, or set its value in state.
*/
private buildOrganizationManagementPreference<T>(
keyDefinition: UserKeyDefinition<T>,
defaultValue: T,
) {
return new OrganizationManagementPreference<T>(
this.getKeyFromState(keyDefinition).state$.pipe(map((x) => x ?? defaultValue)),
this.setKeyInStateFn(keyDefinition),
);
}
/**
* Returns the full `ActiveUserState` value for a given `keyDefinition`
* The returned value can then be called for subscription || set operations
*/
private getKeyFromState<T>(keyDefinition: UserKeyDefinition<T>) {
return this.stateProvider.getActive(keyDefinition);
}
/**
* Returns a function that can be called to set the given `keyDefinition` in state
*/
private setKeyInStateFn<T>(keyDefinition: UserKeyDefinition<T>) {
return async (value: T) => {
await this.getKeyFromState(keyDefinition).update(() => value);
};
}
}

View File

@@ -9,7 +9,6 @@ import {
OrganizationUserInviteRequest,
OrganizationUserResetPasswordEnrollmentRequest,
OrganizationUserResetPasswordRequest,
OrganizationUserUpdateGroupsRequest,
OrganizationUserUpdateRequest,
} from "../../abstractions/organization-user/requests";
import {
@@ -233,20 +232,6 @@ export class OrganizationUserServiceImplementation implements OrganizationUserSe
);
}
putOrganizationUserGroups(
organizationId: string,
id: string,
request: OrganizationUserUpdateGroupsRequest,
): Promise<void> {
return this.apiService.send(
"PUT",
"/organizations/" + organizationId + "/users/" + id + "/groups",
request,
true,
false,
);
}
putOrganizationUserResetPasswordEnrollment(
organizationId: string,
userId: string,

View File

@@ -1,114 +1,142 @@
import { MockProxy, mock, any, mockClear } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { firstValueFrom } from "rxjs";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { FakeActiveUserState } from "../../../../spec/fake-state";
import { StateService } from "../../../platform/abstractions/state.service";
import { Utils } from "../../../platform/misc/utils";
import { UserId } from "../../../types/guid";
import { OrganizationId, UserId } from "../../../types/guid";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
import { OrganizationService, ORGANIZATIONS } from "./organization.service";
describe("Organization Service", () => {
describe("OrganizationService", () => {
let organizationService: OrganizationService;
let stateService: MockProxy<StateService>;
let activeAccount: BehaviorSubject<string>;
let activeAccountUnlocked: BehaviorSubject<boolean>;
const fakeUserId = Utils.newGuid() as UserId;
let fakeAccountService: FakeAccountService;
let fakeStateProvider: FakeStateProvider;
let fakeActiveUserState: FakeActiveUserState<Record<string, OrganizationData>>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
let stateProvider: FakeStateProvider;
let activeUserOrganizationsState: FakeActiveUserState<Record<string, OrganizationData>>;
const resetStateService = async (
customizeStateService: (stateService: MockProxy<StateService>) => void,
) => {
mockClear(stateService);
stateService = mock<StateService>();
stateService.activeAccount$ = activeAccount;
stateService.activeAccountUnlocked$ = activeAccountUnlocked;
customizeStateService(stateService);
organizationService = new OrganizationService(stateService, stateProvider);
await new Promise((r) => setTimeout(r, 50));
};
function prepareStateProvider(): void {
accountService = mockAccountServiceWith(mockUserId);
stateProvider = new FakeStateProvider(accountService);
/**
* It is easier to read arrays than records in code, but we store a record
* in state. This helper methods lets us build organization arrays in tests
* and easily map them to records before storing them in state.
*/
function arrayToRecord(input: OrganizationData[]): Record<OrganizationId, OrganizationData> {
if (input == null) {
return undefined;
}
return Object.fromEntries(input?.map((i) => [i.id, i]));
}
function seedTestData(): void {
activeUserOrganizationsState = stateProvider.activeUser.getFake(ORGANIZATIONS);
activeUserOrganizationsState.nextState({ "1": organizationData("1", "Test Org") });
/**
* There are a few assertions in this spec that check for array equality
* but want to ignore a specific index that _should_ be different. This
* function takes two arrays, and an index. It checks for equality of the
* arrays, but splices out the specified index from both arrays first.
*/
function expectIsEqualExceptForIndex(x: any[], y: any[], indexToExclude: number) {
// Clone the arrays to avoid modifying the reference values
const a = [...x];
const b = [...y];
delete a[indexToExclude];
delete b[indexToExclude];
expect(a).toEqual(b);
}
beforeEach(() => {
activeAccount = new BehaviorSubject("123");
activeAccountUnlocked = new BehaviorSubject(true);
/**
* Builds a simple mock `OrganizationData[]` array that can be used in tests
* to populate state.
* @param count The number of organizations to populate the list with. The
* function returns undefined if this is less than 1. The default value is 1.
* @param suffix A string to append to data fields on each organization.
* This defaults to the index of the organization in the list.
* @returns an `OrganizationData[]` array that can be used to populate
* stateProvider.
*/
function buildMockOrganizations(count = 1, suffix?: string): OrganizationData[] {
if (count < 1) {
return undefined;
}
stateService = mock<StateService>();
stateService.activeAccount$ = activeAccount;
stateService.activeAccountUnlocked$ = activeAccountUnlocked;
function buildMockOrganization(id: OrganizationId, name: string, identifier: string) {
const data = new OrganizationData({} as any, {} as any);
data.id = id;
data.name = name;
data.identifier = identifier;
stateService.getOrganizations.calledWith(any()).mockResolvedValue({
"1": organizationData("1", "Test Org"),
});
return data;
}
prepareStateProvider();
const mockOrganizations = [];
for (let i = 0; i < count; i++) {
const s = suffix ? suffix + i.toString() : i.toString();
mockOrganizations.push(
buildMockOrganization(("org" + s) as OrganizationId, "org" + s, "orgIdentifier" + s),
);
}
organizationService = new OrganizationService(stateService, stateProvider);
return mockOrganizations;
}
seedTestData();
});
/**
* `OrganizationService` deals with multiple accounts at times. This helper
* function can be used to add a new non-active account to the test data.
* This function is **not** needed to handle creation of the first account,
* as that is handled by the `FakeAccountService` in `mockAccountServiceWith()`
* @returns The `UserId` of the newly created state account and the mock data
* created for them as an `Organization[]`.
*/
async function addNonActiveAccountToStateProvider(): Promise<[UserId, OrganizationData[]]> {
const nonActiveUserId = Utils.newGuid() as UserId;
afterEach(() => {
activeAccount.complete();
activeAccountUnlocked.complete();
const mockOrganizations = buildMockOrganizations(10);
const fakeNonActiveUserState = fakeStateProvider.singleUser.getFake(
nonActiveUserId,
ORGANIZATIONS,
);
fakeNonActiveUserState.nextState(arrayToRecord(mockOrganizations));
return [nonActiveUserId, mockOrganizations];
}
beforeEach(async () => {
fakeAccountService = mockAccountServiceWith(fakeUserId);
fakeStateProvider = new FakeStateProvider(fakeAccountService);
fakeActiveUserState = fakeStateProvider.activeUser.getFake(ORGANIZATIONS);
organizationService = new OrganizationService(fakeStateProvider);
});
it("getAll", async () => {
const mockData: OrganizationData[] = buildMockOrganizations(1);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const orgs = await organizationService.getAll();
expect(orgs).toHaveLength(1);
const org = orgs[0];
expect(org).toEqual({
id: "1",
name: "Test Org",
identifier: "test",
});
expect(org).toEqual(new Organization(mockData[0]));
});
describe("canManageSponsorships", () => {
it("can because one is available", async () => {
await resetStateService((stateService) => {
stateService.getOrganizations.mockResolvedValue({
"1": { ...organizationData("1", "Org"), familySponsorshipAvailable: true },
});
});
const mockData: OrganizationData[] = buildMockOrganizations(1);
mockData[0].familySponsorshipAvailable = true;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await organizationService.canManageSponsorships();
expect(result).toBe(true);
});
it("can because one is used", async () => {
await resetStateService((stateService) => {
stateService.getOrganizations.mockResolvedValue({
"1": { ...organizationData("1", "Test Org"), familySponsorshipFriendlyName: "Something" },
});
});
const mockData: OrganizationData[] = buildMockOrganizations(1);
mockData[0].familySponsorshipFriendlyName = "Something";
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await organizationService.canManageSponsorships();
expect(result).toBe(true);
});
it("can not because one isn't available or taken", async () => {
await resetStateService((stateService) => {
stateService.getOrganizations.mockResolvedValue({
"1": { ...organizationData("1", "Org"), familySponsorshipFriendlyName: null },
});
});
const mockData: OrganizationData[] = buildMockOrganizations(1);
mockData[0].familySponsorshipFriendlyName = null;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await organizationService.canManageSponsorships();
expect(result).toBe(false);
});
@@ -116,81 +144,181 @@ describe("Organization Service", () => {
describe("get", () => {
it("exists", async () => {
const result = organizationService.get("1");
expect(result).toEqual({
id: "1",
name: "Test Org",
identifier: "test",
});
const mockData = buildMockOrganizations(1);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await organizationService.get(mockData[0].id);
expect(result).toEqual(new Organization(mockData[0]));
});
it("does not exist", async () => {
const result = organizationService.get("2");
const result = await organizationService.get("this-org-does-not-exist");
expect(result).toBe(undefined);
});
});
it("upsert", async () => {
await organizationService.upsert(organizationData("2", "Test 2"));
describe("organizations$", () => {
describe("null checking behavior", () => {
it("publishes an empty array if organizations in state = undefined", async () => {
const mockData: OrganizationData[] = undefined;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
});
expect(await firstValueFrom(organizationService.organizations$)).toEqual([
{
id: "1",
name: "Test Org",
identifier: "test",
},
{
id: "2",
name: "Test 2",
identifier: "test",
},
]);
});
it("publishes an empty array if organizations in state = null", async () => {
const mockData: OrganizationData[] = null;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
});
describe("getByIdentifier", () => {
it("exists", async () => {
const result = organizationService.getByIdentifier("test");
expect(result).toEqual({
id: "1",
name: "Test Org",
identifier: "test",
it("publishes an empty array if organizations in state = []", async () => {
const mockData: OrganizationData[] = [];
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
});
});
it("does not exist", async () => {
const result = organizationService.getByIdentifier("blah");
describe("parameter handling & returns", () => {
it("publishes all organizations for the active user by default", async () => {
const mockData = buildMockOrganizations(10);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual(mockData);
});
expect(result).toBeUndefined();
it("can be used to publish the organizations of a non active user if requested", async () => {
const activeUserMockData = buildMockOrganizations(10, "activeUserState");
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
const [nonActiveUserId, nonActiveUserMockOrganizations] =
await addNonActiveAccountToStateProvider();
// This can be updated to use
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
// promise based methods are removed from `OrganizationService` and the
// main observable is refactored to accept a userId
const result = await organizationService.getAll(nonActiveUserId);
expect(result).toEqual(nonActiveUserMockOrganizations);
expect(result).not.toEqual(await firstValueFrom(organizationService.organizations$));
});
});
});
describe("delete", () => {
it("exists", async () => {
await organizationService.delete("1");
expect(stateService.getOrganizations).toHaveBeenCalledTimes(2);
expect(stateService.setOrganizations).toHaveBeenCalledTimes(1);
describe("upsert()", () => {
it("can create the organization list if necassary", async () => {
// Notice that no default state is provided in this test, so the list in
// `stateProvider` will be null when the `upsert` method is called.
const mockData = buildMockOrganizations();
await organizationService.upsert(mockData[0]);
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual(mockData.map((x) => new Organization(x)));
});
it("does not exist", async () => {
// 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
organizationService.delete("1");
it("updates an organization that already exists in state, defaulting to the active user", async () => {
const mockData = buildMockOrganizations(10);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const indexToUpdate = 5;
const anUpdatedOrganization = {
...buildMockOrganizations(1, "UPDATED").pop(),
id: mockData[indexToUpdate].id,
};
await organizationService.upsert(anUpdatedOrganization);
const result = await firstValueFrom(organizationService.organizations$);
expect(result[indexToUpdate]).not.toEqual(new Organization(mockData[indexToUpdate]));
expect(result[indexToUpdate].id).toEqual(new Organization(mockData[indexToUpdate]).id);
expectIsEqualExceptForIndex(
result,
mockData.map((x) => new Organization(x)),
indexToUpdate,
);
});
expect(stateService.getOrganizations).toHaveBeenCalledTimes(2);
it("can also update an organization in state for a non-active user, if requested", async () => {
const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations");
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
const [nonActiveUserId, nonActiveUserMockOrganizations] =
await addNonActiveAccountToStateProvider();
const indexToUpdate = 5;
const anUpdatedOrganization = {
...buildMockOrganizations(1, "UPDATED").pop(),
id: nonActiveUserMockOrganizations[indexToUpdate].id,
};
await organizationService.upsert(anUpdatedOrganization, nonActiveUserId);
// This can be updated to use
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
// promise based methods are removed from `OrganizationService` and the
// main observable is refactored to accept a userId
const result = await organizationService.getAll(nonActiveUserId);
expect(result[indexToUpdate]).not.toEqual(
new Organization(nonActiveUserMockOrganizations[indexToUpdate]),
);
expect(result[indexToUpdate].id).toEqual(
new Organization(nonActiveUserMockOrganizations[indexToUpdate]).id,
);
expectIsEqualExceptForIndex(
result,
nonActiveUserMockOrganizations.map((x) => new Organization(x)),
indexToUpdate,
);
// Just to be safe, lets make sure the active user didn't get updated
// at all
const activeUserState = await firstValueFrom(organizationService.organizations$);
expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x)));
expect(activeUserState).not.toEqual(result);
});
});
function organizationData(id: string, name: string) {
const data = new OrganizationData({} as any, {} as any);
data.id = id;
data.name = name;
data.identifier = "test";
describe("replace()", () => {
it("replaces the entire organization list in state, defaulting to the active user", async () => {
const originalData = buildMockOrganizations(10);
fakeActiveUserState.nextState(arrayToRecord(originalData));
return data;
}
const newData = buildMockOrganizations(10, "newData");
await organizationService.replace(arrayToRecord(newData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual(newData);
expect(result).not.toEqual(originalData);
});
// This is more or less a test for logouts
it("can replace state with null", async () => {
const originalData = buildMockOrganizations(2);
fakeActiveUserState.nextState(arrayToRecord(originalData));
await organizationService.replace(null);
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
expect(result).not.toEqual(originalData);
});
it("can also replace state for a non-active user, if requested", async () => {
const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations");
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
const [nonActiveUserId, originalOrganizations] = await addNonActiveAccountToStateProvider();
const newData = buildMockOrganizations(10, "newData");
await organizationService.replace(arrayToRecord(newData), nonActiveUserId);
// This can be updated to use
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
// promise based methods are removed from `OrganizationService` and the
// main observable is refactored to accept a userId
const result = await organizationService.getAll(nonActiveUserId);
expect(result).toEqual(newData);
expect(result).not.toEqual(originalOrganizations);
// Just to be safe, lets make sure the active user didn't get updated
// at all
const activeUserState = await firstValueFrom(organizationService.organizations$);
expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x)));
expect(activeUserState).not.toEqual(result);
});
});
});

View File

@@ -1,111 +1,105 @@
import { BehaviorSubject, concatMap, map, Observable } from "rxjs";
import { map, Observable, firstValueFrom } from "rxjs";
import { Jsonify } from "type-fest";
import { StateService } from "../../../platform/abstractions/state.service";
import { KeyDefinition, ORGANIZATIONS_DISK, StateProvider } from "../../../platform/state";
import {
InternalOrganizationServiceAbstraction,
isMember,
} from "../../abstractions/organization/organization.service.abstraction";
import { ORGANIZATIONS_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { InternalOrganizationServiceAbstraction } from "../../abstractions/organization/organization.service.abstraction";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
export const ORGANIZATIONS = KeyDefinition.record<OrganizationData>(
/**
* The `KeyDefinition` for accessing organization lists in application state.
* @todo Ideally this wouldn't require a `fromJSON()` call, but `OrganizationData`
* has some properties that contain functions. This should probably get
* cleaned up.
*/
export const ORGANIZATIONS = UserKeyDefinition.record<OrganizationData>(
ORGANIZATIONS_DISK,
"organizations",
{
deserializer: (obj: Jsonify<OrganizationData>) => OrganizationData.fromJSON(obj),
clearOn: ["logout"],
},
);
/**
* Filter out organizations from an observable that __do not__ offer a
* families-for-enterprise sponsorship to members.
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToExcludeOrganizationsWithoutFamilySponsorshipSupport() {
return map<Organization[], Organization[]>((orgs) => orgs.filter((o) => o.canManageSponsorships));
}
/**
* Filter out organizations from an observable that the organization user
* __is not__ a direct member of. This will exclude organizations only
* accessible as a provider.
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToExcludeProviderOrganizations() {
return map<Organization[], Organization[]>((orgs) => orgs.filter((o) => o.isMember));
}
/**
* Map an observable stream of organizations down to a boolean indicating
* if any organizations exist (`orgs.length > 0`).
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToBooleanHasAnyOrganizations() {
return map<Organization[], boolean>((orgs) => orgs.length > 0);
}
/**
* Map an observable stream of organizations down to a single organization.
* @param `organizationId` The ID of the organization you'd like to subscribe to
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToSingleOrganization(organizationId: string) {
return map<Organization[], Organization>((orgs) => orgs?.find((o) => o.id === organizationId));
}
export class OrganizationService implements InternalOrganizationServiceAbstraction {
// marked for removal during AC-2009
protected _organizations = new BehaviorSubject<Organization[]>([]);
// marked for removal during AC-2009
organizations$ = this._organizations.asObservable();
// marked for removal during AC-2009
memberOrganizations$ = this.organizations$.pipe(map((orgs) => orgs.filter(isMember)));
organizations$ = this.getOrganizationsFromState$();
memberOrganizations$ = this.organizations$.pipe(mapToExcludeProviderOrganizations());
activeUserOrganizations$: Observable<Organization[]>;
activeUserMemberOrganizations$: Observable<Organization[]>;
constructor(
private stateService: StateService,
private stateProvider: StateProvider,
) {
this.activeUserOrganizations$ = this.stateProvider
.getActive(ORGANIZATIONS)
.state$.pipe(map((data) => Object.values(data).map((o) => new Organization(o))));
this.activeUserMemberOrganizations$ = this.activeUserOrganizations$.pipe(
map((orgs) => orgs.filter(isMember)),
);
this.stateService.activeAccountUnlocked$
.pipe(
concatMap(async (unlocked) => {
if (!unlocked) {
this._organizations.next([]);
return;
}
const data = await this.stateService.getOrganizations();
this.updateObservables(data);
}),
)
.subscribe();
}
constructor(private stateProvider: StateProvider) {}
get$(id: string): Observable<Organization | undefined> {
return this.organizations$.pipe(map((orgs) => orgs.find((o) => o.id === id)));
return this.organizations$.pipe(mapToSingleOrganization(id));
}
async getAll(userId?: string): Promise<Organization[]> {
const organizationsMap = await this.stateService.getOrganizations({ userId: userId });
return Object.values(organizationsMap || {}).map((o) => new Organization(o));
return await firstValueFrom(this.getOrganizationsFromState$(userId as UserId));
}
async canManageSponsorships(): Promise<boolean> {
const organizations = this._organizations.getValue();
return organizations.some(
(o) => o.familySponsorshipAvailable || o.familySponsorshipFriendlyName !== null,
return await firstValueFrom(
this.organizations$.pipe(
mapToExcludeOrganizationsWithoutFamilySponsorshipSupport(),
mapToBooleanHasAnyOrganizations(),
),
);
}
hasOrganizations(): boolean {
const organizations = this._organizations.getValue();
return organizations.length > 0;
async hasOrganizations(): Promise<boolean> {
return await firstValueFrom(this.organizations$.pipe(mapToBooleanHasAnyOrganizations()));
}
async upsert(organization: OrganizationData): Promise<void> {
let organizations = await this.stateService.getOrganizations();
if (organizations == null) {
organizations = {};
}
organizations[organization.id] = organization;
await this.replace(organizations);
async upsert(organization: OrganizationData, userId?: UserId): Promise<void> {
await this.stateFor(userId).update((existingOrganizations) => {
const organizations = existingOrganizations ?? {};
organizations[organization.id] = organization;
return organizations;
});
}
async delete(id: string): Promise<void> {
const organizations = await this.stateService.getOrganizations();
if (organizations == null) {
return;
}
if (organizations[id] == null) {
return;
}
delete organizations[id];
await this.replace(organizations);
}
get(id: string): Organization {
const organizations = this._organizations.getValue();
return organizations.find((organization) => organization.id === id);
async get(id: string): Promise<Organization> {
return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id)));
}
/**
@@ -113,28 +107,46 @@ export class OrganizationService implements InternalOrganizationServiceAbstracti
* @param id id of the organization
*/
async getFromState(id: string): Promise<Organization> {
const organizationsMap = await this.stateService.getOrganizations();
const organization = organizationsMap[id];
if (organization == null) {
return null;
}
return new Organization(organization);
return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id)));
}
getByIdentifier(identifier: string): Organization {
const organizations = this._organizations.getValue();
return organizations.find((organization) => organization.identifier === identifier);
async replace(organizations: { [id: string]: OrganizationData }, userId?: UserId): Promise<void> {
await this.stateFor(userId).update(() => organizations);
}
async replace(organizations: { [id: string]: OrganizationData }) {
await this.stateService.setOrganizations(organizations);
this.updateObservables(organizations);
// Ideally this method would be renamed to organizations$() and the
// $organizations observable as it stands would be removed. This will
// require updates to callers, and so this method exists as a temporary
// workaround until we have time & a plan to update callers.
//
// It can be thought of as "organizations$ but with a userId option".
private getOrganizationsFromState$(userId?: UserId): Observable<Organization[] | undefined> {
return this.stateFor(userId).state$.pipe(this.mapOrganizationRecordToArray());
}
private updateObservables(organizationsMap: { [id: string]: OrganizationData }) {
const organizations = Object.values(organizationsMap || {}).map((o) => new Organization(o));
this._organizations.next(organizations);
/**
* Accepts a record of `OrganizationData`, which is how we store the
* organization list as a JSON object on disk, to an array of
* `Organization`, which is how the data is published to callers of the
* service.
* @returns a function that can be used to pipe organization data from
* stored state to an exposed object easily consumable by others.
*/
private mapOrganizationRecordToArray() {
return map<Record<string, OrganizationData>, Organization[]>((orgs) =>
Object.values(orgs ?? {})?.map((o) => new Organization(o)),
);
}
/**
* Fetches the organization list from on disk state for the specified user.
* @param userId the user ID to fetch the organization list for. Defaults to
* the currently active user.
* @returns an observable of organization state as it is stored on disk.
*/
private stateFor(userId?: UserId) {
return userId
? this.stateProvider.getUser(userId, ORGANIZATIONS)
: this.stateProvider.getActive(ORGANIZATIONS);
}
}

View File

@@ -10,6 +10,7 @@ import { InternalPolicyService } from "../../abstractions/policy/policy.service.
import { PolicyType } from "../../enums";
import { PolicyData } from "../../models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
import { Policy } from "../../models/domain/policy";
import { PolicyRequest } from "../../models/request/policy.request";
import { PolicyResponse } from "../../models/response/policy.response";
@@ -86,9 +87,7 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
const masterPasswordPolicyResponse =
await this.getMasterPasswordPolicyResponseForOrgUser(orgId);
const masterPasswordPolicy = this.policyService.mapPolicyFromResponse(
masterPasswordPolicyResponse,
);
const masterPasswordPolicy = Policy.fromResponse(masterPasswordPolicyResponse);
if (!masterPasswordPolicy) {
return null;

View File

@@ -16,9 +16,7 @@ import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domai
import { Organization } from "../../../admin-console/models/domain/organization";
import { Policy } from "../../../admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options";
import { PolicyResponse } from "../../../admin-console/models/response/policy.response";
import { POLICIES, PolicyService } from "../../../admin-console/services/policy/policy.service";
import { ListResponse } from "../../../models/response/list.response";
import { PolicyId, UserId } from "../../../types/guid";
describe("PolicyService", () => {
@@ -265,66 +263,6 @@ describe("PolicyService", () => {
});
});
describe("mapPoliciesFromToken", () => {
it("null", async () => {
const result = policyService.mapPoliciesFromToken(null);
expect(result).toEqual(null);
});
it("null data", async () => {
const model = new ListResponse(null, PolicyResponse);
model.data = null;
const result = policyService.mapPoliciesFromToken(model);
expect(result).toEqual(null);
});
it("empty array", async () => {
const model = new ListResponse(null, PolicyResponse);
const result = policyService.mapPoliciesFromToken(model);
expect(result).toEqual([]);
});
it("success", async () => {
const policyResponse: any = {
Data: [
{
Id: "1",
OrganizationId: "organization-1",
Type: PolicyType.DisablePersonalVaultExport,
Enabled: true,
Data: { requireUpper: true },
},
{
Id: "2",
OrganizationId: "organization-2",
Type: PolicyType.DisableSend,
Enabled: false,
Data: { minComplexity: 5, minLength: 20 },
},
],
};
const model = new ListResponse(policyResponse, PolicyResponse);
const result = policyService.mapPoliciesFromToken(model);
expect(result).toEqual([
new Policy(
policyData("1", "organization-1", PolicyType.DisablePersonalVaultExport, true, {
requireUpper: true,
}),
),
new Policy(
policyData("2", "organization-2", PolicyType.DisableSend, false, {
minComplexity: 5,
minLength: 20,
}),
),
]);
});
});
describe("get$", () => {
it("returns the specified PolicyType", async () => {
activeUserState.nextState(

View File

@@ -1,6 +1,5 @@
import { combineLatest, firstValueFrom, map, Observable, of } from "rxjs";
import { ListResponse } from "../../../models/response/list.response";
import { KeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state";
import { PolicyId, UserId } from "../../../types/guid";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
@@ -11,7 +10,6 @@ import { MasterPasswordPolicyOptions } from "../../models/domain/master-password
import { Organization } from "../../models/domain/organization";
import { Policy } from "../../models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
import { PolicyResponse } from "../../models/response/policy.response";
const policyRecordToArray = (policiesMap: { [id: string]: PolicyData }) =>
Object.values(policiesMap || {}).map((f) => new Policy(f));
@@ -212,19 +210,6 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
return [resetPasswordPolicyOptions, policy?.enabled ?? false];
}
mapPolicyFromResponse(policyResponse: PolicyResponse): Policy {
const policyData = new PolicyData(policyResponse);
return new Policy(policyData);
}
mapPoliciesFromToken(policiesResponse: ListResponse<PolicyResponse>): Policy[] {
if (policiesResponse?.data == null) {
return null;
}
return policiesResponse.data.map((response) => this.mapPolicyFromResponse(response));
}
async upsert(policy: PolicyData): Promise<void> {
await this.activeUserPolicyState.update((policies) => {
policies ??= {};

View File

@@ -1,5 +1,5 @@
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
import { FakeActiveUserState } from "../../../spec/fake-state";
import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
import { ProviderUserStatusType, ProviderUserType } from "../enums";
@@ -77,11 +77,13 @@ describe("ProviderService", () => {
const fakeUserId = Utils.newGuid() as UserId;
let fakeAccountService: FakeAccountService;
let fakeStateProvider: FakeStateProvider;
let fakeUserState: FakeSingleUserState<Record<string, ProviderData>>;
let fakeActiveUserState: FakeActiveUserState<Record<string, ProviderData>>;
beforeEach(async () => {
fakeAccountService = mockAccountServiceWith(fakeUserId);
fakeStateProvider = new FakeStateProvider(fakeAccountService);
fakeUserState = fakeStateProvider.singleUser.getFake(fakeUserId, PROVIDERS);
fakeActiveUserState = fakeStateProvider.activeUser.getFake(PROVIDERS);
providerService = new ProviderService(fakeStateProvider);
});
@@ -89,7 +91,7 @@ describe("ProviderService", () => {
describe("getAll()", () => {
it("Returns an array of all providers stored in state", async () => {
const mockData: ProviderData[] = buildMockProviders(5);
fakeActiveUserState.nextState(arrayToRecord(mockData));
fakeUserState.nextState(arrayToRecord(mockData));
const providers = await providerService.getAll();
expect(providers).toHaveLength(5);
expect(providers).toEqual(mockData.map((x) => new Provider(x)));
@@ -97,7 +99,7 @@ describe("ProviderService", () => {
it("Returns an empty array if no providers are found in state", async () => {
const mockData: ProviderData[] = undefined;
fakeActiveUserState.nextState(arrayToRecord(mockData));
fakeUserState.nextState(arrayToRecord(mockData));
const result = await providerService.getAll();
expect(result).toEqual([]);
});
@@ -106,7 +108,7 @@ describe("ProviderService", () => {
describe("get()", () => {
it("Returns a single provider from state that matches the specified id", async () => {
const mockData = buildMockProviders(5);
fakeActiveUserState.nextState(arrayToRecord(mockData));
fakeUserState.nextState(arrayToRecord(mockData));
const result = await providerService.get(mockData[3].id);
expect(result).toEqual(new Provider(mockData[3]));
});
@@ -120,15 +122,12 @@ describe("ProviderService", () => {
describe("save()", () => {
it("replaces the entire provider list in state for the active user", async () => {
const originalData = buildMockProviders(10);
fakeActiveUserState.nextState(arrayToRecord(originalData));
fakeUserState.nextState(arrayToRecord(originalData));
const newData = buildMockProviders(10, "newData");
await providerService.save(arrayToRecord(newData));
const newData = arrayToRecord(buildMockProviders(10, "newData"));
await providerService.save(newData);
const result = await providerService.getAll();
expect(result).toEqual(newData);
expect(result).not.toEqual(originalData);
expect(fakeActiveUserState.nextMock).toHaveBeenCalledWith([fakeUserId, newData]);
});
// This is more or less a test for logouts
@@ -136,9 +135,8 @@ describe("ProviderService", () => {
const originalData = buildMockProviders(2);
fakeActiveUserState.nextState(arrayToRecord(originalData));
await providerService.save(null);
const result = await providerService.getAll();
expect(result).toEqual([]);
expect(result).not.toEqual(originalData);
expect(fakeActiveUserState.nextMock).toHaveBeenCalledWith([fakeUserId, null]);
});
});
});

View File

@@ -1,4 +1,4 @@
import { Observable, map, firstValueFrom } from "rxjs";
import { Observable, map, firstValueFrom, of, switchMap, take } from "rxjs";
import { KeyDefinition, PROVIDERS_DISK, StateProvider } from "../../platform/state";
import { UserId } from "../../types/guid";
@@ -18,9 +18,17 @@ export class ProviderService implements ProviderServiceAbstraction {
constructor(private stateProvider: StateProvider) {}
private providers$(userId?: UserId): Observable<Provider[] | undefined> {
return this.stateProvider
.getUserState$(PROVIDERS, userId)
.pipe(this.mapProviderRecordToArray());
// FIXME: Can be replaced with `getUserStateOrDefault$` if we weren't trying to pick this.
return (
userId != null
? this.stateProvider.getUser(userId, PROVIDERS).state$
: this.stateProvider.activeUserId$.pipe(
take(1),
switchMap((userId) =>
userId != null ? this.stateProvider.getUser(userId, PROVIDERS).state$ : of(null),
),
)
).pipe(this.mapProviderRecordToArray());
}
private mapProviderRecordToArray() {

View File

@@ -0,0 +1,29 @@
import { Observable } from "rxjs";
import { UserId } from "../../types/guid";
export abstract class AvatarService {
/**
* An observable monitoring the active user's avatar color.
* The observable updates when the avatar color changes.
*/
avatarColor$: Observable<string | null>;
/**
* Sets the avatar color of the active user
*
* @param color the color to set the avatar color to
* @returns a promise that resolves when the avatar color is set
*/
abstract setAvatarColor(color: string): Promise<void>;
/**
* Gets the avatar color of the specified user.
*
* @remarks This is most useful for account switching where we show an
* avatar for each account. If you only need the active user's
* avatar color, use the avatarColor$ observable above instead.
*
* @param userId the userId of the user whose avatar color should be retreived
* @return an Observable that emits a string of the avatar color of the specified user
*/
abstract getUserAvatarColor$(userId: UserId): Observable<string | null>;
}

View File

@@ -54,6 +54,20 @@ export abstract class SsoLoginServiceAbstraction {
* Do not use this value outside of the SSO login flow.
*/
setOrganizationSsoIdentifier: (organizationIdentifier: string) => Promise<void>;
/**
* Gets the user's email.
* Note: This should only be used during the SSO flow to identify the user that is attempting to log in.
* @returns The user's email.
*/
getSsoEmail: () => Promise<string>;
/**
* Sets the user's email.
* Note: This should only be used during the SSO flow to identify the user that is attempting to log in.
* @param email The user's email.
* @returns A promise that resolves when the email has been set.
*
*/
setSsoEmail: (email: string) => Promise<void>;
/**
* Gets the value of the active user's organization sso identifier.
*

View File

@@ -1,31 +1,208 @@
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { UserId } from "../../types/guid";
import { DecodedAccessToken } from "../services/token.service";
export abstract class TokenService {
/**
* Sets the access token, refresh token, API Key Client ID, and API Key Client Secret in memory or disk
* based on the given vaultTimeoutAction and vaultTimeout and the derived access token user id.
* Note: for platforms that support secure storage, the access & refresh tokens are stored in secure storage instead of on disk.
* Note 2: this method also enforces always setting the access token and the refresh token together as
* we can retrieve the user id required to set the refresh token from the access token for efficiency.
* @param accessToken The access token to set.
* @param refreshToken The refresh token to set.
* @param clientIdClientSecret The API Key Client ID and Client Secret to set.
* @param vaultTimeoutAction The action to take when the vault times out.
* @param vaultTimeout The timeout for the vault.
* @returns A promise that resolves when the tokens have been set.
*/
setTokens: (
accessToken: string,
refreshToken: string,
clientIdClientSecret: [string, string],
) => Promise<any>;
setToken: (token: string) => Promise<any>;
getToken: () => Promise<string>;
setRefreshToken: (refreshToken: string) => Promise<any>;
getRefreshToken: () => Promise<string>;
setClientId: (clientId: string) => Promise<any>;
getClientId: () => Promise<string>;
setClientSecret: (clientSecret: string) => Promise<any>;
getClientSecret: () => Promise<string>;
setTwoFactorToken: (tokenResponse: IdentityTokenResponse) => Promise<any>;
getTwoFactorToken: () => Promise<string>;
clearTwoFactorToken: () => Promise<any>;
clearToken: (userId?: string) => Promise<any>;
decodeToken: (token?: string) => Promise<any>;
getTokenExpirationDate: () => Promise<Date>;
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: number | null,
clientIdClientSecret?: [string, string],
) => Promise<void>;
/**
* Clears the access token, refresh token, API Key Client ID, and API Key Client Secret out of memory, disk, and secure storage if supported.
* @param userId The optional user id to clear the tokens for; if not provided, the active user id is used.
* @returns A promise that resolves when the tokens have been cleared.
*/
clearTokens: (userId?: UserId) => Promise<void>;
/**
* Sets the access token in memory or disk based on the given vaultTimeoutAction and vaultTimeout
* and the user id read off the access token
* Note: for platforms that support secure storage, the access & refresh tokens are stored in secure storage instead of on disk.
* @param accessToken The access token to set.
* @param vaultTimeoutAction The action to take when the vault times out.
* @param vaultTimeout The timeout for the vault.
* @returns A promise that resolves when the access token has been set.
*/
setAccessToken: (
accessToken: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: number | null,
) => Promise<void>;
// TODO: revisit having this public clear method approach once the state service is fully deprecated.
/**
* Clears the access token for the given user id out of memory, disk, and secure storage if supported.
* @param userId The optional user id to clear the access token for; if not provided, the active user id is used.
* @returns A promise that resolves when the access token has been cleared.
*
* Note: This method is required so that the StateService doesn't have to inject the VaultTimeoutSettingsService to
* pass in the vaultTimeoutAction and vaultTimeout.
* This avoids a circular dependency between the StateService, TokenService, and VaultTimeoutSettingsService.
*/
clearAccessToken: (userId?: UserId) => Promise<void>;
/**
* Gets the access token
* @param userId - The optional user id to get the access token for; if not provided, the active user is used.
* @returns A promise that resolves with the access token or undefined.
*/
getAccessToken: (userId?: UserId) => Promise<string | undefined>;
/**
* Gets the refresh token.
* @param userId - The optional user id to get the refresh token for; if not provided, the active user is used.
* @returns A promise that resolves with the refresh token or undefined.
*/
getRefreshToken: (userId?: UserId) => Promise<string | undefined>;
/**
* Sets the API Key Client ID for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout.
* @param clientId The API Key Client ID to set.
* @param vaultTimeoutAction The action to take when the vault times out.
* @param vaultTimeout The timeout for the vault.
* @returns A promise that resolves when the API Key Client ID has been set.
*/
setClientId: (
clientId: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: number | null,
userId?: UserId,
) => Promise<void>;
/**
* Gets the API Key Client ID for the active user.
* @returns A promise that resolves with the API Key Client ID or undefined
*/
getClientId: (userId?: UserId) => Promise<string | undefined>;
/**
* Sets the API Key Client Secret for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout.
* @param clientSecret The API Key Client Secret to set.
* @param vaultTimeoutAction The action to take when the vault times out.
* @param vaultTimeout The timeout for the vault.
* @returns A promise that resolves when the API Key Client Secret has been set.
*/
setClientSecret: (
clientSecret: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: number | null,
userId?: UserId,
) => Promise<void>;
/**
* Gets the API Key Client Secret for the active user.
* @returns A promise that resolves with the API Key Client Secret or undefined
*/
getClientSecret: (userId?: UserId) => Promise<string | undefined>;
/**
* Sets the two factor token for the given email in global state.
* The two factor token is set when the user checks "remember me" when completing two factor
* authentication and it is used to bypass two factor authentication for a period of time.
* @param email The email to set the two factor token for.
* @param twoFactorToken The two factor token to set.
* @returns A promise that resolves when the two factor token has been set.
*/
setTwoFactorToken: (email: string, twoFactorToken: string) => Promise<void>;
/**
* Gets the two factor token for the given email.
* @param email The email to get the two factor token for.
* @returns A promise that resolves with the two factor token for the given email or null if it isn't found.
*/
getTwoFactorToken: (email: string) => Promise<string | null>;
/**
* Clears the two factor token for the given email out of global state.
* @param email The email to clear the two factor token for.
* @returns A promise that resolves when the two factor token has been cleared.
*/
clearTwoFactorToken: (email: string) => Promise<void>;
/**
* Decodes the access token.
* @param token The access token to decode.
* @returns A promise that resolves with the decoded access token.
*/
decodeAccessToken: (token?: string) => Promise<DecodedAccessToken>;
/**
* Gets the expiration date for the access token. Returns if token can't be decoded or has no expiration
* @returns A promise that resolves with the expiration date for the access token.
*/
getTokenExpirationDate: () => Promise<Date | null>;
/**
* Calculates the adjusted time in seconds until the access token expires, considering an optional offset.
*
* @param {number} [offsetSeconds=0] Optional seconds to subtract from the remaining time,
* creating a buffer before actual expiration. Useful for preemptive actions
* before token expiry. A value of 0 or omitting this parameter calculates time
* based on the actual expiration.
* @returns {Promise<number>} Promise resolving to the adjusted seconds remaining.
*/
tokenSecondsRemaining: (offsetSeconds?: number) => Promise<number>;
/**
* Checks if the access token needs to be refreshed.
* @param {number} [minutes=5] - Optional number of minutes before the access token expires to consider refreshing it.
* @returns A promise that resolves with a boolean indicating if the access token needs to be refreshed.
*/
tokenNeedsRefresh: (minutes?: number) => Promise<boolean>;
getUserId: () => Promise<string>;
/**
* Gets the user id for the active user from the access token.
* @returns A promise that resolves with the user id for the active user.
* @deprecated Use AccountService.activeAccount$ instead.
*/
getUserId: () => Promise<UserId>;
/**
* Gets the email for the active user from the access token.
* @returns A promise that resolves with the email for the active user.
* @deprecated Use AccountService.activeAccount$ instead.
*/
getEmail: () => Promise<string>;
/**
* Gets the email verified status for the active user from the access token.
* @returns A promise that resolves with the email verified status for the active user.
*/
getEmailVerified: () => Promise<boolean>;
/**
* Gets the name for the active user from the access token.
* @returns A promise that resolves with the name for the active user.
* @deprecated Use AccountService.activeAccount$ instead.
*/
getName: () => Promise<string>;
/**
* Gets the issuer for the active user from the access token.
* @returns A promise that resolves with the issuer for the active user.
*/
getIssuer: () => Promise<string>;
/**
* Gets whether or not the user authenticated via an external mechanism.
* @returns A promise that resolves with a boolean representing the user's external authN status.
*/
getIsExternal: () => Promise<boolean>;
}

View File

@@ -0,0 +1,33 @@
import { Observable } from "rxjs";
import { ApiService } from "../../abstractions/api.service";
import { UpdateAvatarRequest } from "../../models/request/update-avatar.request";
import { AVATAR_DISK, StateProvider, UserKeyDefinition } from "../../platform/state";
import { UserId } from "../../types/guid";
import { AvatarService as AvatarServiceAbstraction } from "../abstractions/avatar.service";
const AVATAR_COLOR = new UserKeyDefinition<string>(AVATAR_DISK, "avatarColor", {
deserializer: (value) => value,
clearOn: [],
});
export class AvatarService implements AvatarServiceAbstraction {
avatarColor$: Observable<string>;
constructor(
private apiService: ApiService,
private stateProvider: StateProvider,
) {
this.avatarColor$ = this.stateProvider.getActive(AVATAR_COLOR).state$;
}
async setAvatarColor(color: string): Promise<void> {
const { avatarColor } = await this.apiService.putAvatar(new UpdateAvatarRequest(color));
await this.stateProvider.setUserState(AVATAR_COLOR, avatarColor);
}
getUserAvatarColor$(userId: UserId): Observable<string | null> {
return this.stateProvider.getUser(userId, AVATAR_COLOR).state$;
}
}

View File

@@ -7,6 +7,7 @@ import {
SSO_DISK,
StateProvider,
} from "../../platform/state";
import { SsoLoginServiceAbstraction } from "../abstractions/sso-login.service.abstraction";
/**
* Uses disk storage so that the code verifier can be persisted across sso redirects.
@@ -33,16 +34,25 @@ const ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition<string>(
},
);
export class SsoLoginService {
/**
* Uses disk storage so that the user's email can be persisted across sso redirects.
*/
const SSO_EMAIL = new KeyDefinition<string>(SSO_DISK, "ssoEmail", {
deserializer: (state) => state,
});
export class SsoLoginService implements SsoLoginServiceAbstraction {
private codeVerifierState: GlobalState<string>;
private ssoState: GlobalState<string>;
private orgSsoIdentifierState: GlobalState<string>;
private ssoEmailState: GlobalState<string>;
private activeUserOrgSsoIdentifierState: ActiveUserState<string>;
constructor(private stateProvider: StateProvider) {
this.codeVerifierState = this.stateProvider.getGlobal(CODE_VERIFIER);
this.ssoState = this.stateProvider.getGlobal(SSO_STATE);
this.orgSsoIdentifierState = this.stateProvider.getGlobal(ORGANIZATION_SSO_IDENTIFIER);
this.ssoEmailState = this.stateProvider.getGlobal(SSO_EMAIL);
this.activeUserOrgSsoIdentifierState = this.stateProvider.getActive(
ORGANIZATION_SSO_IDENTIFIER,
);
@@ -72,6 +82,14 @@ export class SsoLoginService {
await this.orgSsoIdentifierState.update((_) => organizationIdentifier);
}
getSsoEmail(): Promise<string> {
return firstValueFrom(this.ssoEmailState.state$);
}
async setSsoEmail(email: string): Promise<void> {
await this.ssoEmailState.update((_) => email);
}
getActiveUserOrganizationSsoIdentifier(): Promise<string> {
return firstValueFrom(this.activeUserOrgSsoIdentifierState.state$);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,125 +1,629 @@
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import { firstValueFrom } from "rxjs";
import { decodeJwtTokenToJson } from "@bitwarden/auth/common";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
import { StorageLocation } from "../../platform/enums";
import { StorageOptions } from "../../platform/models/domain/storage-options";
import {
GlobalState,
GlobalStateProvider,
KeyDefinition,
SingleUserStateProvider,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { TokenService as TokenServiceAbstraction } from "../abstractions/token.service";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service";
import {
ACCESS_TOKEN_DISK,
ACCESS_TOKEN_MEMORY,
ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE,
API_KEY_CLIENT_ID_DISK,
API_KEY_CLIENT_ID_MEMORY,
API_KEY_CLIENT_SECRET_DISK,
API_KEY_CLIENT_SECRET_MEMORY,
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
REFRESH_TOKEN_DISK,
REFRESH_TOKEN_MEMORY,
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
} from "./token.state";
export enum TokenStorageLocation {
Disk = "disk",
SecureStorage = "secureStorage",
Memory = "memory",
}
/**
* Type representing the structure of a standard Bitwarden decoded access token.
* src: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
* Note: all claims are technically optional so we must verify their existence before using them.
* Note 2: NumericDate is a number representing a date in seconds since the Unix epoch.
*/
export type DecodedAccessToken = {
/** Issuer - the issuer of the token, typically the URL of the authentication server */
iss?: string;
/** Not Before - a timestamp defining when the token starts being valid */
nbf?: number;
/** Issued At - a timestamp of when the token was issued */
iat?: number;
/** Expiration Time - a NumericDate timestamp of when the token will expire */
exp?: number;
/** Scope - the scope of the access request, such as the permissions the token grants */
scope?: string[];
/** Authentication Method Reference - the methods used in the authentication */
amr?: string[];
/** Client ID - the identifier for the client that requested the token */
client_id?: string;
/** Subject - the unique identifier for the user */
sub?: string;
/** Authentication Time - a timestamp of when the user authentication occurred */
auth_time?: number;
/** Identity Provider - the system or service that authenticated the user */
idp?: string;
/** Premium - a boolean flag indicating whether the account is premium */
premium?: boolean;
/** Email - the user's email address */
email?: string;
/** Email Verified - a boolean flag indicating whether the user's email address has been verified */
email_verified?: boolean;
/**
* Security Stamp - a unique identifier which invalidates the access token if it changes in the db
* (typically after critical account changes like a password change)
*/
sstamp?: string;
/** Name - the name of the user */
name?: string;
/** Organization Owners - a list of organization owner identifiers */
orgowner?: string[];
/** Device - the identifier of the device used */
device?: string;
/** JWT ID - a unique identifier for the JWT */
jti?: string;
};
export class TokenService implements TokenServiceAbstraction {
static decodeToken(token: string): Promise<any> {
if (token == null) {
throw new Error("Token not provided.");
}
private readonly accessTokenSecureStorageKey: string = "_accessToken";
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("JWT must have 3 parts");
}
private readonly refreshTokenSecureStorageKey: string = "_refreshToken";
const decoded = Utils.fromUrlB64ToUtf8(parts[1]);
if (decoded == null) {
throw new Error("Cannot decode the token");
}
private emailTwoFactorTokenRecordGlobalState: GlobalState<Record<string, string>>;
const decodedToken = JSON.parse(decoded);
return decodedToken;
private activeUserIdGlobalState: GlobalState<UserId>;
constructor(
// Note: we cannot use ActiveStateProvider because if we ever want to inject
// this service into the AccountService, we will make a circular dependency
private singleUserStateProvider: SingleUserStateProvider,
private globalStateProvider: GlobalStateProvider,
private readonly platformSupportsSecureStorage: boolean,
private secureStorageService: AbstractStorageService,
) {
this.initializeState();
}
constructor(private stateService: StateService) {}
private initializeState(): void {
this.emailTwoFactorTokenRecordGlobalState = this.globalStateProvider.get(
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
);
this.activeUserIdGlobalState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID);
}
async setTokens(
accessToken: string,
refreshToken: string,
clientIdClientSecret: [string, string],
): Promise<any> {
await this.setToken(accessToken);
await this.setRefreshToken(refreshToken);
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: number | null,
clientIdClientSecret?: [string, string],
): Promise<void> {
if (!accessToken || !refreshToken) {
throw new Error("Access token and refresh token are required.");
}
// get user id the access token
const userId: UserId = await this.getUserIdFromAccessToken(accessToken);
if (!userId) {
throw new Error("User id not found. Cannot set tokens.");
}
await this._setAccessToken(accessToken, vaultTimeoutAction, vaultTimeout, userId);
await this.setRefreshToken(refreshToken, vaultTimeoutAction, vaultTimeout, userId);
if (clientIdClientSecret != null) {
await this.setClientId(clientIdClientSecret[0]);
await this.setClientSecret(clientIdClientSecret[1]);
await this.setClientId(clientIdClientSecret[0], vaultTimeoutAction, vaultTimeout, userId);
await this.setClientSecret(clientIdClientSecret[1], vaultTimeoutAction, vaultTimeout, userId);
}
}
async setClientId(clientId: string): Promise<any> {
return await this.stateService.setApiKeyClientId(clientId);
/**
* Internal helper for set access token which always requires user id.
* This is useful because setTokens always will have a user id from the access token whereas
* the public setAccessToken method does not.
*/
private async _setAccessToken(
accessToken: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: number | null,
userId: UserId,
): Promise<void> {
const storageLocation = await this.determineStorageLocation(
vaultTimeoutAction,
vaultTimeout,
true,
);
switch (storageLocation) {
case TokenStorageLocation.SecureStorage:
await this.saveStringToSecureStorage(userId, this.accessTokenSecureStorageKey, accessToken);
// TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408
// 2024-02-20: Remove access token from memory and disk so that we migrate to secure storage over time.
// Remove these 2 calls to remove the access token from memory and disk after 3 releases.
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null);
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
// Set flag to indicate that the access token has been migrated to secure storage (don't remove this)
await this.setAccessTokenMigratedToSecureStorage(userId);
return;
case TokenStorageLocation.Disk:
await this.singleUserStateProvider
.get(userId, ACCESS_TOKEN_DISK)
.update((_) => accessToken);
return;
case TokenStorageLocation.Memory:
await this.singleUserStateProvider
.get(userId, ACCESS_TOKEN_MEMORY)
.update((_) => accessToken);
return;
}
}
async getClientId(): Promise<string> {
return await this.stateService.getApiKeyClientId();
async setAccessToken(
accessToken: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: number | null,
): Promise<void> {
if (!accessToken) {
throw new Error("Access token is required.");
}
const userId: UserId = await this.getUserIdFromAccessToken(accessToken);
// If we don't have a user id, we can't save the value
if (!userId) {
throw new Error("User id not found. Cannot save access token.");
}
await this._setAccessToken(accessToken, vaultTimeoutAction, vaultTimeout, userId);
}
async setClientSecret(clientSecret: string): Promise<any> {
return await this.stateService.setApiKeyClientSecret(clientSecret);
async clearAccessToken(userId?: UserId): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
// If we don't have a user id, we can't clear the value
if (!userId) {
throw new Error("User id not found. Cannot clear access token.");
}
// TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data.
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
// but we can simply clear all locations to avoid the need to require those parameters
if (this.platformSupportsSecureStorage) {
await this.secureStorageService.remove(
`${userId}${this.accessTokenSecureStorageKey}`,
this.getSecureStorageOptions(userId),
);
}
// Platform doesn't support secure storage, so use state provider implementation
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null);
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
}
async getClientSecret(): Promise<string> {
return await this.stateService.getApiKeyClientSecret();
async getAccessToken(userId?: UserId): Promise<string | undefined> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
return undefined;
}
const accessTokenMigratedToSecureStorage =
await this.getAccessTokenMigratedToSecureStorage(userId);
if (this.platformSupportsSecureStorage && accessTokenMigratedToSecureStorage) {
return await this.getStringFromSecureStorage(userId, this.accessTokenSecureStorageKey);
}
// Try to get the access token from memory
const accessTokenMemory = await this.getStateValueByUserIdAndKeyDef(
userId,
ACCESS_TOKEN_MEMORY,
);
if (accessTokenMemory != null) {
return accessTokenMemory;
}
// If memory is null, read from disk
return await this.getStateValueByUserIdAndKeyDef(userId, ACCESS_TOKEN_DISK);
}
async setToken(token: string): Promise<void> {
await this.stateService.setAccessToken(token);
private async getAccessTokenMigratedToSecureStorage(userId: UserId): Promise<boolean> {
return await firstValueFrom(
this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE).state$,
);
}
async getToken(): Promise<string> {
return await this.stateService.getAccessToken();
private async setAccessTokenMigratedToSecureStorage(userId: UserId): Promise<void> {
await this.singleUserStateProvider
.get(userId, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
.update((_) => true);
}
async setRefreshToken(refreshToken: string): Promise<any> {
return await this.stateService.setRefreshToken(refreshToken);
// Private because we only ever set the refresh token when also setting the access token
// and we need the user id from the access token to save to secure storage
private async setRefreshToken(
refreshToken: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: number | null,
userId: UserId,
): Promise<void> {
// If we don't have a user id, we can't save the value
if (!userId) {
throw new Error("User id not found. Cannot save refresh token.");
}
const storageLocation = await this.determineStorageLocation(
vaultTimeoutAction,
vaultTimeout,
true,
);
switch (storageLocation) {
case TokenStorageLocation.SecureStorage:
await this.saveStringToSecureStorage(
userId,
this.refreshTokenSecureStorageKey,
refreshToken,
);
// TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408
// 2024-02-20: Remove refresh token from memory and disk so that we migrate to secure storage over time.
// Remove these 2 calls to remove the refresh token from memory and disk after 3 releases.
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
// Set flag to indicate that the refresh token has been migrated to secure storage (don't remove this)
await this.setRefreshTokenMigratedToSecureStorage(userId);
return;
case TokenStorageLocation.Disk:
await this.singleUserStateProvider
.get(userId, REFRESH_TOKEN_DISK)
.update((_) => refreshToken);
return;
case TokenStorageLocation.Memory:
await this.singleUserStateProvider
.get(userId, REFRESH_TOKEN_MEMORY)
.update((_) => refreshToken);
return;
}
}
async getRefreshToken(): Promise<string> {
return await this.stateService.getRefreshToken();
async getRefreshToken(userId?: UserId): Promise<string | undefined> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
return undefined;
}
const refreshTokenMigratedToSecureStorage =
await this.getRefreshTokenMigratedToSecureStorage(userId);
if (this.platformSupportsSecureStorage && refreshTokenMigratedToSecureStorage) {
return await this.getStringFromSecureStorage(userId, this.refreshTokenSecureStorageKey);
}
// pre-secure storage migration:
// Always read memory first b/c faster
const refreshTokenMemory = await this.getStateValueByUserIdAndKeyDef(
userId,
REFRESH_TOKEN_MEMORY,
);
if (refreshTokenMemory != null) {
return refreshTokenMemory;
}
// if memory is null, read from disk
const refreshTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, REFRESH_TOKEN_DISK);
if (refreshTokenDisk != null) {
return refreshTokenDisk;
}
return null;
}
async setTwoFactorToken(tokenResponse: IdentityTokenResponse): Promise<any> {
return await this.stateService.setTwoFactorToken(tokenResponse.twoFactorToken);
private async clearRefreshToken(userId: UserId): Promise<void> {
// If we don't have a user id, we can't clear the value
if (!userId) {
throw new Error("User id not found. Cannot clear refresh token.");
}
// TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data.
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
// but we can simply clear all locations to avoid the need to require those parameters
if (this.platformSupportsSecureStorage) {
await this.secureStorageService.remove(
`${userId}${this.refreshTokenSecureStorageKey}`,
this.getSecureStorageOptions(userId),
);
}
// Platform doesn't support secure storage, so use state provider implementation
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
}
async getTwoFactorToken(): Promise<string> {
return await this.stateService.getTwoFactorToken();
private async getRefreshTokenMigratedToSecureStorage(userId: UserId): Promise<boolean> {
return await firstValueFrom(
this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE).state$,
);
}
async clearTwoFactorToken(): Promise<any> {
return await this.stateService.setTwoFactorToken(null);
private async setRefreshTokenMigratedToSecureStorage(userId: UserId): Promise<void> {
await this.singleUserStateProvider
.get(userId, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE)
.update((_) => true);
}
async clearToken(userId?: string): Promise<any> {
await this.stateService.setAccessToken(null, { userId: userId });
await this.stateService.setRefreshToken(null, { userId: userId });
await this.stateService.setApiKeyClientId(null, { userId: userId });
await this.stateService.setApiKeyClientSecret(null, { userId: userId });
async setClientId(
clientId: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: number | null,
userId?: UserId,
): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
// If we don't have a user id, we can't save the value
if (!userId) {
throw new Error("User id not found. Cannot save client id.");
}
const storageLocation = await this.determineStorageLocation(
vaultTimeoutAction,
vaultTimeout,
false,
);
if (storageLocation === TokenStorageLocation.Disk) {
await this.singleUserStateProvider
.get(userId, API_KEY_CLIENT_ID_DISK)
.update((_) => clientId);
} else if (storageLocation === TokenStorageLocation.Memory) {
await this.singleUserStateProvider
.get(userId, API_KEY_CLIENT_ID_MEMORY)
.update((_) => clientId);
}
}
async getClientId(userId?: UserId): Promise<string | undefined> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
return undefined;
}
// Always read memory first b/c faster
const apiKeyClientIdMemory = await this.getStateValueByUserIdAndKeyDef(
userId,
API_KEY_CLIENT_ID_MEMORY,
);
if (apiKeyClientIdMemory != null) {
return apiKeyClientIdMemory;
}
// if memory is null, read from disk
return await this.getStateValueByUserIdAndKeyDef(userId, API_KEY_CLIENT_ID_DISK);
}
private async clearClientId(userId?: UserId): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
// If we don't have a user id, we can't clear the value
if (!userId) {
throw new Error("User id not found. Cannot clear client id.");
}
// TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data.
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
// but we can simply clear both locations to avoid the need to require those parameters
// Platform doesn't support secure storage, so use state provider implementation
await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_ID_MEMORY).update((_) => null);
await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_ID_DISK).update((_) => null);
}
async setClientSecret(
clientSecret: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: number | null,
userId?: UserId,
): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
throw new Error("User id not found. Cannot save client secret.");
}
const storageLocation = await this.determineStorageLocation(
vaultTimeoutAction,
vaultTimeout,
false,
);
if (storageLocation === TokenStorageLocation.Disk) {
await this.singleUserStateProvider
.get(userId, API_KEY_CLIENT_SECRET_DISK)
.update((_) => clientSecret);
} else if (storageLocation === TokenStorageLocation.Memory) {
await this.singleUserStateProvider
.get(userId, API_KEY_CLIENT_SECRET_MEMORY)
.update((_) => clientSecret);
}
}
async getClientSecret(userId?: UserId): Promise<string | undefined> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
return undefined;
}
// Always read memory first b/c faster
const apiKeyClientSecretMemory = await this.getStateValueByUserIdAndKeyDef(
userId,
API_KEY_CLIENT_SECRET_MEMORY,
);
if (apiKeyClientSecretMemory != null) {
return apiKeyClientSecretMemory;
}
// if memory is null, read from disk
return await this.getStateValueByUserIdAndKeyDef(userId, API_KEY_CLIENT_SECRET_DISK);
}
private async clearClientSecret(userId?: UserId): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
// If we don't have a user id, we can't clear the value
if (!userId) {
throw new Error("User id not found. Cannot clear client secret.");
}
// TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data.
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
// but we can simply clear both locations to avoid the need to require those parameters
// Platform doesn't support secure storage, so use state provider implementation
await this.singleUserStateProvider
.get(userId, API_KEY_CLIENT_SECRET_MEMORY)
.update((_) => null);
await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_SECRET_DISK).update((_) => null);
}
async setTwoFactorToken(email: string, twoFactorToken: string): Promise<void> {
await this.emailTwoFactorTokenRecordGlobalState.update((emailTwoFactorTokenRecord) => {
emailTwoFactorTokenRecord ??= {};
emailTwoFactorTokenRecord[email] = twoFactorToken;
return emailTwoFactorTokenRecord;
});
}
async getTwoFactorToken(email: string): Promise<string | null> {
const emailTwoFactorTokenRecord: Record<string, string> = await firstValueFrom(
this.emailTwoFactorTokenRecordGlobalState.state$,
);
if (!emailTwoFactorTokenRecord) {
return null;
}
return emailTwoFactorTokenRecord[email];
}
async clearTwoFactorToken(email: string): Promise<void> {
await this.emailTwoFactorTokenRecordGlobalState.update((emailTwoFactorTokenRecord) => {
emailTwoFactorTokenRecord ??= {};
delete emailTwoFactorTokenRecord[email];
return emailTwoFactorTokenRecord;
});
}
async clearTokens(userId?: UserId): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
throw new Error("User id not found. Cannot clear tokens.");
}
await Promise.all([
this.clearAccessToken(userId),
this.clearRefreshToken(userId),
this.clearClientId(userId),
this.clearClientSecret(userId),
]);
}
// jwthelper methods
// ref https://github.com/auth0/angular-jwt/blob/master/src/angularJwt/services/jwt.js
async decodeToken(token?: string): Promise<any> {
token = token ?? (await this.stateService.getAccessToken());
async decodeAccessToken(token?: string): Promise<DecodedAccessToken> {
token = token ?? (await this.getAccessToken());
if (token == null) {
throw new Error("Token not found.");
throw new Error("Access token not found.");
}
return TokenService.decodeToken(token);
return decodeJwtTokenToJson(token) as DecodedAccessToken;
}
async getTokenExpirationDate(): Promise<Date> {
const decoded = await this.decodeToken();
if (typeof decoded.exp === "undefined") {
// TODO: PM-6678- tech debt - consider consolidating the return types of all these access
// token data retrieval methods to return null if something goes wrong instead of throwing an error.
async getTokenExpirationDate(): Promise<Date | null> {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
// per RFC, exp claim is optional but if it exists, it should be a number
if (!decoded || typeof decoded.exp !== "number") {
return null;
}
const d = new Date(0); // The 0 here is the key, which sets the date to the epoch
d.setUTCSeconds(decoded.exp);
return d;
// The 0 in Date(0) is the key; it sets the date to the epoch
const expirationDate = new Date(0);
expirationDate.setUTCSeconds(decoded.exp);
return expirationDate;
}
async tokenSecondsRemaining(offsetSeconds = 0): Promise<number> {
const d = await this.getTokenExpirationDate();
if (d == null) {
const date = await this.getTokenExpirationDate();
if (date == null) {
return 0;
}
const msRemaining = d.valueOf() - (new Date().valueOf() + offsetSeconds * 1000);
const msRemaining = date.valueOf() - (new Date().valueOf() + offsetSeconds * 1000);
return Math.round(msRemaining / 1000);
}
@@ -128,54 +632,159 @@ export class TokenService implements TokenServiceAbstraction {
return sRemaining < 60 * minutes;
}
async getUserId(): Promise<string> {
const decoded = await this.decodeToken();
if (typeof decoded.sub === "undefined") {
async getUserId(): Promise<UserId> {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
if (!decoded || typeof decoded.sub !== "string") {
throw new Error("No user id found");
}
return decoded.sub as string;
return decoded.sub as UserId;
}
private async getUserIdFromAccessToken(accessToken: string): Promise<UserId> {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken(accessToken);
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
if (!decoded || typeof decoded.sub !== "string") {
throw new Error("No user id found");
}
return decoded.sub as UserId;
}
async getEmail(): Promise<string> {
const decoded = await this.decodeToken();
if (typeof decoded.email === "undefined") {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
if (!decoded || typeof decoded.email !== "string") {
throw new Error("No email found");
}
return decoded.email as string;
return decoded.email;
}
async getEmailVerified(): Promise<boolean> {
const decoded = await this.decodeToken();
if (typeof decoded.email_verified === "undefined") {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
if (!decoded || typeof decoded.email_verified !== "boolean") {
throw new Error("No email verification found");
}
return decoded.email_verified as boolean;
return decoded.email_verified;
}
async getName(): Promise<string> {
const decoded = await this.decodeToken();
if (typeof decoded.name === "undefined") {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
if (!decoded || typeof decoded.name !== "string") {
return null;
}
return decoded.name as string;
return decoded.name;
}
async getIssuer(): Promise<string> {
const decoded = await this.decodeToken();
if (typeof decoded.iss === "undefined") {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
if (!decoded || typeof decoded.iss !== "string") {
throw new Error("No issuer found");
}
return decoded.iss as string;
return decoded.iss;
}
async getIsExternal(): Promise<boolean> {
const decoded = await this.decodeToken();
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
return Array.isArray(decoded.amr) && decoded.amr.includes("external");
}
private async getStateValueByUserIdAndKeyDef(
userId: UserId,
storageLocation: KeyDefinition<string>,
): Promise<string | undefined> {
// read from single user state provider
return await firstValueFrom(this.singleUserStateProvider.get(userId, storageLocation).state$);
}
private async determineStorageLocation(
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: number | null,
useSecureStorage: boolean,
): Promise<TokenStorageLocation> {
if (vaultTimeoutAction === VaultTimeoutAction.LogOut && vaultTimeout != null) {
return TokenStorageLocation.Memory;
} else {
if (useSecureStorage && this.platformSupportsSecureStorage) {
return TokenStorageLocation.SecureStorage;
}
return TokenStorageLocation.Disk;
}
}
private async saveStringToSecureStorage(
userId: UserId,
storageKey: string,
value: string,
): Promise<void> {
await this.secureStorageService.save<string>(
`${userId}${storageKey}`,
value,
this.getSecureStorageOptions(userId),
);
}
private async getStringFromSecureStorage(
userId: UserId,
storageKey: string,
): Promise<string | null> {
// If we have a user ID, read from secure storage.
return await this.secureStorageService.get<string>(
`${userId}${storageKey}`,
this.getSecureStorageOptions(userId),
);
}
private getSecureStorageOptions(userId: UserId): StorageOptions {
return {
storageLocation: StorageLocation.Disk,
useSecureStorage: true,
userId: userId,
};
}
}

View File

@@ -0,0 +1,64 @@
import { KeyDefinition } from "../../platform/state";
import {
ACCESS_TOKEN_DISK,
ACCESS_TOKEN_MEMORY,
ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE,
API_KEY_CLIENT_ID_DISK,
API_KEY_CLIENT_ID_MEMORY,
API_KEY_CLIENT_SECRET_DISK,
API_KEY_CLIENT_SECRET_MEMORY,
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
REFRESH_TOKEN_DISK,
REFRESH_TOKEN_MEMORY,
REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE,
} from "./token.state";
describe.each([
[ACCESS_TOKEN_DISK, "accessTokenDisk"],
[ACCESS_TOKEN_MEMORY, "accessTokenMemory"],
[ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, true],
[REFRESH_TOKEN_DISK, "refreshTokenDisk"],
[REFRESH_TOKEN_MEMORY, "refreshTokenMemory"],
[REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, true],
[EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, { user: "token" }],
[API_KEY_CLIENT_ID_DISK, "apiKeyClientIdDisk"],
[API_KEY_CLIENT_ID_MEMORY, "apiKeyClientIdMemory"],
[API_KEY_CLIENT_SECRET_DISK, "apiKeyClientSecretDisk"],
[API_KEY_CLIENT_SECRET_MEMORY, "apiKeyClientSecretMemory"],
])(
"deserializes state key definitions",
(
keyDefinition:
| KeyDefinition<string>
| KeyDefinition<boolean>
| KeyDefinition<Record<string, string>>,
state: string | boolean | Record<string, string>,
) => {
function getTypeDescription(value: any): string {
if (isRecord(value)) {
return "Record<string, string>";
} else if (Array.isArray(value)) {
return "array";
} else if (value === null) {
return "null";
}
// Fallback for primitive types
return typeof value;
}
function isRecord(value: any): value is Record<string, string> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function testDeserialization<T>(keyDefinition: KeyDefinition<T>, state: T) {
const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state)));
expect(deserialized).toEqual(state);
}
it(`should deserialize state for KeyDefinition<${getTypeDescription(state)}>: "${keyDefinition.key}"`, () => {
testDeserialization(keyDefinition, state);
});
},
);

View File

@@ -0,0 +1,65 @@
import { KeyDefinition, TOKEN_DISK, TOKEN_DISK_LOCAL, TOKEN_MEMORY } from "../../platform/state";
export const ACCESS_TOKEN_DISK = new KeyDefinition<string>(TOKEN_DISK, "accessToken", {
deserializer: (accessToken) => accessToken,
});
export const ACCESS_TOKEN_MEMORY = new KeyDefinition<string>(TOKEN_MEMORY, "accessToken", {
deserializer: (accessToken) => accessToken,
});
export const ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE = new KeyDefinition<boolean>(
TOKEN_DISK,
"accessTokenMigratedToSecureStorage",
{
deserializer: (accessTokenMigratedToSecureStorage) => accessTokenMigratedToSecureStorage,
},
);
export const REFRESH_TOKEN_DISK = new KeyDefinition<string>(TOKEN_DISK, "refreshToken", {
deserializer: (refreshToken) => refreshToken,
});
export const REFRESH_TOKEN_MEMORY = new KeyDefinition<string>(TOKEN_MEMORY, "refreshToken", {
deserializer: (refreshToken) => refreshToken,
});
export const REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE = new KeyDefinition<boolean>(
TOKEN_DISK,
"refreshTokenMigratedToSecureStorage",
{
deserializer: (refreshTokenMigratedToSecureStorage) => refreshTokenMigratedToSecureStorage,
},
);
export const EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL = KeyDefinition.record<string, string>(
TOKEN_DISK_LOCAL,
"emailTwoFactorTokenRecord",
{
deserializer: (emailTwoFactorTokenRecord) => emailTwoFactorTokenRecord,
},
);
export const API_KEY_CLIENT_ID_DISK = new KeyDefinition<string>(TOKEN_DISK, "apiKeyClientId", {
deserializer: (apiKeyClientId) => apiKeyClientId,
});
export const API_KEY_CLIENT_ID_MEMORY = new KeyDefinition<string>(TOKEN_MEMORY, "apiKeyClientId", {
deserializer: (apiKeyClientId) => apiKeyClientId,
});
export const API_KEY_CLIENT_SECRET_DISK = new KeyDefinition<string>(
TOKEN_DISK,
"apiKeyClientSecret",
{
deserializer: (apiKeyClientSecret) => apiKeyClientSecret,
},
);
export const API_KEY_CLIENT_SECRET_MEMORY = new KeyDefinition<string>(
TOKEN_MEMORY,
"apiKeyClientSecret",
{
deserializer: (apiKeyClientSecret) => apiKeyClientSecret,
},
);

View File

@@ -42,7 +42,7 @@ const AUTOFILL_ON_PAGE_LOAD_POLICY_TOAST_HAS_DISPLAYED = new KeyDefinition(
);
const AUTO_COPY_TOTP = new KeyDefinition(AUTOFILL_SETTINGS_DISK, "autoCopyTotp", {
deserializer: (value: boolean) => value ?? false,
deserializer: (value: boolean) => value ?? true,
});
const INLINE_MENU_VISIBILITY = new KeyDefinition(
@@ -144,7 +144,7 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
);
this.autoCopyTotpState = this.stateProvider.getActive(AUTO_COPY_TOTP);
this.autoCopyTotp$ = this.autoCopyTotpState.state$.pipe(map((x) => x ?? false));
this.autoCopyTotp$ = this.autoCopyTotpState.state$.pipe(map((x) => x ?? true));
this.inlineMenuVisibilityState = this.stateProvider.getGlobal(INLINE_MENU_VISIBILITY);
this.inlineMenuVisibility$ = this.inlineMenuVisibilityState.state$.pipe(

View File

@@ -16,6 +16,10 @@ import {
UserKeyDefinition,
} from "../../platform/state";
const SHOW_FAVICONS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "showFavicons", {
deserializer: (value: boolean) => value ?? true,
});
const NEVER_DOMAINS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "neverDomains", {
deserializer: (value: NeverDomains) => value ?? null,
});
@@ -34,6 +38,8 @@ const DEFAULT_URI_MATCH_STRATEGY = new KeyDefinition(
);
export abstract class DomainSettingsService {
showFavicons$: Observable<boolean>;
setShowFavicons: (newValue: boolean) => Promise<void>;
neverDomains$: Observable<NeverDomains>;
setNeverDomains: (newValue: NeverDomains) => Promise<void>;
equivalentDomains$: Observable<EquivalentDomains>;
@@ -44,6 +50,9 @@ export abstract class DomainSettingsService {
}
export class DefaultDomainSettingsService implements DomainSettingsService {
private showFaviconsState: GlobalState<boolean>;
readonly showFavicons$: Observable<boolean>;
private neverDomainsState: GlobalState<NeverDomains>;
readonly neverDomains$: Observable<NeverDomains>;
@@ -54,6 +63,9 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
readonly defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
constructor(private stateProvider: StateProvider) {
this.showFaviconsState = this.stateProvider.getGlobal(SHOW_FAVICONS);
this.showFavicons$ = this.showFaviconsState.state$.pipe(map((x) => x ?? true));
this.neverDomainsState = this.stateProvider.getGlobal(NEVER_DOMAINS);
this.neverDomains$ = this.neverDomainsState.state$.pipe(map((x) => x ?? null));
@@ -66,6 +78,10 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
);
}
async setShowFavicons(newValue: boolean): Promise<void> {
await this.showFaviconsState.update(() => newValue);
}
async setNeverDomains(newValue: NeverDomains): Promise<void> {
await this.neverDomainsState.update(() => newValue);
}

View File

@@ -0,0 +1,36 @@
import { Observable } from "rxjs";
export type BillingAccountProfile = {
hasPremiumPersonally: boolean;
hasPremiumFromAnyOrganization: boolean;
};
export abstract class BillingAccountProfileStateService {
/**
* Emits `true` when the active user's account has been granted premium from any of the
* organizations it is a member of. Otherwise, emits `false`
*/
hasPremiumFromAnyOrganization$: Observable<boolean>;
/**
* Emits `true` when the active user's account has an active premium subscription at the
* individual user level
*/
hasPremiumPersonally$: Observable<boolean>;
/**
* Emits `true` when either `hasPremiumPersonally` or `hasPremiumFromAnyOrganization` is `true`
*/
hasPremiumFromAnySource$: Observable<boolean>;
/**
* Sets the active user's premium status fields upon every full sync, either from their personal
* subscription to premium, or an organization they're a part of that grants them premium.
* @param hasPremiumPersonally
* @param hasPremiumFromAnyOrganization
*/
abstract setHasPremium(
hasPremiumPersonally: boolean,
hasPremiumFromAnyOrganization: boolean,
): Promise<void>;
}

View File

@@ -0,0 +1,165 @@
import { firstValueFrom } from "rxjs";
import {
FakeAccountService,
FakeActiveUserStateProvider,
mockAccountServiceWith,
FakeActiveUserState,
trackEmissions,
} from "../../../../spec";
import { UserId } from "../../../types/guid";
import { BillingAccountProfile } from "../../abstractions/account/billing-account-profile-state.service";
import {
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
DefaultBillingAccountProfileStateService,
} from "./billing-account-profile-state.service";
describe("BillingAccountProfileStateService", () => {
let activeUserStateProvider: FakeActiveUserStateProvider;
let sut: DefaultBillingAccountProfileStateService;
let billingAccountProfileState: FakeActiveUserState<BillingAccountProfile>;
let accountService: FakeAccountService;
const userId = "fakeUserId" as UserId;
beforeEach(() => {
accountService = mockAccountServiceWith(userId);
activeUserStateProvider = new FakeActiveUserStateProvider(accountService);
sut = new DefaultBillingAccountProfileStateService(activeUserStateProvider);
billingAccountProfileState = activeUserStateProvider.getFake(
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
);
});
afterEach(() => {
return jest.resetAllMocks();
});
describe("accountHasPremiumFromAnyOrganization$", () => {
it("should emit changes in hasPremiumFromAnyOrganization", async () => {
billingAccountProfileState.nextState({
hasPremiumPersonally: false,
hasPremiumFromAnyOrganization: true,
});
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true);
});
it("should emit once when calling setHasPremium once", async () => {
const emissions = trackEmissions(sut.hasPremiumFromAnyOrganization$);
const startingEmissionCount = emissions.length;
await sut.setHasPremium(true, true);
const endingEmissionCount = emissions.length;
expect(endingEmissionCount - startingEmissionCount).toBe(1);
});
});
describe("hasPremiumPersonally$", () => {
it("should emit changes in hasPremiumPersonally", async () => {
billingAccountProfileState.nextState({
hasPremiumPersonally: true,
hasPremiumFromAnyOrganization: false,
});
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true);
});
it("should emit once when calling setHasPremium once", async () => {
const emissions = trackEmissions(sut.hasPremiumPersonally$);
const startingEmissionCount = emissions.length;
await sut.setHasPremium(true, true);
const endingEmissionCount = emissions.length;
expect(endingEmissionCount - startingEmissionCount).toBe(1);
});
});
describe("canAccessPremium$", () => {
it("should emit changes in hasPremiumPersonally", async () => {
billingAccountProfileState.nextState({
hasPremiumPersonally: true,
hasPremiumFromAnyOrganization: false,
});
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
});
it("should emit changes in hasPremiumFromAnyOrganization", async () => {
billingAccountProfileState.nextState({
hasPremiumPersonally: false,
hasPremiumFromAnyOrganization: true,
});
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
});
it("should emit changes in both hasPremiumPersonally and hasPremiumFromAnyOrganization", async () => {
billingAccountProfileState.nextState({
hasPremiumPersonally: true,
hasPremiumFromAnyOrganization: true,
});
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
});
it("should emit once when calling setHasPremium once", async () => {
const emissions = trackEmissions(sut.hasPremiumFromAnySource$);
const startingEmissionCount = emissions.length;
await sut.setHasPremium(true, true);
const endingEmissionCount = emissions.length;
expect(endingEmissionCount - startingEmissionCount).toBe(1);
});
});
describe("setHasPremium", () => {
it("should have `hasPremiumPersonally$` emit `true` when passing `true` as an argument for hasPremiumPersonally", async () => {
await sut.setHasPremium(true, false);
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true);
});
it("should have `hasPremiumFromAnyOrganization$` emit `true` when passing `true` as an argument for hasPremiumFromAnyOrganization", async () => {
await sut.setHasPremium(false, true);
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true);
});
it("should have `hasPremiumPersonally$` emit `false` when passing `false` as an argument for hasPremiumPersonally", async () => {
await sut.setHasPremium(false, false);
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(false);
});
it("should have `hasPremiumFromAnyOrganization$` emit `false` when passing `false` as an argument for hasPremiumFromAnyOrganization", async () => {
await sut.setHasPremium(false, false);
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false);
});
it("should have `canAccessPremium$` emit `true` when passing `true` as an argument for hasPremiumPersonally", async () => {
await sut.setHasPremium(true, false);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
});
it("should have `canAccessPremium$` emit `true` when passing `true` as an argument for hasPremiumFromAnyOrganization", async () => {
await sut.setHasPremium(false, true);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
});
it("should have `canAccessPremium$` emit `false` when passing `false` for all arguments", async () => {
await sut.setHasPremium(false, false);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(false);
});
});
});

View File

@@ -0,0 +1,62 @@
import { map, Observable } from "rxjs";
import {
ActiveUserState,
ActiveUserStateProvider,
BILLING_DISK,
KeyDefinition,
} from "../../../platform/state";
import {
BillingAccountProfile,
BillingAccountProfileStateService,
} from "../../abstractions/account/billing-account-profile-state.service";
export const BILLING_ACCOUNT_PROFILE_KEY_DEFINITION = new KeyDefinition<BillingAccountProfile>(
BILLING_DISK,
"accountProfile",
{
deserializer: (billingAccountProfile) => billingAccountProfile,
},
);
export class DefaultBillingAccountProfileStateService implements BillingAccountProfileStateService {
private billingAccountProfileState: ActiveUserState<BillingAccountProfile>;
hasPremiumFromAnyOrganization$: Observable<boolean>;
hasPremiumPersonally$: Observable<boolean>;
hasPremiumFromAnySource$: Observable<boolean>;
constructor(activeUserStateProvider: ActiveUserStateProvider) {
this.billingAccountProfileState = activeUserStateProvider.get(
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
);
this.hasPremiumFromAnyOrganization$ = this.billingAccountProfileState.state$.pipe(
map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumFromAnyOrganization),
);
this.hasPremiumPersonally$ = this.billingAccountProfileState.state$.pipe(
map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumPersonally),
);
this.hasPremiumFromAnySource$ = this.billingAccountProfileState.state$.pipe(
map(
(billingAccountProfile) =>
billingAccountProfile?.hasPremiumFromAnyOrganization ||
billingAccountProfile?.hasPremiumPersonally,
),
);
}
async setHasPremium(
hasPremiumPersonally: boolean,
hasPremiumFromAnyOrganization: boolean,
): Promise<void> {
await this.billingAccountProfileState.update((billingAccountProfile) => {
return {
hasPremiumPersonally: hasPremiumPersonally,
hasPremiumFromAnyOrganization: hasPremiumFromAnyOrganization,
};
});
}
}

View File

@@ -1,3 +1,5 @@
import { Jsonify } from "type-fest";
import { EventType } from "../../enums";
export class EventData {
@@ -5,4 +7,8 @@ export class EventData {
cipherId: string;
date: string;
organizationId: string;
static fromJSON(obj: Jsonify<EventData>): EventData {
return Object.assign(new EventData(), obj);
}
}

View File

@@ -4,7 +4,7 @@ import { ProfileOrganizationResponse } from "../../admin-console/models/response
import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response";
import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response";
import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { OrganizationId, ProviderId } from "../../types/guid";
import { OrganizationId, ProviderId, UserId } from "../../types/guid";
import { UserKey, MasterKey, OrgKey, ProviderKey, PinKey, CipherKey } from "../../types/key";
import { KeySuffixOptions, KdfType, HashPurpose } from "../enums";
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
@@ -62,12 +62,15 @@ export abstract class CryptoService {
getUserKeyFromStorage: (keySuffix: KeySuffixOptions, userId?: string) => Promise<UserKey>;
/**
* Determines whether the user key is available for the given user.
* @param userId The desired user. If not provided, the active user will be used. If no active user exists, the method will return false.
* @returns True if the user key is available
*/
hasUserKey: () => Promise<boolean>;
hasUserKey: (userId?: UserId) => Promise<boolean>;
/**
* @param userId The desired user
* @returns True if the user key is set in memory
* Determines whether the user key is available for the given user in memory.
* @param userId The desired user. If not provided, the active user will be used. If no active user exists, the method will return false.
* @returns True if the user key is available
*/
hasUserKeyInMemory: (userId?: string) => Promise<boolean>;
/**

View File

@@ -1,11 +1,9 @@
import { Observable } from "rxjs";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { BiometricKey } from "../../auth/types/biometric-key";
import { EventData } from "../../models/data/event.data";
import { WindowState } from "../../models/domain/window-state";
import { GeneratorOptions } from "../../tools/generator/generator-options";
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
@@ -48,23 +46,10 @@ export abstract class StateService<T extends Account = Account> {
clean: (options?: StorageOptions) => Promise<UserId>;
init: (initOptions?: InitOptions) => Promise<void>;
getAccessToken: (options?: StorageOptions) => Promise<string>;
setAccessToken: (value: string, options?: StorageOptions) => Promise<void>;
getAlwaysShowDock: (options?: StorageOptions) => Promise<boolean>;
setAlwaysShowDock: (value: boolean, options?: StorageOptions) => Promise<void>;
getApiKeyClientId: (options?: StorageOptions) => Promise<string>;
setApiKeyClientId: (value: string, options?: StorageOptions) => Promise<void>;
getApiKeyClientSecret: (options?: StorageOptions) => Promise<string>;
setApiKeyClientSecret: (value: string, options?: StorageOptions) => Promise<void>;
getAutoConfirmFingerPrints: (options?: StorageOptions) => Promise<boolean>;
setAutoConfirmFingerprints: (value: boolean, options?: StorageOptions) => Promise<void>;
getBiometricFingerprintValidated: (options?: StorageOptions) => Promise<boolean>;
setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise<void>;
getCanAccessPremium: (options?: StorageOptions) => Promise<boolean>;
getHasPremiumPersonally: (options?: StorageOptions) => Promise<boolean>;
setHasPremiumPersonally: (value: boolean, options?: StorageOptions) => Promise<void>;
setHasPremiumFromOrganization: (value: boolean, options?: StorageOptions) => Promise<void>;
getHasPremiumFromOrganization: (options?: StorageOptions) => Promise<boolean>;
getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise<boolean>;
setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise<void>;
/**
@@ -174,20 +159,8 @@ export abstract class StateService<T extends Account = Account> {
* @deprecated Do not call this directly, use SendService
*/
setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this, use SettingsService
*/
getDisableFavicon: (options?: StorageOptions) => Promise<boolean>;
/**
* @deprecated Do not call this, use SettingsService
*/
setDisableFavicon: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableGa: (options?: StorageOptions) => Promise<boolean>;
setDisableGa: (value: boolean, options?: StorageOptions) => Promise<void>;
getDontShowCardsCurrentTab: (options?: StorageOptions) => Promise<boolean>;
setDontShowCardsCurrentTab: (value: boolean, options?: StorageOptions) => Promise<void>;
getDontShowIdentitiesCurrentTab: (options?: StorageOptions) => Promise<boolean>;
setDontShowIdentitiesCurrentTab: (value: boolean, options?: StorageOptions) => Promise<void>;
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
getDeviceKey: (options?: StorageOptions) => Promise<DeviceKey | null>;
@@ -255,8 +228,6 @@ export abstract class StateService<T extends Account = Account> {
* @deprecated Do not call this directly, use SendService
*/
setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise<void>;
getEventCollection: (options?: StorageOptions) => Promise<EventData[]>;
setEventCollection: (value: EventData[], options?: StorageOptions) => Promise<void>;
getEverBeenUnlocked: (options?: StorageOptions) => Promise<boolean>;
setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise<void>;
getForceSetPasswordReason: (options?: StorageOptions) => Promise<ForceSetPasswordReason>;
@@ -287,17 +258,6 @@ export abstract class StateService<T extends Account = Account> {
setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise<void>;
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;
setOrganizationInvitation: (value: any, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this directly, use OrganizationService
*/
getOrganizations: (options?: StorageOptions) => Promise<{ [id: string]: OrganizationData }>;
/**
* @deprecated Do not call this directly, use OrganizationService
*/
setOrganizations: (
value: { [id: string]: OrganizationData },
options?: StorageOptions,
) => Promise<void>;
getPasswordGenerationOptions: (options?: StorageOptions) => Promise<PasswordGeneratorOptions>;
setPasswordGenerationOptions: (
value: PasswordGeneratorOptions,
@@ -318,14 +278,10 @@ export abstract class StateService<T extends Account = Account> {
* Sets the user's Pin, encrypted by the user key
*/
setProtectedPin: (value: string, options?: StorageOptions) => Promise<void>;
getRefreshToken: (options?: StorageOptions) => Promise<string>;
setRefreshToken: (value: string, options?: StorageOptions) => Promise<void>;
getRememberedEmail: (options?: StorageOptions) => Promise<string>;
setRememberedEmail: (value: string, options?: StorageOptions) => Promise<void>;
getSecurityStamp: (options?: StorageOptions) => Promise<string>;
setSecurityStamp: (value: string, options?: StorageOptions) => Promise<void>;
getTwoFactorToken: (options?: StorageOptions) => Promise<string>;
setTwoFactorToken: (value: string, options?: StorageOptions) => Promise<void>;
getUserId: (options?: StorageOptions) => Promise<string>;
getUsesKeyConnector: (options?: StorageOptions) => Promise<boolean>;
setUsesKeyConnector: (value: boolean, options?: StorageOptions) => Promise<void>;
@@ -345,9 +301,6 @@ export abstract class StateService<T extends Account = Account> {
* @deprecated Do not call this directly, use ConfigService
*/
setServerConfig: (value: ServerConfigData, options?: StorageOptions) => Promise<void>;
getAvatarColor: (options?: StorageOptions) => Promise<string | null | undefined>;
setAvatarColor: (value: string, options?: StorageOptions) => Promise<void>;
/**
* fetches string value of URL user tried to navigate to while unauthenticated.
* @param options Defines the storage options for the URL; Defaults to session Storage.

View File

@@ -1,12 +1,10 @@
import { Jsonify } from "type-fest";
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/key-connector-user-decryption-option";
import { TrustedDeviceUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
import { EventData } from "../../../models/data/event.data";
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
import { GeneratorOptions } from "../../../tools/generator/generator-options";
import {
@@ -90,8 +88,6 @@ export class AccountData {
GeneratedPasswordHistory[]
> = new EncryptionPair<GeneratedPasswordHistory[], GeneratedPasswordHistory[]>();
addEditCipherInfo?: AddEditCipherInfo;
eventCollection?: EventData[];
organizations?: { [id: string]: OrganizationData };
static fromJSON(obj: DeepJsonify<AccountData>): AccountData {
if (obj == null) {
@@ -112,7 +108,6 @@ export class AccountKeys {
masterKeyEncryptedUserKey?: string;
deviceKey?: ReturnType<SymmetricCryptoKey["toJSON"]>;
publicKey?: Uint8Array;
apiKeyClientSecret?: string;
/** @deprecated July 2023, left for migration purposes*/
cryptoMasterKey?: SymmetricCryptoKey;
@@ -167,15 +162,12 @@ export class AccountKeys {
}
export class AccountProfile {
apiKeyClientId?: string;
convertAccountToKeyConnector?: boolean;
name?: string;
email?: string;
emailVerified?: boolean;
everBeenUnlocked?: boolean;
forceSetPasswordReason?: ForceSetPasswordReason;
hasPremiumPersonally?: boolean;
hasPremiumFromOrganization?: boolean;
lastSync?: string;
userId?: string;
usesKeyConnector?: boolean;
@@ -195,11 +187,8 @@ export class AccountProfile {
}
export class AccountSettings {
autoConfirmFingerPrints?: boolean;
defaultUriMatch?: UriMatchStrategySetting;
disableGa?: boolean;
dontShowCardsCurrentTab?: boolean;
dontShowIdentitiesCurrentTab?: boolean;
enableAlwaysOnTop?: boolean;
enableBiometric?: boolean;
minimizeOnCopyToClipboard?: boolean;
@@ -235,8 +224,6 @@ export class AccountSettings {
}
export class AccountTokens {
accessToken?: string;
refreshToken?: string;
securityStamp?: string;
static fromJSON(obj: Jsonify<AccountTokens>): AccountTokens {

View File

@@ -10,7 +10,6 @@ export class GlobalState {
theme?: ThemeType = ThemeType.System;
window?: WindowState = new WindowState();
twoFactorToken?: string;
disableFavicon?: boolean;
biometricFingerprintValidated?: boolean;
vaultTimeout?: number;
vaultTimeoutAction?: string;

View File

@@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, of } from "rxjs";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state";
@@ -108,6 +108,52 @@ describe("cryptoService", () => {
});
});
describe.each(["hasUserKey", "hasUserKeyInMemory"])(
`%s`,
(method: "hasUserKey" | "hasUserKeyInMemory") => {
let mockUserKey: UserKey;
beforeEach(() => {
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
});
it.each([true, false])("returns %s if the user key is set", async (hasKey) => {
stateProvider.singleUser
.getFake(mockUserId, USER_KEY)
.nextState(hasKey ? mockUserKey : null);
expect(await cryptoService[method](mockUserId)).toBe(hasKey);
});
it("returns false when no active userId is set", async () => {
accountService.activeAccountSubject.next(null);
expect(await cryptoService[method]()).toBe(false);
});
it.each([true, false])(
"resolves %s for active user id when none is provided",
async (hasKey) => {
stateProvider.activeUserId$ = of(mockUserId);
stateProvider.singleUser
.getFake(mockUserId, USER_KEY)
.nextState(hasKey ? mockUserKey : null);
expect(await cryptoService[method]()).toBe(hasKey);
},
);
},
);
describe("hasUserKey", () => {
it.each([true, false])(
"returns %s when the user key is not in memory, but the auto key is set",
async (hasKey) => {
stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(null);
cryptoService.hasUserKeyStored = jest.fn().mockResolvedValue(hasKey);
expect(await cryptoService.hasUserKey(mockUserId)).toBe(hasKey);
},
);
});
describe("getUserKeyWithLegacySupport", () => {
let mockUserKey: UserKey;
let mockMasterKey: MasterKey;

View File

@@ -202,13 +202,23 @@ export class CryptoService implements CryptoServiceAbstraction {
}
}
async hasUserKey(): Promise<boolean> {
async hasUserKey(userId?: UserId): Promise<boolean> {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
if (userId == null) {
return false;
}
return (
(await this.hasUserKeyInMemory()) || (await this.hasUserKeyStored(KeySuffixOptions.Auto))
(await this.hasUserKeyInMemory(userId)) ||
(await this.hasUserKeyStored(KeySuffixOptions.Auto, userId))
);
}
async hasUserKeyInMemory(userId?: UserId): Promise<boolean> {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
if (userId == null) {
return false;
}
return (await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId))) != null;
}

View File

@@ -45,13 +45,13 @@ describe("EnvironmentService", () => {
storageServiceProvider = new StorageServiceProvider(diskStorageService, memoryStorageService);
accountService = mockAccountServiceWith(undefined);
const singleUserStateProvider = new DefaultSingleUserStateProvider(
storageServiceProvider,
stateEventRegistrarService,
);
stateProvider = new DefaultStateProvider(
new DefaultActiveUserStateProvider(
accountService,
storageServiceProvider,
stateEventRegistrarService,
),
new DefaultSingleUserStateProvider(storageServiceProvider, stateEventRegistrarService),
new DefaultActiveUserStateProvider(accountService, singleUserStateProvider),
singleUserStateProvider,
new DefaultGlobalStateProvider(storageServiceProvider),
new DefaultDerivedStateProvider(memoryStorageService),
);

View File

@@ -1,15 +1,13 @@
import { BehaviorSubject, Observable, map } from "rxjs";
import { Jsonify, JsonValue } from "type-fest";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { AccountService } from "../../auth/abstractions/account.service";
import { TokenService } from "../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { BiometricKey } from "../../auth/types/biometric-key";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { EventData } from "../../models/data/event.data";
import { WindowState } from "../../models/domain/window-state";
import { GeneratorOptions } from "../../tools/generator/generator-options";
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
@@ -96,6 +94,7 @@ export class StateService<
protected stateFactory: StateFactory<TGlobalState, TAccount>,
protected accountService: AccountService,
protected environmentService: EnvironmentService,
protected tokenService: TokenService,
private migrationRunner: MigrationRunner,
protected useAccountCache: boolean = true,
) {
@@ -186,7 +185,7 @@ export class StateService<
// TODO: Temporary update to avoid routing all account status changes through account service for now.
// The determination of state should be handled by the various services that control those values.
const token = await this.getAccessToken({ userId: userId });
const token = await this.tokenService.getAccessToken(userId as UserId);
const autoKey = await this.getUserKeyAutoUnlock({ userId: userId });
const accountStatus =
token == null
@@ -251,18 +250,6 @@ export class StateService<
return currentUser as UserId;
}
async getAccessToken(options?: StorageOptions): Promise<string> {
options = await this.getTimeoutBasedStorageOptions(options);
return (await this.getAccount(options))?.tokens?.accessToken;
}
async setAccessToken(value: string, options?: StorageOptions): Promise<void> {
options = await this.getTimeoutBasedStorageOptions(options);
const account = await this.getAccount(options);
account.tokens.accessToken = value;
await this.saveAccount(account, options);
}
async getAlwaysShowDock(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@@ -281,48 +268,6 @@ export class StateService<
);
}
async getApiKeyClientId(options?: StorageOptions): Promise<string> {
options = await this.getTimeoutBasedStorageOptions(options);
return (await this.getAccount(options))?.profile?.apiKeyClientId;
}
async setApiKeyClientId(value: string, options?: StorageOptions): Promise<void> {
options = await this.getTimeoutBasedStorageOptions(options);
const account = await this.getAccount(options);
account.profile.apiKeyClientId = value;
await this.saveAccount(account, options);
}
async getApiKeyClientSecret(options?: StorageOptions): Promise<string> {
options = await this.getTimeoutBasedStorageOptions(options);
return (await this.getAccount(options))?.keys?.apiKeyClientSecret;
}
async setApiKeyClientSecret(value: string, options?: StorageOptions): Promise<void> {
options = await this.getTimeoutBasedStorageOptions(options);
const account = await this.getAccount(options);
account.keys.apiKeyClientSecret = value;
await this.saveAccount(account, options);
}
async getAutoConfirmFingerPrints(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.settings?.autoConfirmFingerPrints ?? false
);
}
async setAutoConfirmFingerprints(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.settings.autoConfirmFingerPrints = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getBiometricFingerprintValidated(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@@ -341,72 +286,6 @@ export class StateService<
);
}
async getCanAccessPremium(options?: StorageOptions): Promise<boolean> {
if (!(await this.getIsAuthenticated(options))) {
return false;
}
return (
(await this.getHasPremiumPersonally(options)) ||
(await this.getHasPremiumFromOrganization(options))
);
}
async getHasPremiumPersonally(options?: StorageOptions): Promise<boolean> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
return account?.profile?.hasPremiumPersonally;
}
async setHasPremiumPersonally(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.profile.hasPremiumPersonally = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getHasPremiumFromOrganization(options?: StorageOptions): Promise<boolean> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
if (account.profile?.hasPremiumFromOrganization) {
return true;
}
// TODO: older server versions won't send the hasPremiumFromOrganization flag, so we're keeping the old logic
// for backwards compatibility. It can be removed after everyone has upgraded.
const organizations = await this.getOrganizations(options);
if (organizations == null) {
return false;
}
for (const id of Object.keys(organizations)) {
const o = organizations[id];
if (o.enabled && o.usersGetPremium && !o.isProviderUser) {
return true;
}
}
return false;
}
async setHasPremiumFromOrganization(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.profile.hasPremiumFromOrganization = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getConvertAccountToKeyConnector(options?: StorageOptions): Promise<boolean> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
@@ -763,27 +642,6 @@ export class StateService<
);
}
async getDisableFavicon(options?: StorageOptions): Promise<boolean> {
return (
(
await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
)
)?.disableFavicon ?? false
);
}
async setDisableFavicon(value: boolean, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
globals.disableFavicon = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
}
async getDisableGa(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@@ -802,42 +660,6 @@ export class StateService<
);
}
async getDontShowCardsCurrentTab(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.settings?.dontShowCardsCurrentTab ?? false
);
}
async setDontShowCardsCurrentTab(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.settings.dontShowCardsCurrentTab = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getDontShowIdentitiesCurrentTab(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.settings?.dontShowIdentitiesCurrentTab ?? false
);
}
async setDontShowIdentitiesCurrentTab(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.settings.dontShowIdentitiesCurrentTab = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getDuckDuckGoSharedKey(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
@@ -1243,24 +1065,6 @@ export class StateService<
);
}
@withPrototypeForArrayMembers(EventData)
async getEventCollection(options?: StorageOptions): Promise<EventData[]> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.data?.eventCollection;
}
async setEventCollection(value: EventData[], options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.data.eventCollection = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getEverBeenUnlocked(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())))
@@ -1321,7 +1125,10 @@ export class StateService<
}
async getIsAuthenticated(options?: StorageOptions): Promise<boolean> {
return (await this.getAccessToken(options)) != null && (await this.getUserId(options)) != null;
return (
(await this.tokenService.getAccessToken(options?.userId as UserId)) != null &&
(await this.getUserId(options)) != null
);
}
async getKdfConfig(options?: StorageOptions): Promise<KdfConfig> {
@@ -1517,32 +1324,6 @@ export class StateService<
);
}
/**
* @deprecated Do not call this directly, use OrganizationService
*/
async getOrganizations(options?: StorageOptions): Promise<{ [id: string]: OrganizationData }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.data?.organizations;
}
/**
* @deprecated Do not call this directly, use OrganizationService
*/
async setOrganizations(
value: { [id: string]: OrganizationData },
options?: StorageOptions,
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.data.organizations = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getPasswordGenerationOptions(options?: StorageOptions): Promise<PasswordGeneratorOptions> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
@@ -1617,18 +1398,6 @@ export class StateService<
);
}
async getRefreshToken(options?: StorageOptions): Promise<string> {
options = await this.getTimeoutBasedStorageOptions(options);
return (await this.getAccount(options))?.tokens?.refreshToken;
}
async setRefreshToken(value: string, options?: StorageOptions): Promise<void> {
options = await this.getTimeoutBasedStorageOptions(options);
const account = await this.getAccount(options);
account.tokens.refreshToken = value;
await this.saveAccount(account, options);
}
async getRememberedEmail(options?: StorageOptions): Promise<string> {
return (
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
@@ -1663,23 +1432,6 @@ export class StateService<
);
}
async getTwoFactorToken(options?: StorageOptions): Promise<string> {
return (
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.twoFactorToken;
}
async setTwoFactorToken(value: string, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
globals.twoFactorToken = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
}
async getUserId(options?: StorageOptions): Promise<string> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
@@ -1799,23 +1551,6 @@ export class StateService<
)?.settings?.serverConfig;
}
async getAvatarColor(options?: StorageOptions): Promise<string | null | undefined> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.settings?.avatarColor;
}
async setAvatarColor(value: string, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
account.settings.avatarColor = value;
return await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
}
async getDeepLinkRedirectUrl(options?: StorageOptions): Promise<string> {
return (
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
@@ -2003,15 +1738,6 @@ export class StateService<
await this.storageService.remove(keys.tempAccountSettings);
}
if (
account.settings.vaultTimeoutAction === VaultTimeoutAction.LogOut &&
account.settings.vaultTimeout != null
) {
account.tokens.accessToken = null;
account.tokens.refreshToken = null;
account.profile.apiKeyClientId = null;
account.keys.apiKeyClientSecret = null;
}
await this.saveAccount(
account,
this.reconcileOptions(
@@ -2212,7 +1938,7 @@ export class StateService<
}
protected async deAuthenticateAccount(userId: string): Promise<void> {
await this.setAccessToken(null, { userId: userId });
await this.tokenService.clearAccessToken(userId as UserId);
await this.setLastActive(null, { userId: userId });
await this.updateState(async (state) => {
state.authenticatedAccounts = state.authenticatedAccounts.filter((id) => id !== userId);
@@ -2255,16 +1981,6 @@ export class StateService<
return newActiveUser;
}
private async getTimeoutBasedStorageOptions(options?: StorageOptions): Promise<StorageOptions> {
const timeoutAction = await this.getVaultTimeoutAction({ userId: options?.userId });
const timeout = await this.getVaultTimeout({ userId: options?.userId });
const defaultOptions =
timeoutAction === VaultTimeoutAction.LogOut && timeout != null
? await this.defaultInMemoryOptions()
: await this.defaultOnDiskOptions();
return this.reconcileOptions(options, defaultOptions);
}
protected async saveSecureStorageKey<T extends JsonValue>(
key: string,
value: T,

View File

@@ -3,14 +3,12 @@ import { mock } from "jest-mock-extended";
import { mockAccountServiceWith, trackEmissions } from "../../../../spec";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { UserId } from "../../../types/guid";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { SingleUserStateProvider } from "../user-state.provider";
import { DefaultActiveUserStateProvider } from "./default-active-user-state.provider";
describe("DefaultActiveUserStateProvider", () => {
const storageServiceProvider = mock<StorageServiceProvider>();
const stateEventRegistrarService = mock<StateEventRegistrarService>();
const singleUserStateProvider = mock<SingleUserStateProvider>();
const userId = "userId" as UserId;
const accountInfo = {
id: userId,
@@ -22,11 +20,7 @@ describe("DefaultActiveUserStateProvider", () => {
let sut: DefaultActiveUserStateProvider;
beforeEach(() => {
sut = new DefaultActiveUserStateProvider(
accountService,
storageServiceProvider,
stateEventRegistrarService,
);
sut = new DefaultActiveUserStateProvider(accountService, singleUserStateProvider);
});
afterEach(() => {

View File

@@ -1,56 +1,40 @@
import { Observable, map } from "rxjs";
import { Observable, distinctUntilChanged, map } from "rxjs";
import { AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { KeyDefinition } from "../key-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition, isUserKeyDefinition } from "../user-key-definition";
import { ActiveUserState } from "../user-state";
import { ActiveUserStateProvider } from "../user-state.provider";
import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider";
import { DefaultActiveUserState } from "./default-active-user-state";
export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
private cache: Record<string, ActiveUserState<unknown>> = {};
activeUserId$: Observable<UserId | undefined>;
constructor(
private readonly accountService: AccountService,
private readonly storageServiceProvider: StorageServiceProvider,
private readonly stateEventRegistrarService: StateEventRegistrarService,
private readonly singleUserStateProvider: SingleUserStateProvider,
) {
this.activeUserId$ = this.accountService.activeAccount$.pipe(map((account) => account?.id));
this.activeUserId$ = this.accountService.activeAccount$.pipe(
map((account) => account?.id),
// To avoid going to storage when we don't need to, only get updates when there is a true change.
distinctUntilChanged((a, b) => (a == null || b == null ? a == b : a === b)), // Treat null and undefined as equal
);
}
get<T>(keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>): ActiveUserState<T> {
if (!isUserKeyDefinition(keyDefinition)) {
keyDefinition = UserKeyDefinition.fromBaseKeyDefinition(keyDefinition);
}
const [location, storageService] = this.storageServiceProvider.get(
keyDefinition.stateDefinition.defaultStorageLocation,
keyDefinition.stateDefinition.storageLocationOverrides,
);
const cacheKey = this.buildCacheKey(location, keyDefinition);
const existingUserState = this.cache[cacheKey];
if (existingUserState != null) {
// I have to cast out of the unknown generic but this should be safe if rules
// around domain token are made
return existingUserState as ActiveUserState<T>;
}
const newUserState = new DefaultActiveUserState<T>(
// All other providers cache the creation of their corresponding `State` objects, this instance
// doesn't need to do that since it calls `SingleUserStateProvider` it will go through their caching
// layer, because of that, the creation of this instance is quite simple and not worth caching.
return new DefaultActiveUserState(
keyDefinition,
this.accountService,
storageService,
this.stateEventRegistrarService,
this.activeUserId$,
this.singleUserStateProvider,
);
this.cache[cacheKey] = newUserState;
return newUserState;
}
private buildCacheKey(location: string, keyDefinition: UserKeyDefinition<unknown>) {
return `${location}_${keyDefinition.fullName}`;
}
}

View File

@@ -3,19 +3,21 @@
* @jest-environment ../shared/test.environment.ts
*/
import { any, mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs";
import { BehaviorSubject, firstValueFrom, map, of, timeout } from "rxjs";
import { Jsonify } from "type-fest";
import { awaitAsync, trackEmissions } from "../../../../spec";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { AccountInfo } from "../../../auth/abstractions/account.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { UserId } from "../../../types/guid";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition";
import { DefaultActiveUserState } from "./default-active-user-state";
import { DefaultSingleUserStateProvider } from "./default-single-user-state.provider";
class TestState {
date: Date;
@@ -41,23 +43,35 @@ const testKeyDefinition = new UserKeyDefinition<TestState>(testStateDefinition,
});
describe("DefaultActiveUserState", () => {
const accountService = mock<AccountService>();
let diskStorageService: FakeStorageService;
const storageServiceProvider = mock<StorageServiceProvider>();
const stateEventRegistrarService = mock<StateEventRegistrarService>();
let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>;
let singleUserStateProvider: DefaultSingleUserStateProvider;
let userState: DefaultActiveUserState<TestState>;
beforeEach(() => {
activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(undefined);
accountService.activeAccount$ = activeAccountSubject;
diskStorageService = new FakeStorageService();
userState = new DefaultActiveUserState(
testKeyDefinition,
accountService,
diskStorageService,
storageServiceProvider.get.mockReturnValue(["disk", diskStorageService]);
singleUserStateProvider = new DefaultSingleUserStateProvider(
storageServiceProvider,
stateEventRegistrarService,
);
activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(undefined);
userState = new DefaultActiveUserState(
testKeyDefinition,
activeAccountSubject.asObservable().pipe(map((a) => a?.id)),
singleUserStateProvider,
);
});
afterEach(() => {
jest.resetAllMocks();
});
const makeUserId = (id: string) => {
@@ -223,7 +237,16 @@ describe("DefaultActiveUserState", () => {
await changeActiveUser("1");
// This should always return a value right await
const value = await firstValueFrom(userState.state$);
const value = await firstValueFrom(
userState.state$.pipe(
timeout({
first: 20,
with: () => {
throw new Error("Did not emit data from newly active user.");
},
}),
),
);
expect(value).toEqual(user1Data);
// Make it such that there is no active user
@@ -392,9 +415,7 @@ describe("DefaultActiveUserState", () => {
await changeActiveUser(undefined);
// Act
// 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
expect(async () => await userState.update(() => null)).rejects.toThrow(
await expect(async () => await userState.update(() => null)).rejects.toThrow(
"No active user at this time.",
);
});
@@ -563,7 +584,7 @@ describe("DefaultActiveUserState", () => {
});
it("does not await updates if the active user changes", async () => {
const initialUserId = (await firstValueFrom(accountService.activeAccount$)).id;
const initialUserId = (await firstValueFrom(activeAccountSubject)).id;
expect(initialUserId).toBe(userId);
trackEmissions(userState.state$);
await awaitAsync(); // storage updates are behind a promise

View File

@@ -1,118 +1,27 @@
import {
Observable,
map,
switchMap,
firstValueFrom,
filter,
timeout,
merge,
share,
ReplaySubject,
timer,
tap,
throwError,
distinctUntilChanged,
withLatestFrom,
} from "rxjs";
import { Observable, map, switchMap, firstValueFrom, timeout, throwError, NEVER } from "rxjs";
import { AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { StateUpdateOptions } from "../state-update-options";
import { UserKeyDefinition } from "../user-key-definition";
import { ActiveUserState, CombinedState, activeMarker } from "../user-state";
import { getStoredValue } from "./util";
const FAKE = Symbol("fake");
import { SingleUserStateProvider } from "../user-state.provider";
export class DefaultActiveUserState<T> implements ActiveUserState<T> {
[activeMarker]: true;
private updatePromise: Promise<[UserId, T]> | null = null;
private activeUserId$: Observable<UserId | null>;
combinedState$: Observable<CombinedState<T>>;
state$: Observable<T>;
constructor(
protected keyDefinition: UserKeyDefinition<T>,
private accountService: AccountService,
private chosenStorageLocation: AbstractStorageService & ObservableStorageService,
private stateEventRegistrarService: StateEventRegistrarService,
private activeUserId$: Observable<UserId | null>,
private singleUserStateProvider: SingleUserStateProvider,
) {
this.activeUserId$ = this.accountService.activeAccount$.pipe(
// We only care about the UserId but we do want to know about no user as well.
map((a) => a?.id),
// To avoid going to storage when we don't need to, only get updates when there is a true change.
distinctUntilChanged((a, b) => (a == null || b == null ? a == b : a === b)), // Treat null and undefined as equal
);
const userChangeAndInitial$ = this.activeUserId$.pipe(
// If the user has changed, we no longer need to lock an update call
// since that call will be for a user that is no longer active.
tap(() => (this.updatePromise = null)),
switchMap(async (userId) => {
// We've switched or started off with no active user. So,
// emit a fake value so that we can fill our share buffer.
if (userId == null) {
return FAKE;
}
const fullKey = this.keyDefinition.buildKey(userId);
const data = await getStoredValue(
fullKey,
this.chosenStorageLocation,
this.keyDefinition.deserializer,
);
return [userId, data] as CombinedState<T>;
}),
);
const latestStorage$ = this.chosenStorageLocation.updates$.pipe(
// Use withLatestFrom so that we do NOT emit when activeUserId changes because that
// is taken care of above, but we do want to have the latest user id
// when we get a storage update so we can filter the full key
withLatestFrom(
this.activeUserId$.pipe(
// Null userId is already taken care of through the userChange observable above
filter((u) => u != null),
// Take the userId and build the fullKey that we can now create
map((userId) => [userId, this.keyDefinition.buildKey(userId)] as const),
),
this.combinedState$ = this.activeUserId$.pipe(
switchMap((userId) =>
userId != null
? this.singleUserStateProvider.get(userId, this.keyDefinition).combinedState$
: NEVER,
),
// Filter to only storage updates that pertain to our key
filter(([storageUpdate, [_userId, fullKey]]) => storageUpdate.key === fullKey),
switchMap(async ([storageUpdate, [userId, fullKey]]) => {
// We can shortcut on updateType of "remove"
// and just emit null.
if (storageUpdate.updateType === "remove") {
return [userId, null] as CombinedState<T>;
}
return [
userId,
await getStoredValue(
fullKey,
this.chosenStorageLocation,
this.keyDefinition.deserializer,
),
] as CombinedState<T>;
}),
);
this.combinedState$ = merge(userChangeAndInitial$, latestStorage$).pipe(
share({
connector: () => new ReplaySubject<CombinedState<T> | typeof FAKE>(1),
resetOnRefCountZero: () => timer(this.keyDefinition.cleanupDelayMs),
}),
// Filter out FAKE AFTER the share so that we can fill the ReplaySubjects
// buffer with something and avoid emitting when there is no active user.
filter<CombinedState<T>>((d) => d !== (FAKE as unknown)),
);
// State should just be combined state without the user id
@@ -123,52 +32,17 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine> = {},
): Promise<[UserId, T]> {
options = populateOptionsWithDefault(options);
try {
if (this.updatePromise != null) {
await this.updatePromise;
}
this.updatePromise = this.internalUpdate(configureState, options);
const [userId, newState] = await this.updatePromise;
return [userId, newState];
} finally {
this.updatePromise = null;
}
}
private async internalUpdate<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine>,
): Promise<[UserId, T]> {
const [userId, key, currentState] = await this.getStateForUpdate();
const combinedDependencies =
options.combineLatestWith != null
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
: null;
if (!options.shouldUpdate(currentState, combinedDependencies)) {
return [userId, currentState];
}
const newState = configureState(currentState, combinedDependencies);
await this.saveToStorage(key, newState);
if (newState != null && currentState == null) {
// Only register this state as something clearable on the first time it saves something
// worth deleting. This is helpful in making sure there is less of a race to adding events.
await this.stateEventRegistrarService.registerEvents(this.keyDefinition);
}
return [userId, newState];
}
/** For use in update methods, does not wait for update to complete before yielding state.
* The expectation is that that await is already done
*/
protected async getStateForUpdate() {
const userId = await firstValueFrom(
this.activeUserId$.pipe(
timeout({
first: 1000,
with: () => throwError(() => new Error("Timeout while retrieving active user.")),
with: () =>
throwError(
() =>
new Error(
`Timeout while retrieving active user for key ${this.keyDefinition.fullName}.`,
),
),
}),
),
);
@@ -177,15 +51,12 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
`Error storing ${this.keyDefinition.fullName} for the active user: No active user at this time.`,
);
}
const fullKey = this.keyDefinition.buildKey(userId);
return [
userId,
fullKey,
await getStoredValue(fullKey, this.chosenStorageLocation, this.keyDefinition.deserializer),
] as const;
}
protected saveToStorage(key: string, data: T): Promise<void> {
return this.chosenStorageLocation.save(key, data);
await this.singleUserStateProvider
.get(userId, this.keyDefinition)
.update(configureState, options),
];
}
}

View File

@@ -1,120 +1,20 @@
import {
Observable,
ReplaySubject,
defer,
filter,
firstValueFrom,
merge,
share,
switchMap,
timeout,
timer,
} from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { GlobalState } from "../global-state";
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { getStoredValue } from "./util";
export class DefaultGlobalState<T> implements GlobalState<T> {
private storageKey: string;
private updatePromise: Promise<T> | null = null;
readonly state$: Observable<T>;
import { StateBase } from "./state-base";
export class DefaultGlobalState<T>
extends StateBase<T, KeyDefinition<T>>
implements GlobalState<T>
{
constructor(
private keyDefinition: KeyDefinition<T>,
private chosenLocation: AbstractStorageService & ObservableStorageService,
keyDefinition: KeyDefinition<T>,
chosenLocation: AbstractStorageService & ObservableStorageService,
) {
this.storageKey = globalKeyBuilder(this.keyDefinition);
const initialStorageGet$ = defer(() => {
return getStoredValue(this.storageKey, this.chosenLocation, this.keyDefinition.deserializer);
});
const latestStorage$ = this.chosenLocation.updates$.pipe(
filter((s) => s.key === this.storageKey),
switchMap(async (storageUpdate) => {
if (storageUpdate.updateType === "remove") {
return null;
}
return await getStoredValue(
this.storageKey,
this.chosenLocation,
this.keyDefinition.deserializer,
);
}),
);
this.state$ = merge(initialStorageGet$, latestStorage$).pipe(
share({
connector: () => new ReplaySubject<T>(1),
resetOnRefCountZero: () => timer(this.keyDefinition.cleanupDelayMs),
}),
);
}
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine> = {},
): Promise<T> {
options = populateOptionsWithDefault(options);
if (this.updatePromise != null) {
await this.updatePromise;
}
try {
this.updatePromise = this.internalUpdate(configureState, options);
const newState = await this.updatePromise;
return newState;
} finally {
this.updatePromise = null;
}
}
private async internalUpdate<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine>,
): Promise<T> {
const currentState = await this.getStateForUpdate();
const combinedDependencies =
options.combineLatestWith != null
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
: null;
if (!options.shouldUpdate(currentState, combinedDependencies)) {
return currentState;
}
const newState = configureState(currentState, combinedDependencies);
await this.chosenLocation.save(this.storageKey, newState);
return newState;
}
/** For use in update methods, does not wait for update to complete before yielding state.
* The expectation is that that await is already done
*/
private async getStateForUpdate() {
return await getStoredValue(
this.storageKey,
this.chosenLocation,
this.keyDefinition.deserializer,
);
}
async getFromState(): Promise<T> {
if (this.updatePromise != null) {
return await this.updatePromise;
}
return await getStoredValue(
this.storageKey,
this.chosenLocation,
this.keyDefinition.deserializer,
);
super(globalKeyBuilder(keyDefinition), chosenLocation, keyDefinition);
}
}

View File

@@ -1,17 +1,4 @@
import {
Observable,
ReplaySubject,
combineLatest,
defer,
filter,
firstValueFrom,
merge,
of,
share,
switchMap,
timeout,
timer,
} from "rxjs";
import { Observable, combineLatest, of } from "rxjs";
import { UserId } from "../../../types/guid";
import {
@@ -19,105 +6,31 @@ import {
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { UserKeyDefinition } from "../user-key-definition";
import { CombinedState, SingleUserState } from "../user-state";
import { getStoredValue } from "./util";
import { StateBase } from "./state-base";
export class DefaultSingleUserState<T> implements SingleUserState<T> {
private storageKey: string;
private updatePromise: Promise<T> | null = null;
readonly state$: Observable<T>;
export class DefaultSingleUserState<T>
extends StateBase<T, UserKeyDefinition<T>>
implements SingleUserState<T>
{
readonly combinedState$: Observable<CombinedState<T>>;
constructor(
readonly userId: UserId,
private keyDefinition: UserKeyDefinition<T>,
private chosenLocation: AbstractStorageService & ObservableStorageService,
keyDefinition: UserKeyDefinition<T>,
chosenLocation: AbstractStorageService & ObservableStorageService,
private stateEventRegistrarService: StateEventRegistrarService,
) {
this.storageKey = this.keyDefinition.buildKey(this.userId);
const initialStorageGet$ = defer(() => {
return getStoredValue(this.storageKey, this.chosenLocation, this.keyDefinition.deserializer);
});
const latestStorage$ = chosenLocation.updates$.pipe(
filter((s) => s.key === this.storageKey),
switchMap(async (storageUpdate) => {
if (storageUpdate.updateType === "remove") {
return null;
}
return await getStoredValue(
this.storageKey,
this.chosenLocation,
this.keyDefinition.deserializer,
);
}),
);
this.state$ = merge(initialStorageGet$, latestStorage$).pipe(
share({
connector: () => new ReplaySubject<T>(1),
resetOnRefCountZero: () => timer(this.keyDefinition.cleanupDelayMs),
}),
);
super(keyDefinition.buildKey(userId), chosenLocation, keyDefinition);
this.combinedState$ = combineLatest([of(userId), this.state$]);
}
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine> = {},
): Promise<T> {
options = populateOptionsWithDefault(options);
if (this.updatePromise != null) {
await this.updatePromise;
}
try {
this.updatePromise = this.internalUpdate(configureState, options);
const newState = await this.updatePromise;
return newState;
} finally {
this.updatePromise = null;
}
}
private async internalUpdate<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine>,
): Promise<T> {
const currentState = await this.getStateForUpdate();
const combinedDependencies =
options.combineLatestWith != null
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
: null;
if (!options.shouldUpdate(currentState, combinedDependencies)) {
return currentState;
}
const newState = configureState(currentState, combinedDependencies);
await this.chosenLocation.save(this.storageKey, newState);
if (newState != null && currentState == null) {
// Only register this state as something clearable on the first time it saves something
// worth deleting. This is helpful in making sure there is less of a race to adding events.
protected override async doStorageSave(newState: T, oldState: T): Promise<void> {
await super.doStorageSave(newState, oldState);
if (newState != null && oldState == null) {
await this.stateEventRegistrarService.registerEvents(this.keyDefinition);
}
return newState;
}
/** For use in update methods, does not wait for update to complete before yielding state.
* The expectation is that that await is already done
*/
private async getStateForUpdate() {
return await getStoredValue(
this.storageKey,
this.chosenLocation,
this.keyDefinition.deserializer,
);
}
}

View File

@@ -2,9 +2,9 @@
* need to update test environment so structuredClone works appropriately
* @jest-environment ../shared/test.environment.ts
*/
import { of } from "rxjs";
import { Observable, of } from "rxjs";
import { trackEmissions } from "../../../../spec";
import { awaitAsync, trackEmissions } from "../../../../spec";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec/fake-account-service";
import {
FakeActiveUserStateProvider,
@@ -49,47 +49,111 @@ describe("DefaultStateProvider", () => {
});
});
describe.each([
[
"getUserState$",
(keyDefinition: KeyDefinition<string>, userId?: UserId) =>
sut.getUserState$(keyDefinition, userId),
],
[
"getUserStateOrDefault$",
(keyDefinition: KeyDefinition<string>, userId?: UserId) =>
sut.getUserStateOrDefault$(keyDefinition, { userId: userId }),
],
])(
"Shared behavior for %s",
(
_testName: string,
methodUnderTest: (
keyDefinition: KeyDefinition<string>,
userId?: UserId,
) => Observable<string>,
) => {
const accountInfo = { email: "email", name: "name", status: AuthenticationStatus.LoggedOut };
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
deserializer: (s) => s,
});
it("should follow the specified user if userId is provided", async () => {
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(methodUnderTest(keyDefinition, userId));
state.nextState("value2");
state.nextState("value3");
expect(emissions).toEqual(["value", "value2", "value3"]);
});
it("should follow the current active user if no userId is provided", async () => {
accountService.activeAccountSubject.next({ id: userId, ...accountInfo });
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(methodUnderTest(keyDefinition));
state.nextState("value2");
state.nextState("value3");
expect(emissions).toEqual(["value", "value2", "value3"]);
});
it("should continue to follow the state of the user that was active when called, even if active user changes", async () => {
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(methodUnderTest(keyDefinition));
accountService.activeAccountSubject.next({ id: "newUserId" as UserId, ...accountInfo });
const newUserEmissions = trackEmissions(sut.getUserState$(keyDefinition));
state.nextState("value2");
state.nextState("value3");
expect(emissions).toEqual(["value", "value2", "value3"]);
expect(newUserEmissions).toEqual([null]);
});
},
);
describe("getUserState$", () => {
const accountInfo = { email: "email", name: "name", status: AuthenticationStatus.LoggedOut };
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
deserializer: (s) => s,
});
it("should follow the specified user if userId is provided", async () => {
it("should not emit any values until a truthy user id is supplied", async () => {
accountService.activeAccountSubject.next(null);
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(sut.getUserState$(keyDefinition, userId));
state.stateSubject.next([userId, "value"]);
state.nextState("value2");
state.nextState("value3");
const emissions = trackEmissions(sut.getUserState$(keyDefinition));
expect(emissions).toEqual(["value", "value2", "value3"]);
});
await awaitAsync();
expect(emissions).toHaveLength(0);
it("should follow the current active user if no userId is provided", async () => {
accountService.activeAccountSubject.next({ id: userId, ...accountInfo });
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(sut.getUserState$(keyDefinition));
state.nextState("value2");
state.nextState("value3");
await awaitAsync();
expect(emissions).toEqual(["value", "value2", "value3"]);
expect(emissions).toEqual(["value"]);
});
});
describe("getUserStateOrDefault$", () => {
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
deserializer: (s) => s,
});
it("should continue to follow the state of the user that was active when called, even if active user changes", async () => {
const state = singleUserStateProvider.getFake(userId, keyDefinition);
state.nextState("value");
const emissions = trackEmissions(sut.getUserState$(keyDefinition));
it("should emit default value if no userId supplied and first active user id emission in falsy", async () => {
accountService.activeAccountSubject.next(null);
accountService.activeAccountSubject.next({ id: "newUserId" as UserId, ...accountInfo });
const newUserEmissions = trackEmissions(sut.getUserState$(keyDefinition));
state.nextState("value2");
state.nextState("value3");
const emissions = trackEmissions(
sut.getUserStateOrDefault$(keyDefinition, {
userId: undefined,
defaultValue: "I'm default!",
}),
);
expect(emissions).toEqual(["value", "value2", "value3"]);
expect(newUserEmissions).toEqual([null]);
expect(emissions).toEqual(["I'm default!"]);
});
});

View File

@@ -1,4 +1,4 @@
import { Observable, switchMap, take } from "rxjs";
import { Observable, filter, of, switchMap, take } from "rxjs";
import { UserId } from "../../../types/guid";
import { DerivedStateDependencies } from "../../../types/state";
@@ -30,12 +30,30 @@ export class DefaultStateProvider implements StateProvider {
return this.getUser<T>(userId, keyDefinition).state$;
} else {
return this.activeUserId$.pipe(
filter((userId) => userId != null), // Filter out null-ish user ids since we can't get state for a null user id
take(1),
switchMap((userId) => this.getUser<T>(userId, keyDefinition).state$),
);
}
}
getUserStateOrDefault$<T>(
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
config: { userId: UserId | undefined; defaultValue?: T },
): Observable<T> {
const { userId, defaultValue = null } = config;
if (userId) {
return this.getUser<T>(userId, keyDefinition).state$;
} else {
return this.activeUserId$.pipe(
take(1),
switchMap((userId) =>
userId != null ? this.getUser<T>(userId, keyDefinition).state$ : of(defaultValue),
),
);
}
}
async setUserState<T>(
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
value: T,

View File

@@ -34,11 +34,7 @@ describe("Specific State Providers", () => {
storageServiceProvider,
stateEventRegistrarService,
);
activeSut = new DefaultActiveUserStateProvider(
mockAccountServiceWith(null),
storageServiceProvider,
stateEventRegistrarService,
);
activeSut = new DefaultActiveUserStateProvider(mockAccountServiceWith(null), singleSut);
globalSut = new DefaultGlobalStateProvider(storageServiceProvider);
});
@@ -67,21 +63,25 @@ describe("Specific State Providers", () => {
},
);
describe.each([
const globalAndSingle = [
{
getMethod: (keyDefinition: KeyDefinition<boolean>) => globalSut.get(keyDefinition),
expectedInstance: DefaultGlobalState,
},
{
// Use a static user id so that it has the same signature as the rest and then write special tests
// handling differing user id
getMethod: (keyDefinition: KeyDefinition<boolean>) => singleSut.get(fakeUser1, keyDefinition),
expectedInstance: DefaultSingleUserState,
},
];
describe.each([
{
getMethod: (keyDefinition: KeyDefinition<boolean>) => activeSut.get(keyDefinition),
expectedInstance: DefaultActiveUserState,
},
{
getMethod: (keyDefinition: KeyDefinition<boolean>) => globalSut.get(keyDefinition),
expectedInstance: DefaultGlobalState,
},
...globalAndSingle,
])("common behavior %s", ({ getMethod, expectedInstance }) => {
it("returns expected instance", () => {
const state = getMethod(fakeDiskKeyDefinition);
@@ -90,12 +90,6 @@ describe("Specific State Providers", () => {
expect(state).toBeInstanceOf(expectedInstance);
});
it("returns cached instance on repeated request", () => {
const stateFirst = getMethod(fakeDiskKeyDefinition);
const stateCached = getMethod(fakeDiskKeyDefinition);
expect(stateFirst).toStrictEqual(stateCached);
});
it("returns different instances when the storage location differs", () => {
const stateDisk = getMethod(fakeDiskKeyDefinition);
const stateMemory = getMethod(fakeMemoryKeyDefinition);
@@ -115,6 +109,14 @@ describe("Specific State Providers", () => {
});
});
describe.each(globalAndSingle)("Global And Single Behavior", ({ getMethod }) => {
it("returns cached instance on repeated request", () => {
const stateFirst = getMethod(fakeDiskKeyDefinition);
const stateCached = getMethod(fakeDiskKeyDefinition);
expect(stateFirst).toStrictEqual(stateCached);
});
});
describe("DefaultSingleUserStateProvider only behavior", () => {
const fakeUser2 = "00000000-0000-1000-a000-000000000002" as UserId;

View File

@@ -0,0 +1,109 @@
import {
Observable,
ReplaySubject,
defer,
filter,
firstValueFrom,
merge,
share,
switchMap,
timeout,
timer,
} from "rxjs";
import { Jsonify } from "type-fest";
import { StorageKey } from "../../../types/state";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { getStoredValue } from "./util";
// The parts of a KeyDefinition this class cares about to make it work
type KeyDefinitionRequirements<T> = {
deserializer: (jsonState: Jsonify<T>) => T;
cleanupDelayMs: number;
};
export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>> {
private updatePromise: Promise<T>;
readonly state$: Observable<T>;
constructor(
protected readonly key: StorageKey,
protected readonly storageService: AbstractStorageService & ObservableStorageService,
protected readonly keyDefinition: KeyDef,
) {
const storageUpdate$ = storageService.updates$.pipe(
filter((storageUpdate) => storageUpdate.key === key),
switchMap(async (storageUpdate) => {
if (storageUpdate.updateType === "remove") {
return null;
}
return await getStoredValue(key, storageService, keyDefinition.deserializer);
}),
);
this.state$ = merge(
defer(() => getStoredValue(key, storageService, keyDefinition.deserializer)),
storageUpdate$,
).pipe(
share({
connector: () => new ReplaySubject(1),
resetOnRefCountZero: () => timer(keyDefinition.cleanupDelayMs),
}),
);
}
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine> = {},
): Promise<T> {
options = populateOptionsWithDefault(options);
if (this.updatePromise != null) {
await this.updatePromise;
}
try {
this.updatePromise = this.internalUpdate(configureState, options);
const newState = await this.updatePromise;
return newState;
} finally {
this.updatePromise = null;
}
}
private async internalUpdate<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine>,
): Promise<T> {
const currentState = await this.getStateForUpdate();
const combinedDependencies =
options.combineLatestWith != null
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
: null;
if (!options.shouldUpdate(currentState, combinedDependencies)) {
return currentState;
}
const newState = configureState(currentState, combinedDependencies);
await this.doStorageSave(newState, currentState);
return newState;
}
protected async doStorageSave(newState: T, oldState: T) {
await this.storageService.save(this.key, newState);
}
/** For use in update methods, does not wait for update to complete before yielding state.
* The expectation is that that await is already done
*/
private async getStateForUpdate() {
return await getStoredValue(this.key, this.storageService, this.keyDefinition.deserializer);
}
}

View File

@@ -2,9 +2,9 @@
* Default storage location options.
*
* `disk` generally means state that is accessible between restarts of the application,
* with the exception of the web client. In web this means `sessionStorage`. The data is
* through refreshes of the page but not available once that tab is closed or from any
* other tabs.
* with the exception of the web client. In web this means `sessionStorage`. The data
* persists through refreshes of the page but not available once that tab is closed or
* from any other tabs.
*
* `memory` means that the information stored there goes away during application
* restarts.

View File

@@ -22,11 +22,27 @@ import { StateDefinition } from "./state-definition";
export const ORGANIZATIONS_DISK = new StateDefinition("organizations", "disk");
export const POLICIES_DISK = new StateDefinition("policies", "disk");
export const PROVIDERS_DISK = new StateDefinition("providers", "disk");
export const ORGANIZATION_MANAGEMENT_PREFERENCES_DISK = new StateDefinition(
"organizationManagementPreferences",
"disk",
{
web: "disk-local",
},
);
// Billing
export const BILLING_DISK = new StateDefinition("billing", "disk");
// Auth
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" });
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
export const TOKEN_DISK = new StateDefinition("token", "disk");
export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", {
web: "disk-local",
});
export const TOKEN_MEMORY = new StateDefinition("token", "memory");
export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory");
// Autofill
@@ -37,15 +53,11 @@ export const USER_NOTIFICATION_SETTINGS_DISK = new StateDefinition(
"disk",
);
// Billing
export const DOMAIN_SETTINGS_DISK = new StateDefinition("domainSettings", "disk");
export const AUTOFILL_SETTINGS_DISK = new StateDefinition("autofillSettings", "disk");
export const AUTOFILL_SETTINGS_DISK_LOCAL = new StateDefinition("autofillSettingsLocal", "disk", {
web: "disk-local",
});
export const BILLING_DISK = new StateDefinition("billing", "disk");
// Components
@@ -76,6 +88,7 @@ export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", {
export const GENERATOR_DISK = new StateDefinition("generator", "disk");
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");
export const EVENT_COLLECTION_DISK = new StateDefinition("eventCollection", "disk");
// Vault

View File

@@ -24,8 +24,11 @@ export abstract class StateProvider {
/**
* Gets a state observable for a given key and userId.
*
* @remarks If userId is falsy the observable returned will point to the currently active user _and not update if the active user changes_.
* @remarks If userId is falsy the observable returned will attempt to point to the currently active user _and not update if the active user changes_.
* This is different to how `getActive` works and more similar to `getUser` for whatever user happens to be active at the time of the call.
* If no user happens to be active at the time this method is called with a falsy userId then this observable will not emit a value until
* a user becomes active. If you are not confident a user is active at the time this method is called, you may want to pipe a call to `timeout`
* or instead call {@link getUserStateOrDefault$} and supply a value you would rather have given in the case of no passed in userId and no active user.
*
* @note consider converting your {@link KeyDefinition} to a {@link UserKeyDefinition} for additional features.
*
@@ -37,14 +40,49 @@ export abstract class StateProvider {
/**
* Gets a state observable for a given key and userId.
*
* @remarks If userId is falsy the observable returned will point to the currently active user _and not update if the active user changes_.
* @remarks If userId is falsy the observable returned will attempt to point to the currently active user _and not update if the active user changes_.
* This is different to how `getActive` works and more similar to `getUser` for whatever user happens to be active at the time of the call.
* If no user happens to be active at the time this method is called with a falsy userId then this observable will not emit a value until
* a user becomes active. If you are not confident a user is active at the time this method is called, you may want to pipe a call to `timeout`
* or instead call {@link getUserStateOrDefault$} and supply a value you would rather have given in the case of no passed in userId and no active user.
*
* @param keyDefinition - The key definition for the state you want to get.
* @param userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned.
*/
abstract getUserState$<T>(keyDefinition: UserKeyDefinition<T>, userId?: UserId): Observable<T>;
/**
* Gets a state observable for a given key and userId
*
* @remarks If userId is falsy the observable return will first attempt to point to the currently active user but will not follow subsequent active user changes,
* if there is no immediately available active user, then it will fallback to returning a default value in an observable that immediately completes.
*
* @note consider converting your {@link KeyDefinition} to a {@link UserKeyDefinition} for additional features.
*
* @param keyDefinition - The key definition for the state you want to get.
* @param config.userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned.
* @param config.defaultValue - The default value that should be wrapped in an observable if no active user is immediately available and no truthy userId is passed in.
*/
abstract getUserStateOrDefault$<T>(
keyDefinition: KeyDefinition<T>,
config: { userId: UserId | undefined; defaultValue?: T },
): Observable<T>;
/**
* Gets a state observable for a given key and userId
*
* @remarks If userId is falsy the observable return will first attempt to point to the currently active user but will not follow subsequent active user changes,
* if there is no immediately available active user, then it will fallback to returning a default value in an observable that immediately completes.
*
* @param keyDefinition - The key definition for the state you want to get.
* @param config.userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned.
* @param config.defaultValue - The default value that should be wrapped in an observable if no active user is immediately available and no truthy userId is passed in.
*/
abstract getUserStateOrDefault$<T>(
keyDefinition: UserKeyDefinition<T>,
config: { userId: UserId | undefined; defaultValue?: T },
): Observable<T>;
/**
* Sets the state for a given key and userId.
*

View File

@@ -138,7 +138,9 @@ export class UserKeyDefinition<T> {
buildKey(userId: UserId) {
if (!Utils.isGuid(userId)) {
throw new Error("You cannot build a user key without a valid UserId");
throw new Error(
`You cannot build a user key without a valid UserId, building for key ${this.fullName}`,
);
}
return `user_${userId}_${this.stateDefinition.name}_${this.key}` as StorageKey;
}

View File

@@ -1,37 +0,0 @@
import { BehaviorSubject, Observable } from "rxjs";
import { AvatarUpdateService as AvatarUpdateServiceAbstraction } from "../../abstractions/account/avatar-update.service";
import { ApiService } from "../../abstractions/api.service";
import { UpdateAvatarRequest } from "../../models/request/update-avatar.request";
import { ProfileResponse } from "../../models/response/profile.response";
import { StateService } from "../../platform/abstractions/state.service";
export class AvatarUpdateService implements AvatarUpdateServiceAbstraction {
private _avatarUpdate$ = new BehaviorSubject<string | null>(null);
avatarUpdate$: Observable<string | null> = this._avatarUpdate$.asObservable();
constructor(
private apiService: ApiService,
private stateService: StateService,
) {
// 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.loadColorFromState();
}
loadColorFromState(): Promise<string | null> {
return this.stateService.getAvatarColor().then((color) => {
this._avatarUpdate$.next(color);
return color;
});
}
pushUpdate(color: string | null): Promise<ProfileResponse | void> {
return this.apiService.putAvatar(new UpdateAvatarRequest(color)).then((response) => {
// 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.stateService.setAvatarColor(response.avatarColor);
this._avatarUpdate$.next(response.avatarColor);
});
}
}

View File

@@ -93,6 +93,7 @@ import { SubscriptionResponse } from "../billing/models/response/subscription.re
import { TaxInfoResponse } from "../billing/models/response/tax-info.response";
import { TaxRateResponse } from "../billing/models/response/tax-rate.response";
import { DeviceType } from "../enums";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
import { CollectionBulkDeleteRequest } from "../models/request/collection-bulk-delete.request";
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
import { EventRequest } from "../models/request/event.request";
@@ -116,6 +117,7 @@ import { UserKeyResponse } from "../models/response/user-key.response";
import { AppIdService } from "../platform/abstractions/app-id.service";
import { EnvironmentService } from "../platform/abstractions/environment.service";
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
import { StateService } from "../platform/abstractions/state.service";
import { Utils } from "../platform/misc/utils";
import { AttachmentRequest } from "../vault/models/request/attachment.request";
import { CipherBulkDeleteRequest } from "../vault/models/request/cipher-bulk-delete.request";
@@ -154,6 +156,7 @@ export class ApiService implements ApiServiceAbstraction {
private platformUtilsService: PlatformUtilsService,
private environmentService: EnvironmentService,
private appIdService: AppIdService,
private stateService: StateService,
private logoutCallback: (expired: boolean) => Promise<void>,
private customUserAgent: string = null,
) {
@@ -224,7 +227,6 @@ export class ApiService implements ApiServiceAbstraction {
responseJson.TwoFactorProviders2 &&
Object.keys(responseJson.TwoFactorProviders2).length
) {
await this.tokenService.clearTwoFactorToken();
return new IdentityTwoFactorResponse(responseJson);
} else if (
response.status === 400 &&
@@ -1578,10 +1580,10 @@ export class ApiService implements ApiServiceAbstraction {
// Helpers
async getActiveBearerToken(): Promise<string> {
let accessToken = await this.tokenService.getToken();
let accessToken = await this.tokenService.getAccessToken();
if (await this.tokenService.tokenNeedsRefresh()) {
await this.doAuthRefresh();
accessToken = await this.tokenService.getToken();
accessToken = await this.tokenService.getAccessToken();
}
return accessToken;
}
@@ -1749,7 +1751,7 @@ export class ApiService implements ApiServiceAbstraction {
headers.set("User-Agent", this.customUserAgent);
}
const decodedToken = await this.tokenService.decodeToken();
const decodedToken = await this.tokenService.decodeAccessToken();
const response = await this.fetch(
new Request(this.environmentService.getIdentityUrl() + "/connect/token", {
body: this.qsStringify({
@@ -1767,10 +1769,15 @@ export class ApiService implements ApiServiceAbstraction {
if (response.status === 200) {
const responseJson = await response.json();
const tokenResponse = new IdentityTokenResponse(responseJson);
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction();
const vaultTimeout = await this.stateService.getVaultTimeout();
await this.tokenService.setTokens(
tokenResponse.accessToken,
tokenResponse.refreshToken,
null,
vaultTimeoutAction as VaultTimeoutAction,
vaultTimeout,
);
} else {
const error = await this.handleError(response, true, true);
@@ -1796,7 +1803,14 @@ export class ApiService implements ApiServiceAbstraction {
throw new Error("Invalid response received when refreshing api token");
}
await this.tokenService.setToken(response.accessToken);
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction();
const vaultTimeout = await this.stateService.getVaultTimeout();
await this.tokenService.setAccessToken(
response.accessToken,
vaultTimeoutAction as VaultTimeoutAction,
vaultTimeout,
);
}
async send(

View File

@@ -1,61 +1,105 @@
import { firstValueFrom, map, from, zip } from "rxjs";
import { EventCollectionService as EventCollectionServiceAbstraction } from "../../abstractions/event/event-collection.service";
import { EventUploadService } from "../../abstractions/event/event-upload.service";
import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { EventType } from "../../enums";
import { EventData } from "../../models/data/event.data";
import { StateService } from "../../platform/abstractions/state.service";
import { StateProvider } from "../../platform/state";
import { CipherService } from "../../vault/abstractions/cipher.service";
import { EVENT_COLLECTION } from "./key-definitions";
export class EventCollectionService implements EventCollectionServiceAbstraction {
constructor(
private cipherService: CipherService,
private stateService: StateService,
private stateProvider: StateProvider,
private organizationService: OrganizationService,
private eventUploadService: EventUploadService,
private accountService: AccountService,
) {}
/** Adds an event to the active user's event collection
* @param eventType the event type to be added
* @param cipherId if provided the id of the cipher involved in the event
* @param uploadImmediately in some cases the recorded events should be uploaded right after being added
* @param organizationId the organizationId involved in the event. If the cipherId is not provided an organizationId is required
*/
async collect(
eventType: EventType,
cipherId: string = null,
uploadImmediately = false,
organizationId: string = null,
): Promise<any> {
const authed = await this.stateService.getIsAuthenticated();
if (!authed) {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
const eventStore = this.stateProvider.getUser(userId, EVENT_COLLECTION);
if (!(await this.shouldUpdate(cipherId, organizationId))) {
return;
}
const organizations = await this.organizationService.getAll();
if (organizations == null) {
return;
}
const orgIds = new Set<string>(organizations.filter((o) => o.useEvents).map((o) => o.id));
if (orgIds.size === 0) {
return;
}
if (cipherId != null) {
const cipher = await this.cipherService.get(cipherId);
if (cipher == null || cipher.organizationId == null || !orgIds.has(cipher.organizationId)) {
return;
}
}
if (organizationId != null) {
if (!orgIds.has(organizationId)) {
return;
}
}
let eventCollection = await this.stateService.getEventCollection();
if (eventCollection == null) {
eventCollection = [];
}
const event = new EventData();
event.type = eventType;
event.cipherId = cipherId;
event.date = new Date().toISOString();
event.organizationId = organizationId;
eventCollection.push(event);
await this.stateService.setEventCollection(eventCollection);
await eventStore.update((events) => {
events = events ?? [];
events.push(event);
return events;
});
if (uploadImmediately) {
await this.eventUploadService.uploadEvents();
}
}
/** Verifies if the event collection should be updated for the provided information
* @param cipherId the cipher for the event
* @param organizationId the organization for the event
*/
private async shouldUpdate(
cipherId: string = null,
organizationId: string = null,
): Promise<boolean> {
const orgIds$ = this.organizationService.organizations$.pipe(
map((orgs) => orgs?.filter((o) => o.useEvents)?.map((x) => x.id) ?? []),
);
const cipher$ = from(this.cipherService.get(cipherId));
const [accountInfo, orgIds, cipher] = await firstValueFrom(
zip(this.accountService.activeAccount$, orgIds$, cipher$),
);
// The user must be authorized
if (accountInfo.status != AuthenticationStatus.Unlocked) {
return false;
}
// User must have organizations assigned to them
if (orgIds == null || orgIds.length == 0) {
return false;
}
// If the cipher is null there must be an organization id provided
if (cipher == null && organizationId == null) {
return false;
}
// If the cipher is present it must be in the user's org list
if (cipher != null && !orgIds.includes(cipher?.organizationId)) {
return false;
}
// If the organization id is provided it must be in the user's org list
if (organizationId != null && !orgIds.includes(organizationId)) {
return false;
}
return true;
}
}

View File

@@ -1,15 +1,24 @@
import { firstValueFrom, map } from "rxjs";
import { ApiService } from "../../abstractions/api.service";
import { EventUploadService as EventUploadServiceAbstraction } from "../../abstractions/event/event-upload.service";
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { EventData } from "../../models/data/event.data";
import { EventRequest } from "../../models/request/event.request";
import { LogService } from "../../platform/abstractions/log.service";
import { StateService } from "../../platform/abstractions/state.service";
import { StateProvider } from "../../platform/state";
import { UserId } from "../../types/guid";
import { EVENT_COLLECTION } from "./key-definitions";
export class EventUploadService implements EventUploadServiceAbstraction {
private inited = false;
constructor(
private apiService: ApiService,
private stateService: StateService,
private stateProvider: StateProvider,
private logService: LogService,
private accountService: AccountService,
) {}
init(checkOnInterval: boolean) {
@@ -26,12 +35,26 @@ export class EventUploadService implements EventUploadServiceAbstraction {
}
}
async uploadEvents(userId?: string): Promise<void> {
const authed = await this.stateService.getIsAuthenticated({ userId: userId });
if (!authed) {
/** Upload the event collection from state.
* @param userId upload events for provided user. If not active user will be used.
*/
async uploadEvents(userId?: UserId): Promise<void> {
if (!userId) {
userId = await firstValueFrom(this.stateProvider.activeUserId$);
}
// Get the auth status from the provided user or the active user
const userAuth$ = this.accountService.accounts$.pipe(
map((accounts) => accounts[userId]?.status === AuthenticationStatus.Unlocked),
);
const isAuthenticated = await firstValueFrom(userAuth$);
if (!isAuthenticated) {
return;
}
const eventCollection = await this.stateService.getEventCollection({ userId: userId });
const eventCollection = await this.takeEvents(userId);
if (eventCollection == null || eventCollection.length === 0) {
return;
}
@@ -45,15 +68,23 @@ export class EventUploadService implements EventUploadServiceAbstraction {
});
try {
await this.apiService.postEventsCollect(request);
// 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.clearEvents(userId);
} catch (e) {
this.logService.error(e);
// Add the events back to state if there was an error and they were not uploaded.
await this.stateProvider.setUserState(EVENT_COLLECTION, eventCollection, userId);
}
}
private async clearEvents(userId?: string): Promise<any> {
await this.stateService.setEventCollection(null, { userId: userId });
/** Return user's events and then clear them from state
* @param userId the user to grab and clear events for
*/
private async takeEvents(userId: UserId): Promise<EventData[]> {
let taken = null;
await this.stateProvider.getUser(userId, EVENT_COLLECTION).update((current) => {
taken = current ?? [];
return [];
});
return taken;
}
}

View File

@@ -0,0 +1,10 @@
import { EventData } from "../../models/data/event.data";
import { KeyDefinition, EVENT_COLLECTION_DISK } from "../../platform/state";
export const EVENT_COLLECTION: KeyDefinition<EventData[]> = KeyDefinition.array<EventData>(
EVENT_COLLECTION_DISK,
"events",
{
deserializer: (s) => EventData.fromJSON(s),
},
);

View File

@@ -1,40 +0,0 @@
import { BehaviorSubject, concatMap } from "rxjs";
import { SettingsService as SettingsServiceAbstraction } from "../abstractions/settings.service";
import { StateService } from "../platform/abstractions/state.service";
import { Utils } from "../platform/misc/utils";
export class SettingsService implements SettingsServiceAbstraction {
protected _disableFavicon = new BehaviorSubject<boolean>(null);
disableFavicon$ = this._disableFavicon.asObservable();
constructor(private stateService: StateService) {
this.stateService.activeAccountUnlocked$
.pipe(
concatMap(async (unlocked) => {
if (Utils.global.bitwardenContainerService == null) {
return;
}
if (!unlocked) {
return;
}
const disableFavicon = await this.stateService.getDisableFavicon();
this._disableFavicon.next(disableFavicon);
}),
)
.subscribe();
}
async setDisableFavicon(value: boolean) {
this._disableFavicon.next(value);
await this.stateService.setDisableFavicon(value);
}
getDisableFavicon(): boolean {
return this._disableFavicon.getValue();
}
}

View File

@@ -29,7 +29,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
async setVaultTimeoutOptions(timeout: number, action: VaultTimeoutAction): Promise<void> {
// We swap these tokens from being on disk for lock actions, and in memory for logout actions
// Get them here to set them to their new location after changing the timeout action and clearing if needed
const token = await this.tokenService.getToken();
const accessToken = await this.tokenService.getAccessToken();
const refreshToken = await this.tokenService.getRefreshToken();
const clientId = await this.tokenService.getClientId();
const clientSecret = await this.tokenService.getClientSecret();
@@ -37,21 +37,22 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
await this.stateService.setVaultTimeout(timeout);
const currentAction = await this.stateService.getVaultTimeoutAction();
if (
(timeout != null || timeout === 0) &&
action === VaultTimeoutAction.LogOut &&
action !== currentAction
) {
// if we have a vault timeout and the action is log out, reset tokens
await this.tokenService.clearToken();
await this.tokenService.clearTokens();
}
await this.stateService.setVaultTimeoutAction(action);
await this.tokenService.setToken(token);
await this.tokenService.setRefreshToken(refreshToken);
await this.tokenService.setClientId(clientId);
await this.tokenService.setClientSecret(clientSecret);
await this.tokenService.setTokens(accessToken, refreshToken, action, timeout, [
clientId,
clientSecret,
]);
await this.cryptoService.refreshAdditionalKeys();
}

View File

@@ -24,15 +24,22 @@ import { RevertLastSyncMigrator } from "./migrations/26-revert-move-last-sync-to
import { BadgeSettingsMigrator } from "./migrations/27-move-badge-settings-to-state-providers";
import { MoveBiometricUnlockToStateProviders } from "./migrations/28-move-biometric-unlock-to-state-providers";
import { UserNotificationSettingsKeyMigrator } from "./migrations/29-move-user-notification-settings-to-state-provider";
import { FixPremiumMigrator } from "./migrations/3-fix-premium";
import { PolicyMigrator } from "./migrations/30-move-policy-state-to-state-provider";
import { EnableContextMenuMigrator } from "./migrations/31-move-enable-context-menu-to-autofill-settings-state-provider";
import { PreferredLanguageMigrator } from "./migrations/32-move-preferred-language";
import { AppIdMigrator } from "./migrations/33-move-app-id-to-state-providers";
import { DomainSettingsMigrator } from "./migrations/34-move-domain-settings-to-state-providers";
import { MoveThemeToStateProviderMigrator } from "./migrations/35-move-theme-to-state-providers";
import { LocalDataMigrator } from "./migrations/36-move-local-data-to-state-provider";
import { VaultSettingsKeyMigrator } from "./migrations/36-move-show-card-and-identity-to-state-provider";
import { AvatarColorMigrator } from "./migrations/37-move-avatar-color-to-state-providers";
import { TokenServiceStateProviderMigrator } from "./migrations/38-migrate-token-svc-to-state-provider";
import { MoveBillingAccountProfileMigrator } from "./migrations/39-move-billing-account-profile-to-state-providers";
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
import { OrganizationMigrator } from "./migrations/40-move-organization-state-to-state-provider";
import { EventCollectionMigrator } from "./migrations/41-move-event-collection-to-state-provider";
import { EnableFaviconMigrator } from "./migrations/42-move-enable-favicon-to-domain-settings-state-provider";
import { AutoConfirmFingerPrintsMigrator } from "./migrations/43-move-auto-confirm-finger-prints-to-state-provider";
import { LocalDataMigrator } from "./migrations/44-move-local-data-to-state-provider";
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";
@@ -40,14 +47,13 @@ import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global";
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 2;
export const CURRENT_VERSION = 36;
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 44;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
return MigrationBuilder.create()
.with(MinVersionMigrator)
.with(FixPremiumMigrator, 2, 3)
.with(RemoveEverBeenUnlockedMigrator, 3, 4)
.with(AddKeyTypeToOrgKeysMigrator, 4, 5)
.with(RemoveLegacyEtmKeyMigrator, 5, 6)
@@ -80,7 +86,15 @@ export function createMigrationBuilder() {
.with(AppIdMigrator, 32, 33)
.with(DomainSettingsMigrator, 33, 34)
.with(MoveThemeToStateProviderMigrator, 34, 35)
.with(LocalDataMigrator, 35, CURRENT_VERSION);
.with(VaultSettingsKeyMigrator, 35, 36)
.with(AvatarColorMigrator, 36, 37)
.with(TokenServiceStateProviderMigrator, 37, 38)
.with(MoveBillingAccountProfileMigrator, 38, 39)
.with(OrganizationMigrator, 39, 40)
.with(EventCollectionMigrator, 40, 41)
.with(EnableFaviconMigrator, 41, 42)
.with(AutoConfirmFingerPrintsMigrator, 42, 43)
.with(LocalDataMigrator, 43, CURRENT_VERSION);
}
export async function currentVersion(

Some files were not shown because too many files have changed in this diff Show More