1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +00:00

Merge branch 'main' into vault/pm-5273

# Conflicts:
#	libs/common/src/platform/state/state-definitions.ts
#	libs/common/src/state-migrations/migrate.ts
This commit is contained in:
Carlos Gonçalves
2024-03-07 15:44:21 +00:00
646 changed files with 21369 additions and 12700 deletions

View File

@@ -23,6 +23,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
@@ -73,6 +74,7 @@ export class LockComponent implements OnInit, OnDestroy {
protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
protected userVerificationService: UserVerificationService,
protected pinCryptoService: PinCryptoServiceAbstraction,
protected biometricStateService: BiometricStateService,
) {}
async ngOnInit() {
@@ -117,7 +119,7 @@ export class LockComponent implements OnInit, OnDestroy {
return;
}
await this.stateService.setBiometricPromptCancelled(true);
await this.biometricStateService.setPromptCancelled();
const userKey = await this.cryptoService.getUserKeyFromStorage(KeySuffixOptions.Biometric);
if (userKey) {
@@ -274,7 +276,7 @@ export class LockComponent implements OnInit, OnDestroy {
private async doContinue(evaluatePasswordAfterUnlock: boolean) {
await this.stateService.setEverBeenUnlocked(true);
await this.stateService.setBiometricPromptCancelled(false);
await this.biometricStateService.resetPromptCancelled();
this.messagingService.send("unlocked");
if (evaluatePasswordAfterUnlock) {

View File

@@ -4,11 +4,11 @@ import { Subject, takeUntil } from "rxjs";
import {
AuthRequestLoginCredentials,
AuthRequestServiceAbstraction,
LoginStrategyServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
@@ -86,7 +86,7 @@ export class LoginViaAuthRequestComponent
private stateService: StateService,
private loginService: LoginService,
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
private authReqCryptoService: AuthRequestCryptoServiceAbstraction,
private authRequestService: AuthRequestServiceAbstraction,
private loginStrategyService: LoginStrategyServiceAbstraction,
) {
super(environmentService, i18nService, platformUtilsService);
@@ -367,14 +367,14 @@ export class LoginViaAuthRequestComponent
if (adminAuthReqResponse.masterPasswordHash) {
// Flow 2: masterPasswordHash is not null
// key is authRequestPublicKey(masterKey) + we have authRequestPublicKey(masterPasswordHash)
await this.authReqCryptoService.setKeysAfterDecryptingSharedMasterKeyAndHash(
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
adminAuthReqResponse,
privateKey,
);
} else {
// Flow 3: masterPasswordHash is null
// we can assume key is authRequestPublicKey(userKey) and we can just decrypt with userKey and proceed to vault
await this.authReqCryptoService.setUserKeyAfterDecryptingSharedUserKey(
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
adminAuthReqResponse,
privateKey,
);
@@ -404,7 +404,7 @@ export class LoginViaAuthRequestComponent
// if masterPasswordHash is null, we will always receive key as authRequestPublicKey(userKey)
if (response.masterPasswordHash) {
const { masterKey, masterKeyHash } =
await this.authReqCryptoService.decryptPubKeyEncryptedMasterKeyAndHash(
await this.authRequestService.decryptPubKeyEncryptedMasterKeyAndHash(
response.key,
response.masterPasswordHash,
this.authRequestKeyPair.privateKey,
@@ -419,7 +419,7 @@ export class LoginViaAuthRequestComponent
masterKeyHash,
);
} else {
const userKey = await this.authReqCryptoService.decryptPubKeyEncryptedUserKey(
const userKey = await this.authRequestService.decryptPubKeyEncryptedUserKey(
response.key,
this.authRequestKeyPair.privateKey,
);

View File

@@ -1,6 +1,8 @@
import { LOCALE_ID, NgModule } from "@angular/core";
import {
AuthRequestServiceAbstraction,
AuthRequestService,
PinCryptoServiceAbstraction,
PinCryptoService,
LoginStrategyServiceAbstraction,
@@ -47,7 +49,6 @@ import {
InternalAccountService,
} from "@bitwarden/common/auth/abstractions/account.service";
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction";
import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
@@ -66,7 +67,6 @@ import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstract
import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation";
import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation";
@@ -86,12 +86,16 @@ import {
AutofillSettingsServiceAbstraction,
AutofillSettingsService,
} from "@bitwarden/common/autofill/services/autofill-settings.service";
import {
BadgeSettingsServiceAbstraction,
BadgeSettingsService,
} from "@bitwarden/common/autofill/services/badge-settings.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
import { BillingBannerServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-banner.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 { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
import { BillingBannerService } from "@bitwarden/common/billing/services/billing-banner.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { PaymentMethodWarningsService } from "@bitwarden/common/billing/services/payment-method-warnings.service";
import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
@@ -102,6 +106,7 @@ import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.
import { EnvironmentService as EnvironmentServiceAbstraction } from "@bitwarden/common/platform/abstractions/environment.service";
import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -125,18 +130,20 @@ import { EncryptServiceImplementation } from "@bitwarden/common/platform/service
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service";
import { StateService } from "@bitwarden/common/platform/services/state.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import {
ActiveUserStateProvider,
DerivedStateProvider,
GlobalStateProvider,
SingleUserStateProvider,
StateProvider,
DerivedStateProvider,
} from "@bitwarden/common/platform/state";
/* eslint-disable import/no-restricted-paths -- We need the implementations to inject, but generally these should not be accessed */
import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider";
@@ -144,6 +151,8 @@ import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/im
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-single-user-state.provider";
import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider";
import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service";
import { StateEventRunnerService } from "@bitwarden/common/platform/state/state-event-runner.service";
/* eslint-enable import/no-restricted-paths */
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
import { ApiService } from "@bitwarden/common/services/api.service";
@@ -318,7 +327,7 @@ import { ModalService } from "./modal.service";
PasswordStrengthServiceAbstraction,
PolicyServiceAbstraction,
DeviceTrustCryptoServiceAbstraction,
AuthRequestCryptoServiceAbstraction,
AuthRequestServiceAbstraction,
],
},
{
@@ -434,10 +443,16 @@ import { ModalService } from "./modal.service";
deps: [CryptoFunctionServiceAbstraction, LogService, StateServiceAbstraction],
},
{ provide: TokenServiceAbstraction, useClass: TokenService, deps: [StateServiceAbstraction] },
{
provide: KeyGenerationServiceAbstraction,
useClass: KeyGenerationService,
deps: [CryptoFunctionServiceAbstraction],
},
{
provide: CryptoServiceAbstraction,
useClass: CryptoService,
deps: [
KeyGenerationServiceAbstraction,
CryptoFunctionServiceAbstraction,
EncryptService,
PlatformUtilsServiceAbstraction,
@@ -479,7 +494,7 @@ import { ModalService } from "./modal.service";
deps: [
CryptoServiceAbstraction,
I18nServiceAbstraction,
CryptoFunctionServiceAbstraction,
KeyGenerationServiceAbstraction,
StateServiceAbstraction,
],
},
@@ -508,7 +523,6 @@ import { ModalService } from "./modal.service";
FolderApiServiceAbstraction,
OrganizationServiceAbstraction,
SendApiServiceAbstraction,
StateProvider,
LOGOUT_CALLBACK,
],
},
@@ -526,6 +540,7 @@ import { ModalService } from "./modal.service";
TokenServiceAbstraction,
PolicyServiceAbstraction,
StateServiceAbstraction,
BiometricStateService,
],
},
{
@@ -542,6 +557,7 @@ import { ModalService } from "./modal.service";
StateServiceAbstraction,
AuthServiceAbstraction,
VaultTimeoutSettingsServiceAbstraction,
StateEventRunnerService,
LOCKED_CALLBACK,
LOGOUT_CALLBACK,
],
@@ -665,7 +681,7 @@ import { ModalService } from "./modal.service";
{
provide: PolicyServiceAbstraction,
useClass: PolicyService,
deps: [StateServiceAbstraction, OrganizationServiceAbstraction],
deps: [StateServiceAbstraction, StateProvider, OrganizationServiceAbstraction],
},
{
provide: InternalPolicyService,
@@ -674,7 +690,7 @@ import { ModalService } from "./modal.service";
{
provide: PolicyApiServiceAbstraction,
useClass: PolicyApiService,
deps: [PolicyServiceAbstraction, ApiServiceAbstraction, StateServiceAbstraction],
deps: [InternalPolicyService, ApiServiceAbstraction],
},
{
provide: KeyConnectorServiceAbstraction,
@@ -686,7 +702,7 @@ import { ModalService } from "./modal.service";
TokenServiceAbstraction,
LogService,
OrganizationServiceAbstraction,
CryptoFunctionServiceAbstraction,
KeyGenerationServiceAbstraction,
LOGOUT_CALLBACK,
],
},
@@ -732,7 +748,7 @@ import { ModalService } from "./modal.service";
{
provide: ProviderServiceAbstraction,
useClass: ProviderService,
deps: [StateServiceAbstraction],
deps: [StateProvider],
},
{
provide: TwoFactorServiceAbstraction,
@@ -828,6 +844,7 @@ import { ModalService } from "./modal.service";
provide: DeviceTrustCryptoServiceAbstraction,
useClass: DeviceTrustCryptoService,
deps: [
KeyGenerationServiceAbstraction,
CryptoFunctionServiceAbstraction,
CryptoServiceAbstraction,
EncryptService,
@@ -839,9 +856,14 @@ import { ModalService } from "./modal.service";
],
},
{
provide: AuthRequestCryptoServiceAbstraction,
useClass: AuthRequestCryptoServiceImplementation,
deps: [CryptoServiceAbstraction],
provide: AuthRequestServiceAbstraction,
useClass: AuthRequestService,
deps: [
AppIdServiceAbstraction,
CryptoServiceAbstraction,
ApiServiceAbstraction,
StateServiceAbstraction,
],
},
{
provide: PinCryptoServiceAbstraction,
@@ -875,20 +897,35 @@ import { ModalService } from "./modal.service";
LogService,
],
},
{
provide: StorageServiceProvider,
useClass: StorageServiceProvider,
deps: [OBSERVABLE_DISK_STORAGE, OBSERVABLE_MEMORY_STORAGE],
},
{
provide: StateEventRegistrarService,
useClass: StateEventRegistrarService,
deps: [GlobalStateProvider, StorageServiceProvider],
},
{
provide: StateEventRunnerService,
useClass: StateEventRunnerService,
deps: [GlobalStateProvider, StorageServiceProvider],
},
{
provide: GlobalStateProvider,
useClass: DefaultGlobalStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE],
deps: [StorageServiceProvider],
},
{
provide: ActiveUserStateProvider,
useClass: DefaultActiveUserStateProvider,
deps: [AccountServiceAbstraction, OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE],
deps: [AccountServiceAbstraction, StorageServiceProvider, StateEventRegistrarService],
},
{
provide: SingleUserStateProvider,
useClass: DefaultSingleUserStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE],
deps: [StorageServiceProvider, StateEventRegistrarService],
},
{
provide: DerivedStateProvider,
@@ -905,11 +942,6 @@ import { ModalService } from "./modal.service";
DerivedStateProvider,
],
},
{
provide: BillingBannerServiceAbstraction,
useClass: BillingBannerService,
deps: [StateProvider],
},
{
provide: OrganizationBillingServiceAbstraction,
useClass: OrganizationBillingService,
@@ -918,6 +950,8 @@ import { ModalService } from "./modal.service";
EncryptService,
I18nServiceAbstraction,
OrganizationApiServiceAbstraction,
OrganizationServiceAbstraction,
StateProvider,
],
},
{
@@ -925,6 +959,11 @@ import { ModalService } from "./modal.service";
useClass: AutofillSettingsService,
deps: [StateProvider, PolicyServiceAbstraction],
},
{
provide: BadgeSettingsServiceAbstraction,
useClass: BadgeSettingsService,
deps: [StateProvider],
},
{
provide: BiometricStateService,
useClass: DefaultBiometricStateService,
@@ -949,6 +988,11 @@ import { ModalService } from "./modal.service";
useClass: BillingApiService,
deps: [ApiServiceAbstraction],
},
{
provide: PaymentMethodWarningsServiceAbstraction,
useClass: PaymentMethodWarningsService,
deps: [BillingApiServiceAbstraction, StateProvider],
},
],
})
export class JslibServicesModule {}

View File

@@ -180,7 +180,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.formGroup.controls.hideEmail.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((val) => {
if (!val && this.disableHideEmail) {
if (!val && this.disableHideEmail && this.formGroup.controls.hideEmail.enabled) {
this.formGroup.controls.hideEmail.disable();
}
});

View File

@@ -111,7 +111,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected messagingService: MessagingService,
protected eventCollectionService: EventCollectionService,
protected policyService: PolicyService,
private logService: LogService,
protected logService: LogService,
protected passwordRepromptService: PasswordRepromptService,
private organizationService: OrganizationService,
protected sendApiService: SendApiService,
@@ -226,8 +226,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
if (!this.allowPersonal && this.organizationId == undefined) {
this.organizationId = this.defaultOwnerId;
}
this.resetMaskState();
}
async load() {
@@ -274,8 +272,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.cipher.secureNote.type = SecureNoteType.Generic;
this.cipher.reprompt = CipherRepromptType.None;
}
this.resetMaskState();
}
if (this.cipher != null && (!this.editMode || loadedAddEditCipherInfo || this.cloneMode)) {
@@ -517,12 +513,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
return true;
}
resetMaskState() {
// toggle masks off for maskable login properties with no value on init/load
this.showTotpSeed = !this.cipher?.login?.totp;
this.showPassword = !this.cipher?.login?.password;
}
togglePassword() {
this.showPassword = !this.showPassword;

View File

@@ -0,0 +1,26 @@
<main
class="tw-flex tw-min-h-screen tw-max-w-xl tw-w-full tw-mx-auto tw-flex-col tw-gap-9 tw-px-4 tw-pb-4 tw-pt-14 tw-text-main"
>
<div class="tw-text-center">
<div class="tw-px-8">
<div *ngIf="icon" class="tw-mb-8">
<bit-icon [icon]="icon"></bit-icon>
</div>
<bit-icon [icon]="logo" class="tw-mx-auto tw-block tw-max-w-72 sm:tw-max-w-xs"></bit-icon>
</div>
<h1 *ngIf="title" bitTypography="h3" class="tw-mt-8 sm:tw-text-2xl">
{{ title }}
</h1>
<p *ngIf="subtitle" bitTypography="body1">{{ subtitle }}</p>
</div>
<div class="tw-mb-auto tw-mx-auto tw-max-w-md tw-grid tw-gap-9">
<div class="tw-rounded-xl sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8">
<ng-content></ng-content>
</div>
<ng-content select="[slot=secondary]"></ng-content>
</div>
<footer class="tw-text-center">
<div>&copy; {{ year }} Bitwarden Inc.</div>
<div>{{ version }}</div>
</footer>
</main>

View File

@@ -0,0 +1,31 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { IconModule, Icon } from "../../../../components/src/icon";
import { TypographyModule } from "../../../../components/src/typography";
import { BitwardenLogo } from "../../icons/bitwarden-logo";
@Component({
standalone: true,
selector: "auth-anon-layout",
templateUrl: "./anon-layout.component.html",
imports: [IconModule, CommonModule, TypographyModule],
})
export class AnonLayoutComponent {
@Input() title: string;
@Input() subtitle: string;
@Input() icon: Icon;
protected logo = BitwardenLogo;
protected version: string;
protected year = "2024";
constructor(private platformUtilsService: PlatformUtilsService) {}
async ngOnInit() {
this.year = new Date().getFullYear().toString();
this.version = await this.platformUtilsService.getApplicationVersion();
}
}

View File

@@ -0,0 +1,107 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ButtonModule } from "../../../../components/src/button";
import { IconLock } from "../../icons/icon-lock";
import { AnonLayoutComponent } from "./anon-layout.component";
class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
getApplicationVersion = () => Promise.resolve("Version 2023.1.1");
}
export default {
title: "Auth/Anon Layout",
component: AnonLayoutComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule],
providers: [
{
provide: PlatformUtilsService,
useClass: MockPlatformUtilsService,
},
],
}),
],
args: {
title: "The Page Title",
subtitle: "The subtitle (optional)",
icon: IconLock,
},
} as Meta;
type Story = StoryObj<AnonLayoutComponent>;
export const WithPrimaryContent: Story = {
render: (args) => ({
props: args,
template:
/**
* The projected content (i.e. the <div> ) and styling below is just a
* sample and could be replaced with any content and styling
*/
`
<auth-anon-layout [title]="title" [subtitle]="subtitle">
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</auth-anon-layout>
`,
}),
};
export const WithSecondaryContent: Story = {
render: (args) => ({
props: args,
template:
// Notice that slot="secondary" is requred to project any secondary content:
`
<auth-anon-layout [title]="title" [subtitle]="subtitle">
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
<div slot="secondary" class="text-center">
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
<button bitButton>Perform Action</button>
</div>
</auth-anon-layout>
`,
}),
};
export const WithLongContent: Story = {
render: (args) => ({
props: args,
template: `
<auth-anon-layout title="Page Title lorem ipsum dolor consectetur sit amet expedita quod est" subtitle="Subtitle here Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?">
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam? Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit.</div>
</div>
<div slot="secondary" class="text-center">
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias laborum nostrum natus. Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias laborum nostrum natus. Expedita, quod est? </p>
<button bitButton>Perform Action</button>
</div>
</auth-anon-layout>
`,
}),
};
export const WithIcon: Story = {
render: (args) => ({
props: args,
template: `
<auth-anon-layout [title]="title" [subtitle]="subtitle" [icon]="icon">
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
</auth-anon-layout>
`,
}),
};

View File

@@ -0,0 +1,57 @@
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
export abstract class AuthRequestServiceAbstraction {
/**
* Approve or deny an auth request.
* @param approve True to approve, false to deny.
* @param authRequest The auth request to approve or deny, must have an id and key.
* @returns The updated auth request, the `requestApproved` field will be true if
* approval was successful.
* @throws If the auth request is missing an id or key.
*/
abstract approveOrDenyAuthRequest: (
approve: boolean,
authRequest: AuthRequestResponse,
) => Promise<AuthRequestResponse>;
/**
* Sets the `UserKey` from an auth request. Auth request must have a `UserKey`.
* @param authReqResponse The auth request.
* @param authReqPrivateKey The private key corresponding to the public key sent in the auth request.
*/
abstract setUserKeyAfterDecryptingSharedUserKey: (
authReqResponse: AuthRequestResponse,
authReqPrivateKey: ArrayBuffer,
) => Promise<void>;
/**
* Sets the `MasterKey` and `MasterKeyHash` from an auth request. Auth request must have a `MasterKey` and `MasterKeyHash`.
* @param authReqResponse The auth request.
* @param authReqPrivateKey The private key corresponding to the public key sent in the auth request.
*/
abstract setKeysAfterDecryptingSharedMasterKeyAndHash: (
authReqResponse: AuthRequestResponse,
authReqPrivateKey: ArrayBuffer,
) => Promise<void>;
/**
* Decrypts a `UserKey` from a public key encrypted `UserKey`.
* @param pubKeyEncryptedUserKey The public key encrypted `UserKey`.
* @param privateKey The private key corresponding to the public key used to encrypt the `UserKey`.
* @returns The decrypted `UserKey`.
*/
abstract decryptPubKeyEncryptedUserKey: (
pubKeyEncryptedUserKey: string,
privateKey: ArrayBuffer,
) => Promise<UserKey>;
/**
* Decrypts a `MasterKey` and `MasterKeyHash` from a public key encrypted `MasterKey` and `MasterKeyHash`.
* @param pubKeyEncryptedMasterKey The public key encrypted `MasterKey`.
* @param pubKeyEncryptedMasterKeyHash The public key encrypted `MasterKeyHash`.
* @param privateKey The private key corresponding to the public key used to encrypt the `MasterKey` and `MasterKeyHash`.
* @returns The decrypted `MasterKey` and `MasterKeyHash`.
*/
abstract decryptPubKeyEncryptedMasterKeyAndHash: (
pubKeyEncryptedMasterKey: string,
pubKeyEncryptedMasterKeyHash: string,
privateKey: ArrayBuffer,
) => Promise<{ masterKey: MasterKey; masterKeyHash: string }>;
}

View File

@@ -1,2 +1,3 @@
export * from "./pin-crypto.service.abstraction";
export * from "./login-strategy.service";
export * from "./auth-request.service.abstraction";

View File

@@ -2,7 +2,6 @@ import { Observable } from "rxjs";
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 { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
import { MasterKey } from "@bitwarden/common/types/key";
@@ -39,10 +38,5 @@ export abstract class LoginStrategyServiceAbstraction {
authingWithPassword: () => boolean;
authingWithPasswordless: () => boolean;
authResponsePushNotification: (notification: AuthRequestPushNotification) => Promise<any>;
passwordlessLogin: (
id: string,
key: string,
requestApproved: boolean,
) => Promise<AuthRequestResponse>;
getPushNotificationObs$: () => Observable<any>;
}

View File

@@ -1,7 +1,6 @@
import { mock, MockProxy } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
@@ -20,6 +19,7 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { DeviceKey, UserKey, MasterKey } from "@bitwarden/common/types/key";
import { AuthRequestServiceAbstraction } from "../abstractions";
import { SsoLoginCredentials } from "../models/domain/login-credentials";
import { identityTokenResponseFactory } from "./login.strategy.spec";
@@ -40,7 +40,7 @@ describe("SsoLoginStrategy", () => {
let twoFactorService: MockProxy<TwoFactorService>;
let keyConnectorService: MockProxy<KeyConnectorService>;
let deviceTrustCryptoService: MockProxy<DeviceTrustCryptoServiceAbstraction>;
let authRequestCryptoService: MockProxy<AuthRequestCryptoServiceAbstraction>;
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
let i18nService: MockProxy<I18nService>;
let ssoLoginStrategy: SsoLoginStrategy;
@@ -66,7 +66,7 @@ describe("SsoLoginStrategy", () => {
twoFactorService = mock<TwoFactorService>();
keyConnectorService = mock<KeyConnectorService>();
deviceTrustCryptoService = mock<DeviceTrustCryptoServiceAbstraction>();
authRequestCryptoService = mock<AuthRequestCryptoServiceAbstraction>();
authRequestService = mock<AuthRequestServiceAbstraction>();
i18nService = mock<I18nService>();
tokenService.getTwoFactorToken.mockResolvedValue(null);
@@ -85,7 +85,7 @@ describe("SsoLoginStrategy", () => {
twoFactorService,
keyConnectorService,
deviceTrustCryptoService,
authRequestCryptoService,
authRequestService,
i18nService,
);
credentials = new SsoLoginCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId);

View File

@@ -1,5 +1,4 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
@@ -18,6 +17,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { AuthRequestServiceAbstraction } from "../abstractions";
import { SsoLoginCredentials } from "../models/domain/login-credentials";
import { LoginStrategy } from "./login.strategy";
@@ -44,7 +44,7 @@ export class SsoLoginStrategy extends LoginStrategy {
twoFactorService: TwoFactorService,
private keyConnectorService: KeyConnectorService,
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
private authReqCryptoService: AuthRequestCryptoServiceAbstraction,
private authRequestService: AuthRequestServiceAbstraction,
private i18nService: I18nService,
) {
super(
@@ -199,14 +199,14 @@ export class SsoLoginStrategy extends LoginStrategy {
// if masterPasswordHash has a value, we will always receive authReqResponse.key
// as authRequestPublicKey(masterKey) + authRequestPublicKey(masterPasswordHash)
if (adminAuthReqResponse.masterPasswordHash) {
await this.authReqCryptoService.setKeysAfterDecryptingSharedMasterKeyAndHash(
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
adminAuthReqResponse,
adminAuthReqStorable.privateKey,
);
} else {
// if masterPasswordHash is null, we will always receive authReqResponse.key
// as authRequestPublicKey(userKey)
await this.authReqCryptoService.setUserKeyAfterDecryptingSharedUserKey(
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
adminAuthReqResponse,
adminAuthReqStorable.privateKey,
);

View File

@@ -1,32 +1,77 @@
import { mock } from "jest-mock-extended";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { Utils } from "../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { UserKey, MasterKey } from "../../types/key";
import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction";
import { AuthRequestResponse } from "../models/response/auth-request.response";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { AuthRequestCryptoServiceImplementation } from "./auth-request-crypto.service.implementation";
import { AuthRequestService } from "./auth-request.service";
describe("AuthRequestCryptoService", () => {
let authReqCryptoService: AuthRequestCryptoServiceAbstraction;
describe("AuthRequestService", () => {
let sut: AuthRequestService;
const appIdService = mock<AppIdService>();
const cryptoService = mock<CryptoService>();
const apiService = mock<ApiService>();
const stateService = mock<StateService>();
let mockPrivateKey: Uint8Array;
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
authReqCryptoService = new AuthRequestCryptoServiceImplementation(cryptoService);
sut = new AuthRequestService(appIdService, cryptoService, apiService, stateService);
mockPrivateKey = new Uint8Array(64);
});
it("instantiates", () => {
expect(authReqCryptoService).not.toBeFalsy();
});
describe("approveOrDenyAuthRequest", () => {
beforeEach(() => {
cryptoService.rsaEncrypt.mockResolvedValue({
encryptedString: "ENCRYPTED_STRING",
} as EncString);
appIdService.getAppId.mockResolvedValue("APP_ID");
});
it("should throw if auth request is missing id or key", async () => {
const authRequestNoId = new AuthRequestResponse({ id: "", key: "KEY" });
const authRequestNoPublicKey = new AuthRequestResponse({ id: "123", publicKey: "" });
await expect(sut.approveOrDenyAuthRequest(true, authRequestNoId)).rejects.toThrow(
"Auth request has no id",
);
await expect(sut.approveOrDenyAuthRequest(true, authRequestNoPublicKey)).rejects.toThrow(
"Auth request has no public key",
);
});
it("should use the master key and hash if they exist", async () => {
cryptoService.getMasterKey.mockResolvedValueOnce({ encKey: new Uint8Array(64) } as MasterKey);
stateService.getKeyHash.mockResolvedValueOnce("KEY_HASH");
await sut.approveOrDenyAuthRequest(
true,
new AuthRequestResponse({ id: "123", publicKey: "KEY" }),
);
expect(cryptoService.rsaEncrypt).toHaveBeenCalledWith(new Uint8Array(64), expect.anything());
});
it("should use the user key if the master key and hash do not exist", async () => {
cryptoService.getUserKey.mockResolvedValueOnce({ key: new Uint8Array(64) } as UserKey);
await sut.approveOrDenyAuthRequest(
true,
new AuthRequestResponse({ id: "123", publicKey: "KEY" }),
);
expect(cryptoService.rsaEncrypt).toHaveBeenCalledWith(new Uint8Array(64), expect.anything());
});
});
describe("setUserKeyAfterDecryptingSharedUserKey", () => {
it("decrypts and sets user key when given valid auth request response and private key", async () => {
// Arrange
@@ -35,20 +80,15 @@ describe("AuthRequestCryptoService", () => {
} as AuthRequestResponse;
const mockDecryptedUserKey = {} as UserKey;
jest
.spyOn(authReqCryptoService, "decryptPubKeyEncryptedUserKey")
.mockResolvedValueOnce(mockDecryptedUserKey);
jest.spyOn(sut, "decryptPubKeyEncryptedUserKey").mockResolvedValueOnce(mockDecryptedUserKey);
cryptoService.setUserKey.mockResolvedValueOnce(undefined);
// Act
await authReqCryptoService.setUserKeyAfterDecryptingSharedUserKey(
mockAuthReqResponse,
mockPrivateKey,
);
await sut.setUserKeyAfterDecryptingSharedUserKey(mockAuthReqResponse, mockPrivateKey);
// Assert
expect(authReqCryptoService.decryptPubKeyEncryptedUserKey).toBeCalledWith(
expect(sut.decryptPubKeyEncryptedUserKey).toBeCalledWith(
mockAuthReqResponse.key,
mockPrivateKey,
);
@@ -68,12 +108,10 @@ describe("AuthRequestCryptoService", () => {
const mockDecryptedMasterKeyHash = "mockDecryptedMasterKeyHash";
const mockDecryptedUserKey = {} as UserKey;
jest
.spyOn(authReqCryptoService, "decryptPubKeyEncryptedMasterKeyAndHash")
.mockResolvedValueOnce({
masterKey: mockDecryptedMasterKey,
masterKeyHash: mockDecryptedMasterKeyHash,
});
jest.spyOn(sut, "decryptPubKeyEncryptedMasterKeyAndHash").mockResolvedValueOnce({
masterKey: mockDecryptedMasterKey,
masterKeyHash: mockDecryptedMasterKeyHash,
});
cryptoService.setMasterKey.mockResolvedValueOnce(undefined);
cryptoService.setMasterKeyHash.mockResolvedValueOnce(undefined);
@@ -81,13 +119,10 @@ describe("AuthRequestCryptoService", () => {
cryptoService.setUserKey.mockResolvedValueOnce(undefined);
// Act
await authReqCryptoService.setKeysAfterDecryptingSharedMasterKeyAndHash(
mockAuthReqResponse,
mockPrivateKey,
);
await sut.setKeysAfterDecryptingSharedMasterKeyAndHash(mockAuthReqResponse, mockPrivateKey);
// Assert
expect(authReqCryptoService.decryptPubKeyEncryptedMasterKeyAndHash).toBeCalledWith(
expect(sut.decryptPubKeyEncryptedMasterKeyAndHash).toBeCalledWith(
mockAuthReqResponse.key,
mockAuthReqResponse.masterPasswordHash,
mockPrivateKey,
@@ -109,7 +144,7 @@ describe("AuthRequestCryptoService", () => {
cryptoService.rsaDecrypt.mockResolvedValueOnce(mockDecryptedUserKeyBytes);
// Act
const result = await authReqCryptoService.decryptPubKeyEncryptedUserKey(
const result = await sut.decryptPubKeyEncryptedUserKey(
mockPubKeyEncryptedUserKey,
mockPrivateKey,
);
@@ -138,7 +173,7 @@ describe("AuthRequestCryptoService", () => {
.mockResolvedValueOnce(mockDecryptedMasterKeyHashBytes);
// Act
const result = await authReqCryptoService.decryptPubKeyEncryptedMasterKeyAndHash(
const result = await sut.decryptPubKeyEncryptedMasterKeyAndHash(
mockPubKeyEncryptedMasterKey,
mockPubKeyEncryptedMasterKeyHash,
mockPrivateKey,

View File

@@ -0,0 +1,129 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PasswordlessAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { AuthRequestServiceAbstraction } from "../../abstractions/auth-request.service.abstraction";
export class AuthRequestService implements AuthRequestServiceAbstraction {
constructor(
private appIdService: AppIdService,
private cryptoService: CryptoService,
private apiService: ApiService,
private stateService: StateService,
) {}
async approveOrDenyAuthRequest(
approve: boolean,
authRequest: AuthRequestResponse,
): Promise<AuthRequestResponse> {
if (!authRequest.id) {
throw new Error("Auth request has no id");
}
if (!authRequest.publicKey) {
throw new Error("Auth request has no public key");
}
const pubKey = Utils.fromB64ToArray(authRequest.publicKey);
const masterKey = await this.cryptoService.getMasterKey();
const masterKeyHash = await this.stateService.getKeyHash();
let encryptedMasterKeyHash;
let keyToEncrypt;
if (masterKey && masterKeyHash) {
// Only encrypt the master password hash if masterKey exists as
// we won't have a masterKeyHash without a masterKey
encryptedMasterKeyHash = await this.cryptoService.rsaEncrypt(
Utils.fromUtf8ToArray(masterKeyHash),
pubKey,
);
keyToEncrypt = masterKey.encKey;
} else {
const userKey = await this.cryptoService.getUserKey();
keyToEncrypt = userKey.key;
}
const encryptedKey = await this.cryptoService.rsaEncrypt(keyToEncrypt, pubKey);
const response = new PasswordlessAuthRequest(
encryptedKey.encryptedString,
encryptedMasterKeyHash?.encryptedString,
await this.appIdService.getAppId(),
approve,
);
return await this.apiService.putAuthRequest(authRequest.id, response);
}
async setUserKeyAfterDecryptingSharedUserKey(
authReqResponse: AuthRequestResponse,
authReqPrivateKey: Uint8Array,
) {
const userKey = await this.decryptPubKeyEncryptedUserKey(
authReqResponse.key,
authReqPrivateKey,
);
await this.cryptoService.setUserKey(userKey);
}
async setKeysAfterDecryptingSharedMasterKeyAndHash(
authReqResponse: AuthRequestResponse,
authReqPrivateKey: Uint8Array,
) {
const { masterKey, masterKeyHash } = await this.decryptPubKeyEncryptedMasterKeyAndHash(
authReqResponse.key,
authReqResponse.masterPasswordHash,
authReqPrivateKey,
);
// Decrypt and set user key in state
const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey);
// Set masterKey + masterKeyHash in state after decryption (in case decryption fails)
await this.cryptoService.setMasterKey(masterKey);
await this.cryptoService.setMasterKeyHash(masterKeyHash);
await this.cryptoService.setUserKey(userKey);
}
// Decryption helpers
async decryptPubKeyEncryptedUserKey(
pubKeyEncryptedUserKey: string,
privateKey: Uint8Array,
): Promise<UserKey> {
const decryptedUserKeyBytes = await this.cryptoService.rsaDecrypt(
pubKeyEncryptedUserKey,
privateKey,
);
return new SymmetricCryptoKey(decryptedUserKeyBytes) as UserKey;
}
async decryptPubKeyEncryptedMasterKeyAndHash(
pubKeyEncryptedMasterKey: string,
pubKeyEncryptedMasterKeyHash: string,
privateKey: Uint8Array,
): Promise<{ masterKey: MasterKey; masterKeyHash: string }> {
const decryptedMasterKeyArrayBuffer = await this.cryptoService.rsaDecrypt(
pubKeyEncryptedMasterKey,
privateKey,
);
const decryptedMasterKeyHashArrayBuffer = await this.cryptoService.rsaDecrypt(
pubKeyEncryptedMasterKeyHash,
privateKey,
);
const masterKey = new SymmetricCryptoKey(decryptedMasterKeyArrayBuffer) as MasterKey;
const masterKeyHash = Utils.fromBufferToUtf8(decryptedMasterKeyHashArrayBuffer);
return {
masterKey,
masterKeyHash,
};
}
}

View File

@@ -1,2 +1,3 @@
export * from "./pin-crypto/pin-crypto.service.implementation";
export * from "./login-strategies/login-strategy.service";
export * from "./auth-request/auth-request.service";

View File

@@ -2,7 +2,6 @@ import { Observable, Subject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
@@ -11,8 +10,6 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
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 { 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";
@@ -26,11 +23,10 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { KdfType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { MasterKey } from "@bitwarden/common/types/key";
import { LoginStrategyServiceAbstraction } from "../../abstractions";
import { AuthRequestServiceAbstraction, LoginStrategyServiceAbstraction } from "../../abstractions";
import { AuthRequestLoginStrategy } from "../../login-strategies/auth-request-login.strategy";
import { PasswordLoginStrategy } from "../../login-strategies/password-login.strategy";
import { SsoLoginStrategy } from "../../login-strategies/sso-login.strategy";
@@ -110,7 +106,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
protected policyService: PolicyService,
protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
protected authReqCryptoService: AuthRequestCryptoServiceAbstraction,
protected authRequestService: AuthRequestServiceAbstraction,
) {}
async logIn(
@@ -160,7 +156,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
this.twoFactorService,
this.keyConnectorService,
this.deviceTrustCryptoService,
this.authReqCryptoService,
this.authRequestService,
this.i18nService,
);
break;
@@ -290,45 +286,6 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
return this.pushNotificationSubject.asObservable();
}
async passwordlessLogin(
id: string,
key: string,
requestApproved: boolean,
): Promise<AuthRequestResponse> {
const pubKey = Utils.fromB64ToArray(key);
const masterKey = await this.cryptoService.getMasterKey();
let keyToEncrypt;
let encryptedMasterKeyHash = null;
if (masterKey) {
keyToEncrypt = masterKey.encKey;
// Only encrypt the master password hash if masterKey exists as
// we won't have a masterKeyHash without a masterKey
const masterKeyHash = await this.stateService.getKeyHash();
if (masterKeyHash != null) {
encryptedMasterKeyHash = await this.cryptoService.rsaEncrypt(
Utils.fromUtf8ToArray(masterKeyHash),
pubKey,
);
}
} else {
const userKey = await this.cryptoService.getUserKey();
keyToEncrypt = userKey.key;
}
const encryptedKey = await this.cryptoService.rsaEncrypt(keyToEncrypt, pubKey);
const request = new PasswordlessAuthRequest(
encryptedKey.encryptedString,
encryptedMasterKeyHash?.encryptedString,
await this.appIdService.getAppId(),
requestApproved,
);
return await this.apiService.putAuthRequest(id, request);
}
private saveState(
strategy:
| UserApiLoginStrategy

View File

@@ -0,0 +1,9 @@
import { svgIcon } from "@bitwarden/components";
export const BitwardenLogo = svgIcon`
<svg viewBox="0 0 290 45" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Bitwarden</title>
<path class="tw-fill-primary-500" fill-rule="evenodd" clip-rule="evenodd" d="M69.799 10.713c3.325 0 5.911 1.248 7.811 3.848 1.9 2.549 2.85 6.033 2.85 10.453 0 4.576-.95 8.113-2.902 10.61-1.953 2.547-4.592 3.743-7.918 3.743-3.325 0-5.858-1.144-7.758-3.536h-.528l-1.003 2.444a.976.976 0 0 1-.897.572H55.23a.94.94 0 0 1-.95-.936V1.352a.94.94 0 0 1 .95-.936h5.7a.94.94 0 0 1 .95.936v8.009c0 1.144-.105 2.964-.316 5.46h.317c1.741-2.704 4.433-4.108 7.917-4.108Zm-2.428 6.084c-1.847 0-3.273.572-4.17 1.717-.844 1.144-1.32 3.068-1.32 5.668v.832c0 2.964.423 5.097 1.32 6.345.897 1.248 2.322 1.924 4.275 1.924 1.531 0 2.85-.728 3.748-2.184.897-1.404 1.372-3.537 1.372-6.189 0-2.704-.475-4.732-1.372-6.084-.95-1.352-2.27-2.029-3.853-2.029ZM93.022 38.9h-5.7a.94.94 0 0 1-.95-.936V12.221a.94.94 0 0 1 .95-.936h5.7a.94.94 0 0 1 .95.936v25.69c.053.468-.422.988-.95.988Zm20.849-5.564c1.108 0 2.428-.208 4.011-.624a.632.632 0 0 1 .792.624v4.316a.64.64 0 0 1-.37.572c-1.794.728-4.064 1.092-6.597 1.092-3.062 0-5.278-.728-6.651-2.288-1.372-1.508-2.111-3.796-2.111-6.812V16.953h-3.008c-.37 0-.634-.26-.634-.624v-2.444c0-.052.053-.104.053-.156l4.17-2.444 2.058-5.408c.106-.26.317-.417.581-.417h3.8c.369 0 .633.26.633.625v5.252h7.548c.158 0 .317.156.317.312v4.68c0 .364-.264.624-.634.624h-7.178v13.21c0 1.04.317 1.872.897 2.34.528.572 1.373.832 2.323.832Zm35.521 5.564c-.739 0-1.319-.468-1.636-1.144l-5.595-16.797c-.369-1.196-.844-3.016-1.478-5.357h-.158l-.528 1.873-1.108 3.536-5.753 16.797c-.211.676-.845 1.092-1.584 1.092a1.628 1.628 0 0 1-1.583-1.196l-7.02-24.182c-.211-.728.369-1.508 1.214-1.508h.158c.528 0 1.003.364 1.161.884l4.117 14.717c1.003 3.849 1.689 6.657 2.006 8.53h.158c.95-3.85 1.689-6.397 2.164-7.698l5.331-15.393c.211-.624.792-1.04 1.531-1.04.686 0 1.267.416 1.478 1.04l4.961 15.29c1.214 3.9 1.953 6.396 2.217 7.696h.158c.159-1.04.792-3.952 2.006-8.633l3.958-14.509c.159-.52.634-.884 1.162-.884.791 0 1.372.728 1.161 1.508l-6.651 24.182c-.211.728-.844 1.196-1.636 1.196h-.211Zm31.352 0a.962.962 0 0 1-.95-.832l-.475-3.432h-.264c-1.372 1.716-2.745 2.964-4.223 3.692-1.425.728-3.166 1.04-5.119 1.04-2.692 0-4.751-.676-6.228-2.028-1.32-1.196-2.059-2.808-2.164-4.836-.212-2.704.95-5.305 3.166-6.813 2.27-1.456 5.437-2.34 9.712-2.34l5.173-.156v-1.768c0-2.6-.528-4.473-1.637-5.773-1.108-1.3-2.744-1.924-5.067-1.924-2.216 0-4.433.52-6.756 1.612-.58.26-1.266 0-1.53-.572s0-1.248.58-1.456c2.639-1.04 5.226-1.612 7.865-1.612 3.008 0 5.225.78 6.756 2.34 1.478 1.508 2.216 3.953 2.216 7.125v16.901c-.052.312-.527.832-1.055.832Zm-10.926-1.768c2.956 0 5.226-.832 6.862-2.444 1.689-1.612 2.533-3.952 2.533-6.813v-2.6l-4.75.208c-3.853.156-6.545.78-8.234 1.768-1.636.988-2.481 2.6-2.481 4.68 0 1.665.528 3.017 1.531 3.953 1.161.78 2.639 1.248 4.539 1.248Zm31.246-25.638c.792 0 1.584.052 2.481.156a1.176 1.176 0 0 1 1.003 1.352c-.106.624-.739.988-1.372.884-.792-.104-1.584-.208-2.375-.208-2.323 0-4.223.988-5.701 2.912-1.478 1.925-2.217 4.42-2.217 7.333v13.625c0 .676-.527 1.196-1.214 1.196-.686 0-1.213-.52-1.213-1.196V13.105c0-.572.475-1.04 1.055-1.04.581 0 1.056.416 1.056.988l.211 3.848h.158c1.109-1.976 2.323-3.38 3.589-4.16 1.214-.832 2.745-1.248 4.539-1.248Zm18.579 0c1.953 0 3.695.364 5.12 1.04 1.478.676 2.745 1.924 3.853 3.64h.158a122.343 122.343 0 0 1-.158-6.084V1.612c0-.676.528-1.196 1.214-1.196.686 0 1.214.52 1.214 1.196v36.351c0 .468-.37.832-.845.832a.852.852 0 0 1-.844-.78l-.528-3.38h-.211c-2.058 3.068-5.067 4.576-8.92 4.576-3.8 0-6.598-1.144-8.656-3.484-1.953-2.34-3.008-5.668-3.008-10.089 0-4.628.95-8.165 2.955-10.66 2.006-2.237 4.856-3.485 8.656-3.485Zm0 2.236c-3.008 0-5.225 1.04-6.756 3.12-1.478 2.029-2.216 4.993-2.216 8.945 0 7.593 3.008 11.39 9.025 11.39 3.114 0 5.331-.885 6.756-2.653 1.478-1.768 2.164-4.68 2.164-8.737v-.416c0-4.16-.686-7.124-2.164-8.893-1.372-1.872-3.642-2.756-6.809-2.756Zm31.616 25.638c-3.959 0-7.02-1.196-9.289-3.64-2.217-2.392-3.326-5.772-3.326-10.089 0-4.316 1.056-7.748 3.22-10.297 2.164-2.6 5.014-3.9 8.656-3.9 3.167 0 5.753 1.092 7.548 3.276 1.9 2.184 2.797 5.2 2.797 8.997v1.976h-19.634c.052 3.692.897 6.5 2.639 8.477 1.741 1.976 4.169 2.86 7.389 2.86 1.531 0 2.956-.104 4.117-.312.844-.156 1.847-.416 3.061-.832.686-.26 1.425.26 1.425.988 0 .416-.264.832-.686.988-1.267.52-2.481.832-3.589 1.04-1.32.364-2.745.468-4.328.468Zm-.739-25.69c-2.639 0-4.75.832-6.334 2.548-1.583 1.665-2.48 4.16-2.797 7.333h16.89c0-3.068-.686-5.564-2.059-7.28-1.372-1.717-3.272-2.6-5.7-2.6ZM288.733 38.9c-.686 0-1.214-.52-1.214-1.196V21.426c0-2.704-.58-4.68-1.689-5.877-1.214-1.196-2.955-1.872-5.383-1.872-3.273 0-5.648.78-7.126 2.444-1.478 1.613-2.322 4.265-2.322 7.853V37.6c0 .676-.528 1.196-1.214 1.196-.686 0-1.214-.52-1.214-1.196V13.105c0-.624.475-1.092 1.108-1.092.581 0 1.003.416 1.109.936l.316 2.704h.159c1.794-2.808 4.908-4.212 9.448-4.212 6.175 0 9.289 3.276 9.289 9.829V37.6c-.053.727-.633 1.3-1.267 1.3ZM90.225 0c-2.48 0-4.486 1.872-4.486 4.212v.416c0 2.289 2.058 4.213 4.486 4.213s4.486-1.924 4.486-4.213v-.364C94.711 1.872 92.653 0 90.225 0Z" />
<path class="tw-fill-primary-500" d="M32.041 24.546V5.95H18.848v33.035c2.336-1.22 4.427-2.547 6.272-3.98 4.614-3.565 6.921-7.051 6.921-10.46Zm5.654-22.314v22.314c0 1.665-.329 3.317-.986 4.953-.658 1.637-1.473 3.09-2.445 4.359-.971 1.268-2.13 2.503-3.475 3.704-1.345 1.2-2.586 2.199-3.725 2.993a46.963 46.963 0 0 1-3.563 2.251c-1.237.707-2.116 1.187-2.636 1.439-.52.251-.938.445-1.252.58-.235.117-.49.175-.765.175s-.53-.058-.766-.174c-.314-.136-.731-.33-1.252-.581-.52-.252-1.398-.732-2.635-1.439a47.003 47.003 0 0 1-3.564-2.251c-1.138-.794-2.38-1.792-3.725-2.993-1.345-1.2-2.503-2.436-3.475-3.704-.972-1.27-1.787-2.722-2.444-4.359C.329 27.863 0 26.211 0 24.546V2.232c0-.504.187-.94.56-1.308A1.823 1.823 0 0 1 1.885.372H35.81c.511 0 .953.184 1.326.552.373.368.56.804.56 1.308Z" />
</svg>
`;

View File

@@ -0,0 +1,7 @@
import { svgIcon } from "@bitwarden/components";
export const IconLock = svgIcon`
<svg width="65" height="80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path class="tw-fill-primary-500" d="M36.554 52.684a4.133 4.133 0 0 0-.545-2.085 4.088 4.088 0 0 0-1.514-1.518 4.022 4.022 0 0 0-4.114.072 4.094 4.094 0 0 0-1.461 1.57 4.153 4.153 0 0 0 .175 4.16c.393.616.94 1.113 1.588 1.44v6.736a1.864 1.864 0 0 0 .498 1.365c.17.18.376.328.603.425a1.781 1.781 0 0 0 1.437 0c.227-.097.432-.242.603-.425a1.864 1.864 0 0 0 .499-1.365v-6.745a4.05 4.05 0 0 0 1.62-1.498c.392-.64.604-1.377.611-2.132ZM57.86 25.527h-2.242c-.175 0-.35-.037-.514-.105a1.3 1.3 0 0 1-.434-.297 1.379 1.379 0 0 1-.39-.963v-1a23 23 0 0 0-5.455-15.32A22.46 22.46 0 0 0 34.673.101a21.633 21.633 0 0 0-8.998 1.032 21.777 21.777 0 0 0-7.813 4.637 22.118 22.118 0 0 0-5.286 7.446 22.376 22.376 0 0 0-1.855 8.975v1.62c0 .03-.118 1.705-1.555 1.73h-2.02A6.723 6.723 0 0 0 2.37 27.56 6.887 6.887 0 0 0 .4 32.403V73.12a6.905 6.905 0 0 0 1.97 4.847A6.76 6.76 0 0 0 7.146 80h50.713a6.746 6.746 0 0 0 4.77-2.03 6.925 6.925 0 0 0 1.971-4.845V32.403a6.91 6.91 0 0 0-1.965-4.85 6.793 6.793 0 0 0-2.19-1.493 6.676 6.676 0 0 0-2.588-.53l.002-.003Zm-42.2-3.335c-.007-2.55.549-5.07 1.625-7.373a17.085 17.085 0 0 1 4.606-5.945 16.8 16.8 0 0 1 6.684-3.358 16.71 16.71 0 0 1 7.462-.115c3.835.91 7.245 3.12 9.665 6.266a17.61 17.61 0 0 1 3.64 11.02v1.475c0 .18-.035.358-.102.523a1.349 1.349 0 0 1-1.244.842H17.722a1.876 1.876 0 0 1-.744-.085 1.894 1.894 0 0 1-1.119-.957 1.98 1.98 0 0 1-.204-.728v-1.565h.005ZM59.663 73.12c0 .487-.19.952-.529 1.3a1.796 1.796 0 0 1-1.279.545H7.146a1.826 1.826 0 0 1-1.807-1.845V32.403a1.85 1.85 0 0 1 .523-1.3c.168-.17.365-.308.585-.4.22-.093.454-.14.691-.143h50.719c.479.005.938.2 1.276.545.339.345.526.81.526 1.295v40.717l.003.003Z" />
</svg>
`;

View File

@@ -13,6 +13,7 @@ import {
DerivedState,
DeriveDefinition,
DerivedStateProvider,
UserKeyDefinition,
} from "../src/platform/state";
import { UserId } from "../src/types/guid";
import { DerivedStateDependencies } from "../src/types/state";
@@ -31,7 +32,8 @@ export class FakeGlobalStateProvider implements GlobalStateProvider {
states: Map<string, GlobalState<unknown>> = new Map();
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
this.mock.get(keyDefinition);
let result = this.states.get(keyDefinition.fullName);
const cacheKey = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
let result = this.states.get(cacheKey);
if (result == null) {
let fake: FakeGlobalState<T>;
@@ -43,10 +45,10 @@ export class FakeGlobalStateProvider implements GlobalStateProvider {
}
fake.keyDefinition = keyDefinition;
result = fake;
this.states.set(keyDefinition.fullName, result);
this.states.set(cacheKey, result);
result = new FakeGlobalState<T>();
this.states.set(keyDefinition.fullName, result);
this.states.set(cacheKey, result);
}
return result as GlobalState<T>;
}
@@ -67,9 +69,16 @@ export class FakeSingleUserStateProvider implements SingleUserStateProvider {
mock = mock<SingleUserStateProvider>();
establishedMocks: Map<string, FakeSingleUserState<unknown>> = new Map();
states: Map<string, SingleUserState<unknown>> = new Map();
get<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T> {
get<T>(
userId: UserId,
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
): SingleUserState<T> {
this.mock.get(userId, keyDefinition);
let result = this.states.get(`${keyDefinition.fullName}_${userId}`);
if (keyDefinition instanceof KeyDefinition) {
keyDefinition = UserKeyDefinition.fromBaseKeyDefinition(keyDefinition);
}
const cacheKey = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}_${userId}`;
let result = this.states.get(cacheKey);
if (result == null) {
let fake: FakeSingleUserState<T>;
@@ -81,7 +90,7 @@ export class FakeSingleUserStateProvider implements SingleUserStateProvider {
}
fake.keyDefinition = keyDefinition;
result = fake;
this.states.set(`${keyDefinition.fullName}_${userId}`, result);
this.states.set(cacheKey, result);
}
return result as SingleUserState<T>;
}
@@ -108,8 +117,12 @@ export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
this.activeUserId$ = accountService.activeAccountSubject.asObservable().pipe(map((a) => a.id));
}
get<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
let result = this.states.get(keyDefinition.fullName);
get<T>(keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>): ActiveUserState<T> {
if (keyDefinition instanceof KeyDefinition) {
keyDefinition = UserKeyDefinition.fromBaseKeyDefinition(keyDefinition);
}
const cacheKey = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
let result = this.states.get(cacheKey);
if (result == null) {
// Look for established mock
@@ -119,7 +132,7 @@ export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
result = new FakeActiveUserState<T>(this.accountService);
}
result.keyDefinition = keyDefinition;
this.states.set(keyDefinition.fullName, result);
this.states.set(cacheKey, result);
}
return result as ActiveUserState<T>;
}
@@ -150,7 +163,7 @@ export class FakeStateProvider implements StateProvider {
}
async setUserState<T>(
keyDefinition: KeyDefinition<T>,
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
value: T,
userId?: UserId,
): Promise<[UserId, T]> {
@@ -162,7 +175,7 @@ export class FakeStateProvider implements StateProvider {
}
}
getActive<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
getActive<T>(keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>): ActiveUserState<T> {
return this.activeUser.get(keyDefinition);
}
@@ -170,7 +183,10 @@ export class FakeStateProvider implements StateProvider {
return this.global.get(keyDefinition);
}
getUser<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T> {
getUser<T>(
userId: UserId,
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
): SingleUserState<T> {
return this.singleUser.get(userId, keyDefinition);
}

View File

@@ -7,6 +7,7 @@ import {
ActiveUserState,
KeyDefinition,
DeriveDefinition,
UserKeyDefinition,
} from "../src/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class
import { StateUpdateOptions } from "../src/platform/state/state-update-options";
@@ -40,10 +41,10 @@ export class FakeGlobalState<T> implements GlobalState<T> {
this.stateSubject.next(initialValue ?? null);
}
update: <TCombine>(
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options?: StateUpdateOptions<T, TCombine>,
) => Promise<T> = jest.fn(async (configureState, options) => {
): Promise<T> {
options = populateOptionsWithDefault(options);
if (this.stateSubject["_buffer"].length == 0) {
// throw a more helpful not initialized error
@@ -63,9 +64,9 @@ export class FakeGlobalState<T> implements GlobalState<T> {
this.stateSubject.next(newState);
this.nextMock(newState);
return newState;
});
}
updateMock = this.update as jest.MockedFunction<typeof this.update>;
/** Tracks update values resolved by `FakeState.update` */
nextMock = jest.fn<void, [T]>();
get state$() {
@@ -126,10 +127,9 @@ export class FakeSingleUserState<T> implements SingleUserState<T> {
return newState;
}
updateMock = this.update as jest.MockedFunction<typeof this.update>;
/** Tracks update values resolved by `FakeState.update` */
nextMock = jest.fn<void, [T]>();
private _keyDefinition: KeyDefinition<T> | null = null;
private _keyDefinition: UserKeyDefinition<T> | null = null;
get keyDefinition() {
if (this._keyDefinition == null) {
throw new Error(
@@ -138,7 +138,7 @@ export class FakeSingleUserState<T> implements SingleUserState<T> {
}
return this._keyDefinition;
}
set keyDefinition(value: KeyDefinition<T>) {
set keyDefinition(value: UserKeyDefinition<T>) {
this._keyDefinition = value;
}
}
@@ -188,11 +188,10 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
return [this.userId, newState];
}
updateMock = this.update as jest.MockedFunction<typeof this.update>;
/** Tracks update values resolved by `FakeState.update` */
nextMock = jest.fn<void, [[UserId, T]]>();
private _keyDefinition: KeyDefinition<T> | null = null;
private _keyDefinition: UserKeyDefinition<T> | null = null;
get keyDefinition() {
if (this._keyDefinition == null) {
throw new Error(
@@ -201,7 +200,7 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
}
return this._keyDefinition;
}
set keyDefinition(value: KeyDefinition<T>) {
set keyDefinition(value: UserKeyDefinition<T>) {
this._keyDefinition = value;
}
}

View File

@@ -2,4 +2,5 @@ export * from "./utils";
export * from "./intercept-console";
export * from "./matchers";
export * from "./fake-state-provider";
export * from "./fake-state";
export * from "./fake-account-service";

View File

@@ -9,7 +9,6 @@ import { OrganizationTaxInfoUpdateRequest } from "../../../billing/models/reques
import { PaymentRequest } from "../../../billing/models/request/payment.request";
import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request";
import { BillingResponse } from "../../../billing/models/response/billing.response";
import { OrganizationRisksSubscriptionFailureResponse } from "../../../billing/models/response/organization-risks-subscription-failure.response";
import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response";
import { PaymentResponse } from "../../../billing/models/response/payment.response";
import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response";
@@ -79,6 +78,5 @@ export class OrganizationApiServiceAbstraction {
id: string,
request: OrganizationCollectionManagementUpdateRequest,
) => Promise<OrganizationResponse>;
risksSubscriptionFailure: (id: string) => Promise<OrganizationRisksSubscriptionFailureResponse>;
enableCollectionEnhancements: (id: string) => Promise<void>;
}

View File

@@ -37,6 +37,10 @@ export function canAccessBillingTab(org: Organization): boolean {
}
export function canAccessOrgAdmin(org: Organization): boolean {
// Admin console can only be accessed by Owners for disabled organizations
if (!org.enabled && !org.isOwner) {
return false;
}
return (
canAccessMembersTab(org) ||
canAccessGroupsTab(org) ||

View File

@@ -1,8 +1,9 @@
import { UserId } from "../../types/guid";
import { ProviderData } from "../models/data/provider.data";
import { Provider } from "../models/domain/provider";
export abstract class ProviderService {
get: (id: string) => Promise<Provider>;
getAll: () => Promise<Provider[]>;
save: (providers: { [id: string]: ProviderData }) => Promise<any>;
save: (providers: { [id: string]: ProviderData }, userId?: UserId) => Promise<any>;
}

View File

@@ -5,10 +5,14 @@ export class PolicyData {
id: string;
organizationId: string;
type: PolicyType;
data: any;
data: Record<string, string | number | boolean>;
enabled: boolean;
constructor(response: PolicyResponse) {
constructor(response?: PolicyResponse) {
if (response == null) {
return;
}
this.id = response.id;
this.organizationId = response.organizationId;
this.type = response.type;

View File

@@ -202,11 +202,11 @@ export class Organization {
return this.canEditAnyCollection;
}
// Post Flexible Collections V1, the allowAdminAccessToAllCollectionItems flag can restrict admins
// Providers are not affected by allowAdminAccessToAllCollectionItems flag
// note: canEditAnyCollection may change in the V1 to also ignore the allowAdminAccessToAllCollectionItems flag
// Providers and custom users with canEditAnyCollection are not affected by allowAdminAccessToAllCollectionItems flag
return (
this.isProviderUser ||
(this.allowAdminAccessToAllCollectionItems && this.canEditAnyCollection)
(this.type === OrganizationUserType.Custom && this.permissions.editAnyCollection) ||
(this.allowAdminAccessToAllCollectionItems && this.isAdmin)
);
}

View File

@@ -1,4 +1,5 @@
import { PaymentMethodType, PlanType } from "../../../billing/enums";
import { InitiationPath } from "../../../models/request/reference-event.request";
import { OrganizationKeysRequest } from "./organization-keys.request";
@@ -23,9 +24,9 @@ export class OrganizationCreateRequest {
billingAddressState: string;
billingAddressPostalCode: string;
billingAddressCountry: string;
useSecretsManager: boolean;
additionalSmSeats: number;
additionalServiceAccounts: number;
isFromSecretsManagerTrial: boolean;
initiationPath: InitiationPath;
}

View File

@@ -10,7 +10,6 @@ import { OrganizationTaxInfoUpdateRequest } from "../../../billing/models/reques
import { PaymentRequest } from "../../../billing/models/request/payment.request";
import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request";
import { BillingResponse } from "../../../billing/models/response/billing.response";
import { OrganizationRisksSubscriptionFailureResponse } from "../../../billing/models/response/organization-risks-subscription-failure.response";
import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response";
import { PaymentResponse } from "../../../billing/models/response/payment.response";
import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response";
@@ -344,20 +343,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
return data;
}
async risksSubscriptionFailure(
id: string,
): Promise<OrganizationRisksSubscriptionFailureResponse> {
const r = await this.apiService.send(
"GET",
"/organizations/" + id + "/risks-subscription-failure",
null,
true,
true,
);
return new OrganizationRisksSubscriptionFailureResponse(r);
}
async enableCollectionEnhancements(id: string): Promise<void> {
await this.apiService.send(
"POST",

View File

@@ -4,7 +4,6 @@ import { ApiService } from "../../../abstractions/api.service";
import { HttpStatusCode } from "../../../enums";
import { ErrorResponse } from "../../../models/response/error.response";
import { ListResponse } from "../../../models/response/list.response";
import { StateService } from "../../../platform/abstractions/state.service";
import { Utils } from "../../../platform/misc/utils";
import { PolicyApiServiceAbstraction } from "../../abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "../../abstractions/policy/policy.service.abstraction";
@@ -18,7 +17,6 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
constructor(
private policyService: InternalPolicyService,
private apiService: ApiService,
private stateService: StateService,
) {}
async getPolicy(organizationId: string, type: PolicyType): Promise<PolicyResponse> {

View File

@@ -1,8 +1,14 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { FakeActiveUserState } from "../../../../spec/fake-state";
import { OrganizationService } from "../../../admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserStatusType, PolicyType } from "../../../admin-console/enums";
import {
OrganizationUserStatusType,
OrganizationUserType,
PolicyType,
} from "../../../admin-console/enums";
import { PermissionsApi } from "../../../admin-console/models/api/permissions.api";
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
import { PolicyData } from "../../../admin-console/models/data/policy.data";
@@ -11,18 +17,20 @@ 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 { PolicyService } from "../../../admin-console/services/policy/policy.service";
import { POLICIES, PolicyService } from "../../../admin-console/services/policy/policy.service";
import { ListResponse } from "../../../models/response/list.response";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { ContainerService } from "../../../platform/services/container.service";
import { StateService } from "../../../platform/services/state.service";
import { PolicyId, UserId } from "../../../types/guid";
describe("PolicyService", () => {
let policyService: PolicyService;
let cryptoService: MockProxy<CryptoService>;
let stateService: MockProxy<StateService>;
let stateProvider: FakeStateProvider;
let organizationService: MockProxy<OrganizationService>;
let encryptService: MockProxy<EncryptService>;
let activeAccount: BehaviorSubject<string>;
@@ -30,6 +38,9 @@ describe("PolicyService", () => {
beforeEach(() => {
stateService = mock<StateService>();
const accountService = mockAccountServiceWith("userId" as UserId);
stateProvider = new FakeStateProvider(accountService);
organizationService = mock<OrganizationService>();
organizationService.getAll
.calledWith("user")
@@ -64,7 +75,7 @@ describe("PolicyService", () => {
stateService.getUserId.mockResolvedValue("user");
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
policyService = new PolicyService(stateService, organizationService);
policyService = new PolicyService(stateService, stateProvider, organizationService);
});
afterEach(() => {
@@ -378,6 +389,227 @@ describe("PolicyService", () => {
});
});
// TODO: remove this nesting once fully migrated to StateProvider
describe("stateProvider methods", () => {
let policyState$: FakeActiveUserState<Record<PolicyId, PolicyData>>;
beforeEach(() => {
policyState$ = stateProvider.activeUser.getFake(POLICIES);
organizationService.organizations$ = new BehaviorSubject([
// User
organization("org1", true, true, OrganizationUserStatusType.Confirmed, false),
// Owner
organization(
"org2",
true,
true,
OrganizationUserStatusType.Confirmed,
false,
OrganizationUserType.Owner,
),
// Does not use policies
organization("org3", true, false, OrganizationUserStatusType.Confirmed, false),
// Another User
organization("org4", true, true, OrganizationUserStatusType.Confirmed, false),
// Another User
organization("org5", true, true, OrganizationUserStatusType.Confirmed, false),
]);
});
describe("get_vNext$", () => {
it("returns the specified PolicyType", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.get_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual({
id: "policy2",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
});
});
it("does not return disabled policies", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, false),
]),
);
const result = await firstValueFrom(
policyService.get_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBeNull();
});
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, false),
]),
);
const result = await firstValueFrom(
policyService.get_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBeNull();
});
it("does not return policies for organizations that do not use policies", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org3", PolicyType.ActivateAutofill, true),
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(policyService.get_vNext$(PolicyType.ActivateAutofill));
expect(result).toBeNull();
});
});
describe("getAll_vNext$", () => {
it("returns the specified PolicyTypes", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return disabled policies", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
]),
);
const result = await firstValueFrom(
policyService.getAll_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return policies for organizations that do not use policies", async () => {
policyState$.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll_vNext$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
});
});
function policyData(
id: string,
organizationId: string,
@@ -401,6 +633,7 @@ describe("PolicyService", () => {
usePolicies: boolean,
status: OrganizationUserStatusType,
managePolicies: boolean,
type: OrganizationUserType = OrganizationUserType.User,
) {
const organizationData = new OrganizationData({} as any, {} as any);
organizationData.id = id;
@@ -408,6 +641,24 @@ describe("PolicyService", () => {
organizationData.usePolicies = usePolicies;
organizationData.status = status;
organizationData.permissions = new PermissionsApi({ managePolicies: managePolicies } as any);
organizationData.type = type;
return organizationData;
}
function organization(
id: string,
enabled: boolean,
usePolicies: boolean,
status: OrganizationUserStatusType,
managePolicies: boolean,
type: OrganizationUserType = OrganizationUserType.User,
) {
return new Organization(
organizationData(id, enabled, usePolicies, status, managePolicies, type),
);
}
function arrayToRecord(input: PolicyData[]): Record<PolicyId, PolicyData> {
return Object.fromEntries(input.map((i) => [i.id, i]));
}
});

View File

@@ -1,11 +1,13 @@
import { BehaviorSubject, concatMap, map, Observable, of } from "rxjs";
import { BehaviorSubject, combineLatest, concatMap, map, Observable, of } from "rxjs";
import { ListResponse } from "../../../models/response/list.response";
import { StateService } from "../../../platform/abstractions/state.service";
import { Utils } from "../../../platform/misc/utils";
import { KeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state";
import { PolicyId, UserId } from "../../../types/guid";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
import { InternalPolicyService as InternalPolicyServiceAbstraction } from "../../abstractions/policy/policy.service.abstraction";
import { OrganizationUserStatusType, OrganizationUserType, PolicyType } from "../../enums";
import { OrganizationUserStatusType, PolicyType } from "../../enums";
import { PolicyData } from "../../models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
import { Organization } from "../../models/domain/organization";
@@ -13,13 +15,26 @@ 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));
export const POLICIES = KeyDefinition.record<PolicyData, PolicyId>(POLICIES_DISK, "policies", {
deserializer: (policyData) => policyData,
});
export class PolicyService implements InternalPolicyServiceAbstraction {
protected _policies: BehaviorSubject<Policy[]> = new BehaviorSubject([]);
policies$ = this._policies.asObservable();
private activeUserPolicyState = this.stateProvider.getActive(POLICIES);
activeUserPolicies$ = this.activeUserPolicyState.state$.pipe(
map((policyData) => policyRecordToArray(policyData)),
);
constructor(
protected stateService: StateService,
private stateProvider: StateProvider,
private organizationService: OrganizationService,
) {
this.stateService.activeAccountUnlocked$
@@ -42,6 +57,56 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
.subscribe();
}
// --- StateProvider methods - not yet wired up
get_vNext$(policyType: PolicyType) {
const filteredPolicies$ = this.activeUserPolicies$.pipe(
map((policies) => policies.filter((p) => p.type === policyType)),
);
return combineLatest([filteredPolicies$, this.organizationService.organizations$]).pipe(
map(
([policies, organizations]) =>
this.enforcedPolicyFilter(policies, organizations)?.at(0) ?? null,
),
);
}
getAll_vNext$(policyType: PolicyType, userId?: UserId) {
const filteredPolicies$ = this.stateProvider.getUserState$(POLICIES, userId).pipe(
map((policyData) => policyRecordToArray(policyData)),
map((policies) => policies.filter((p) => p.type === policyType)),
);
return combineLatest([filteredPolicies$, this.organizationService.organizations$]).pipe(
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
);
}
policyAppliesToActiveUser_vNext$(policyType: PolicyType) {
return this.get_vNext$(policyType).pipe(map((policy) => policy != null));
}
private enforcedPolicyFilter(policies: Policy[], organizations: Organization[]) {
const orgDict = Object.fromEntries(organizations.map((o) => [o.id, o]));
return policies.filter((policy) => {
const organization = orgDict[policy.organizationId];
// This shouldn't happen, i.e. the user should only have policies for orgs they are a member of
// But if it does, err on the side of enforcing the policy
if (organization == null) {
return true;
}
return (
policy.enabled &&
organization.status >= OrganizationUserStatusType.Accepted &&
organization.usePolicies &&
!this.isExemptFromPolicy(policy.type, organization)
);
});
}
// --- End StateProvider methods
get$(policyType: PolicyType, policyFilter?: (policy: Policy) => boolean): Observable<Policy> {
return this.policies$.pipe(
concatMap(async (policies) => {
@@ -260,14 +325,6 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
await this.stateService.setEncryptedPolicies(null, { userId: userId });
}
private isExemptFromPolicies(organization: Organization, policyType: PolicyType) {
if (policyType === PolicyType.MaximumVaultTimeout) {
return organization.type === OrganizationUserType.Owner;
}
return organization.isExemptFromPolicies;
}
private async updateObservables(policiesMap: { [id: string]: PolicyData }) {
const policies = Object.values(policiesMap || {}).map((f) => new Policy(f));
@@ -291,7 +348,21 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
o.status >= OrganizationUserStatusType.Accepted &&
o.usePolicies &&
policySet.has(o.id) &&
!this.isExemptFromPolicies(o, policyType),
!this.isExemptFromPolicy(policyType, o),
);
}
/**
* Determines whether an orgUser is exempt from a specific policy because of their role
* Generally orgUsers who can manage policies are exempt from them, but some policies are stricter
*/
private isExemptFromPolicy(policyType: PolicyType, organization: Organization) {
switch (policyType) {
case PolicyType.MaximumVaultTimeout:
// Max Vault Timeout applies to everyone except owners
return organization.isOwner;
default:
return organization.canManagePolicies;
}
}
}

View File

@@ -1,7 +1,56 @@
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
import { FakeActiveUserState } from "../../../spec/fake-state";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
import { ProviderUserStatusType, ProviderUserType } from "../enums";
import { ProviderData } from "../models/data/provider.data";
import { Provider } from "../models/domain/provider";
import { PROVIDERS } from "./provider.service";
import { PROVIDERS, ProviderService } from "./provider.service";
/**
* It is easier to read arrays than records in code, but we store a record
* in state. This helper methods lets us build provider arrays in tests
* and easily map them to records before storing them in state.
*/
function arrayToRecord(input: ProviderData[]): Record<string, ProviderData> {
if (input == null) {
return undefined;
}
return Object.fromEntries(input?.map((i) => [i.id, i]));
}
/**
* Builds a simple mock `ProviderData[]` 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 provider.
* This defaults to the index of the organization in the list.
* @returns a `ProviderData[]` array that can be used to populate
* stateProvider.
*/
function buildMockProviders(count = 1, suffix?: string): ProviderData[] {
if (count < 1) {
return undefined;
}
function buildMockProvider(id: string, name: string): ProviderData {
const data = new ProviderData({} as any);
data.id = id;
data.name = name;
return data;
}
const mockProviders = [];
for (let i = 0; i < count; i++) {
const s = suffix ? suffix + i.toString() : i.toString();
mockProviders.push(buildMockProvider("provider" + s, "provider" + s));
}
return mockProviders;
}
describe("PROVIDERS key definition", () => {
const sut = PROVIDERS;
@@ -21,3 +70,75 @@ describe("PROVIDERS key definition", () => {
expect(result).toEqual(expectedResult);
});
});
describe("ProviderService", () => {
let providerService: ProviderService;
const fakeUserId = Utils.newGuid() as UserId;
let fakeAccountService: FakeAccountService;
let fakeStateProvider: FakeStateProvider;
let fakeActiveUserState: FakeActiveUserState<Record<string, ProviderData>>;
beforeEach(async () => {
fakeAccountService = mockAccountServiceWith(fakeUserId);
fakeStateProvider = new FakeStateProvider(fakeAccountService);
fakeActiveUserState = fakeStateProvider.activeUser.getFake(PROVIDERS);
providerService = new ProviderService(fakeStateProvider);
});
describe("getAll()", () => {
it("Returns an array of all providers stored in state", async () => {
const mockData: ProviderData[] = buildMockProviders(5);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const providers = await providerService.getAll();
expect(providers).toHaveLength(5);
expect(providers).toEqual(mockData.map((x) => new Provider(x)));
});
it("Returns an empty array if no providers are found in state", async () => {
const mockData: ProviderData[] = undefined;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await providerService.getAll();
expect(result).toEqual([]);
});
});
describe("get()", () => {
it("Returns a single provider from state that matches the specified id", async () => {
const mockData = buildMockProviders(5);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await providerService.get(mockData[3].id);
expect(result).toEqual(new Provider(mockData[3]));
});
it("Returns undefined if the specified provider id is not found", async () => {
const result = await providerService.get("this-provider-does-not-exist");
expect(result).toBe(undefined);
});
});
describe("save()", () => {
it("replaces the entire provider list in state for the active user", async () => {
const originalData = buildMockProviders(10);
fakeActiveUserState.nextState(arrayToRecord(originalData));
const newData = buildMockProviders(10, "newData");
await providerService.save(arrayToRecord(newData));
const result = await providerService.getAll();
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 = buildMockProviders(2);
fakeActiveUserState.nextState(arrayToRecord(originalData));
await providerService.save(null);
const result = await providerService.getAll();
expect(result).toEqual([]);
expect(result).not.toEqual(originalData);
});
});
});

View File

@@ -1,5 +1,7 @@
import { StateService } from "../../platform/abstractions/state.service";
import { KeyDefinition, PROVIDERS_DISK } from "../../platform/state";
import { Observable, map, firstValueFrom } from "rxjs";
import { KeyDefinition, PROVIDERS_DISK, StateProvider } from "../../platform/state";
import { UserId } from "../../types/guid";
import { ProviderService as ProviderServiceAbstraction } from "../abstractions/provider.service";
import { ProviderData } from "../models/data/provider.data";
import { Provider } from "../models/domain/provider";
@@ -8,32 +10,34 @@ export const PROVIDERS = KeyDefinition.record<ProviderData>(PROVIDERS_DISK, "pro
deserializer: (obj: ProviderData) => obj,
});
function mapToSingleProvider(providerId: string) {
return map<Provider[], Provider>((providers) => providers?.find((p) => p.id === providerId));
}
export class ProviderService implements ProviderServiceAbstraction {
constructor(private stateService: StateService) {}
constructor(private stateProvider: StateProvider) {}
private providers$(userId?: UserId): Observable<Provider[] | undefined> {
return this.stateProvider
.getUserState$(PROVIDERS, userId)
.pipe(this.mapProviderRecordToArray());
}
private mapProviderRecordToArray() {
return map<Record<string, ProviderData>, Provider[]>((providers) =>
Object.values(providers ?? {})?.map((o) => new Provider(o)),
);
}
async get(id: string): Promise<Provider> {
const providers = await this.stateService.getProviders();
// eslint-disable-next-line
if (providers == null || !providers.hasOwnProperty(id)) {
return null;
}
return new Provider(providers[id]);
return await firstValueFrom(this.providers$().pipe(mapToSingleProvider(id)));
}
async getAll(): Promise<Provider[]> {
const providers = await this.stateService.getProviders();
const response: Provider[] = [];
for (const id in providers) {
// eslint-disable-next-line
if (providers.hasOwnProperty(id)) {
response.push(new Provider(providers[id]));
}
}
return response;
return await firstValueFrom(this.providers$());
}
async save(providers: { [id: string]: ProviderData }) {
await this.stateService.setProviders(providers);
async save(providers: { [id: string]: ProviderData }, userId?: UserId) {
await this.stateProvider.setUserState(PROVIDERS, providers, userId);
}
}

View File

@@ -1,24 +0,0 @@
import { UserKey, MasterKey } from "../../types/key";
import { AuthRequestResponse } from "../models/response/auth-request.response";
export abstract class AuthRequestCryptoServiceAbstraction {
setUserKeyAfterDecryptingSharedUserKey: (
authReqResponse: AuthRequestResponse,
authReqPrivateKey: ArrayBuffer,
) => Promise<void>;
setKeysAfterDecryptingSharedMasterKeyAndHash: (
authReqResponse: AuthRequestResponse,
authReqPrivateKey: ArrayBuffer,
) => Promise<void>;
decryptPubKeyEncryptedUserKey: (
pubKeyEncryptedUserKey: string,
privateKey: ArrayBuffer,
) => Promise<UserKey>;
decryptPubKeyEncryptedMasterKeyAndHash: (
pubKeyEncryptedMasterKey: string,
pubKeyEncryptedMasterKeyHash: string,
privateKey: ArrayBuffer,
) => Promise<{ masterKey: MasterKey; masterKeyHash: string }>;
}

View File

@@ -1,78 +0,0 @@
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { Utils } from "../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { UserKey, MasterKey } from "../../types/key";
import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction";
import { AuthRequestResponse } from "../models/response/auth-request.response";
export class AuthRequestCryptoServiceImplementation implements AuthRequestCryptoServiceAbstraction {
constructor(private cryptoService: CryptoService) {}
async setUserKeyAfterDecryptingSharedUserKey(
authReqResponse: AuthRequestResponse,
authReqPrivateKey: Uint8Array,
) {
const userKey = await this.decryptPubKeyEncryptedUserKey(
authReqResponse.key,
authReqPrivateKey,
);
await this.cryptoService.setUserKey(userKey);
}
async setKeysAfterDecryptingSharedMasterKeyAndHash(
authReqResponse: AuthRequestResponse,
authReqPrivateKey: Uint8Array,
) {
const { masterKey, masterKeyHash } = await this.decryptPubKeyEncryptedMasterKeyAndHash(
authReqResponse.key,
authReqResponse.masterPasswordHash,
authReqPrivateKey,
);
// Decrypt and set user key in state
const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey);
// Set masterKey + masterKeyHash in state after decryption (in case decryption fails)
await this.cryptoService.setMasterKey(masterKey);
await this.cryptoService.setMasterKeyHash(masterKeyHash);
await this.cryptoService.setUserKey(userKey);
}
// Decryption helpers
async decryptPubKeyEncryptedUserKey(
pubKeyEncryptedUserKey: string,
privateKey: Uint8Array,
): Promise<UserKey> {
const decryptedUserKeyBytes = await this.cryptoService.rsaDecrypt(
pubKeyEncryptedUserKey,
privateKey,
);
return new SymmetricCryptoKey(decryptedUserKeyBytes) as UserKey;
}
async decryptPubKeyEncryptedMasterKeyAndHash(
pubKeyEncryptedMasterKey: string,
pubKeyEncryptedMasterKeyHash: string,
privateKey: Uint8Array,
): Promise<{ masterKey: MasterKey; masterKeyHash: string }> {
const decryptedMasterKeyArrayBuffer = await this.cryptoService.rsaDecrypt(
pubKeyEncryptedMasterKey,
privateKey,
);
const decryptedMasterKeyHashArrayBuffer = await this.cryptoService.rsaDecrypt(
pubKeyEncryptedMasterKeyHash,
privateKey,
);
const masterKey = new SymmetricCryptoKey(decryptedMasterKeyArrayBuffer) as MasterKey;
const masterKeyHash = Utils.fromBufferToUtf8(decryptedMasterKeyHashArrayBuffer);
return {
masterKey,
masterKeyHash,
};
}
}

View File

@@ -5,11 +5,11 @@ import { CryptoFunctionService } from "../../platform/abstractions/crypto-functi
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { EncString } from "../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../types/csprng";
import { UserKey, DeviceKey } from "../../types/key";
import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
@@ -22,6 +22,7 @@ import {
export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstraction {
constructor(
private keyGenerationService: KeyGenerationService,
private cryptoFunctionService: CryptoFunctionService,
private cryptoService: CryptoService,
private encryptService: EncryptService,
@@ -165,10 +166,7 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
private async makeDeviceKey(): Promise<DeviceKey> {
// Create 512-bit device key
const randomBytes: CsprngArray = await this.cryptoFunctionService.aesGenerateKey(512);
const deviceKey = new SymmetricCryptoKey(randomBytes) as DeviceKey;
return deviceKey;
return (await this.keyGenerationService.createKey(512)) as DeviceKey;
}
async decryptUserKeyWithDeviceKey(

View File

@@ -7,6 +7,7 @@ import { CryptoFunctionService } from "../../platform/abstractions/crypto-functi
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { EncryptionType } from "../../platform/enums/encryption-type.enum";
@@ -24,6 +25,7 @@ import { DeviceTrustCryptoService } from "./device-trust-crypto.service.implemen
describe("deviceTrustCryptoService", () => {
let deviceTrustCryptoService: DeviceTrustCryptoService;
const keyGenerationService = mock<KeyGenerationService>();
const cryptoFunctionService = mock<CryptoFunctionService>();
const cryptoService = mock<CryptoService>();
const encryptService = mock<EncryptService>();
@@ -37,6 +39,7 @@ describe("deviceTrustCryptoService", () => {
jest.clearAllMocks();
deviceTrustCryptoService = new DeviceTrustCryptoService(
keyGenerationService,
cryptoFunctionService,
cryptoService,
encryptService,
@@ -166,17 +169,18 @@ describe("deviceTrustCryptoService", () => {
describe("makeDeviceKey", () => {
it("creates a new non-null 64 byte device key, securely stores it, and returns it", async () => {
const mockRandomBytes = new Uint8Array(deviceKeyBytesLength) as CsprngArray;
const mockDeviceKey = new SymmetricCryptoKey(mockRandomBytes) as DeviceKey;
const cryptoFuncSvcGenerateKeySpy = jest
.spyOn(cryptoFunctionService, "aesGenerateKey")
.mockResolvedValue(mockRandomBytes);
const keyGenSvcGenerateKeySpy = jest
.spyOn(keyGenerationService, "createKey")
.mockResolvedValue(mockDeviceKey);
// TypeScript will allow calling private methods if the object is of type 'any'
// This is a hacky workaround, but it allows for cleaner tests
const deviceKey = await (deviceTrustCryptoService as any).makeDeviceKey();
expect(cryptoFuncSvcGenerateKeySpy).toHaveBeenCalledTimes(1);
expect(cryptoFuncSvcGenerateKeySpy).toHaveBeenCalledWith(deviceKeyBytesLength * 8);
expect(keyGenSvcGenerateKeySpy).toHaveBeenCalledTimes(1);
expect(keyGenSvcGenerateKeySpy).toHaveBeenCalledWith(deviceKeyBytesLength * 8);
expect(deviceKey).not.toBeNull();
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);

View File

@@ -2,8 +2,8 @@ import { ApiService } from "../../abstractions/api.service";
import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserType } from "../../admin-console/enums";
import { KeysRequest } from "../../models/request/keys.request";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { LogService } from "../../platform/abstractions/log.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
@@ -24,7 +24,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
private tokenService: TokenService,
private logService: LogService,
private organizationService: OrganizationService,
private cryptoFunctionService: CryptoFunctionService,
private keyGenerationService: KeyGenerationService,
private logoutCallback: (expired: boolean, userId?: string) => Promise<void>,
) {}
@@ -94,11 +94,11 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
keyConnectorUrl: legacyKeyConnectorUrl,
userDecryptionOptions,
} = tokenResponse;
const password = await this.cryptoFunctionService.aesGenerateKey(512);
const password = await this.keyGenerationService.createKey(512);
const kdfConfig = new KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
const masterKey = await this.cryptoService.makeMasterKey(
Utils.fromBufferToB64(password),
password.keyB64,
await this.tokenService.getEmail(),
kdf,
kdfConfig,

View File

@@ -0,0 +1,58 @@
export const TYPE_CHECK = {
FUNCTION: "function",
NUMBER: "number",
STRING: "string",
} as const;
export const EVENTS = {
CHANGE: "change",
INPUT: "input",
KEYDOWN: "keydown",
KEYPRESS: "keypress",
KEYUP: "keyup",
BLUR: "blur",
CLICK: "click",
FOCUS: "focus",
SCROLL: "scroll",
RESIZE: "resize",
DOMCONTENTLOADED: "DOMContentLoaded",
LOAD: "load",
MESSAGE: "message",
VISIBILITYCHANGE: "visibilitychange",
FOCUSOUT: "focusout",
} as const;
export const ClearClipboardDelay = {
Never: null as null,
TenSeconds: 10,
TwentySeconds: 20,
ThirtySeconds: 30,
OneMinute: 60,
TwoMinutes: 120,
FiveMinutes: 300,
} as const;
/* Context Menu item Ids */
export const AUTOFILL_CARD_ID = "autofill-card";
export const AUTOFILL_ID = "autofill";
export const SHOW_AUTOFILL_BUTTON = "show-autofill-button";
export const AUTOFILL_IDENTITY_ID = "autofill-identity";
export const COPY_IDENTIFIER_ID = "copy-identifier";
export const COPY_PASSWORD_ID = "copy-password";
export const COPY_USERNAME_ID = "copy-username";
export const COPY_VERIFICATION_CODE_ID = "copy-totp";
export const CREATE_CARD_ID = "create-card";
export const CREATE_IDENTITY_ID = "create-identity";
export const CREATE_LOGIN_ID = "create-login";
export const GENERATE_PASSWORD_ID = "generate-password";
export const NOOP_COMMAND_SUFFIX = "noop";
export const ROOT_ID = "root";
export const SEPARATOR_ID = "separator";
export const NOTIFICATION_BAR_LIFESPAN_MS = 150000; // 150 seconds
export const AutofillOverlayVisibility = {
Off: 0,
OnButtonClick: 1,
OnFieldFocus: 2,
} as const;

View File

@@ -1,12 +1,7 @@
import { filter, switchMap, tap, firstValueFrom, map, Observable } from "rxjs";
import { map, Observable } from "rxjs";
import {
AutofillOverlayVisibility,
InlineMenuVisibilitySetting,
} from "../../../../../apps/browser/src/autofill/utils/autofill-overlay.enum";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../admin-console/enums/index";
import { Policy } from "../../admin-console/models/domain/policy";
import { PolicyType } from "../../admin-console/enums";
import {
AUTOFILL_SETTINGS_DISK,
AUTOFILL_SETTINGS_DISK_LOCAL,
@@ -15,6 +10,8 @@ import {
KeyDefinition,
StateProvider,
} from "../../platform/state";
import { ClearClipboardDelay, AutofillOverlayVisibility } from "../constants";
import { ClearClipboardDelaySetting, InlineMenuVisibilitySetting } from "../types";
const AUTOFILL_ON_PAGE_LOAD = new KeyDefinition(AUTOFILL_SETTINGS_DISK, "autofillOnPageLoad", {
deserializer: (value: boolean) => value ?? false,
@@ -28,10 +25,6 @@ const AUTOFILL_ON_PAGE_LOAD_DEFAULT = new KeyDefinition(
},
);
const AUTO_COPY_TOTP = new KeyDefinition(AUTOFILL_SETTINGS_DISK, "autoCopyTotp", {
deserializer: (value: boolean) => value ?? false,
});
const AUTOFILL_ON_PAGE_LOAD_CALLOUT_DISMISSED = new KeyDefinition(
AUTOFILL_SETTINGS_DISK,
"autofillOnPageLoadCalloutIsDismissed",
@@ -40,14 +33,18 @@ const AUTOFILL_ON_PAGE_LOAD_CALLOUT_DISMISSED = new KeyDefinition(
},
);
const ACTIVATE_AUTOFILL_ON_PAGE_LOAD_FROM_POLICY = new KeyDefinition(
AUTOFILL_SETTINGS_DISK_LOCAL,
"activateAutofillOnPageLoadFromPolicy",
const AUTOFILL_ON_PAGE_LOAD_POLICY_TOAST_HAS_DISPLAYED = new KeyDefinition(
AUTOFILL_SETTINGS_DISK,
"autofillOnPageLoadPolicyToastHasDisplayed",
{
deserializer: (value: boolean) => value ?? false,
},
);
const AUTO_COPY_TOTP = new KeyDefinition(AUTOFILL_SETTINGS_DISK, "autoCopyTotp", {
deserializer: (value: boolean) => value ?? false,
});
const INLINE_MENU_VISIBILITY = new KeyDefinition(
AUTOFILL_SETTINGS_DISK_LOCAL,
"inlineMenuVisibility",
@@ -56,20 +53,30 @@ const INLINE_MENU_VISIBILITY = new KeyDefinition(
},
);
const CLEAR_CLIPBOARD_DELAY = new KeyDefinition(
AUTOFILL_SETTINGS_DISK_LOCAL,
"clearClipboardDelay",
{
deserializer: (value: ClearClipboardDelaySetting) => value ?? ClearClipboardDelay.Never,
},
);
export abstract class AutofillSettingsServiceAbstraction {
autofillOnPageLoad$: Observable<boolean>;
setAutofillOnPageLoad: (newValue: boolean) => Promise<void>;
autofillOnPageLoadDefault$: Observable<boolean>;
setAutofillOnPageLoadDefault: (newValue: boolean) => Promise<void>;
autoCopyTotp$: Observable<boolean>;
setAutoCopyTotp: (newValue: boolean) => Promise<void>;
autofillOnPageLoadCalloutIsDismissed$: Observable<boolean>;
setAutofillOnPageLoadCalloutIsDismissed: (newValue: boolean) => Promise<void>;
activateAutofillOnPageLoadFromPolicy$: Observable<boolean>;
setActivateAutofillOnPageLoadFromPolicy: (newValue: boolean) => Promise<void>;
setAutofillOnPageLoadPolicyToastHasDisplayed: (newValue: boolean) => Promise<void>;
autofillOnPageLoadPolicyToastHasDisplayed$: Observable<boolean>;
autoCopyTotp$: Observable<boolean>;
setAutoCopyTotp: (newValue: boolean) => Promise<void>;
inlineMenuVisibility$: Observable<InlineMenuVisibilitySetting>;
setInlineMenuVisibility: (newValue: InlineMenuVisibilitySetting) => Promise<void>;
handleActivateAutofillPolicy: (policies: Observable<Policy[]>) => Observable<boolean[]>;
clearClipboardDelay$: Observable<ClearClipboardDelaySetting>;
setClearClipboardDelay: (newValue: ClearClipboardDelaySetting) => Promise<void>;
}
export class AutofillSettingsService implements AutofillSettingsServiceAbstraction {
@@ -79,21 +86,26 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
private autofillOnPageLoadDefaultState: ActiveUserState<boolean>;
readonly autofillOnPageLoadDefault$: Observable<boolean>;
private autoCopyTotpState: ActiveUserState<boolean>;
readonly autoCopyTotp$: Observable<boolean>;
private autofillOnPageLoadCalloutIsDismissedState: ActiveUserState<boolean>;
readonly autofillOnPageLoadCalloutIsDismissed$: Observable<boolean>;
private activateAutofillOnPageLoadFromPolicyState: ActiveUserState<boolean>;
readonly activateAutofillOnPageLoadFromPolicy$: Observable<boolean>;
private autofillOnPageLoadPolicyToastHasDisplayedState: ActiveUserState<boolean>;
readonly autofillOnPageLoadPolicyToastHasDisplayed$: Observable<boolean>;
private autoCopyTotpState: ActiveUserState<boolean>;
readonly autoCopyTotp$: Observable<boolean>;
private inlineMenuVisibilityState: GlobalState<InlineMenuVisibilitySetting>;
readonly inlineMenuVisibility$: Observable<InlineMenuVisibilitySetting>;
private clearClipboardDelayState: ActiveUserState<ClearClipboardDelaySetting>;
readonly clearClipboardDelay$: Observable<ClearClipboardDelaySetting>;
constructor(
private stateProvider: StateProvider,
policyService: PolicyService,
private policyService: PolicyService,
) {
this.autofillOnPageLoadState = this.stateProvider.getActive(AUTOFILL_ON_PAGE_LOAD);
this.autofillOnPageLoad$ = this.autofillOnPageLoadState.state$.pipe(map((x) => x ?? false));
@@ -105,27 +117,35 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
map((x) => x ?? true),
);
this.autoCopyTotpState = this.stateProvider.getActive(AUTO_COPY_TOTP);
this.autoCopyTotp$ = this.autoCopyTotpState.state$.pipe(map((x) => x ?? false));
this.autofillOnPageLoadCalloutIsDismissedState = this.stateProvider.getActive(
AUTOFILL_ON_PAGE_LOAD_CALLOUT_DISMISSED,
);
this.autofillOnPageLoadCalloutIsDismissed$ =
this.autofillOnPageLoadCalloutIsDismissedState.state$.pipe(map((x) => x ?? false));
this.activateAutofillOnPageLoadFromPolicyState = this.stateProvider.getActive(
ACTIVATE_AUTOFILL_ON_PAGE_LOAD_FROM_POLICY,
this.activateAutofillOnPageLoadFromPolicy$ = this.policyService.policyAppliesToActiveUser$(
PolicyType.ActivateAutofill,
);
this.activateAutofillOnPageLoadFromPolicy$ =
this.activateAutofillOnPageLoadFromPolicyState.state$.pipe(map((x) => x ?? false));
this.autofillOnPageLoadPolicyToastHasDisplayedState = this.stateProvider.getActive(
AUTOFILL_ON_PAGE_LOAD_POLICY_TOAST_HAS_DISPLAYED,
);
this.autofillOnPageLoadPolicyToastHasDisplayed$ = this.autofillOnPageLoadState.state$.pipe(
map((x) => x ?? false),
);
this.autoCopyTotpState = this.stateProvider.getActive(AUTO_COPY_TOTP);
this.autoCopyTotp$ = this.autoCopyTotpState.state$.pipe(map((x) => x ?? false));
this.inlineMenuVisibilityState = this.stateProvider.getGlobal(INLINE_MENU_VISIBILITY);
this.inlineMenuVisibility$ = this.inlineMenuVisibilityState.state$.pipe(
map((x) => x ?? AutofillOverlayVisibility.Off),
);
policyService.policies$.pipe(this.handleActivateAutofillPolicy.bind(this)).subscribe();
this.clearClipboardDelayState = this.stateProvider.getActive(CLEAR_CLIPBOARD_DELAY);
this.clearClipboardDelay$ = this.clearClipboardDelayState.state$.pipe(
map((x) => x ?? ClearClipboardDelay.Never),
);
}
async setAutofillOnPageLoad(newValue: boolean): Promise<void> {
@@ -136,39 +156,23 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
await this.autofillOnPageLoadDefaultState.update(() => newValue);
}
async setAutoCopyTotp(newValue: boolean): Promise<void> {
await this.autoCopyTotpState.update(() => newValue);
}
async setAutofillOnPageLoadCalloutIsDismissed(newValue: boolean): Promise<void> {
await this.autofillOnPageLoadCalloutIsDismissedState.update(() => newValue);
}
async setActivateAutofillOnPageLoadFromPolicy(newValue: boolean): Promise<void> {
await this.activateAutofillOnPageLoadFromPolicyState.update(() => newValue);
async setAutofillOnPageLoadPolicyToastHasDisplayed(newValue: boolean): Promise<void> {
await this.autofillOnPageLoadPolicyToastHasDisplayedState.update(() => newValue);
}
async setAutoCopyTotp(newValue: boolean): Promise<void> {
await this.autoCopyTotpState.update(() => newValue);
}
async setInlineMenuVisibility(newValue: InlineMenuVisibilitySetting): Promise<void> {
await this.inlineMenuVisibilityState.update(() => newValue);
}
/**
* If the ActivateAutofill policy is enabled, save a flag indicating if we need to
* enable Autofill on page load.
*/
handleActivateAutofillPolicy(policies$: Observable<Policy[]>): Observable<boolean[]> {
return policies$.pipe(
map((policies) => policies.find((p) => p.type == PolicyType.ActivateAutofill && p.enabled)),
filter((p) => p != null),
switchMap(async (_) => [
await firstValueFrom(this.activateAutofillOnPageLoadFromPolicy$),
await firstValueFrom(this.autofillOnPageLoad$),
]),
tap(([activated, autofillEnabled]) => {
if (activated === undefined) {
void this.setActivateAutofillOnPageLoadFromPolicy(!autofillEnabled);
}
}),
);
async setClearClipboardDelay(newValue: ClearClipboardDelaySetting): Promise<void> {
await this.clearClipboardDelayState.update(() => newValue);
}
}

View File

@@ -0,0 +1,31 @@
import { map, Observable } from "rxjs";
import {
BADGE_SETTINGS_DISK,
ActiveUserState,
KeyDefinition,
StateProvider,
} from "../../platform/state";
const ENABLE_BADGE_COUNTER = new KeyDefinition(BADGE_SETTINGS_DISK, "enableBadgeCounter", {
deserializer: (value: boolean) => value ?? true,
});
export abstract class BadgeSettingsServiceAbstraction {
enableBadgeCounter$: Observable<boolean>;
setEnableBadgeCounter: (newValue: boolean) => Promise<void>;
}
export class BadgeSettingsService implements BadgeSettingsServiceAbstraction {
private enableBadgeCounterState: ActiveUserState<boolean>;
readonly enableBadgeCounter$: Observable<boolean>;
constructor(private stateProvider: StateProvider) {
this.enableBadgeCounterState = this.stateProvider.getActive(ENABLE_BADGE_COUNTER);
this.enableBadgeCounter$ = this.enableBadgeCounterState.state$.pipe(map((x) => x ?? true));
}
async setEnableBadgeCounter(newValue: boolean): Promise<void> {
await this.enableBadgeCounterState.update(() => newValue);
}
}

View File

@@ -0,0 +1,60 @@
import { map, Observable } from "rxjs";
import {
USER_NOTIFICATION_SETTINGS_DISK,
GlobalState,
KeyDefinition,
StateProvider,
} from "../../platform/state";
const ENABLE_ADDED_LOGIN_PROMPT = new KeyDefinition(
USER_NOTIFICATION_SETTINGS_DISK,
"enableAddedLoginPrompt",
{
deserializer: (value: boolean) => value ?? true,
},
);
const ENABLE_CHANGED_PASSWORD_PROMPT = new KeyDefinition(
USER_NOTIFICATION_SETTINGS_DISK,
"enableChangedPasswordPrompt",
{
deserializer: (value: boolean) => value ?? true,
},
);
export abstract class UserNotificationSettingsServiceAbstraction {
enableAddedLoginPrompt$: Observable<boolean>;
setEnableAddedLoginPrompt: (newValue: boolean) => Promise<void>;
enableChangedPasswordPrompt$: Observable<boolean>;
setEnableChangedPasswordPrompt: (newValue: boolean) => Promise<void>;
}
export class UserNotificationSettingsService implements UserNotificationSettingsServiceAbstraction {
private enableAddedLoginPromptState: GlobalState<boolean>;
readonly enableAddedLoginPrompt$: Observable<boolean>;
private enableChangedPasswordPromptState: GlobalState<boolean>;
readonly enableChangedPasswordPrompt$: Observable<boolean>;
constructor(private stateProvider: StateProvider) {
this.enableAddedLoginPromptState = this.stateProvider.getGlobal(ENABLE_ADDED_LOGIN_PROMPT);
this.enableAddedLoginPrompt$ = this.enableAddedLoginPromptState.state$.pipe(
map((x) => x ?? true),
);
this.enableChangedPasswordPromptState = this.stateProvider.getGlobal(
ENABLE_CHANGED_PASSWORD_PROMPT,
);
this.enableChangedPasswordPrompt$ = this.enableChangedPasswordPromptState.state$.pipe(
map((x) => x ?? true),
);
}
async setEnableAddedLoginPrompt(newValue: boolean): Promise<void> {
await this.enableAddedLoginPromptState.update(() => newValue);
}
async setEnableChangedPasswordPrompt(newValue: boolean): Promise<void> {
await this.enableChangedPasswordPromptState.update(() => newValue);
}
}

View File

@@ -0,0 +1,7 @@
import { ClearClipboardDelay, AutofillOverlayVisibility } from "../constants";
export type ClearClipboardDelaySetting =
(typeof ClearClipboardDelay)[keyof typeof ClearClipboardDelay];
export type InlineMenuVisibilitySetting =
(typeof AutofillOverlayVisibility)[keyof typeof AutofillOverlayVisibility];

View File

@@ -1,4 +1,5 @@
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response";
export abstract class BillingApiServiceAbstraction {
cancelOrganizationSubscription: (
@@ -6,4 +7,5 @@ export abstract class BillingApiServiceAbstraction {
request: SubscriptionCancellationRequest,
) => Promise<void>;
cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise<void>;
getBillingStatus: (id: string) => Promise<OrganizationBillingStatusResponse>;
}

View File

@@ -1,6 +0,0 @@
import { Observable } from "rxjs";
export class BillingBannerServiceAbstraction {
paymentMethodBannerStates$: Observable<{ organizationId: string; visible: boolean }[]>;
setPaymentMethodBannerState: (organizationId: string, visible: boolean) => Promise<void>;
}

View File

@@ -1,10 +1,12 @@
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
import { InitiationPath } from "../../models/request/reference-event.request";
import { PaymentMethodType, PlanType } from "../enums";
export type OrganizationInformation = {
name: string;
billingEmail: string;
businessName?: string;
initiationPath?: InitiationPath;
};
export type PlanInformation = {

View File

@@ -0,0 +1,31 @@
import { Observable } from "rxjs";
import { PaymentMethodWarning } from "../models/domain/payment-method-warning";
export abstract class PaymentMethodWarningsServiceAbstraction {
/**
* An {@link Observable} record in the {@link ActiveUserState} of the user's organization IDs each mapped to their respective {@link PaymentMethodWarning}.
*/
paymentMethodWarnings$: Observable<Record<string, PaymentMethodWarning>>;
/**
* Updates the {@link ActiveUserState} by setting `acknowledged` to `true` for the {@link PaymentMethodWarning} represented by the provided organization ID.
* @param organizationId - The ID of the organization whose warning you'd like to acknowledge.
*/
acknowledge: (organizationId: string) => Promise<void>;
/**
* Updates the {@link ActiveUserState} by setting `risksSubscriptionFailure` to `false` for the {@link PaymentMethodWarning} represented by the provided organization ID.
* @param organizationId - The ID of the organization whose subscription risk you'd like to remove.
*/
removeSubscriptionRisk: (organizationId: string) => Promise<void>;
/**
* Clears the {@link PaymentMethodWarning} record from the {@link ActiveUserState}.
*/
clear: () => Promise<void>;
/**
* Tries to retrieve the {@link PaymentMethodWarning} for the provided organization ID from the {@link ActiveUserState}.
* If the warning does not exist, or if the warning has been in state for longer than a week, fetches the current {@link OrganizationBillingStatusResponse} for the organization
* from the API and uses it to update the warning in state.
* @param organizationId - The ID of the organization whose {@link PaymentMethodWarning} you'd like to update.
*/
update: (organizationId: string) => Promise<void>;
}

View File

@@ -0,0 +1,13 @@
import { BILLING_DISK, KeyDefinition } from "../../platform/state";
import { PaymentMethodWarning } from "../models/domain/payment-method-warning";
export const PAYMENT_METHOD_WARNINGS_KEY = KeyDefinition.record<PaymentMethodWarning>(
BILLING_DISK,
"paymentMethodWarnings",
{
deserializer: (warnings) => ({
...warnings,
savedAt: new Date(warnings.savedAt),
}),
},
);

View File

@@ -0,0 +1,6 @@
export type PaymentMethodWarning = {
organizationName: string;
risksSubscriptionFailure: boolean;
acknowledged: boolean;
savedAt: Date;
};

View File

@@ -0,0 +1,15 @@
import { BaseResponse } from "../../../models/response/base.response";
export class OrganizationBillingStatusResponse extends BaseResponse {
organizationId: string;
organizationName: string;
risksSubscriptionFailure: boolean;
constructor(response: any) {
super(response);
this.organizationId = this.getResponseProperty("OrganizationId");
this.organizationName = this.getResponseProperty("OrganizationName");
this.risksSubscriptionFailure = this.getResponseProperty("RisksSubscriptionFailure");
}
}

View File

@@ -1,6 +1,7 @@
import { ApiService } from "../../abstractions/api.service";
import { BillingApiServiceAbstraction } from "../../billing/abstractions/billilng-api.service.abstraction";
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response";
export class BillingApiService implements BillingApiServiceAbstraction {
constructor(private apiService: ApiService) {}
@@ -21,4 +22,16 @@ export class BillingApiService implements BillingApiServiceAbstraction {
cancelPremiumUserSubscription(request: SubscriptionCancellationRequest): Promise<void> {
return this.apiService.send("POST", "/accounts/churn-premium", request, true, false);
}
async getBillingStatus(id: string): Promise<OrganizationBillingStatusResponse> {
const r = await this.apiService.send(
"GET",
"/organizations/" + id + "/billing-status",
null,
true,
true,
);
return new OrganizationBillingStatusResponse(r);
}
}

View File

@@ -1,44 +0,0 @@
import { map, Observable } from "rxjs";
import {
ActiveUserState,
BILLING_BANNERS_DISK,
KeyDefinition,
StateProvider,
} from "../../platform/state";
import { BillingBannerServiceAbstraction } from "../abstractions/billing-banner.service.abstraction";
const PAYMENT_METHOD_BANNERS_KEY = KeyDefinition.record<boolean>(
BILLING_BANNERS_DISK,
"paymentMethodBanners",
{
deserializer: (b) => b,
},
);
export class BillingBannerService implements BillingBannerServiceAbstraction {
private paymentMethodBannerStates: ActiveUserState<Record<string, boolean>>;
paymentMethodBannerStates$: Observable<{ organizationId: string; visible: boolean }[]>;
constructor(private stateProvider: StateProvider) {
this.paymentMethodBannerStates = this.stateProvider.getActive(PAYMENT_METHOD_BANNERS_KEY);
this.paymentMethodBannerStates$ = this.paymentMethodBannerStates.state$.pipe(
map((billingBannerStates) =>
!billingBannerStates
? []
: Object.entries(billingBannerStates).map(([organizationId, visible]) => ({
organizationId,
visible,
})),
),
);
}
async setPaymentMethodBannerState(organizationId: string, visibility: boolean): Promise<void> {
await this.paymentMethodBannerStates.update((states) => {
states ??= {};
states[organizationId] = visibility;
return states;
});
}
}

View File

@@ -76,6 +76,18 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
};
}
private prohibitsAdditionalSeats(planType: PlanType) {
switch (planType) {
case PlanType.Free:
case PlanType.FamiliesAnnually:
case PlanType.FamiliesAnnually2019:
case PlanType.TeamsStarter:
return true;
default:
return false;
}
}
private setOrganizationInformation(
request: OrganizationCreateRequest,
information: OrganizationInformation,
@@ -83,6 +95,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
request.name = information.name;
request.businessName = information.businessName;
request.billingEmail = information.billingEmail;
request.initiationPath = information.initiationPath;
}
private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void {
@@ -121,7 +134,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
): void {
request.planType = information.type;
if (request.planType === PlanType.Free) {
if (this.prohibitsAdditionalSeats(request.planType)) {
request.useSecretsManager = information.subscribeToSecretsManager;
request.isFromSecretsManagerTrial = information.isFromSecretsManagerTrial;
return;

View File

@@ -0,0 +1,186 @@
import { any, mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
import { FakeActiveUserState } from "../../../spec/fake-state";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
import { BillingApiServiceAbstraction as BillingApiService } from "../abstractions/billilng-api.service.abstraction";
import { PAYMENT_METHOD_WARNINGS_KEY } from "../models/billing-keys.state";
import { PaymentMethodWarning } from "../models/domain/payment-method-warning";
import { OrganizationBillingStatusResponse } from "../models/response/organization-billing-status.response";
import { PaymentMethodWarningsService } from "./payment-method-warnings.service";
describe("Payment Method Warnings Service", () => {
let paymentMethodWarningsService: PaymentMethodWarningsService;
let billingApiService: MockProxy<BillingApiService>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
let stateProvider: FakeStateProvider;
let activeUserState: FakeActiveUserState<Record<string, PaymentMethodWarning>>;
function getPastDate(daysAgo: number) {
const date = new Date();
date.setDate(date.getDate() - daysAgo);
return date;
}
const getBillingStatusResponse = (organizationId: string) =>
new OrganizationBillingStatusResponse({
OrganizationId: organizationId,
OrganizationName: "Teams Organization",
RisksSubscriptionFailure: true,
});
beforeEach(() => {
accountService = mockAccountServiceWith(mockUserId);
stateProvider = new FakeStateProvider(accountService);
activeUserState = stateProvider.activeUser.getFake(PAYMENT_METHOD_WARNINGS_KEY);
billingApiService = mock<BillingApiService>();
paymentMethodWarningsService = new PaymentMethodWarningsService(
billingApiService,
stateProvider,
);
});
it("acknowledge", async () => {
const organizationId = "1";
const state: Record<string, PaymentMethodWarning> = {
[organizationId]: {
organizationName: "Teams Organization",
risksSubscriptionFailure: true,
acknowledged: false,
savedAt: getPastDate(3),
},
};
activeUserState.nextState(state);
await paymentMethodWarningsService.acknowledge(organizationId);
expect(await firstValueFrom(paymentMethodWarningsService.paymentMethodWarnings$)).toEqual({
[organizationId]: {
...state[organizationId],
acknowledged: true,
},
});
});
it("clear", async () => {
const state: Record<string, PaymentMethodWarning> = {
"1": {
organizationName: "Teams Organization",
risksSubscriptionFailure: true,
acknowledged: false,
savedAt: getPastDate(3),
},
};
activeUserState.nextState(state);
await paymentMethodWarningsService.clear();
expect(await firstValueFrom(paymentMethodWarningsService.paymentMethodWarnings$)).toEqual({});
});
it("removeSubscriptionRisk", async () => {
const organizationId = "1";
const state: Record<string, PaymentMethodWarning> = {
[organizationId]: {
organizationName: "Teams Organization",
risksSubscriptionFailure: true,
acknowledged: false,
savedAt: getPastDate(3),
},
};
activeUserState.nextState(state);
await paymentMethodWarningsService.removeSubscriptionRisk(organizationId);
expect(await firstValueFrom(paymentMethodWarningsService.paymentMethodWarnings$)).toEqual({
[organizationId]: {
...state[organizationId],
risksSubscriptionFailure: false,
},
});
});
describe("update", () => {
it("Does nothing if the stored payment method warning is less than a week old", async () => {
const organizationId = "1";
const state: Record<string, PaymentMethodWarning> = {
[organizationId]: {
organizationName: "Teams Organization",
risksSubscriptionFailure: true,
acknowledged: false,
savedAt: getPastDate(3),
},
};
activeUserState.nextState(state);
await paymentMethodWarningsService.update(organizationId);
expect(billingApiService.getBillingStatus).not.toHaveBeenCalled();
});
it("Retrieves the billing status from the API and uses it to update the state if the state is null", async () => {
const organizationId = "1";
activeUserState.nextState(null);
billingApiService.getBillingStatus.mockResolvedValue(
getBillingStatusResponse(organizationId),
);
await paymentMethodWarningsService.update(organizationId);
expect(await firstValueFrom(paymentMethodWarningsService.paymentMethodWarnings$)).toEqual({
[organizationId]: {
organizationName: "Teams Organization",
risksSubscriptionFailure: true,
acknowledged: false,
savedAt: any(),
},
});
expect(billingApiService.getBillingStatus).toHaveBeenCalledTimes(1);
});
it("Retrieves the billing status from the API and uses it to update the state if the stored warning is null", async () => {
const organizationId = "1";
activeUserState.nextState({
[organizationId]: null,
});
billingApiService.getBillingStatus.mockResolvedValue(
getBillingStatusResponse(organizationId),
);
await paymentMethodWarningsService.update(organizationId);
expect(await firstValueFrom(paymentMethodWarningsService.paymentMethodWarnings$)).toEqual({
[organizationId]: {
organizationName: "Teams Organization",
risksSubscriptionFailure: true,
acknowledged: false,
savedAt: any(),
},
});
expect(billingApiService.getBillingStatus).toHaveBeenCalledTimes(1);
});
it("Retrieves the billing status from the API and uses it to update the state if the stored warning is older than a week", async () => {
const organizationId = "1";
activeUserState.nextState({
[organizationId]: {
organizationName: "Teams Organization",
risksSubscriptionFailure: false,
acknowledged: false,
savedAt: getPastDate(10),
},
});
billingApiService.getBillingStatus.mockResolvedValue(
new OrganizationBillingStatusResponse({
OrganizationId: organizationId,
OrganizationName: "Teams Organization",
RisksSubscriptionFailure: true,
}),
);
await paymentMethodWarningsService.update(organizationId);
expect(await firstValueFrom(paymentMethodWarningsService.paymentMethodWarnings$)).toEqual({
[organizationId]: {
organizationName: "Teams Organization",
risksSubscriptionFailure: true,
acknowledged: false,
savedAt: any(),
},
});
expect(billingApiService.getBillingStatus).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,74 @@
import { firstValueFrom, map, Observable } from "rxjs";
import { ActiveUserState, StateProvider } from "../../platform/state";
import { BillingApiServiceAbstraction as BillingApiService } from "../abstractions/billilng-api.service.abstraction";
import { PaymentMethodWarningsServiceAbstraction } from "../abstractions/payment-method-warnings-service.abstraction";
import { PAYMENT_METHOD_WARNINGS_KEY } from "../models/billing-keys.state";
import { PaymentMethodWarning } from "../models/domain/payment-method-warning";
export class PaymentMethodWarningsService implements PaymentMethodWarningsServiceAbstraction {
private paymentMethodWarningsState: ActiveUserState<Record<string, PaymentMethodWarning>>;
paymentMethodWarnings$: Observable<Record<string, PaymentMethodWarning>>;
constructor(
private billingApiService: BillingApiService,
private stateProvider: StateProvider,
) {
this.paymentMethodWarningsState = this.stateProvider.getActive(PAYMENT_METHOD_WARNINGS_KEY);
this.paymentMethodWarnings$ = this.paymentMethodWarningsState.state$;
}
async acknowledge(organizationId: string): Promise<void> {
await this.paymentMethodWarningsState.update((state) => {
const current = state[organizationId];
state[organizationId] = {
...current,
acknowledged: true,
};
return state;
});
}
async removeSubscriptionRisk(organizationId: string): Promise<void> {
await this.paymentMethodWarningsState.update((state) => {
const current = state[organizationId];
state[organizationId] = {
...current,
risksSubscriptionFailure: false,
};
return state;
});
}
async clear(): Promise<void> {
await this.paymentMethodWarningsState.update(() => ({}));
}
async update(organizationId: string): Promise<void> {
const warning = await firstValueFrom(
this.paymentMethodWarningsState.state$.pipe(
map((state) => (!state ? null : state[organizationId])),
),
);
if (!warning || warning.savedAt < this.getOneWeekAgo()) {
const { organizationName, risksSubscriptionFailure } =
await this.billingApiService.getBillingStatus(organizationId);
await this.paymentMethodWarningsState.update((state) => {
state ??= {};
state[organizationId] = {
organizationName,
risksSubscriptionFailure,
acknowledged: false,
savedAt: new Date(),
};
return state;
});
}
}
private getOneWeekAgo = (): Date => {
const date = new Date();
date.setDate(date.getDate() - 7);
return date;
};
}

View File

@@ -8,6 +8,7 @@ export enum FeatureFlag {
KeyRotationImprovements = "key-rotation-improvements",
FlexibleCollectionsMigration = "flexible-collections-migration",
AC1607_PresentUserOffboardingSurvey = "AC-1607_present-user-offboarding-survey",
ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners",
}
// Replace this with a type safe lookup of the feature flag values in PM-2282

View File

@@ -1,6 +1,14 @@
export type InitiationPath =
| "Registration form"
| "Password Manager trial from marketing website"
| "Secrets Manager trial from marketing website"
| "New organization creation in-product"
| "Upgrade in-product";
export class ReferenceEventRequest {
id: string;
session: string;
layout: string;
flow: string;
initiationPath: InitiationPath;
}

View File

@@ -0,0 +1,59 @@
import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { CsprngArray } from "../../types/csprng";
import { KdfType } from "../enums";
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
export abstract class KeyGenerationService {
/**
* Generates a key of the given length suitable for use in AES encryption
* @param bitLength Length of key.
* 256 bits = 32 bytes
* 512 bits = 64 bytes
* @returns Generated key.
*/
createKey: (bitLength: 256 | 512) => Promise<SymmetricCryptoKey>;
/**
* Generates key material from CSPRNG and derives a 64 byte key from it.
* Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869}
* for details.
* @param bitLength Length of key material.
* @param purpose Purpose for the key derivation function.
* Different purposes results in different keys, even with the same material.
* @param salt Optional. If not provided will be generated from CSPRNG.
* @returns An object containing the salt, key material, and derived key.
*/
createKeyWithPurpose: (
bitLength: 128 | 192 | 256 | 512,
purpose: string,
salt?: string,
) => Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }>;
/**
* Derives a 64 byte key from key material.
* @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}.
* Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} for details.
* @param material key material.
* @param salt Salt for the key derivation function.
* @param purpose Purpose for the key derivation function.
* Different purposes results in different keys, even with the same material.
* @returns 64 byte derived key.
*/
deriveKeyFromMaterial: (
material: CsprngArray,
salt: string,
purpose: string,
) => Promise<SymmetricCryptoKey>;
/**
* Derives a 32 byte key from a password using a key derivation function.
* @param password Password to derive the key from.
* @param salt Salt for the key derivation function.
* @param kdf Key derivation function to use.
* @param kdfConfig Configuration for the key derivation function.
* @returns 32 byte derived key.
*/
deriveKeyFromPassword: (
password: string | Uint8Array,
salt: string | Uint8Array,
kdf: KdfType,
kdfConfig: KdfConfig,
) => Promise<SymmetricCryptoKey>;
}

View File

@@ -2,7 +2,6 @@ import { Observable } from "rxjs";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { PolicyData } from "../../admin-console/models/data/policy.data";
import { ProviderData } from "../../admin-console/models/data/provider.data";
import { Policy } from "../../admin-console/models/domain/policy";
import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
@@ -74,15 +73,11 @@ export abstract class StateService<T extends Account = Account> {
setAutoConfirmFingerprints: (value: boolean, options?: StorageOptions) => Promise<void>;
getBiometricFingerprintValidated: (options?: StorageOptions) => Promise<boolean>;
setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise<void>;
getBiometricUnlock: (options?: StorageOptions) => Promise<boolean>;
setBiometricUnlock: (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>;
getClearClipboard: (options?: StorageOptions) => Promise<number>;
setClearClipboard: (value: number, options?: StorageOptions) => Promise<void>;
getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise<boolean>;
setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise<void>;
/**
@@ -169,18 +164,6 @@ export abstract class StateService<T extends Account = Account> {
* @deprecated For migration purposes only, use setUserKeyBiometric instead
*/
setCryptoMasterKeyBiometric: (value: BiometricKey, options?: StorageOptions) => Promise<void>;
/**
* Gets a flag for if the biometrics process has been cancelled.
* Process reload occurs when biometrics is cancelled, so we store to disk to prevent
* it from reprompting and creating a loop.
*/
getBiometricPromptCancelled: (options?: StorageOptions) => Promise<boolean>;
/**
* Sets a flag for if the biometrics process has been cancelled.
* Process reload occurs when biometrics is cancelled, so we store to disk to prevent
* it from reprompting and creating a loop.
*/
setBiometricPromptCancelled: (value: boolean, options?: StorageOptions) => Promise<void>;
getDecryptedCiphers: (options?: StorageOptions) => Promise<CipherView[]>;
setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise<void>;
getDecryptedPasswordGenerationHistory: (
@@ -216,17 +199,6 @@ export abstract class StateService<T extends Account = Account> {
setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise<void>;
getDefaultUriMatch: (options?: StorageOptions) => Promise<UriMatchType>;
setDefaultUriMatch: (value: UriMatchType, options?: StorageOptions) => Promise<void>;
getDisableAddLoginNotification: (options?: StorageOptions) => Promise<boolean>;
setDisableAddLoginNotification: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableAutoBiometricsPrompt: (options?: StorageOptions) => Promise<boolean>;
setDisableAutoBiometricsPrompt: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableBadgeCounter: (options?: StorageOptions) => Promise<boolean>;
setDisableBadgeCounter: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableChangedPasswordNotification: (options?: StorageOptions) => Promise<boolean>;
setDisableChangedPasswordNotification: (
value: boolean,
options?: StorageOptions,
) => Promise<void>;
getDisableContextMenuItem: (options?: StorageOptions) => Promise<boolean>;
setDisableContextMenuItem: (value: boolean, options?: StorageOptions) => Promise<void>;
/**
@@ -281,8 +253,6 @@ export abstract class StateService<T extends Account = Account> {
value: boolean,
options?: StorageOptions,
) => Promise<void>;
getEnableFullWidth: (options?: StorageOptions) => Promise<boolean>;
setEnableFullWidth: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableMinimizeToTray: (options?: StorageOptions) => Promise<boolean>;
setEnableMinimizeToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableStartToTray: (options?: StorageOptions) => Promise<boolean>;
@@ -350,6 +320,8 @@ export abstract class StateService<T extends Account = Account> {
setKeyHash: (value: string, options?: StorageOptions) => Promise<void>;
getLastActive: (options?: StorageOptions) => Promise<number>;
setLastActive: (value: number, options?: StorageOptions) => Promise<void>;
getLastSync: (options?: StorageOptions) => Promise<string>;
setLastSync: (value: string, options?: StorageOptions) => Promise<void>;
getLocalData: (options?: StorageOptions) => Promise<{ [cipherId: string]: LocalData }>;
setLocalData: (
value: { [cipherId: string]: LocalData },
@@ -398,8 +370,6 @@ 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>;
getProviders: (options?: StorageOptions) => Promise<{ [id: string]: ProviderData }>;
setProviders: (value: { [id: string]: ProviderData }, options?: StorageOptions) => Promise<void>;
getRefreshToken: (options?: StorageOptions) => Promise<string>;
setRefreshToken: (value: string, options?: StorageOptions) => Promise<void>;
getRememberedEmail: (options?: StorageOptions) => Promise<string>;
@@ -440,13 +410,6 @@ export abstract class StateService<T extends Account = Account> {
getAvatarColor: (options?: StorageOptions) => Promise<string | null | undefined>;
setAvatarColor: (value: string, options?: StorageOptions) => Promise<void>;
getSMOnboardingTasks: (
options?: StorageOptions,
) => Promise<Record<string, Record<string, boolean>>>;
setSMOnboardingTasks: (
value: Record<string, Record<string, boolean>>,
options?: StorageOptions,
) => Promise<void>;
/**
* fetches string value of URL user tried to navigate to while unauthenticated.
* @param options Defines the storage options for the URL; Defaults to session Storage.

View File

@@ -8,7 +8,14 @@ import { UserId } from "../../types/guid";
import { EncryptedString } from "../models/domain/enc-string";
import { BiometricStateService, DefaultBiometricStateService } from "./biometric-state.service";
import { ENCRYPTED_CLIENT_KEY_HALF, REQUIRE_PASSWORD_ON_START } from "./biometric.state";
import {
BIOMETRIC_UNLOCK_ENABLED,
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
ENCRYPTED_CLIENT_KEY_HALF,
PROMPT_AUTOMATICALLY,
PROMPT_CANCELLED,
REQUIRE_PASSWORD_ON_START,
} from "./biometric.state";
describe("BiometricStateService", () => {
let sut: BiometricStateService;
@@ -29,33 +36,39 @@ describe("BiometricStateService", () => {
});
describe("requirePasswordOnStart$", () => {
it("should track the requirePasswordOnStart state", async () => {
it("emits when the require password on start state changes", async () => {
const state = stateProvider.activeUser.getFake(REQUIRE_PASSWORD_ON_START);
state.nextState(undefined);
expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(false);
state.nextState(true);
expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(true);
});
it("emits false when the require password on start state is undefined", async () => {
const state = stateProvider.activeUser.getFake(REQUIRE_PASSWORD_ON_START);
state.nextState(undefined);
expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(false);
});
});
describe("encryptedClientKeyHalf$", () => {
it("should track the encryptedClientKeyHalf state", async () => {
it("emits when the encryptedClientKeyHalf state changes", async () => {
const state = stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF);
state.nextState(undefined);
expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toBe(null);
state.nextState(encryptedClientKeyHalf);
expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toEqual(encClientKeyHalf);
});
it("emits false when the encryptedClientKeyHalf state is undefined", async () => {
const state = stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF);
state.nextState(undefined);
expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toBe(null);
});
});
describe("setEncryptedClientKeyHalf", () => {
it("should update the encryptedClientKeyHalf$", async () => {
it("updates encryptedClientKeyHalf$", async () => {
await sut.setEncryptedClientKeyHalf(encClientKeyHalf);
expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toEqual(encClientKeyHalf);
@@ -63,13 +76,13 @@ describe("BiometricStateService", () => {
});
describe("setRequirePasswordOnStart", () => {
it("should update the requirePasswordOnStart$", async () => {
it("updates the requirePasswordOnStart$", async () => {
await sut.setRequirePasswordOnStart(true);
expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(true);
});
it("should remove the encryptedClientKeyHalf if the value is false", async () => {
it("removes the encryptedClientKeyHalf when the set value is false", async () => {
await sut.setEncryptedClientKeyHalf(encClientKeyHalf, userId);
await sut.setRequirePasswordOnStart(false);
@@ -81,7 +94,7 @@ describe("BiometricStateService", () => {
expect(keyHalfState.nextMock).toHaveBeenCalledWith(null);
});
it("should not remove the encryptedClientKeyHalf if the value is true", async () => {
it("does not remove the encryptedClientKeyHalf when the value is true", async () => {
await sut.setEncryptedClientKeyHalf(encClientKeyHalf);
await sut.setRequirePasswordOnStart(true);
@@ -90,10 +103,108 @@ describe("BiometricStateService", () => {
});
describe("getRequirePasswordOnStart", () => {
it("should return the requirePasswordOnStart value", async () => {
it("returns the requirePasswordOnStart state value", async () => {
stateProvider.singleUser.mockFor(userId, REQUIRE_PASSWORD_ON_START.key, true);
expect(await sut.getRequirePasswordOnStart(userId)).toBe(true);
});
});
describe("require password on start callout", () => {
it("is false when not set", async () => {
expect(await firstValueFrom(sut.dismissedRequirePasswordOnStartCallout$)).toBe(false);
});
it("is true when set", async () => {
await sut.setDismissedRequirePasswordOnStartCallout();
expect(await firstValueFrom(sut.dismissedRequirePasswordOnStartCallout$)).toBe(true);
});
it("updates disk state when called", async () => {
await sut.setDismissedRequirePasswordOnStartCallout();
expect(
stateProvider.activeUser.getFake(DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT).nextMock,
).toHaveBeenCalledWith([userId, true]);
});
});
describe("setPromptCancelled", () => {
test("observable is updated", async () => {
await sut.setPromptCancelled();
expect(await firstValueFrom(sut.promptCancelled$)).toBe(true);
});
it("updates state", async () => {
await sut.setPromptCancelled();
const nextMock = stateProvider.activeUser.getFake(PROMPT_CANCELLED).nextMock;
expect(nextMock).toHaveBeenCalledWith([userId, true]);
expect(nextMock).toHaveBeenCalledTimes(1);
});
});
describe("setPromptAutomatically", () => {
test("observable is updated", async () => {
await sut.setPromptAutomatically(true);
expect(await firstValueFrom(sut.promptAutomatically$)).toBe(true);
});
it("updates state", async () => {
await sut.setPromptAutomatically(true);
const nextMock = stateProvider.activeUser.getFake(PROMPT_AUTOMATICALLY).nextMock;
expect(nextMock).toHaveBeenCalledWith([userId, true]);
expect(nextMock).toHaveBeenCalledTimes(1);
});
});
describe("biometricUnlockEnabled$", () => {
it("emits when biometricUnlockEnabled state is updated", async () => {
const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED);
state.nextState(true);
expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true);
});
it("emits false when biometricUnlockEnabled state is undefined", async () => {
const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED);
state.nextState(undefined);
expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(false);
});
});
describe("setBiometricUnlockEnabled", () => {
it("updates biometricUnlockEnabled$", async () => {
await sut.setBiometricUnlockEnabled(true);
expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true);
});
it("updates state", async () => {
await sut.setBiometricUnlockEnabled(true);
expect(
stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED).nextMock,
).toHaveBeenCalledWith([userId, true]);
});
});
describe("getBiometricUnlockEnabled", () => {
it("returns biometricUnlockEnabled state for the given user", async () => {
stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true);
expect(await sut.getBiometricUnlockEnabled(userId)).toBe(true);
});
it("returns false when the state is not set", async () => {
stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(undefined);
expect(await sut.getBiometricUnlockEnabled(userId)).toBe(false);
});
});
});

View File

@@ -4,9 +4,20 @@ import { UserId } from "../../types/guid";
import { EncryptedString, EncString } from "../models/domain/enc-string";
import { ActiveUserState, StateProvider } from "../state";
import { ENCRYPTED_CLIENT_KEY_HALF, REQUIRE_PASSWORD_ON_START } from "./biometric.state";
import {
BIOMETRIC_UNLOCK_ENABLED,
ENCRYPTED_CLIENT_KEY_HALF,
REQUIRE_PASSWORD_ON_START,
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
PROMPT_AUTOMATICALLY,
PROMPT_CANCELLED,
} from "./biometric.state";
export abstract class BiometricStateService {
/**
* `true` if the currently active user has elected to store a biometric key to unlock their vault.
*/
biometricUnlockEnabled$: Observable<boolean>; // used to be biometricUnlock
/**
* If the user has elected to require a password on first unlock of an application instance, this key will store the
* encrypted client key half used to unlock the vault.
@@ -20,6 +31,24 @@ export abstract class BiometricStateService {
* tracks the currently active user
*/
requirePasswordOnStart$: Observable<boolean>;
/**
* Indicates the user has been warned about the security implications of using biometrics and, depending on the OS,
*
* tracks the currently active user.
*/
dismissedRequirePasswordOnStartCallout$: Observable<boolean>;
/**
* Whether the user has cancelled the biometric prompt.
*
* tracks the currently active user
*/
promptCancelled$: Observable<boolean>;
/**
* Whether the user has elected to automatically prompt for biometrics.
*
* tracks the currently active user
*/
promptAutomatically$: Observable<boolean>;
/**
* Updates the require password on start state for the currently active user.
@@ -28,19 +57,59 @@ export abstract class BiometricStateService {
* @param value whether or not a password is required on first unlock after opening the application
*/
abstract setRequirePasswordOnStart(value: boolean): Promise<void>;
/**
* Updates the biometric unlock enabled state for the currently active user.
* @param enabled whether or not to store a biometric key to unlock the vault
*/
abstract setBiometricUnlockEnabled(enabled: boolean): Promise<void>;
/**
* Gets the biometric unlock enabled state for the given user.
* @param userId user Id to check
*/
abstract getBiometricUnlockEnabled(userId: UserId): Promise<boolean>;
abstract setEncryptedClientKeyHalf(encryptedKeyHalf: EncString, userId?: UserId): Promise<void>;
abstract getEncryptedClientKeyHalf(userId: UserId): Promise<EncString>;
abstract getRequirePasswordOnStart(userId: UserId): Promise<boolean>;
abstract removeEncryptedClientKeyHalf(userId: UserId): Promise<void>;
/**
* Updates the active user's state to reflect that they've been warned about requiring password on start.
*/
abstract setDismissedRequirePasswordOnStartCallout(): Promise<void>;
/**
* Updates the active user's state to reflect that they've cancelled the biometric prompt this lock.
*/
abstract setPromptCancelled(): Promise<void>;
/**
* Resets the active user's state to reflect that they haven't cancelled the biometric prompt this lock.
*/
abstract resetPromptCancelled(): Promise<void>;
/**
* Updates the currently active user's setting for auto prompting for biometrics on application start and lock
* @param prompt Whether or not to prompt for biometrics on application start.
*/
abstract setPromptAutomatically(prompt: boolean): Promise<void>;
abstract logout(userId: UserId): Promise<void>;
}
export class DefaultBiometricStateService implements BiometricStateService {
private biometricUnlockEnabledState: ActiveUserState<boolean>;
private requirePasswordOnStartState: ActiveUserState<boolean>;
private encryptedClientKeyHalfState: ActiveUserState<EncryptedString | undefined>;
private dismissedRequirePasswordOnStartCalloutState: ActiveUserState<boolean>;
private promptCancelledState: ActiveUserState<boolean>;
private promptAutomaticallyState: ActiveUserState<boolean>;
biometricUnlockEnabled$: Observable<boolean>;
encryptedClientKeyHalf$: Observable<EncString | undefined>;
requirePasswordOnStart$: Observable<boolean>;
dismissedRequirePasswordOnStartCallout$: Observable<boolean>;
promptCancelled$: Observable<boolean>;
promptAutomatically$: Observable<boolean>;
constructor(private stateProvider: StateProvider) {
this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED);
this.biometricUnlockEnabled$ = this.biometricUnlockEnabledState.state$.pipe(map(Boolean));
this.requirePasswordOnStartState = this.stateProvider.getActive(REQUIRE_PASSWORD_ON_START);
this.requirePasswordOnStart$ = this.requirePasswordOnStartState.state$.pipe(
map((value) => !!value),
@@ -50,6 +119,27 @@ export class DefaultBiometricStateService implements BiometricStateService {
this.encryptedClientKeyHalf$ = this.encryptedClientKeyHalfState.state$.pipe(
map(encryptedClientKeyHalfToEncString),
);
this.dismissedRequirePasswordOnStartCalloutState = this.stateProvider.getActive(
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
);
this.dismissedRequirePasswordOnStartCallout$ =
this.dismissedRequirePasswordOnStartCalloutState.state$.pipe(map(Boolean));
this.promptCancelledState = this.stateProvider.getActive(PROMPT_CANCELLED);
this.promptCancelled$ = this.promptCancelledState.state$.pipe(map(Boolean));
this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY);
this.promptAutomatically$ = this.promptAutomaticallyState.state$.pipe(map(Boolean));
}
async setBiometricUnlockEnabled(enabled: boolean): Promise<void> {
await this.biometricUnlockEnabledState.update(() => enabled);
}
async getBiometricUnlockEnabled(userId: UserId): Promise<boolean> {
return await firstValueFrom(
this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)),
);
}
async setRequirePasswordOnStart(value: boolean): Promise<void> {
@@ -97,6 +187,25 @@ export class DefaultBiometricStateService implements BiometricStateService {
async logout(userId: UserId): Promise<void> {
await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => null);
await this.stateProvider.getUser(userId, PROMPT_CANCELLED).update(() => null);
// Persist auto prompt setting through logout
// Persist dismissed require password on start callout through logout
}
async setDismissedRequirePasswordOnStartCallout(): Promise<void> {
await this.dismissedRequirePasswordOnStartCalloutState.update(() => true);
}
async setPromptCancelled(): Promise<void> {
await this.promptCancelledState.update(() => true);
}
async resetPromptCancelled(): Promise<void> {
await this.promptCancelledState.update(() => null);
}
async setPromptAutomatically(prompt: boolean): Promise<void> {
await this.promptAutomaticallyState.update(() => prompt);
}
}

View File

@@ -1,25 +1,35 @@
import { ENCRYPTED_CLIENT_KEY_HALF, REQUIRE_PASSWORD_ON_START } from "./biometric.state";
import { EncryptedString } from "../models/domain/enc-string";
import { KeyDefinition } from "../state";
describe("require password on start", () => {
const sut = REQUIRE_PASSWORD_ON_START;
import {
BIOMETRIC_UNLOCK_ENABLED,
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
ENCRYPTED_CLIENT_KEY_HALF,
PROMPT_AUTOMATICALLY,
PROMPT_CANCELLED,
REQUIRE_PASSWORD_ON_START,
} from "./biometric.state";
it("should deserialize require password on start state", () => {
const requirePasswordOnStart = "requirePasswordOnStart";
describe.each([
[ENCRYPTED_CLIENT_KEY_HALF, "encryptedClientKeyHalf"],
[DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT, true],
[PROMPT_CANCELLED, true],
[PROMPT_AUTOMATICALLY, true],
[REQUIRE_PASSWORD_ON_START, true],
[BIOMETRIC_UNLOCK_ENABLED, "test"],
])(
"deserializes state %s",
(
...args: [KeyDefinition<EncryptedString>, EncryptedString] | [KeyDefinition<boolean>, boolean]
) => {
function testDeserialization<T>(keyDefinition: KeyDefinition<T>, state: T) {
const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state)));
expect(deserialized).toEqual(state);
}
const result = sut.deserializer(JSON.parse(JSON.stringify(requirePasswordOnStart)));
expect(result).toEqual(requirePasswordOnStart);
});
});
describe("encrypted client key half", () => {
const sut = ENCRYPTED_CLIENT_KEY_HALF;
it("should deserialize encrypted client key half state", () => {
const encryptedClientKeyHalf = "encryptedClientKeyHalf";
const result = sut.deserializer(JSON.parse(JSON.stringify(encryptedClientKeyHalf)));
expect(result).toEqual(encryptedClientKeyHalf);
});
});
it("should deserialize state", () => {
const [keyDefinition, state] = args;
testDeserialization(keyDefinition, state);
});
},
);

View File

@@ -1,6 +1,17 @@
import { EncryptedString } from "../models/domain/enc-string";
import { KeyDefinition, BIOMETRIC_SETTINGS_DISK } from "../state";
/**
* Indicates whether the user elected to store a biometric key to unlock their vault.
*/
export const BIOMETRIC_UNLOCK_ENABLED = new KeyDefinition<boolean>(
BIOMETRIC_SETTINGS_DISK,
"biometricUnlockEnabled",
{
deserializer: (obj) => obj,
},
);
/**
* Boolean indicating the user has elected to require a password to use their biometric key upon starting the application.
*
@@ -28,3 +39,38 @@ export const ENCRYPTED_CLIENT_KEY_HALF = new KeyDefinition<EncryptedString>(
deserializer: (obj) => obj,
},
);
/**
* Indicates the user has been warned about the security implications of using biometrics and, depending on the OS,
* recommended to require a password on first unlock of an application instance.
*/
export const DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT = new KeyDefinition<boolean>(
BIOMETRIC_SETTINGS_DISK,
"dismissedBiometricRequirePasswordOnStartCallout",
{
deserializer: (obj) => obj,
},
);
/**
* Stores whether the user has elected to cancel the biometric prompt. This is stored on disk due to process-reload
* wiping memory state. We don't want to prompt the user again if they've elected to cancel.
*/
export const PROMPT_CANCELLED = new KeyDefinition<boolean>(
BIOMETRIC_SETTINGS_DISK,
"promptCancelled",
{
deserializer: (obj) => obj,
},
);
/**
* Stores whether the user has elected to automatically prompt for biometric unlock on application start.
*/
export const PROMPT_AUTOMATICALLY = new KeyDefinition<boolean>(
BIOMETRIC_SETTINGS_DISK,
"promptAutomatically",
{
deserializer: (obj) => obj,
},
);

View File

@@ -2,7 +2,6 @@ import { Jsonify } from "type-fest";
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
import { PolicyData } from "../../../admin-console/models/data/policy.data";
import { ProviderData } from "../../../admin-console/models/data/provider.data";
import { Policy } from "../../../admin-console/models/domain/policy";
import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
@@ -96,7 +95,6 @@ export class AccountData {
addEditCipherInfo?: AddEditCipherInfo;
eventCollection?: EventData[];
organizations?: { [id: string]: OrganizationData };
providers?: { [id: string]: ProviderData };
static fromJSON(obj: DeepJsonify<AccountData>): AccountData {
if (obj == null) {
@@ -181,6 +179,7 @@ export class AccountProfile {
forceSetPasswordReason?: ForceSetPasswordReason;
hasPremiumPersonally?: boolean;
hasPremiumFromOrganization?: boolean;
lastSync?: string;
userId?: string;
usesKeyConnector?: boolean;
keyHash?: string;
@@ -200,17 +199,12 @@ export class AccountProfile {
export class AccountSettings {
autoConfirmFingerPrints?: boolean;
biometricUnlock?: boolean;
clearClipboard?: number;
defaultUriMatch?: UriMatchType;
disableAutoBiometricsPrompt?: boolean;
disableBadgeCounter?: boolean;
disableGa?: boolean;
dontShowCardsCurrentTab?: boolean;
dontShowIdentitiesCurrentTab?: boolean;
enableAlwaysOnTop?: boolean;
enableBiometric?: boolean;
enableFullWidth?: boolean;
equivalentDomains?: any;
minimizeOnCopyToClipboard?: boolean;
passwordGenerationOptions?: PasswordGeneratorOptions;
@@ -225,9 +219,7 @@ export class AccountSettings {
serverConfig?: ServerConfigData;
approveLoginRequests?: boolean;
avatarColor?: string;
smOnboardingTasks?: Record<string, Record<string, boolean>>;
trustDeviceChoiceForDecryption?: boolean;
biometricPromptCancelled?: boolean;
/** @deprecated July 2023, left for migration purposes*/
pinProtected?: EncryptionPair<string, EncString> = new EncryptionPair<string, EncString>();

View File

@@ -26,8 +26,6 @@ export class GlobalState {
enableBrowserIntegrationFingerprint?: boolean;
enableDuckDuckGoBrowserIntegration?: boolean;
neverDomains?: { [id: string]: unknown };
disableAddLoginNotification?: boolean;
disableChangedPasswordNotification?: boolean;
disableContextMenuItem?: boolean;
deepLinkRedirectUrl?: string;
}

View File

@@ -10,6 +10,7 @@ import { UserId } from "../../types/guid";
import { UserKey, MasterKey, PinKey } from "../../types/key";
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
import { EncryptService } from "../abstractions/encrypt.service";
import { KeyGenerationService } from "../abstractions/key-generation.service";
import { LogService } from "../abstractions/log.service";
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
import { StateService } from "../abstractions/state.service";
@@ -18,11 +19,18 @@ import { EncString } from "../models/domain/enc-string";
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
import { CryptoService } from "../services/crypto.service";
import { USER_EVER_HAD_USER_KEY, USER_KEY } from "./key-state/user-key.state";
import { USER_ENCRYPTED_ORGANIZATION_KEYS } from "./key-state/org-keys.state";
import { USER_ENCRYPTED_PROVIDER_KEYS } from "./key-state/provider-keys.state";
import {
USER_ENCRYPTED_PRIVATE_KEY,
USER_EVER_HAD_USER_KEY,
USER_KEY,
} from "./key-state/user-key.state";
describe("cryptoService", () => {
let cryptoService: CryptoService;
const keyGenerationService = mock<KeyGenerationService>();
const cryptoFunctionService = mock<CryptoFunctionService>();
const encryptService = mock<EncryptService>();
const platformUtilService = mock<PlatformUtilsService>();
@@ -38,6 +46,7 @@ describe("cryptoService", () => {
stateProvider = new FakeStateProvider(accountService);
cryptoService = new CryptoService(
keyGenerationService,
cryptoFunctionService,
encryptService,
platformUtilService,
@@ -312,4 +321,218 @@ describe("cryptoService", () => {
},
);
});
describe("clearOrgKeys", () => {
let forceMemorySpy: jest.Mock;
beforeEach(() => {
forceMemorySpy = cryptoService["activeUserOrgKeysState"].forceValue = jest.fn();
});
it("clears in memory org keys when called with memoryOnly", async () => {
await cryptoService.clearOrgKeys(true);
expect(forceMemorySpy).toHaveBeenCalledWith({});
});
it("does not clear memory when called with the non active user and memory only", async () => {
await cryptoService.clearOrgKeys(true, "someOtherUser" as UserId);
expect(forceMemorySpy).not.toHaveBeenCalled();
});
it("does not write to disk state if called with memory only", async () => {
await cryptoService.clearOrgKeys(true);
expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalled();
});
it("clears disk state when called with diskOnly", async () => {
await cryptoService.clearOrgKeys(false);
expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith(
mockUserId,
USER_ENCRYPTED_ORGANIZATION_KEYS,
);
expect(
stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_ORGANIZATION_KEYS).nextMock,
).toHaveBeenCalledWith(null);
});
it("clears another user's disk state when called with diskOnly and that user", async () => {
await cryptoService.clearOrgKeys(false, "someOtherUser" as UserId);
expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith(
"someOtherUser" as UserId,
USER_ENCRYPTED_ORGANIZATION_KEYS,
);
expect(
stateProvider.singleUser.getFake(
"someOtherUser" as UserId,
USER_ENCRYPTED_ORGANIZATION_KEYS,
).nextMock,
).toHaveBeenCalledWith(null);
});
it("does not clear active user disk state when called with diskOnly and a different specified user", async () => {
await cryptoService.clearOrgKeys(false, "someOtherUser" as UserId);
expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalledWith(
mockUserId,
USER_ENCRYPTED_ORGANIZATION_KEYS,
);
});
});
describe("clearProviderKeys", () => {
let forceMemorySpy: jest.Mock;
beforeEach(() => {
forceMemorySpy = cryptoService["activeUserProviderKeysState"].forceValue = jest.fn();
});
it("clears in memory org keys when called with memoryOnly", async () => {
await cryptoService.clearProviderKeys(true);
expect(forceMemorySpy).toHaveBeenCalledWith({});
});
it("does not clear memory when called with the non active user and memory only", async () => {
await cryptoService.clearProviderKeys(true, "someOtherUser" as UserId);
expect(forceMemorySpy).not.toHaveBeenCalled();
});
it("does not write to disk state if called with memory only", async () => {
await cryptoService.clearProviderKeys(true);
expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalled();
});
it("clears disk state when called with diskOnly", async () => {
await cryptoService.clearProviderKeys(false);
expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith(
mockUserId,
USER_ENCRYPTED_PROVIDER_KEYS,
);
expect(
stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PROVIDER_KEYS).nextMock,
).toHaveBeenCalledWith(null);
});
it("clears another user's disk state when called with diskOnly and that user", async () => {
await cryptoService.clearProviderKeys(false, "someOtherUser" as UserId);
expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith(
"someOtherUser" as UserId,
USER_ENCRYPTED_PROVIDER_KEYS,
);
expect(
stateProvider.singleUser.getFake("someOtherUser" as UserId, USER_ENCRYPTED_PROVIDER_KEYS)
.nextMock,
).toHaveBeenCalledWith(null);
});
it("does not clear active user disk state when called with diskOnly and a different specified user", async () => {
await cryptoService.clearProviderKeys(false, "someOtherUser" as UserId);
expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalledWith(
mockUserId,
USER_ENCRYPTED_PROVIDER_KEYS,
);
});
});
describe("clearKeyPair", () => {
let forceMemoryPrivateKeySpy: jest.Mock;
let forceMemoryPublicKeySpy: jest.Mock;
beforeEach(() => {
forceMemoryPrivateKeySpy = cryptoService["activeUserPrivateKeyState"].forceValue = jest.fn();
forceMemoryPublicKeySpy = cryptoService["activeUserPublicKeyState"].forceValue = jest.fn();
});
it("clears in memory org keys when called with memoryOnly", async () => {
await cryptoService.clearKeyPair(true);
expect(forceMemoryPrivateKeySpy).toHaveBeenCalledWith(null);
expect(forceMemoryPublicKeySpy).toHaveBeenCalledWith(null);
});
it("does not clear memory when called with the non active user and memory only", async () => {
await cryptoService.clearKeyPair(true, "someOtherUser" as UserId);
expect(forceMemoryPrivateKeySpy).not.toHaveBeenCalled();
expect(forceMemoryPublicKeySpy).not.toHaveBeenCalled();
});
it("does not write to disk state if called with memory only", async () => {
await cryptoService.clearKeyPair(true);
expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalled();
});
it("clears disk state when called with diskOnly", async () => {
await cryptoService.clearKeyPair(false);
expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith(
mockUserId,
USER_ENCRYPTED_PRIVATE_KEY,
);
expect(
stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY).nextMock,
).toHaveBeenCalledWith(null);
});
it("clears another user's disk state when called with diskOnly and that user", async () => {
await cryptoService.clearKeyPair(false, "someOtherUser" as UserId);
expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith(
"someOtherUser" as UserId,
USER_ENCRYPTED_PRIVATE_KEY,
);
expect(
stateProvider.singleUser.getFake("someOtherUser" as UserId, USER_ENCRYPTED_PRIVATE_KEY)
.nextMock,
).toHaveBeenCalledWith(null);
});
it("does not clear active user disk state when called with diskOnly and a different specified user", async () => {
await cryptoService.clearKeyPair(false, "someOtherUser" as UserId);
expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalledWith(
mockUserId,
USER_ENCRYPTED_PRIVATE_KEY,
);
});
});
describe("clearUserKey", () => {
it("clears the user key for the active user when no userId is specified", async () => {
await cryptoService.clearUserKey(false);
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(USER_KEY, null, undefined);
});
it("clears the user key for the specified user when a userId is specified", async () => {
await cryptoService.clearUserKey(false, "someOtherUser" as UserId);
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(USER_KEY, null, "someOtherUser");
});
it("sets the maximum account status of the active user id to locked when user id is not specified", async () => {
await cryptoService.clearUserKey(false);
expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith(
mockUserId,
AuthenticationStatus.Locked,
);
});
it("sets the maximum account status of the specified user id to locked when user id is specified", async () => {
await cryptoService.clearUserKey(false, "someOtherUser" as UserId);
expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith(
"someOtherUser" as UserId,
AuthenticationStatus.Locked,
);
});
it("clears all stored user keys when clearAll is true", async () => {
const clearAllSpy = (cryptoService["clearAllStoredUserKeys"] = jest.fn());
await cryptoService.clearUserKey(true);
expect(clearAllSpy).toHaveBeenCalledWith(mockUserId);
});
});
});

View File

@@ -9,6 +9,7 @@ import { AccountService } from "../../auth/abstractions/account.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { Utils } from "../../platform/misc/utils";
import { CsprngArray } from "../../types/csprng";
import { OrganizationId, ProviderId, UserId } from "../../types/guid";
import {
OrgKey,
@@ -23,6 +24,7 @@ import {
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
import { CryptoService as CryptoServiceAbstraction } from "../abstractions/crypto.service";
import { EncryptService } from "../abstractions/encrypt.service";
import { KeyGenerationService } from "../abstractions/key-generation.service";
import { LogService } from "../abstractions/log.service";
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
import { StateService } from "../abstractions/state.service";
@@ -80,6 +82,7 @@ export class CryptoService implements CryptoServiceAbstraction {
readonly everHadUserKey$: Observable<boolean>;
constructor(
protected keyGenerationService: KeyGenerationService,
protected cryptoFunctionService: CryptoFunctionService,
protected encryptService: EncryptService,
protected platformUtilService: PlatformUtilsService,
@@ -219,8 +222,8 @@ export class CryptoService implements CryptoServiceAbstraction {
throw new Error("No Master Key found.");
}
const newUserKey = await this.cryptoFunctionService.aesGenerateKey(512);
return this.buildProtectedSymmetricKey(masterKey, newUserKey);
const newUserKey = await this.keyGenerationService.createKey(512);
return this.buildProtectedSymmetricKey(masterKey, newUserKey.key);
}
async clearUserKey(clearStoredKeys = true, userId?: UserId): Promise<void> {
@@ -294,7 +297,12 @@ export class CryptoService implements CryptoServiceAbstraction {
kdf: KdfType,
KdfConfig: KdfConfig,
): Promise<MasterKey> {
return (await this.makeKey(password, email, kdf, KdfConfig)) as MasterKey;
return (await this.keyGenerationService.deriveKeyFromPassword(
password,
email,
kdf,
KdfConfig,
)) as MasterKey;
}
async clearMasterKey(userId?: UserId): Promise<void> {
@@ -452,17 +460,15 @@ export class CryptoService implements CryptoServiceAbstraction {
throw new Error("No key provided");
}
const newSymKey = await this.cryptoFunctionService.aesGenerateKey(512);
return this.buildProtectedSymmetricKey(key, newSymKey);
const newSymKey = await this.keyGenerationService.createKey(512);
return this.buildProtectedSymmetricKey(key, newSymKey.key);
}
async clearOrgKeys(memoryOnly?: boolean, userId?: UserId): Promise<void> {
const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const userIdIsActive = userId == null || userId === activeUserId;
if (memoryOnly && userIdIsActive) {
// org keys are only cached for active users
await this.activeUserOrgKeysState.forceValue({});
} else {
if (!memoryOnly) {
if (userId == null && activeUserId == null) {
// nothing to do
return;
@@ -470,13 +476,17 @@ export class CryptoService implements CryptoServiceAbstraction {
await this.stateProvider
.getUser(userId ?? activeUserId, USER_ENCRYPTED_ORGANIZATION_KEYS)
.update(() => null);
return;
}
// org keys are only cached for active users
if (userIdIsActive) {
await this.activeUserOrgKeysState.forceValue({});
}
}
async setProviderKeys(providers: ProfileProviderResponse[]): Promise<void> {
// 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.activeUserEncryptedProviderKeysState.update((_) => {
await this.activeUserEncryptedProviderKeysState.update((_) => {
const encProviderKeys: { [providerId: ProviderId]: EncryptedString } = {};
providers.forEach((provider) => {
@@ -503,10 +513,8 @@ export class CryptoService implements CryptoServiceAbstraction {
async clearProviderKeys(memoryOnly?: boolean, userId?: UserId): Promise<void> {
const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const userIdIsActive = userId == null || userId === activeUserId;
if (memoryOnly && userIdIsActive) {
// provider keys are only cached for active users
await this.activeUserProviderKeysState.forceValue({});
} else {
if (!memoryOnly) {
if (userId == null && activeUserId == null) {
// nothing to do
return;
@@ -514,6 +522,12 @@ export class CryptoService implements CryptoServiceAbstraction {
await this.stateProvider
.getUser(userId ?? activeUserId, USER_ENCRYPTED_PROVIDER_KEYS)
.update(() => null);
return;
}
// provider keys are only cached for active users
if (userIdIsActive) {
await this.activeUserProviderKeysState.forceValue({});
}
}
@@ -522,10 +536,10 @@ export class CryptoService implements CryptoServiceAbstraction {
}
async makeOrgKey<T extends OrgKey | ProviderKey>(): Promise<[EncString, T]> {
const shareKey = await this.cryptoFunctionService.aesGenerateKey(512);
const shareKey = await this.keyGenerationService.createKey(512);
const publicKey = await this.getPublicKey();
const encShareKey = await this.rsaEncrypt(shareKey, publicKey);
return [encShareKey, new SymmetricCryptoKey(shareKey) as T];
const encShareKey = await this.rsaEncrypt(shareKey.key, publicKey);
return [encShareKey, shareKey as T];
}
async setPrivateKey(encPrivateKey: EncryptedString): Promise<void> {
@@ -570,25 +584,27 @@ export class CryptoService implements CryptoServiceAbstraction {
async clearKeyPair(memoryOnly?: boolean, userId?: UserId): Promise<void[]> {
const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const userIdIsActive = userId == null || userId === activeUserId;
if (memoryOnly && userIdIsActive) {
// key pair is only cached for active users
await this.activeUserPrivateKeyState.forceValue(null);
await this.activeUserPublicKeyState.forceValue(null);
return;
} else {
if (!memoryOnly) {
if (userId == null && activeUserId == null) {
// nothing to do
return;
}
// below updates decrypted private key and public keys if this is the active user as well since those are derived from the encrypted private key
await this.stateProvider
.getUser(userId ?? activeUserId, USER_ENCRYPTED_PRIVATE_KEY)
.update(() => null);
return;
}
// decrypted key pair is only cached for active users
if (userIdIsActive) {
await this.activeUserPrivateKeyState.forceValue(null);
await this.activeUserPublicKeyState.forceValue(null);
}
}
async makePinKey(pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig): Promise<PinKey> {
const pinKey = await this.makeKey(pin, salt, kdf, kdfConfig);
const pinKey = await this.keyGenerationService.deriveKeyFromPassword(pin, salt, kdf, kdfConfig);
return (await this.stretchKey(pinKey)) as PinKey;
}
@@ -636,20 +652,16 @@ export class CryptoService implements CryptoServiceAbstraction {
return new SymmetricCryptoKey(masterKey) as MasterKey;
}
async makeSendKey(keyMaterial: Uint8Array): Promise<SymmetricCryptoKey> {
const sendKey = await this.cryptoFunctionService.hkdf(
async makeSendKey(keyMaterial: CsprngArray): Promise<SymmetricCryptoKey> {
return await this.keyGenerationService.deriveKeyFromMaterial(
keyMaterial,
"bitwarden-send",
"send",
64,
"sha256",
);
return new SymmetricCryptoKey(sendKey);
}
async makeCipherKey(): Promise<CipherKey> {
const randomBytes = await this.cryptoFunctionService.aesGenerateKey(512);
return new SymmetricCryptoKey(randomBytes) as CipherKey;
return (await this.keyGenerationService.createKey(512)) as CipherKey;
}
async clearKeys(userId?: UserId): Promise<any> {
@@ -802,8 +814,7 @@ export class CryptoService implements CryptoServiceAbstraction {
publicKey: string;
privateKey: EncString;
}> {
const rawKey = await this.cryptoFunctionService.aesGenerateKey(512);
const userKey = new SymmetricCryptoKey(rawKey) as UserKey;
const userKey = (await this.keyGenerationService.createKey(512)) as UserKey;
const [publicKey, privateKey] = await this.makeKeyPair(userKey);
await this.setUserKey(userKey);
await this.activeUserEncryptedPrivateKeyState.update(() => privateKey.encryptedString);
@@ -986,46 +997,6 @@ export class CryptoService implements CryptoServiceAbstraction {
return [new SymmetricCryptoKey(newSymKey) as T, protectedSymKey];
}
private async makeKey(
password: string,
salt: string,
kdf: KdfType,
kdfConfig: KdfConfig,
): Promise<SymmetricCryptoKey> {
let key: Uint8Array = null;
if (kdf == null || kdf === KdfType.PBKDF2_SHA256) {
if (kdfConfig.iterations == null) {
kdfConfig.iterations = PBKDF2_ITERATIONS.defaultValue;
}
key = await this.cryptoFunctionService.pbkdf2(password, salt, "sha256", kdfConfig.iterations);
} else if (kdf == KdfType.Argon2id) {
if (kdfConfig.iterations == null) {
kdfConfig.iterations = ARGON2_ITERATIONS.defaultValue;
}
if (kdfConfig.memory == null) {
kdfConfig.memory = ARGON2_MEMORY.defaultValue;
}
if (kdfConfig.parallelism == null) {
kdfConfig.parallelism = ARGON2_PARALLELISM.defaultValue;
}
const saltHash = await this.cryptoFunctionService.hash(salt, "sha256");
key = await this.cryptoFunctionService.argon2(
password,
saltHash,
kdfConfig.iterations,
kdfConfig.memory * 1024, // convert to KiB from MiB
kdfConfig.parallelism,
);
} else {
throw new Error("Unknown Kdf.");
}
return new SymmetricCryptoKey(key);
}
// --LEGACY METHODS--
// We previously used the master key for additional keys, but now we use the user key.
// These methods support migrating the old keys to the new ones.

View File

@@ -1,3 +1,4 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, timeout } from "rxjs";
import { awaitAsync } from "../../../spec";
@@ -14,9 +15,11 @@ import { DefaultDerivedStateProvider } from "../state/implementations/default-de
import { DefaultGlobalStateProvider } from "../state/implementations/default-global-state.provider";
import { DefaultSingleUserStateProvider } from "../state/implementations/default-single-user-state.provider";
import { DefaultStateProvider } from "../state/implementations/default-state.provider";
/* eslint-disable import/no-restricted-paths */
import { StateEventRegistrarService } from "../state/state-event-registrar.service";
/* eslint-enable import/no-restricted-paths */
import { EnvironmentService } from "./environment.service";
import { StorageServiceProvider } from "./storage-service.provider";
// There are a few main states EnvironmentService could be in when first used
// 1. Not initialized, no active user. Hopefully not to likely but possible
@@ -26,6 +29,8 @@ import { EnvironmentService } from "./environment.service";
describe("EnvironmentService", () => {
let diskStorageService: FakeStorageService;
let memoryStorageService: FakeStorageService;
let storageServiceProvider: StorageServiceProvider;
const stateEventRegistrarService = mock<StateEventRegistrarService>();
let accountService: FakeAccountService;
let stateProvider: StateProvider;
@@ -37,16 +42,17 @@ describe("EnvironmentService", () => {
beforeEach(async () => {
diskStorageService = new FakeStorageService();
memoryStorageService = new FakeStorageService();
storageServiceProvider = new StorageServiceProvider(diskStorageService, memoryStorageService);
accountService = mockAccountServiceWith(undefined);
stateProvider = new DefaultStateProvider(
new DefaultActiveUserStateProvider(
accountService,
memoryStorageService as any,
diskStorageService,
storageServiceProvider,
stateEventRegistrarService,
),
new DefaultSingleUserStateProvider(memoryStorageService as any, diskStorageService),
new DefaultGlobalStateProvider(memoryStorageService as any, diskStorageService),
new DefaultSingleUserStateProvider(storageServiceProvider, stateEventRegistrarService),
new DefaultGlobalStateProvider(storageServiceProvider),
new DefaultDerivedStateProvider(memoryStorageService),
);

View File

@@ -0,0 +1,102 @@
import { mock } from "jest-mock-extended";
import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { CsprngArray } from "../../types/csprng";
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
import { KdfType } from "../enums";
import { KeyGenerationService } from "./key-generation.service";
describe("KeyGenerationService", () => {
let sut: KeyGenerationService;
const cryptoFunctionService = mock<CryptoFunctionService>();
beforeEach(() => {
sut = new KeyGenerationService(cryptoFunctionService);
});
describe("createKey", () => {
test.each([256, 512])(
"it should delegate key creation to crypto function service",
async (bitLength: 256 | 512) => {
cryptoFunctionService.aesGenerateKey
.calledWith(bitLength)
.mockResolvedValue(new Uint8Array(bitLength / 8) as CsprngArray);
await sut.createKey(bitLength);
expect(cryptoFunctionService.aesGenerateKey).toHaveBeenCalledWith(bitLength);
},
);
});
describe("createMaterialAndKey", () => {
test.each([128, 192, 256, 512])(
"should create a 64 byte key from different material lengths",
async (bitLength: 128 | 192 | 256 | 512) => {
const inputMaterial = new Uint8Array(bitLength / 8) as CsprngArray;
const inputSalt = "salt";
const purpose = "purpose";
cryptoFunctionService.aesGenerateKey.calledWith(bitLength).mockResolvedValue(inputMaterial);
cryptoFunctionService.hkdf
.calledWith(inputMaterial, inputSalt, purpose, 64, "sha256")
.mockResolvedValue(new Uint8Array(64));
const { salt, material, derivedKey } = await sut.createKeyWithPurpose(
bitLength,
purpose,
inputSalt,
);
expect(salt).toEqual(inputSalt);
expect(material).toEqual(inputMaterial);
expect(derivedKey.key.length).toEqual(64);
},
);
});
describe("deriveKeyFromMaterial", () => {
it("should derive a 64 byte key from material", async () => {
const material = new Uint8Array(32) as CsprngArray;
const salt = "salt";
const purpose = "purpose";
cryptoFunctionService.hkdf.mockResolvedValue(new Uint8Array(64));
const key = await sut.deriveKeyFromMaterial(material, salt, purpose);
expect(key.key.length).toEqual(64);
});
});
describe("deriveKeyFromPassword", () => {
it("should derive a 32 byte key from a password using pbkdf2", async () => {
const password = "password";
const salt = "salt";
const kdf = KdfType.PBKDF2_SHA256;
const kdfConfig = new KdfConfig(600_000);
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
const key = await sut.deriveKeyFromPassword(password, salt, kdf, kdfConfig);
expect(key.key.length).toEqual(32);
});
it("should derive a 32 byte key from a password using argon2id", async () => {
const password = "password";
const salt = "salt";
const kdf = KdfType.Argon2id;
const kdfConfig = new KdfConfig(600_000, 15);
cryptoFunctionService.hash.mockResolvedValue(new Uint8Array(32));
cryptoFunctionService.argon2.mockResolvedValue(new Uint8Array(32));
const key = await sut.deriveKeyFromPassword(password, salt, kdf, kdfConfig);
expect(key.key.length).toEqual(32);
});
});
});

View File

@@ -0,0 +1,85 @@
import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { CsprngArray } from "../../types/csprng";
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "../abstractions/key-generation.service";
import {
ARGON2_ITERATIONS,
ARGON2_MEMORY,
ARGON2_PARALLELISM,
KdfType,
PBKDF2_ITERATIONS,
} from "../enums";
import { Utils } from "../misc/utils";
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
export class KeyGenerationService implements KeyGenerationServiceAbstraction {
constructor(private cryptoFunctionService: CryptoFunctionService) {}
async createKey(bitLength: 256 | 512): Promise<SymmetricCryptoKey> {
const key = await this.cryptoFunctionService.aesGenerateKey(bitLength);
return new SymmetricCryptoKey(key);
}
async createKeyWithPurpose(
bitLength: 128 | 192 | 256 | 512,
purpose: string,
salt?: string,
): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> {
if (salt == null) {
const bytes = await this.cryptoFunctionService.randomBytes(32);
salt = Utils.fromBufferToUtf8(bytes);
}
const material = await this.cryptoFunctionService.aesGenerateKey(bitLength);
const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256");
return { salt, material, derivedKey: new SymmetricCryptoKey(key) };
}
async deriveKeyFromMaterial(
material: CsprngArray,
salt: string,
purpose: string,
): Promise<SymmetricCryptoKey> {
const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256");
return new SymmetricCryptoKey(key);
}
async deriveKeyFromPassword(
password: string | Uint8Array,
salt: string | Uint8Array,
kdf: KdfType,
kdfConfig: KdfConfig,
): Promise<SymmetricCryptoKey> {
let key: Uint8Array = null;
if (kdf == null || kdf === KdfType.PBKDF2_SHA256) {
if (kdfConfig.iterations == null) {
kdfConfig.iterations = PBKDF2_ITERATIONS.defaultValue;
}
key = await this.cryptoFunctionService.pbkdf2(password, salt, "sha256", kdfConfig.iterations);
} else if (kdf == KdfType.Argon2id) {
if (kdfConfig.iterations == null) {
kdfConfig.iterations = ARGON2_ITERATIONS.defaultValue;
}
if (kdfConfig.memory == null) {
kdfConfig.memory = ARGON2_MEMORY.defaultValue;
}
if (kdfConfig.parallelism == null) {
kdfConfig.parallelism = ARGON2_PARALLELISM.defaultValue;
}
const saltHash = await this.cryptoFunctionService.hash(salt, "sha256");
key = await this.cryptoFunctionService.argon2(
password,
saltHash,
kdfConfig.iterations,
kdfConfig.memory * 1024, // convert to KiB from MiB
kdfConfig.parallelism,
);
} else {
throw new Error("Unknown Kdf.");
}
return new SymmetricCryptoKey(key);
}
}

View File

@@ -3,7 +3,6 @@ import { Jsonify, JsonValue } from "type-fest";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { PolicyData } from "../../admin-console/models/data/policy.data";
import { ProviderData } from "../../admin-console/models/data/provider.data";
import { Policy } from "../../admin-console/models/domain/policy";
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
@@ -378,24 +377,6 @@ export class StateService<
);
}
async getBiometricUnlock(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.settings?.biometricUnlock ?? false
);
}
async setBiometricUnlock(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.settings.biometricUnlock = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getCanAccessPremium(options?: StorageOptions): Promise<boolean> {
if (!(await this.getIsAuthenticated(options))) {
return false;
@@ -462,27 +443,6 @@ export class StateService<
);
}
async getClearClipboard(options?: StorageOptions): Promise<number> {
return (
(
await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
)
)?.settings?.clearClipboard ?? null
);
}
async setClearClipboard(value: number, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
account.settings.clearClipboard = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
}
async getConvertAccountToKeyConnector(options?: StorageOptions): Promise<boolean> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
@@ -775,24 +735,6 @@ export class StateService<
await this.saveSecureStorageKey(partialKeys.biometricKey, value, options);
}
async getBiometricPromptCancelled(options?: StorageOptions): Promise<boolean> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
return account?.settings?.biometricPromptCancelled;
}
async setBiometricPromptCancelled(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.settings.biometricPromptCancelled = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
@withPrototypeForArrayMembers(CipherView, CipherView.fromJSON)
async getDecryptedCiphers(options?: StorageOptions): Promise<CipherView[]> {
return (
@@ -910,81 +852,6 @@ export class StateService<
);
}
async getDisableAddLoginNotification(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.disableAddLoginNotification ?? false
);
}
async setDisableAddLoginNotification(value: boolean, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
globals.disableAddLoginNotification = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getDisableAutoBiometricsPrompt(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.settings?.disableAutoBiometricsPrompt ?? false
);
}
async setDisableAutoBiometricsPrompt(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.settings.disableAutoBiometricsPrompt = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getDisableBadgeCounter(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.settings?.disableBadgeCounter ?? false
);
}
async setDisableBadgeCounter(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.settings.disableBadgeCounter = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getDisableChangedPasswordNotification(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.disableChangedPasswordNotification ?? false
);
}
async setDisableChangedPasswordNotification(
value: boolean,
options?: StorageOptions,
): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
globals.disableChangedPasswordNotification = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getDisableContextMenuItem(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@@ -1359,27 +1226,6 @@ export class StateService<
);
}
async getEnableFullWidth(options?: StorageOptions): Promise<boolean> {
return (
(
await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
)
)?.settings?.enableFullWidth ?? false
);
}
async setEnableFullWidth(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
account.settings.enableFullWidth = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
}
async getEnableMinimizeToTray(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@@ -1733,6 +1579,23 @@ export class StateService<
await this.storageService.save(keys.accountActivity, accountActivity, options);
}
async getLastSync(options?: StorageOptions): Promise<string> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()))
)?.profile?.lastSync;
}
async setLastSync(value: string, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()),
);
account.profile.lastSync = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()),
);
}
async getLocalData(options?: StorageOptions): Promise<{ [cipherId: string]: LocalData }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
@@ -1957,27 +1820,6 @@ export class StateService<
);
}
@withPrototypeForObjectValues(ProviderData)
async getProviders(options?: StorageOptions): Promise<{ [id: string]: ProviderData }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.data?.providers;
}
async setProviders(
value: { [id: string]: ProviderData },
options?: StorageOptions,
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.data.providers = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getRefreshToken(options?: StorageOptions): Promise<string> {
options = await this.getTimeoutBasedStorageOptions(options);
return (await this.getAccount(options))?.tokens?.refreshToken;
@@ -2211,28 +2053,6 @@ export class StateService<
);
}
async getSMOnboardingTasks(
options?: StorageOptions,
): Promise<Record<string, Record<string, boolean>>> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.settings?.smOnboardingTasks;
}
async setSMOnboardingTasks(
value: Record<string, Record<string, boolean>>,
options?: StorageOptions,
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
account.settings.smOnboardingTasks = 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()))

View File

@@ -0,0 +1,28 @@
import { mock } from "jest-mock-extended";
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
import { StorageServiceProvider } from "./storage-service.provider";
describe("StorageServiceProvider", () => {
const mockDiskStorage = mock<AbstractStorageService & ObservableStorageService>();
const mockMemoryStorage = mock<AbstractStorageService & ObservableStorageService>();
const sut = new StorageServiceProvider(mockDiskStorage, mockMemoryStorage);
describe("get", () => {
it("gets disk service when default location is disk", () => {
const [computedLocation, computedService] = sut.get("disk", {});
expect(computedLocation).toBe("disk");
expect(computedService).toStrictEqual(mockDiskStorage);
});
it("gets memory service when default location is memory", () => {
const [computedLocation, computedService] = sut.get("memory", {});
expect(computedLocation).toBe("memory");
expect(computedService).toStrictEqual(mockMemoryStorage);
});
});
});

View File

@@ -0,0 +1,39 @@
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
// eslint-disable-next-line import/no-restricted-paths
import { ClientLocations, StorageLocation } from "../state/state-definition";
export type PossibleLocation = StorageLocation | ClientLocations[keyof ClientLocations];
/**
* A provider for getting client specific computed storage locations and services.
*/
export class StorageServiceProvider {
constructor(
protected readonly diskStorageService: AbstractStorageService & ObservableStorageService,
protected readonly memoryStorageService: AbstractStorageService & ObservableStorageService,
) {}
/**
* Computes the location and corresponding service for a given client.
*
* **NOTE** The default implementation does not respect client overrides and if clients
* have special overrides they are responsible for implementing this service.
* @param defaultLocation The default location to use if no client specific override is preferred.
* @param overrides Client specific overrides
* @returns The computed storage location and corresponding storage service to use to get/store state.
* @throws If there is no configured storage service for the given inputs.
*/
get(
defaultLocation: PossibleLocation,
overrides: Partial<ClientLocations>,
): [location: PossibleLocation, service: AbstractStorageService & ObservableStorageService] {
switch (defaultLocation) {
case "disk":
return [defaultLocation, this.diskStorageService];
case "memory":
return [defaultLocation, this.memoryStorageService];
default:
throw new Error(`Unexpected location: ${defaultLocation}`);
}
}
}

View File

@@ -3,6 +3,7 @@ import { firstValueFrom, timeout } from "rxjs";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { AuthService } from "../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { MessagingService } from "../abstractions/messaging.service";
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
@@ -20,6 +21,7 @@ export class SystemService implements SystemServiceAbstraction {
private platformUtilsService: PlatformUtilsService,
private reloadCallback: () => Promise<void> = null,
private stateService: StateService,
private autofillSettingsService: AutofillSettingsServiceAbstraction,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
) {}
@@ -93,26 +95,33 @@ export class SystemService implements SystemServiceAbstraction {
clearTimeout(this.clearClipboardTimeout);
this.clearClipboardTimeout = null;
}
if (Utils.isNullOrWhitespace(clipboardValue)) {
return;
}
await this.stateService.getClearClipboard().then((clearSeconds) => {
if (clearSeconds == null) {
return;
const clearClipboardDelay = await firstValueFrom(
this.autofillSettingsService.clearClipboardDelay$,
);
if (clearClipboardDelay == null) {
return;
}
if (timeoutMs == null) {
timeoutMs = clearClipboardDelay * 1000;
}
this.clearClipboardTimeoutFunction = async () => {
const clipboardValueNow = await this.platformUtilsService.readFromClipboard();
if (clipboardValue === clipboardValueNow) {
this.platformUtilsService.copyToClipboard("", { clearing: true });
}
if (timeoutMs == null) {
timeoutMs = clearSeconds * 1000;
}
this.clearClipboardTimeoutFunction = async () => {
const clipboardValueNow = await this.platformUtilsService.readFromClipboard();
if (clipboardValue === clipboardValueNow) {
this.platformUtilsService.copyToClipboard("", { clearing: true });
}
};
this.clearClipboardTimeout = setTimeout(async () => {
await this.clearPendingClipboard();
}, timeoutMs);
});
};
this.clearClipboardTimeout = setTimeout(async () => {
await this.clearPendingClipboard();
}, timeoutMs);
}
async clearPendingClipboard() {

View File

@@ -0,0 +1,38 @@
import { Jsonify } from "type-fest";
/**
*
* @param elementDeserializer
* @returns
*/
export function array<T>(
elementDeserializer: (element: Jsonify<T>) => T,
): (array: Jsonify<T[]>) => T[] {
return (array) => {
if (array == null) {
return null;
}
return array.map((element) => elementDeserializer(element));
};
}
/**
*
* @param valueDeserializer
*/
export function record<T, TKey extends string = string>(
valueDeserializer: (value: Jsonify<T>) => T,
): (record: Jsonify<Record<TKey, T>>) => Record<TKey, T> {
return (jsonValue: Jsonify<Record<TKey, T> | null>) => {
if (jsonValue == null) {
return null;
}
const output: Record<string, T> = {};
for (const key in jsonValue) {
output[key] = valueDeserializer((jsonValue as Record<string, Jsonify<T>>)[key]);
}
return output;
};
}

View File

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

View File

@@ -2,13 +2,10 @@ import { Observable, map } from "rxjs";
import { AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-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";
@@ -20,15 +17,22 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
activeUserId$: Observable<UserId | undefined>;
constructor(
protected readonly accountService: AccountService,
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
protected readonly diskStorage: AbstractStorageService & ObservableStorageService,
private readonly accountService: AccountService,
private readonly storageServiceProvider: StorageServiceProvider,
private readonly stateEventRegistrarService: StateEventRegistrarService,
) {
this.activeUserId$ = this.accountService.activeAccount$.pipe(map((account) => account?.id));
}
get<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
const cacheKey = this.buildCacheKey(keyDefinition);
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
@@ -36,36 +40,17 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
return existingUserState as ActiveUserState<T>;
}
const newUserState = this.buildActiveUserState(keyDefinition);
const newUserState = new DefaultActiveUserState<T>(
keyDefinition,
this.accountService,
storageService,
this.stateEventRegistrarService,
);
this.cache[cacheKey] = newUserState;
return newUserState;
}
private buildCacheKey(keyDefinition: KeyDefinition<unknown>) {
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}`;
}
protected buildActiveUserState<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T> {
return new DefaultActiveUserState<T>(
keyDefinition,
this.accountService,
this.getLocation(keyDefinition.stateDefinition),
);
}
protected getLocationString(keyDefinition: KeyDefinition<unknown>): string {
return keyDefinition.stateDefinition.defaultStorageLocation;
}
protected getLocation(stateDefinition: StateDefinition) {
// The default implementations don't support the client overrides
// it is up to the client to extend this class and add that support
const location = stateDefinition.defaultStorageLocation;
switch (location) {
case "disk":
return this.diskStorage;
case "memory":
return this.memoryStorage;
}
private buildCacheKey(location: string, keyDefinition: UserKeyDefinition<unknown>) {
return `${location}_${keyDefinition.fullName}`;
}
}

View File

@@ -11,8 +11,9 @@ import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { UserId } from "../../../types/guid";
import { KeyDefinition, userKeyBuilder } from "../key-definition";
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";
@@ -32,15 +33,17 @@ class TestState {
}
const testStateDefinition = new StateDefinition("fake", "disk");
const cleanupDelayMs = 10;
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
const cleanupDelayMs = 15;
const testKeyDefinition = new UserKeyDefinition<TestState>(testStateDefinition, "fake", {
deserializer: TestState.fromJSON,
cleanupDelayMs,
clearOn: [],
});
describe("DefaultActiveUserState", () => {
const accountService = mock<AccountService>();
let diskStorageService: FakeStorageService;
const stateEventRegistrarService = mock<StateEventRegistrarService>();
let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>;
let userState: DefaultActiveUserState<TestState>;
@@ -49,7 +52,12 @@ describe("DefaultActiveUserState", () => {
accountService.activeAccount$ = activeAccountSubject;
diskStorageService = new FakeStorageService();
userState = new DefaultActiveUserState(testKeyDefinition, accountService, diskStorageService);
userState = new DefaultActiveUserState(
testKeyDefinition,
accountService,
diskStorageService,
stateEventRegistrarService,
);
});
const makeUserId = (id: string) => {
@@ -390,6 +398,48 @@ describe("DefaultActiveUserState", () => {
"No active user at this time.",
);
});
it.each([null, undefined])(
"should register user key definition when state transitions from null-ish (%s) to non-null",
async (startingValue: TestState | null) => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": startingValue,
});
await userState.update(() => ({ array: ["one"], date: new Date() }));
expect(stateEventRegistrarService.registerEvents).toHaveBeenCalledWith(testKeyDefinition);
},
);
it("should not register user key definition when state has preexisting value", async () => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
date: new Date(2019, 1),
array: [],
},
});
await userState.update(() => ({ array: ["one"], date: new Date() }));
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
});
it.each([null, undefined])(
"should not register user key definition when setting value to null-ish (%s) value",
async (updatedValue: TestState | null) => {
diskStorageService.internalUpdateStore({
"user_00000000-0000-1000-a000-000000000001_fake_fake": {
date: new Date(2019, 1),
array: [],
},
});
await userState.update(() => updatedValue);
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
},
);
});
describe("update races", () => {
@@ -592,7 +642,7 @@ describe("DefaultActiveUserState", () => {
beforeEach(async () => {
await changeActiveUser("1");
userKey = userKeyBuilder(userId, testKeyDefinition);
userKey = testKeyDefinition.buildKey(userId);
});
function assertClean() {

View File

@@ -21,8 +21,9 @@ import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { KeyDefinition, userKeyBuilder } from "../key-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { UserKeyDefinition } from "../user-key-definition";
import { ActiveUserState, CombinedState, activeMarker } from "../user-state";
import { getStoredValue } from "./util";
@@ -39,9 +40,10 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
state$: Observable<T>;
constructor(
protected keyDefinition: KeyDefinition<T>,
protected keyDefinition: UserKeyDefinition<T>,
private accountService: AccountService,
private chosenStorageLocation: AbstractStorageService & ObservableStorageService,
private stateEventRegistrarService: StateEventRegistrarService,
) {
this.activeUserId$ = this.accountService.activeAccount$.pipe(
// We only care about the UserId but we do want to know about no user as well.
@@ -61,7 +63,7 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
return FAKE;
}
const fullKey = userKeyBuilder(userId, this.keyDefinition);
const fullKey = this.keyDefinition.buildKey(userId);
const data = await getStoredValue(
fullKey,
this.chosenStorageLocation,
@@ -80,7 +82,7 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
// 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, userKeyBuilder(userId, this.keyDefinition)] as const),
map((userId) => [userId, this.keyDefinition.buildKey(userId)] as const),
),
),
// Filter to only storage updates that pertain to our key
@@ -150,6 +152,11 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
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];
}
@@ -168,7 +175,7 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
if (userId == null) {
throw new Error("No active user at this time.");
}
const fullKey = userKeyBuilder(userId, this.keyDefinition);
const fullKey = this.keyDefinition.buildKey(userId);
return [
userId,
fullKey,

View File

@@ -1,25 +1,21 @@
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { GlobalState } from "../global-state";
import { GlobalStateProvider } from "../global-state.provider";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { DefaultGlobalState } from "./default-global-state";
export class DefaultGlobalStateProvider implements GlobalStateProvider {
private globalStateCache: Record<string, GlobalState<unknown>> = {};
constructor(
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
protected readonly diskStorage: AbstractStorageService & ObservableStorageService,
) {}
constructor(private storageServiceProvider: StorageServiceProvider) {}
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
const cacheKey = this.buildCacheKey(keyDefinition);
const [location, storageService] = this.storageServiceProvider.get(
keyDefinition.stateDefinition.defaultStorageLocation,
keyDefinition.stateDefinition.storageLocationOverrides,
);
const cacheKey = this.buildCacheKey(location, keyDefinition);
const existingGlobalState = this.globalStateCache[cacheKey];
if (existingGlobalState != null) {
// The cast into the actual generic is safe because of rules around key definitions
@@ -27,30 +23,13 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider {
return existingGlobalState as DefaultGlobalState<T>;
}
const newGlobalState = new DefaultGlobalState<T>(
keyDefinition,
this.getLocation(keyDefinition.stateDefinition),
);
const newGlobalState = new DefaultGlobalState<T>(keyDefinition, storageService);
this.globalStateCache[cacheKey] = newGlobalState;
return newGlobalState;
}
private buildCacheKey(keyDefinition: KeyDefinition<unknown>) {
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}`;
}
protected getLocationString(keyDefinition: KeyDefinition<unknown>): string {
return keyDefinition.stateDefinition.defaultStorageLocation;
}
protected getLocation(stateDefinition: StateDefinition) {
const location = stateDefinition.defaultStorageLocation;
switch (location) {
case "disk":
return this.diskStorage;
case "memory":
return this.memoryStorage;
}
private buildCacheKey(location: string, keyDefinition: KeyDefinition<unknown>) {
return `${location}_${keyDefinition.fullName}`;
}
}

View File

@@ -1,11 +1,8 @@
import { UserId } from "../../../types/guid";
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition, isUserKeyDefinition } from "../user-key-definition";
import { SingleUserState } from "../user-state";
import { SingleUserStateProvider } from "../user-state.provider";
@@ -15,12 +12,22 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider {
private cache: Record<string, SingleUserState<unknown>> = {};
constructor(
protected readonly memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
protected readonly diskStorage: AbstractStorageService & ObservableStorageService,
private readonly storageServiceProvider: StorageServiceProvider,
private readonly stateEventRegistrarService: StateEventRegistrarService,
) {}
get<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T> {
const cacheKey = this.buildCacheKey(userId, keyDefinition);
get<T>(
userId: UserId,
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
): SingleUserState<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, userId, 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
@@ -28,38 +35,21 @@ export class DefaultSingleUserStateProvider implements SingleUserStateProvider {
return existingUserState as SingleUserState<T>;
}
const newUserState = this.buildSingleUserState(userId, keyDefinition);
const newUserState = new DefaultSingleUserState<T>(
userId,
keyDefinition,
storageService,
this.stateEventRegistrarService,
);
this.cache[cacheKey] = newUserState;
return newUserState;
}
private buildCacheKey(userId: UserId, keyDefinition: KeyDefinition<unknown>) {
return `${this.getLocationString(keyDefinition)}_${keyDefinition.fullName}_${userId}`;
}
protected buildSingleUserState<T>(
private buildCacheKey(
location: string,
userId: UserId,
keyDefinition: KeyDefinition<T>,
): SingleUserState<T> {
return new DefaultSingleUserState<T>(
userId,
keyDefinition,
this.getLocation(keyDefinition.stateDefinition),
);
}
protected getLocationString(keyDefinition: KeyDefinition<unknown>): string {
return keyDefinition.stateDefinition.defaultStorageLocation;
}
protected getLocation(stateDefinition: StateDefinition) {
// The default implementations don't support the client overrides
// it is up to the client to extend this class and add that support
switch (stateDefinition.defaultStorageLocation) {
case "disk":
return this.diskStorage;
case "memory":
return this.memoryStorage;
}
keyDefinition: UserKeyDefinition<unknown>,
) {
return `${location}_${keyDefinition.fullName}_${userId}`;
}
}

View File

@@ -3,6 +3,7 @@
* @jest-environment ../shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { Jsonify } from "type-fest";
@@ -10,8 +11,9 @@ import { trackEmissions, awaitAsync } from "../../../../spec";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { UserId } from "../../../types/guid";
import { Utils } from "../../misc/utils";
import { KeyDefinition, userKeyBuilder } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { UserKeyDefinition } from "../user-key-definition";
import { DefaultSingleUserState } from "./default-single-user-state";
@@ -31,21 +33,28 @@ class TestState {
const testStateDefinition = new StateDefinition("fake", "disk");
const cleanupDelayMs = 10;
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", {
const testKeyDefinition = new UserKeyDefinition<TestState>(testStateDefinition, "fake", {
deserializer: TestState.fromJSON,
cleanupDelayMs,
clearOn: [],
});
const userId = Utils.newGuid() as UserId;
const userKey = userKeyBuilder(userId, testKeyDefinition);
const userKey = testKeyDefinition.buildKey(userId);
describe("DefaultSingleUserState", () => {
let diskStorageService: FakeStorageService;
let userState: DefaultSingleUserState<TestState>;
const stateEventRegistrarService = mock<StateEventRegistrarService>();
const newData = { date: new Date() };
beforeEach(() => {
diskStorageService = new FakeStorageService();
userState = new DefaultSingleUserState(userId, testKeyDefinition, diskStorageService);
userState = new DefaultSingleUserState(
userId,
testKeyDefinition,
diskStorageService,
stateEventRegistrarService,
);
});
afterEach(() => {
@@ -254,6 +263,49 @@ describe("DefaultSingleUserState", () => {
expect(emissions).toHaveLength(2);
expect(emissions).toEqual(expect.arrayContaining([initialState, newState]));
});
it.each([null, undefined])(
"should register user key definition when state transitions from null-ish (%s) to non-null",
async (startingValue: TestState | null) => {
const initialState: Record<string, TestState> = {};
initialState[userKey] = startingValue;
diskStorageService.internalUpdateStore(initialState);
await userState.update(() => ({ array: ["one"], date: new Date() }));
expect(stateEventRegistrarService.registerEvents).toHaveBeenCalledWith(testKeyDefinition);
},
);
it("should not register user key definition when state has preexisting value", async () => {
const initialState: Record<string, TestState> = {};
initialState[userKey] = {
date: new Date(2019, 1),
};
diskStorageService.internalUpdateStore(initialState);
await userState.update(() => ({ array: ["one"], date: new Date() }));
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
});
it.each([null, undefined])(
"should not register user key definition when setting value to null-ish (%s) value",
async (updatedValue: TestState | null) => {
const initialState: Record<string, TestState> = {};
initialState[userKey] = {
date: new Date(2019, 1),
};
diskStorageService.internalUpdateStore(initialState);
await userState.update(() => updatedValue);
expect(stateEventRegistrarService.registerEvents).not.toHaveBeenCalled();
},
);
});
describe("update races", () => {

View File

@@ -18,8 +18,9 @@ import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { KeyDefinition, userKeyBuilder } from "../key-definition";
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";
@@ -33,10 +34,11 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
constructor(
readonly userId: UserId,
private keyDefinition: KeyDefinition<T>,
private keyDefinition: UserKeyDefinition<T>,
private chosenLocation: AbstractStorageService & ObservableStorageService,
private stateEventRegistrarService: StateEventRegistrarService,
) {
this.storageKey = userKeyBuilder(this.userId, this.keyDefinition);
this.storageKey = this.keyDefinition.buildKey(this.userId);
const initialStorageGet$ = defer(() => {
return getStoredValue(this.storageKey, this.chosenLocation, this.keyDefinition.deserializer);
});
@@ -100,6 +102,11 @@ export class DefaultSingleUserState<T> implements SingleUserState<T> {
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.
await this.stateEventRegistrarService.registerEvents(this.keyDefinition);
}
return newState;
}

View File

@@ -8,6 +8,7 @@ import { DerivedStateProvider } from "../derived-state.provider";
import { GlobalStateProvider } from "../global-state.provider";
import { KeyDefinition } from "../key-definition";
import { StateProvider } from "../state.provider";
import { UserKeyDefinition } from "../user-key-definition";
import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider";
export class DefaultStateProvider implements StateProvider {
@@ -21,7 +22,10 @@ export class DefaultStateProvider implements StateProvider {
this.activeUserId$ = this.activeUserStateProvider.activeUserId$;
}
getUserState$<T>(keyDefinition: KeyDefinition<T>, userId?: UserId): Observable<T> {
getUserState$<T>(
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
userId?: UserId,
): Observable<T> {
if (userId) {
return this.getUser<T>(userId, keyDefinition).state$;
} else {
@@ -33,7 +37,7 @@ export class DefaultStateProvider implements StateProvider {
}
async setUserState<T>(
keyDefinition: KeyDefinition<T>,
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
value: T,
userId?: UserId,
): Promise<[UserId, T]> {

View File

@@ -1,8 +1,12 @@
import { mock } from "jest-mock-extended";
import { mockAccountServiceWith } from "../../../../spec/fake-account-service";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { UserId } from "../../../types/guid";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { KeyDefinition } from "../key-definition";
import { StateDefinition } from "../state-definition";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { DefaultActiveUserState } from "./default-active-user-state";
import { DefaultActiveUserStateProvider } from "./default-active-user-state.provider";
@@ -12,6 +16,9 @@ import { DefaultSingleUserState } from "./default-single-user-state";
import { DefaultSingleUserStateProvider } from "./default-single-user-state.provider";
describe("Specific State Providers", () => {
const storageServiceProvider = mock<StorageServiceProvider>();
const stateEventRegistrarService = mock<StateEventRegistrarService>();
let singleSut: DefaultSingleUserStateProvider;
let activeSut: DefaultActiveUserStateProvider;
let globalSut: DefaultGlobalStateProvider;
@@ -19,19 +26,20 @@ describe("Specific State Providers", () => {
const fakeUser1 = "00000000-0000-1000-a000-000000000001" as UserId;
beforeEach(() => {
storageServiceProvider.get.mockImplementation((location) => {
return [location, new FakeStorageService()];
});
singleSut = new DefaultSingleUserStateProvider(
new FakeStorageService() as any,
new FakeStorageService(),
storageServiceProvider,
stateEventRegistrarService,
);
activeSut = new DefaultActiveUserStateProvider(
mockAccountServiceWith(null),
new FakeStorageService() as any,
new FakeStorageService(),
);
globalSut = new DefaultGlobalStateProvider(
new FakeStorageService() as any,
new FakeStorageService(),
storageServiceProvider,
stateEventRegistrarService,
);
globalSut = new DefaultGlobalStateProvider(storageServiceProvider);
});
const fakeDiskStateDefinition = new StateDefinition("fake", "disk");

View File

@@ -4,8 +4,11 @@ export { DerivedState } from "./derived-state";
export { GlobalState } from "./global-state";
export { StateProvider } from "./state.provider";
export { GlobalStateProvider } from "./global-state.provider";
export { ActiveUserState, SingleUserState } from "./user-state";
export { ActiveUserState, SingleUserState, CombinedState } from "./user-state";
export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
export { KeyDefinition } from "./key-definition";
export { StateUpdateOptions } from "./state-update-options";
export { UserKeyDefinition } from "./user-key-definition";
export { StateEventRunnerService } from "./state-event-runner.service";
export * from "./state-definitions";

View File

@@ -1,15 +1,14 @@
import { Jsonify } from "type-fest";
import { UserId } from "../../types/guid";
import { StorageKey } from "../../types/state";
import { Utils } from "../misc/utils";
import { array, record } from "./deserialization-helpers";
import { StateDefinition } from "./state-definition";
/**
* A set of options for customizing the behavior of a {@link KeyDefinition}
*/
type KeyDefinitionOptions<T> = {
export type KeyDefinitionOptions<T> = {
/**
* A function to use to safely convert your type from json to your expected type.
*
@@ -78,8 +77,7 @@ export class KeyDefinition<T> {
* @param key The key to be added to the KeyDefinition
* @param options The options to customize the final {@link KeyDefinition}.
* @returns A {@link KeyDefinition} initialized for arrays, the options run
* the deserializer on the provided options for each element of an array
* **unless that array is null, in which case it will return an empty list.**
* the deserializer on the provided options for each element of an array.
*
* @example
* ```typescript
@@ -96,12 +94,7 @@ export class KeyDefinition<T> {
) {
return new KeyDefinition<T[]>(stateDefinition, key, {
...options,
deserializer: (jsonValue) => {
if (jsonValue == null) {
return null;
}
return jsonValue.map((v) => options.deserializer(v));
},
deserializer: array((e) => options.deserializer(e)),
});
}
@@ -111,7 +104,7 @@ export class KeyDefinition<T> {
* @param key The key to be added to the KeyDefinition
* @param options The options to customize the final {@link KeyDefinition}.
* @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each
* value in a record and returns every key as a string **unless that record is null, in which case it will return an record.**
* value in a record and returns every key as a string.
*
* @example
* ```typescript
@@ -128,17 +121,7 @@ export class KeyDefinition<T> {
) {
return new KeyDefinition<Record<TKey, T>>(stateDefinition, key, {
...options,
deserializer: (jsonValue) => {
if (jsonValue == null) {
return null;
}
const output: Record<string, T> = {};
for (const key in jsonValue) {
output[key] = options.deserializer((jsonValue as Record<string, Jsonify<T>>)[key]);
}
return output;
},
deserializer: record((v) => options.deserializer(v)),
});
}
@@ -146,24 +129,11 @@ export class KeyDefinition<T> {
return `${this.stateDefinition.name}_${this.key}`;
}
private get errorKeyName() {
protected get errorKeyName() {
return `${this.stateDefinition.name} > ${this.key}`;
}
}
/**
* Creates a {@link StorageKey} that points to the data at the given key definition for the specified user.
* @param userId The userId of the user you want the key to be for.
* @param keyDefinition The key definition of which data the key should point to.
* @returns A key that is ready to be used in a storage service to get data.
*/
export function userKeyBuilder(userId: UserId, keyDefinition: KeyDefinition<unknown>): StorageKey {
if (!Utils.isGuid(userId)) {
throw new Error("You cannot build a user key without a valid UserId");
}
return `user_${userId}_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey;
}
/**
* Creates a {@link StorageKey}
* @param keyDefinition The key definition of which data the key should point to.

View File

@@ -17,50 +17,72 @@ import { StateDefinition } from "./state-definition";
*
*/
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const BILLING_BANNERS_DISK = new StateDefinition("billingBanners", "disk");
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", {
web: "disk-local",
});
export const GENERATOR_DISK = new StateDefinition("generator", "disk");
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
// Admin Console
export const ORGANIZATIONS_DISK = new StateDefinition("organizations", "disk");
export const POLICIES_DISK = new StateDefinition("policies", "disk");
export const POLICIES_MEMORY = new StateDefinition("policies", "memory");
export const PROVIDERS_DISK = new StateDefinition("providers", "disk");
export const FOLDER_DISK = new StateDefinition("folder", "disk", { web: "memory" });
// Auth
export const SYNC_STATE = new StateDefinition("sync", "disk", { web: "memory" });
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", {
web: "disk-local",
});
// Autofill
export const BADGE_SETTINGS_DISK = new StateDefinition("badgeSettings", "disk");
export const USER_NOTIFICATION_SETTINGS_DISK = new StateDefinition(
"userNotificationSettings",
"disk",
);
// Billing
export const COLLECTION_DATA = new StateDefinition("collection", "disk", {
web: "memory",
});
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
export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanner", "disk", {
web: "disk-local",
});
// Platform
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
// Secrets Manager
export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", {
web: "disk-local",
});
// Tools
export const GENERATOR_DISK = new StateDefinition("generator", "disk");
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");
// Vault
export const COLLECTION_DATA = new StateDefinition("collection", "disk", {
web: "memory",
});
export const FOLDER_DISK = new StateDefinition("folder", "disk", { web: "memory" });
export const VAULT_FILTER_DISK = new StateDefinition("vaultFilter", "disk", {
web: "disk-local",
});
export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", {
web: "disk-local",
});
export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", {
web: "disk-local",
});
export const CIPHERS_DISK = new StateDefinition("localData", "disk", { web: "disk-local" });

View File

@@ -0,0 +1,85 @@
import { mock } from "jest-mock-extended";
import { FakeGlobalStateProvider } from "../../../spec";
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
import { StorageServiceProvider } from "../services/storage-service.provider";
import { StateDefinition } from "./state-definition";
import { STATE_LOCK_EVENT, StateEventRegistrarService } from "./state-event-registrar.service";
import { UserKeyDefinition } from "./user-key-definition";
describe("StateEventRegistrarService", () => {
const globalStateProvider = new FakeGlobalStateProvider();
const lockState = globalStateProvider.getFake(STATE_LOCK_EVENT);
const storageServiceProvider = mock<StorageServiceProvider>();
const sut = new StateEventRegistrarService(globalStateProvider, storageServiceProvider);
describe("registerEvents", () => {
const fakeKeyDefinition = new UserKeyDefinition<boolean>(
new StateDefinition("fakeState", "disk"),
"fakeKey",
{
deserializer: (s) => s,
clearOn: ["lock"],
},
);
beforeEach(() => {
jest.resetAllMocks();
});
it("adds event on null storage", async () => {
storageServiceProvider.get.mockReturnValue([
"disk",
mock<AbstractStorageService & ObservableStorageService>(),
]);
await sut.registerEvents(fakeKeyDefinition);
expect(lockState.nextMock).toHaveBeenCalledWith([
{
key: "fakeKey",
location: "disk",
state: "fakeState",
},
]);
});
it("adds event on empty array in storage", async () => {
lockState.stateSubject.next([]);
storageServiceProvider.get.mockReturnValue([
"disk",
mock<AbstractStorageService & ObservableStorageService>(),
]);
await sut.registerEvents(fakeKeyDefinition);
expect(lockState.nextMock).toHaveBeenCalledWith([
{
key: "fakeKey",
location: "disk",
state: "fakeState",
},
]);
});
it("doesn't add a duplicate", async () => {
lockState.stateSubject.next([
{
key: "fakeKey",
location: "disk",
state: "fakeState",
},
]);
storageServiceProvider.get.mockReturnValue([
"disk",
mock<AbstractStorageService & ObservableStorageService>(),
]);
await sut.registerEvents(fakeKeyDefinition);
expect(lockState.nextMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,76 @@
import { PossibleLocation, StorageServiceProvider } from "../services/storage-service.provider";
import { GlobalState } from "./global-state";
import { GlobalStateProvider } from "./global-state.provider";
import { KeyDefinition } from "./key-definition";
import { CLEAR_EVENT_DISK } from "./state-definitions";
import { ClearEvent, UserKeyDefinition } from "./user-key-definition";
export type StateEventInfo = {
state: string;
key: string;
location: PossibleLocation;
};
export const STATE_LOCK_EVENT = KeyDefinition.array<StateEventInfo>(CLEAR_EVENT_DISK, "lock", {
deserializer: (e) => e,
});
export const STATE_LOGOUT_EVENT = KeyDefinition.array<StateEventInfo>(CLEAR_EVENT_DISK, "logout", {
deserializer: (e) => e,
});
export class StateEventRegistrarService {
private readonly stateEventStateMap: { [Prop in ClearEvent]: GlobalState<StateEventInfo[]> };
constructor(
globalStateProvider: GlobalStateProvider,
private storageServiceProvider: StorageServiceProvider,
) {
this.stateEventStateMap = {
lock: globalStateProvider.get(STATE_LOCK_EVENT),
logout: globalStateProvider.get(STATE_LOGOUT_EVENT),
};
}
async registerEvents(keyDefinition: UserKeyDefinition<unknown>) {
for (const clearEvent of keyDefinition.clearOn) {
const eventState = this.stateEventStateMap[clearEvent];
// Determine the storage location for this
const [storageLocation] = this.storageServiceProvider.get(
keyDefinition.stateDefinition.defaultStorageLocation,
keyDefinition.stateDefinition.storageLocationOverrides,
);
const newEvent: StateEventInfo = {
state: keyDefinition.stateDefinition.name,
key: keyDefinition.key,
location: storageLocation,
};
// Only update the event state if the existing list doesn't have a matching entry
await eventState.update(
(existingTickets) => {
existingTickets ??= [];
existingTickets.push(newEvent);
return existingTickets;
},
{
shouldUpdate: (currentTickets) => {
return (
// If the current tickets are null, then it will for sure be added
currentTickets == null ||
// If an existing match couldn't be found, we also need to add one
currentTickets.findIndex(
(e) =>
e.state === newEvent.state &&
e.key === newEvent.key &&
e.location === newEvent.location,
) === -1
);
},
},
);
}
}
}

View File

@@ -0,0 +1,69 @@
import { mock } from "jest-mock-extended";
import { FakeGlobalStateProvider } from "../../../spec";
import { UserId } from "../../types/guid";
import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service";
import { StorageServiceProvider } from "../services/storage-service.provider";
import { STATE_LOCK_EVENT } from "./state-event-registrar.service";
import { StateEventRunnerService } from "./state-event-runner.service";
describe("EventRunnerService", () => {
const fakeGlobalStateProvider = new FakeGlobalStateProvider();
const lockState = fakeGlobalStateProvider.getFake(STATE_LOCK_EVENT);
const storageServiceProvider = mock<StorageServiceProvider>();
const sut = new StateEventRunnerService(fakeGlobalStateProvider, storageServiceProvider);
describe("handleEvent", () => {
it("does nothing if there are no events in state", async () => {
const mockStorageService = mock<AbstractStorageService & ObservableStorageService>();
storageServiceProvider.get.mockReturnValue(["disk", mockStorageService]);
await sut.handleEvent("lock", "bff09d3c-762a-4551-9275-45b137b2f073" as UserId);
expect(lockState.nextMock).not.toHaveBeenCalled();
});
it("loops through and acts on all events", async () => {
const mockDiskStorageService = mock<AbstractStorageService & ObservableStorageService>();
const mockMemoryStorageService = mock<AbstractStorageService & ObservableStorageService>();
lockState.stateSubject.next([
{
state: "fakeState1",
key: "fakeKey1",
location: "disk",
},
{
state: "fakeState2",
key: "fakeKey2",
location: "memory",
},
]);
storageServiceProvider.get.mockImplementation((defaultLocation, overrides) => {
if (defaultLocation === "disk") {
return [defaultLocation, mockDiskStorageService];
} else if (defaultLocation === "memory") {
return [defaultLocation, mockMemoryStorageService];
}
});
mockMemoryStorageService.get.mockResolvedValue("something");
await sut.handleEvent("lock", "bff09d3c-762a-4551-9275-45b137b2f073" as UserId);
expect(mockDiskStorageService.get).toHaveBeenCalledTimes(1);
expect(mockDiskStorageService.get).toHaveBeenCalledWith(
"user_bff09d3c-762a-4551-9275-45b137b2f073_fakeState1_fakeKey1",
);
expect(mockMemoryStorageService.get).toHaveBeenCalledTimes(1);
expect(mockMemoryStorageService.get).toHaveBeenCalledWith(
"user_bff09d3c-762a-4551-9275-45b137b2f073_fakeState2_fakeKey2",
);
expect(mockMemoryStorageService.remove).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,80 @@
import { firstValueFrom } from "rxjs";
import { UserId } from "../../types/guid";
import { StorageServiceProvider } from "../services/storage-service.provider";
import { GlobalState } from "./global-state";
import { GlobalStateProvider } from "./global-state.provider";
import { StateDefinition, StorageLocation } from "./state-definition";
import {
STATE_LOCK_EVENT,
STATE_LOGOUT_EVENT,
StateEventInfo,
} from "./state-event-registrar.service";
import { ClearEvent, UserKeyDefinition } from "./user-key-definition";
export class StateEventRunnerService {
private readonly stateEventMap: { [Prop in ClearEvent]: GlobalState<StateEventInfo[]> };
constructor(
globalStateProvider: GlobalStateProvider,
private storageServiceProvider: StorageServiceProvider,
) {
this.stateEventMap = {
lock: globalStateProvider.get(STATE_LOCK_EVENT),
logout: globalStateProvider.get(STATE_LOGOUT_EVENT),
};
}
async handleEvent(event: ClearEvent, userId: UserId) {
let tickets = await firstValueFrom(this.stateEventMap[event].state$);
tickets ??= [];
const failures: string[] = [];
for (const ticket of tickets) {
try {
const [, service] = this.storageServiceProvider.get(
ticket.location,
{}, // The storage location is already the computed storage location for this client
);
const ticketStorageKey = this.storageKeyFor(userId, ticket);
// Evaluate current value so we can avoid writing to state if we don't need to
const currentValue = await service.get(ticketStorageKey);
if (currentValue != null) {
await service.remove(ticketStorageKey);
}
} catch (err: unknown) {
let errorMessage = "Unknown Error";
if (typeof err === "object" && "message" in err && typeof err.message === "string") {
errorMessage = err.message;
}
failures.push(
`${errorMessage} in ${ticket.state} > ${ticket.key} located ${ticket.location}`,
);
}
}
if (failures.length > 0) {
// Throw aggregated error
throw new Error(
`One or more errors occurred while handling event '${event}' for user ${userId}.\n${failures.join("\n")}`,
);
}
}
private storageKeyFor(userId: UserId, ticket: StateEventInfo) {
const userKey = new UserKeyDefinition<unknown>(
new StateDefinition(ticket.state, ticket.location as unknown as StorageLocation),
ticket.key,
{
deserializer: (v) => v,
clearOn: [],
},
);
return userKey.buildKey(userId);
}
}

View File

@@ -9,6 +9,7 @@ import { GlobalState } from "./global-state";
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs
import { GlobalStateProvider } from "./global-state.provider";
import { KeyDefinition } from "./key-definition";
import { UserKeyDefinition } from "./user-key-definition";
import { ActiveUserState, SingleUserState } from "./user-state";
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs
import { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
@@ -29,22 +30,72 @@ export abstract class StateProvider {
* @param userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned.
*/
getUserState$: <T>(keyDefinition: KeyDefinition<T>, userId?: UserId) => Observable<T>;
/**
* Sets the state for a given key and userId.
*
* @overload
* @param keyDefinition - The key definition for the state you want to set.
* @param value - The value to set the state to.
* @param userId - The userId for which you want to set the state for. If not provided, the state for the currently active user will be set.
*/
setUserState: <T>(
abstract setUserState<T>(
keyDefinition: UserKeyDefinition<T>,
value: T,
userId?: UserId,
): Promise<[UserId, T]>;
/**
* Sets the state for a given key and userId.
*
* **NOTE** Consider converting your {@link KeyDefinition} to a {@link UserKeyDefinition} for additional features.
*
* @overload
* @param keyDefinition - The key definition for the state you want to set.
* @param value - The value to set the state to.
* @param userId - The userId for which you want to set the state for. If not provided, the state for the currently active user will be set.
*/
abstract setUserState<T>(
keyDefinition: KeyDefinition<T>,
value: T,
userId?: UserId,
) => Promise<[UserId, T]>;
): Promise<[UserId, T]>;
abstract setUserState<T>(
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
value: T,
userId?: UserId,
): Promise<[UserId, T]>;
/** @see{@link ActiveUserStateProvider.get} */
getActive: <T>(keyDefinition: KeyDefinition<T>) => ActiveUserState<T>;
abstract getActive<T>(keyDefinition: UserKeyDefinition<T>): ActiveUserState<T>;
/**
* @see{@link ActiveUserStateProvider.get}
*
* **NOTE** Consider converting your {@link KeyDefinition} to a {@link UserKeyDefinition} for additional features.
*/
abstract getActive<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T>;
/** @see{@link ActiveUserStateProvider.get} */
abstract getActive<T>(keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>): ActiveUserState<T>;
/** @see{@link SingleUserStateProvider.get} */
getUser: <T>(userId: UserId, keyDefinition: KeyDefinition<T>) => SingleUserState<T>;
abstract getUser<T>(userId: UserId, keyDefinition: UserKeyDefinition<T>): SingleUserState<T>;
/**
* @see{@link SingleUserStateProvider.get}
*
* **NOTE** Consider converting your {@link KeyDefinition} to a {@link UserKeyDefinition} for additional features.
*/
abstract getUser<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T>;
/** @see{@link SingleUserStateProvider.get} */
abstract getUser<T>(
userId: UserId,
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
): SingleUserState<T>;
/** @see{@link GlobalStateProvider.get} */
getGlobal: <T>(keyDefinition: KeyDefinition<T>) => GlobalState<T>;
getDerived: <TFrom, TTo, TDeps extends DerivedStateDependencies>(

View File

@@ -0,0 +1,149 @@
import { UserId } from "../../types/guid";
import { StorageKey } from "../../types/state";
import { Utils } from "../misc/utils";
import { array, record } from "./deserialization-helpers";
import { KeyDefinition, KeyDefinitionOptions } from "./key-definition";
import { StateDefinition } from "./state-definition";
export type ClearEvent = "lock" | "logout";
type UserKeyDefinitionOptions<T> = KeyDefinitionOptions<T> & {
clearOn: ClearEvent[];
};
const USER_KEY_DEFINITION_MARKER: unique symbol = Symbol("UserKeyDefinition");
export function isUserKeyDefinition<T>(
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
): keyDefinition is UserKeyDefinition<T> {
return (
USER_KEY_DEFINITION_MARKER in keyDefinition &&
keyDefinition[USER_KEY_DEFINITION_MARKER] === true
);
}
export class UserKeyDefinition<T> {
readonly [USER_KEY_DEFINITION_MARKER] = true;
/**
* A unique array of events that the state stored at this key should be cleared on.
*/
readonly clearOn: ClearEvent[];
constructor(
readonly stateDefinition: StateDefinition,
readonly key: string,
private readonly options: UserKeyDefinitionOptions<T>,
) {
if (options.deserializer == null) {
throw new Error(`'deserializer' is a required property on key ${this.errorKeyName}`);
}
if (options.cleanupDelayMs <= 0) {
throw new Error(
`'cleanupDelayMs' must be greater than 0. Value of ${options.cleanupDelayMs} passed to key ${this.errorKeyName} `,
);
}
// Filter out repeat values
this.clearOn = Array.from(new Set(options.clearOn));
}
/**
* Gets the deserializer configured for this {@link KeyDefinition}
*/
get deserializer() {
return this.options.deserializer;
}
/**
* Gets the number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
*/
get cleanupDelayMs() {
return this.options.cleanupDelayMs < 0 ? 0 : this.options.cleanupDelayMs ?? 1000;
}
/**
*
* @param keyDefinition
* @returns
*
* @deprecated You should not use this to convert, just create a {@link UserKeyDefinition}
*/
static fromBaseKeyDefinition<T>(keyDefinition: KeyDefinition<T>) {
return new UserKeyDefinition<T>(keyDefinition.stateDefinition, keyDefinition.key, {
...keyDefinition["options"],
clearOn: [], // Default to not clearing
});
}
/**
* Creates a {@link UserKeyDefinition} for state that is an array.
* @param stateDefinition The state definition to be added to the UserKeyDefinition
* @param key The key to be added to the KeyDefinition
* @param options The options to customize the final {@link UserKeyDefinition}.
* @returns A {@link UserKeyDefinition} initialized for arrays, the options run
* the deserializer on the provided options for each element of an array
* **unless that array is null, in which case it will return an empty list.**
*
* @example
* ```typescript
* const MY_KEY = UserKeyDefinition.array<MyArrayElement>(MY_STATE, "key", {
* deserializer: (myJsonElement) => convertToElement(myJsonElement),
* });
* ```
*/
static array<T>(
stateDefinition: StateDefinition,
key: string,
// We have them provide options for the element of the array, depending on future options we add, this could get a little weird.
options: UserKeyDefinitionOptions<T>,
) {
return new UserKeyDefinition<T[]>(stateDefinition, key, {
...options,
deserializer: array((e) => options.deserializer(e)),
});
}
/**
* Creates a {@link UserKeyDefinition} for state that is a record.
* @param stateDefinition The state definition to be added to the UserKeyDefinition
* @param key The key to be added to the KeyDefinition
* @param options The options to customize the final {@link UserKeyDefinition}.
* @returns A {@link UserKeyDefinition} that contains a serializer that will run the provided deserializer for each
* value in a record and returns every key as a string **unless that record is null, in which case it will return an record.**
*
* @example
* ```typescript
* const MY_KEY = UserKeyDefinition.record<MyRecordValue>(MY_STATE, "key", {
* deserializer: (myJsonValue) => convertToValue(myJsonValue),
* });
* ```
*/
static record<T, TKey extends string = string>(
stateDefinition: StateDefinition,
key: string,
// We have them provide options for the value of the record, depending on future options we add, this could get a little weird.
options: UserKeyDefinitionOptions<T>, // The array helper forces an initialValue of an empty record
) {
return new UserKeyDefinition<Record<TKey, T>>(stateDefinition, key, {
...options,
deserializer: record((v) => options.deserializer(v)),
});
}
get fullName() {
return `${this.stateDefinition.name}_${this.key}`;
}
buildKey(userId: UserId) {
if (!Utils.isGuid(userId)) {
throw new Error("You cannot build a user key without a valid UserId");
}
return `user_${userId}_${this.stateDefinition.name}_${this.key}` as StorageKey;
}
private get errorKeyName() {
return `${this.stateDefinition.name} > ${this.key}`;
}
}

View File

@@ -3,6 +3,7 @@ import { Observable } from "rxjs";
import { UserId } from "../../types/guid";
import { KeyDefinition } from "./key-definition";
import { UserKeyDefinition } from "./user-key-definition";
import { ActiveUserState, SingleUserState } from "./user-state";
/** A provider for getting an implementation of state scoped to a given key and userId */
@@ -10,10 +11,25 @@ export abstract class SingleUserStateProvider {
/**
* Gets a {@link SingleUserState} scoped to the given {@link KeyDefinition} and {@link UserId}
*
* **NOTE** Consider converting your {@link KeyDefinition} to a {@link UserKeyDefinition} for additional features.
*
* @param userId - The {@link UserId} for which you want the user state for.
* @param keyDefinition - The {@link KeyDefinition} for which you want the user state for.
*/
get: <T>(userId: UserId, keyDefinition: KeyDefinition<T>) => SingleUserState<T>;
abstract get<T>(userId: UserId, keyDefinition: KeyDefinition<T>): SingleUserState<T>;
/**
* Gets a {@link SingleUserState} scoped to the given {@link UserKeyDefinition} and {@link UserId}
*
* @param userId - The {@link UserId} for which you want the user state for.
* @param userKeyDefinition - The {@link UserKeyDefinition} for which you want the user state for.
*/
abstract get<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T>;
abstract get<T>(
userId: UserId,
keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>,
): SingleUserState<T>;
}
/** A provider for getting an implementation of state scoped to a given key, but always pointing
@@ -24,11 +40,24 @@ export abstract class ActiveUserStateProvider {
* Convenience re-emission of active user ID from {@link AccountService.activeAccount$}
*/
activeUserId$: Observable<UserId | undefined>;
/**
* Gets a {@link ActiveUserState} scoped to the given {@link KeyDefinition}, but updates when active user changes such
* that the emitted values always represents the state for the currently active user.
*
* @param keyDefinition - The {@link UserKeyDefinition} for which you want the user state for.
*/
abstract get<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T>;
/**
* Gets a {@link ActiveUserState} scoped to the given {@link KeyDefinition}, but updates when active user changes such
* that the emitted values always represents the state for the currently active user.
*
* **NOTE** Consider converting your {@link KeyDefinition} to a {@link UserKeyDefinition} for additional features.
*
* @param keyDefinition - The {@link KeyDefinition} for which you want the user state for.
*/
get: <T>(keyDefinition: KeyDefinition<T>) => ActiveUserState<T>;
abstract get<T>(keyDefinition: KeyDefinition<T>): ActiveUserState<T>;
abstract get<T>(keyDefinition: KeyDefinition<T> | UserKeyDefinition<T>): ActiveUserState<T>;
}

View File

@@ -1,5 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, of } from "rxjs";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { Policy } from "../../admin-console/models/domain/policy";
@@ -7,6 +7,7 @@ import { TokenService } from "../../auth/abstractions/token.service";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { StateService } from "../../platform/abstractions/state.service";
import { BiometricStateService } from "../../platform/biometrics/biometric-state.service";
import { AccountDecryptionOptions } from "../../platform/models/domain/account";
import { EncString } from "../../platform/models/domain/enc-string";
@@ -17,6 +18,7 @@ describe("VaultTimeoutSettingsService", () => {
let tokenService: MockProxy<TokenService>;
let policyService: MockProxy<PolicyService>;
let stateService: MockProxy<StateService>;
const biometricStateService = mock<BiometricStateService>();
let service: VaultTimeoutSettingsService;
beforeEach(() => {
@@ -29,7 +31,14 @@ describe("VaultTimeoutSettingsService", () => {
tokenService,
policyService,
stateService,
biometricStateService,
);
biometricStateService.biometricUnlockEnabled$ = of(false);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("availableVaultTimeoutActions$", () => {
@@ -66,7 +75,7 @@ describe("VaultTimeoutSettingsService", () => {
});
it("contains Lock when the user has biometrics configured", async () => {
stateService.getBiometricUnlock.mockResolvedValue(true);
biometricStateService.biometricUnlockEnabled$ = of(true);
const result = await firstValueFrom(service.availableVaultTimeoutActions$());
@@ -79,7 +88,7 @@ describe("VaultTimeoutSettingsService", () => {
);
stateService.getPinKeyEncryptedUserKey.mockResolvedValue(null);
stateService.getProtectedPin.mockResolvedValue(null);
stateService.getBiometricUnlock.mockResolvedValue(false);
biometricStateService.biometricUnlockEnabled$ = of(false);
const result = await firstValueFrom(service.availableVaultTimeoutActions$());
@@ -127,7 +136,7 @@ describe("VaultTimeoutSettingsService", () => {
`(
"returns $expected when policy is $policy, has unlock method is $unlockMethod, and user preference is $userPreference",
async ({ unlockMethod, policy, userPreference, expected }) => {
stateService.getBiometricUnlock.mockResolvedValue(unlockMethod);
biometricStateService.biometricUnlockEnabled$ = of(unlockMethod);
stateService.getAccountDecryptionOptions.mockResolvedValue(
new AccountDecryptionOptions({ hasMasterPassword: false }),
);

View File

@@ -1,4 +1,4 @@
import { defer } from "rxjs";
import { defer, firstValueFrom } from "rxjs";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
@@ -7,6 +7,8 @@ import { TokenService } from "../../auth/abstractions/token.service";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { StateService } from "../../platform/abstractions/state.service";
import { BiometricStateService } from "../../platform/biometrics/biometric-state.service";
import { UserId } from "../../types/guid";
/**
* - DISABLED: No Pin set
@@ -21,6 +23,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
private tokenService: TokenService,
private policyService: PolicyService,
private stateService: StateService,
private biometricStateService: BiometricStateService,
) {}
async setVaultTimeoutOptions(timeout: number, action: VaultTimeoutAction): Promise<void> {
@@ -74,7 +77,11 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
}
async isBiometricLockSet(userId?: string): Promise<boolean> {
return await this.stateService.getBiometricUnlock({ userId });
const biometricUnlockPromise =
userId == null
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
: this.biometricStateService.getBiometricUnlockEnabled(userId as UserId);
return await biometricUnlockPromise;
}
async getVaultTimeout(userId?: string): Promise<number> {

View File

@@ -11,6 +11,7 @@ import { MessagingService } from "../../platform/abstractions/messaging.service"
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Account } from "../../platform/models/domain/account";
import { StateEventRunnerService } from "../../platform/state";
import { CipherService } from "../../vault/abstractions/cipher.service";
import { CollectionService } from "../../vault/abstractions/collection.service";
import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction";
@@ -28,6 +29,7 @@ describe("VaultTimeoutService", () => {
let stateService: MockProxy<StateService>;
let authService: MockProxy<AuthService>;
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>;
@@ -48,6 +50,7 @@ describe("VaultTimeoutService", () => {
stateService = mock();
authService = mock();
vaultTimeoutSettingsService = mock();
stateEventRunnerService = mock();
lockedCallback = jest.fn();
loggedOutCallback = jest.fn();
@@ -73,6 +76,7 @@ describe("VaultTimeoutService", () => {
stateService,
authService,
vaultTimeoutSettingsService,
stateEventRunnerService,
lockedCallback,
loggedOutCallback,
);
@@ -103,7 +107,8 @@ describe("VaultTimeoutService", () => {
return Promise.resolve(accounts[userId]?.authStatus);
});
stateService.getIsAuthenticated.mockImplementation((options) => {
return Promise.resolve(accounts[options.userId]?.isAuthenticated);
// Just like actual state service, if no userId is given fallback to active userId
return Promise.resolve(accounts[options.userId ?? globalSetups?.userId]?.isAuthenticated);
});
vaultTimeoutSettingsService.getVaultTimeout.mockImplementation((userId) => {
@@ -337,4 +342,80 @@ describe("VaultTimeoutService", () => {
expectNoAction("1");
});
});
describe("lock", () => {
const setupLock = () => {
setupAccounts(
{
user1: {
authStatus: AuthenticationStatus.Unlocked,
isAuthenticated: true,
},
user2: {
authStatus: AuthenticationStatus.Unlocked,
isAuthenticated: true,
},
},
{
userId: "user1",
},
);
};
it("should call state event runner with currently active user if no user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock();
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user1");
});
it("should call messaging service locked message if no user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock();
// Currently these pass `undefined` (or what they were given) as the userId back
// but we could change this to give the user that was locked (active) to these methods
// so they don't have to get it their own way, but that is a behavioral change that needs
// to be tested.
expect(messagingService.send).toHaveBeenCalledWith("locked", { userId: undefined });
});
it("should call locked callback if no user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock();
// Currently these pass `undefined` (or what they were given) as the userId back
// but we could change this to give the user that was locked (active) to these methods
// so they don't have to get it their own way, but that is a behavioral change that needs
// to be tested.
expect(lockedCallback).toHaveBeenCalledWith(undefined);
});
it("should call state event runner with user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock("user2");
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user2");
});
it("should call messaging service locked message with user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock("user2");
expect(messagingService.send).toHaveBeenCalledWith("locked", { userId: "user2" });
});
it("should call locked callback with user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock("user2");
expect(lockedCallback).toHaveBeenCalledWith("user2");
});
});
});

View File

@@ -11,6 +11,8 @@ import { CryptoService } from "../../platform/abstractions/crypto.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { StateEventRunnerService } from "../../platform/state";
import { UserId } from "../../types/guid";
import { CipherService } from "../../vault/abstractions/cipher.service";
import { CollectionService } from "../../vault/abstractions/collection.service";
import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction";
@@ -29,6 +31,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
private stateService: StateService,
private authService: AuthService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private stateEventRunnerService: StateEventRunnerService,
private lockedCallback: (userId?: string) => Promise<void> = null,
private loggedOutCallback: (expired: boolean, userId?: string) => Promise<void> = null,
) {}
@@ -81,7 +84,9 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.logOut(userId);
}
if (userId == null || userId === (await this.stateService.getUserId())) {
const currentUserId = await this.stateService.getUserId();
if (userId == null || userId === currentUserId) {
this.searchService.clearIndex();
await this.folderService.clearCache();
await this.collectionService.clearActiveUserCache();
@@ -98,6 +103,11 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.cipherService.clearCache(userId);
await this.stateEventRunnerService.handleEvent("lock", (userId ?? currentUserId) as UserId);
// FIXME: We should send the userId of the user that was locked, in the case of this method being passed
// undefined then it should give back the currentUserId. Better yet, this method shouldn't take
// an undefined userId at all. All receivers need to be checked for how they handle getting undefined.
this.messagingService.send("locked", { userId: userId });
if (this.lockedCallback != null) {

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