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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
export * from "./abstractions";
|
||||
export * from "./models";
|
||||
export * from "./services";
|
||||
export * from "./utilities";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
1
libs/auth/src/common/utilities/index.ts
Normal file
1
libs/auth/src/common/utilities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./decode-jwt-token-to-json.utility";
|
||||
@@ -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>(
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
export abstract class EventUploadService {
|
||||
uploadEvents: (userId?: string) => Promise<void>;
|
||||
uploadEvents: (userId?: UserId) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export abstract class SettingsService {
|
||||
disableFavicon$: Observable<boolean>;
|
||||
|
||||
setDisableFavicon: (value: boolean) => Promise<any>;
|
||||
getDisableFavicon: () => boolean;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export class OrganizationUserUpdateGroupsRequest {
|
||||
groupIds: string[] = [];
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ??= {};
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
29
libs/common/src/auth/abstractions/avatar.service.ts
Normal file
29
libs/common/src/auth/abstractions/avatar.service.ts
Normal 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>;
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
33
libs/common/src/auth/services/avatar.service.ts
Normal file
33
libs/common/src/auth/services/avatar.service.ts
Normal 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$;
|
||||
}
|
||||
}
|
||||
@@ -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$);
|
||||
}
|
||||
|
||||
2237
libs/common/src/auth/services/token.service.spec.ts
Normal file
2237
libs/common/src/auth/services/token.service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
64
libs/common/src/auth/services/token.state.spec.ts
Normal file
64
libs/common/src/auth/services/token.state.spec.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
);
|
||||
65
libs/common/src/auth/services/token.state.ts
Normal file
65
libs/common/src/auth/services/token.state.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
109
libs/common/src/platform/state/implementations/state-base.ts
Normal file
109
libs/common/src/platform/state/implementations/state-base.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
10
libs/common/src/services/event/key-definitions.ts
Normal file
10
libs/common/src/services/event/key-definitions.ts
Normal 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),
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user