1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 14:04:03 +00:00

Merge branch 'main' into km/userkey-rotation-v2

This commit is contained in:
Bernd Schoolmann
2025-02-05 10:22:02 +01:00
672 changed files with 10740 additions and 5219 deletions

View File

@@ -1,22 +0,0 @@
{
"overrides": [
{
"files": ["*.ts"],
"extends": ["plugin:@angular-eslint/recommended"],
"rules": {
"@angular-eslint/component-class-suffix": "error",
"@angular-eslint/contextual-lifecycle": "error",
"@angular-eslint/directive-class-suffix": "error",
"@angular-eslint/no-empty-lifecycle-method": "error",
"@angular-eslint/no-input-rename": "error",
"@angular-eslint/no-inputs-metadata-property": "error",
"@angular-eslint/no-output-native": "error",
"@angular-eslint/no-output-on-prefix": "error",
"@angular-eslint/no-output-rename": "error",
"@angular-eslint/no-outputs-metadata-property": "error",
"@angular-eslint/use-lifecycle-interface": "error",
"@angular-eslint/use-pipe-transform-interface": "error"
}
}
]
}

View File

@@ -195,7 +195,7 @@ export class BaseLoginDecryptionOptionsComponentV1 implements OnInit, OnDestroy
async loadNewUserData() {
const autoEnrollStatus$ = defer(() =>
this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(),
this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeAccountId),
).pipe(
switchMap((organizationIdentifier) => {
if (organizationIdentifier == undefined) {

View File

@@ -10,12 +10,10 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { PasswordColorText } from "../../tools/password-strength/password-strength.component";
@@ -41,10 +39,8 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
protected i18nService: I18nService,
protected keyService: KeyService,
protected messagingService: MessagingService,
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
protected platformUtilsService: PlatformUtilsService,
protected policyService: PolicyService,
protected stateService: StateService,
protected dialogService: DialogService,
protected kdfConfigService: KdfConfigService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,

View File

@@ -64,11 +64,12 @@ export class LoginViaAuthRequestComponentV1
protected StateEnum = State;
protected state = State.StandardAuthRequest;
protected webVaultUrl: string;
protected twoFactorRoute = "2fa";
protected successRoute = "vault";
protected forcePasswordResetRoute = "update-temp-password";
private resendTimeout = 12000;
protected deviceManagementUrl: string;
private authRequestKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array };
@@ -95,6 +96,12 @@ export class LoginViaAuthRequestComponentV1
) {
super(environmentService, i18nService, platformUtilsService, toastService);
// Get the web vault URL from the environment service
environmentService.environment$.pipe(takeUntil(this.destroy$)).subscribe((env) => {
this.webVaultUrl = env.getWebVaultUrl();
this.deviceManagementUrl = `${this.webVaultUrl}/#/settings/security/device-management`;
});
// Gets signalR push notification
// Only fires on approval to prevent enumeration
this.authRequestService.authRequestPushNotification$

View File

@@ -26,7 +26,6 @@ import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
@@ -34,7 +33,6 @@ import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component";
@@ -49,7 +47,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
resetPasswordAutoEnroll = false;
onSuccessfulChangePassword: () => Promise<void>;
successRoute = "vault";
userId: UserId;
activeUserId: UserId;
forceSetPasswordReason: ForceSetPasswordReason = ForceSetPasswordReason.None;
ForceSetPasswordReason = ForceSetPasswordReason;
@@ -60,7 +58,6 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
i18nService: I18nService,
keyService: KeyService,
messagingService: MessagingService,
passwordGenerationService: PasswordGenerationServiceAbstraction,
platformUtilsService: PlatformUtilsService,
private policyApiService: PolicyApiServiceAbstraction,
policyService: PolicyService,
@@ -68,7 +65,6 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
private apiService: ApiService,
private syncService: SyncService,
private route: ActivatedRoute,
stateService: StateService,
private organizationApiService: OrganizationApiServiceAbstraction,
private organizationUserApiService: OrganizationUserApiService,
private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
@@ -82,10 +78,8 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
i18nService,
keyService,
messagingService,
passwordGenerationService,
platformUtilsService,
policyService,
stateService,
dialogService,
kdfConfigService,
masterPasswordService,
@@ -102,10 +96,10 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
await this.syncService.fullSync(true);
this.syncLoading = false;
this.userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
this.activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
this.forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(this.userId),
this.masterPasswordService.forceSetPasswordReason$(this.activeUserId),
);
this.route.queryParams
@@ -117,7 +111,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
} else {
// Try to get orgSsoId from state as fallback
// Note: this is primarily for the TDE user w/out MP obtains admin MP reset permission scenario.
return this.ssoLoginService.getActiveUserOrganizationSsoIdentifier();
return this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeUserId);
}
}),
filter((orgSsoId) => orgSsoId != null),
@@ -173,10 +167,10 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
// in case we have a local private key, and are not sure whether it has been posted to the server, we post the local private key instead of generating a new one
const existingUserPrivateKey = (await firstValueFrom(
this.keyService.userPrivateKey$(this.userId),
this.keyService.userPrivateKey$(this.activeUserId),
)) as Uint8Array;
const existingUserPublicKey = await firstValueFrom(
this.keyService.userPublicKey$(this.userId),
this.keyService.userPublicKey$(this.activeUserId),
);
if (existingUserPrivateKey != null && existingUserPublicKey != null) {
const existingUserPublicKeyB64 = Utils.fromBufferToB64(existingUserPublicKey);
@@ -223,7 +217,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
return this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
this.orgId,
this.userId,
this.activeUserId,
resetRequest,
);
});
@@ -266,7 +260,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.None,
this.userId,
this.activeUserId,
);
// User now has a password so update account decryption options in state
@@ -275,9 +269,9 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
);
userDecryptionOpts.hasMasterPassword = true;
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
await this.kdfConfigService.setKdfConfig(this.userId, this.kdfConfig);
await this.masterPasswordService.setMasterKey(masterKey, this.userId);
await this.keyService.setUserKey(userKey[0], this.userId);
await this.kdfConfigService.setKdfConfig(this.activeUserId, this.kdfConfig);
await this.masterPasswordService.setMasterKey(masterKey, this.activeUserId);
await this.keyService.setUserKey(userKey[0], this.activeUserId);
// Set private key only for new JIT provisioned users in MP encryption orgs
// Existing TDE users will have private key set on sync or on login
@@ -286,7 +280,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
this.forceSetPasswordReason !=
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission
) {
await this.keyService.setPrivateKey(keyPair[1].encryptedString, this.userId);
await this.keyService.setPrivateKey(keyPair[1].encryptedString, this.activeUserId);
}
const localMasterKeyHash = await this.keyService.hashMasterKey(
@@ -294,6 +288,6 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
masterKey,
HashPurpose.LocalAuthorization,
);
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, this.userId);
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, this.activeUserId);
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, NavigationExtras, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { first } from "rxjs/operators";
@@ -27,6 +28,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
@@ -55,6 +57,7 @@ export class SsoComponent implements OnInit {
protected redirectUri: string;
protected state: string;
protected codeChallenge: string;
protected activeUserId: UserId;
constructor(
protected ssoLoginService: SsoLoginServiceAbstraction,
@@ -74,7 +77,11 @@ export class SsoComponent implements OnInit {
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected accountService: AccountService,
protected toastService: ToastService,
) {}
) {
this.accountService.activeAccount$.pipe(takeUntilDestroyed()).subscribe((account) => {
this.activeUserId = account?.id;
});
}
async ngOnInit() {
// eslint-disable-next-line rxjs/no-async-subscribe
@@ -226,7 +233,10 @@ export class SsoComponent implements OnInit {
// - TDE login decryption options component
// - Browser SSO on extension open
// Note: you cannot set this in state before 2FA b/c there won't be an account in state.
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier);
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(
orgSsoIdentifier,
this.activeUserId,
);
// Users enrolled in admin acct recovery can be forced to set a new password after
// having the admin set a temp password for them (affects TDE & standard users)

View File

@@ -69,7 +69,7 @@
</a>
</div>
<div class="text-center">
<a bitLink href="#" appStopClick (click)="selectOtherTwofactorMethod()">{{
<a bitLink href="#" appStopClick (click)="selectOtherTwoFactorMethod()">{{
"useAnotherTwoStepMethod" | i18n
}}</a>
</div>

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit, ViewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { ActivatedRoute, NavigationExtras, Router, RouterLink } from "@angular/router";
import { Subject, takeUntil, lastValueFrom, first, firstValueFrom } from "rxjs";
@@ -31,6 +32,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
AsyncActionsModule,
ButtonModule,
@@ -126,6 +128,7 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements
protected changePasswordRoute = "set-password";
protected forcePasswordResetRoute = "update-temp-password";
protected successRoute = "vault";
protected activeUserId: UserId;
constructor(
protected loginStrategyService: LoginStrategyServiceAbstraction,
@@ -148,6 +151,10 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements
protected toastService: ToastService,
) {
super(environmentService, i18nService, platformUtilsService, toastService);
this.accountService.activeAccount$.pipe(takeUntilDestroyed()).subscribe((account) => {
this.activeUserId = account?.id;
});
}
async ngOnInit() {
@@ -214,7 +221,7 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements
}
}
async selectOtherTwofactorMethod() {
async selectOtherTwoFactorMethod() {
const dialogRef = TwoFactorOptionsComponent.open(this.dialogService);
const response: TwoFactorOptionsDialogResultType = await lastValueFrom(dialogRef.closed);
if (response.result === TwoFactorOptionsDialogResult.Provider) {
@@ -262,7 +269,10 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements
// Save off the OrgSsoIdentifier for use in the TDE flows
// - TDE login decryption options component
// - Browser SSO on extension open
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier);
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(
this.orgIdentifier,
this.activeUserId,
);
this.loginEmailService.clearValues();
// note: this flow affects both TDE & standard users

View File

@@ -35,6 +35,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { CaptchaProtectedComponent } from "./captcha-protected.component";
@@ -73,6 +74,8 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
protected successRoute = "vault";
protected twoFactorTimeoutRoute = "authentication-timeout";
protected activeUserId: UserId;
get isDuoProvider(): boolean {
return (
this.selectedProviderType === TwoFactorProviderType.Duo ||
@@ -102,8 +105,13 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
protected toastService: ToastService,
) {
super(environmentService, i18nService, platformUtilsService, toastService);
this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win);
this.accountService.activeAccount$.pipe(takeUntilDestroyed()).subscribe((account) => {
this.activeUserId = account?.id;
});
// Add subscription to authenticationSessionTimeout$ and navigate to twoFactorTimeoutRoute if expired
this.loginStrategyService.authenticationSessionTimeout$
.pipe(takeUntilDestroyed())
@@ -287,7 +295,10 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
// Save off the OrgSsoIdentifier for use in the TDE flows
// - TDE login decryption options component
// - Browser SSO on extension open
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier);
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(
this.orgIdentifier,
this.activeUserId,
);
this.loginEmailService.clearValues();
// note: this flow affects both TDE & standard users

View File

@@ -16,11 +16,9 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
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 { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component";
@@ -39,12 +37,10 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent {
protected router: Router,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
passwordGenerationService: PasswordGenerationServiceAbstraction,
policyService: PolicyService,
keyService: KeyService,
messagingService: MessagingService,
private apiService: ApiService,
stateService: StateService,
private userVerificationService: UserVerificationService,
private logService: LogService,
dialogService: DialogService,
@@ -57,10 +53,8 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent {
i18nService,
keyService,
messagingService,
passwordGenerationService,
platformUtilsService,
policyService,
stateService,
dialogService,
kdfConfigService,
masterPasswordService,

View File

@@ -20,12 +20,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
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 { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component";
@@ -51,12 +49,10 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
constructor(
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
passwordGenerationService: PasswordGenerationServiceAbstraction,
policyService: PolicyService,
keyService: KeyService,
messagingService: MessagingService,
private apiService: ApiService,
stateService: StateService,
private syncService: SyncService,
private logService: LogService,
private userVerificationService: UserVerificationService,
@@ -71,10 +67,8 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
i18nService,
keyService,
messagingService,
passwordGenerationService,
platformUtilsService,
policyService,
stateService,
dialogService,
kdfConfigService,
masterPasswordService,

View File

@@ -2,3 +2,4 @@ export * from "./add-account-credit-dialog/add-account-credit-dialog.component";
export * from "./invoices/invoices.component";
export * from "./invoices/no-invoices.component";
export * from "./manage-tax-information/manage-tax-information.component";
export * from "./premium.component";

View File

@@ -6,8 +6,6 @@ import { firstValueFrom, Observable, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -20,13 +18,11 @@ export class PremiumComponent implements OnInit {
price = 10;
refreshPromise: Promise<any>;
cloudWebVaultUrl: string;
extensionRefreshFlagEnabled: boolean;
constructor(
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected apiService: ApiService,
protected configService: ConfigService,
private logService: LogService,
protected dialogService: DialogService,
private environmentService: EnvironmentService,
@@ -43,9 +39,6 @@ export class PremiumComponent implements OnInit {
async ngOnInit() {
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
this.extensionRefreshFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.ExtensionRefresh,
);
}
async refresh() {
@@ -66,15 +59,13 @@ export class PremiumComponent implements OnInit {
const dialogOpts: SimpleDialogOptions = {
title: { key: "continueToBitwardenDotCom" },
content: {
key: this.extensionRefreshFlagEnabled ? "premiumPurchaseAlertV2" : "premiumPurchaseAlert",
key: "premiumPurchaseAlertV2",
},
type: "info",
};
if (this.extensionRefreshFlagEnabled) {
dialogOpts.acceptButtonText = { key: "continue" };
dialogOpts.cancelButtonText = { key: "close" };
}
dialogOpts.acceptButtonText = { key: "continue" };
dialogOpts.cancelButtonText = { key: "close" };
const confirmed = await this.dialogService.openSimpleDialog(dialogOpts);

View File

@@ -30,6 +30,7 @@ import {
} from "@bitwarden/components";
import { TwoFactorIconComponent } from "./auth/components/two-factor-icon.component";
import { NotPremiumDirective } from "./billing/directives/not-premium.directive";
import { DeprecatedCalloutComponent } from "./components/callout.component";
import { A11yInvalidDirective } from "./directives/a11y-invalid.directive";
import { ApiActionDirective } from "./directives/api-action.directive";
@@ -40,7 +41,6 @@ import { IfFeatureDirective } from "./directives/if-feature.directive";
import { InputStripSpacesDirective } from "./directives/input-strip-spaces.directive";
import { InputVerbatimDirective } from "./directives/input-verbatim.directive";
import { LaunchClickDirective } from "./directives/launch-click.directive";
import { NotPremiumDirective } from "./directives/not-premium.directive";
import { StopClickDirective } from "./directives/stop-click.directive";
import { StopPropDirective } from "./directives/stop-prop.directive";
import { TextDragDirective } from "./directives/text-drag.directive";

View File

@@ -47,7 +47,6 @@ import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstracti
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
@@ -177,6 +176,16 @@ import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/inter
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
import { Account } from "@bitwarden/common/platform/models/domain/account";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { NotificationsService } from "@bitwarden/common/platform/notifications";
// eslint-disable-next-line no-restricted-imports -- Needed for service creation
import {
DefaultNotificationsService,
NoopNotificationsService,
SignalRConnectionService,
UnsupportedWebPushConnectionService,
WebPushConnectionService,
WebPushNotificationsApiService,
} from "@bitwarden/common/platform/notifications/internal";
import {
TaskSchedulerService,
DefaultTaskSchedulerService,
@@ -194,7 +203,6 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo
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 { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service";
import { StateService } from "@bitwarden/common/platform/services/state.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
@@ -228,7 +236,6 @@ import { ApiService } from "@bitwarden/common/services/api.service";
import { AuditService } from "@bitwarden/common/services/audit.service";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/services/notifications.service";
import { SearchService } from "@bitwarden/common/services/search.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
@@ -275,12 +282,6 @@ import {
PasswordGenerationServiceAbstraction,
UsernameGenerationServiceAbstraction,
} from "@bitwarden/generator-legacy";
import {
ImportApiService,
ImportApiServiceAbstraction,
ImportService,
ImportServiceAbstraction,
} from "@bitwarden/importer/core";
import {
KeyService,
DefaultKeyService,
@@ -295,7 +296,7 @@ import {
DefaultUserAsymmetricKeysRegenerationApiService,
} from "@bitwarden/key-management";
import { SafeInjectionToken } from "@bitwarden/ui-common";
import { PasswordRepromptService } from "@bitwarden/vault";
import { NewDeviceVerificationNoticeService, PasswordRepromptService } from "@bitwarden/vault";
import {
VaultExportService,
VaultExportServiceAbstraction,
@@ -305,9 +306,6 @@ import {
IndividualVaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { NewDeviceVerificationNoticeService } from "../../../vault/src/services/new-device-verification-notice.service";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
@@ -556,7 +554,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: InternalAccountService,
useClass: AccountServiceImplementation,
deps: [MessagingServiceAbstraction, LogService, GlobalStateProvider],
deps: [MessagingServiceAbstraction, LogService, GlobalStateProvider, SingleUserStateProvider],
}),
safeProvider({
provide: AccountServiceAbstraction,
@@ -798,7 +796,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: SsoLoginServiceAbstraction,
useClass: SsoLoginService,
deps: [StateProvider],
deps: [StateProvider, LogService],
}),
safeProvider({
provide: STATE_FACTORY,
@@ -819,26 +817,6 @@ const safeProviders: SafeProvider[] = [
MigrationRunner,
],
}),
safeProvider({
provide: ImportApiServiceAbstraction,
useClass: ImportApiService,
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: ImportServiceAbstraction,
useClass: ImportService,
deps: [
CipherServiceAbstraction,
FolderServiceAbstraction,
ImportApiServiceAbstraction,
I18nServiceAbstraction,
CollectionService,
KeyService,
EncryptService,
PinServiceAbstraction,
AccountServiceAbstraction,
],
}),
safeProvider({
provide: IndividualVaultExportServiceAbstraction,
useClass: IndividualVaultExportService,
@@ -879,19 +857,36 @@ const safeProviders: SafeProvider[] = [
deps: [LogService, I18nServiceAbstraction, StateProvider],
}),
safeProvider({
provide: NotificationsServiceAbstraction,
useClass: devFlagEnabled("noopNotifications") ? NoopNotificationsService : NotificationsService,
provide: WebPushNotificationsApiService,
useClass: WebPushNotificationsApiService,
deps: [ApiServiceAbstraction, AppIdServiceAbstraction],
}),
safeProvider({
provide: SignalRConnectionService,
useClass: SignalRConnectionService,
deps: [ApiServiceAbstraction, LogService],
}),
safeProvider({
provide: WebPushConnectionService,
useClass: UnsupportedWebPushConnectionService,
deps: [],
}),
safeProvider({
provide: NotificationsService,
useClass: devFlagEnabled("noopNotifications")
? NoopNotificationsService
: DefaultNotificationsService,
deps: [
LogService,
SyncService,
AppIdServiceAbstraction,
ApiServiceAbstraction,
EnvironmentService,
LOGOUT_CALLBACK,
StateServiceAbstraction,
AuthServiceAbstraction,
MessagingServiceAbstraction,
TaskSchedulerService,
AccountServiceAbstraction,
SignalRConnectionService,
AuthServiceAbstraction,
WebPushConnectionService,
],
}),
safeProvider({

View File

@@ -16,6 +16,7 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -26,7 +27,7 @@ import { KeyService } from "@bitwarden/key-management";
export class AttachmentsComponent implements OnInit {
@Input() cipherId: string;
@Input() viewOnly: boolean;
@Output() onUploadedAttachment = new EventEmitter();
@Output() onUploadedAttachment = new EventEmitter<CipherView>();
@Output() onDeletedAttachment = new EventEmitter();
@Output() onReuploadedAttachment = new EventEmitter();
@@ -34,7 +35,7 @@ export class AttachmentsComponent implements OnInit {
cipherDomain: Cipher;
canAccessAttachments: boolean;
formPromise: Promise<any>;
deletePromises: { [id: string]: Promise<any> } = {};
deletePromises: { [id: string]: Promise<CipherData> } = {};
reuploadPromises: { [id: string]: Promise<any> } = {};
emergencyAccessId?: string = null;
protected componentName = "";
@@ -96,7 +97,7 @@ export class AttachmentsComponent implements OnInit {
title: null,
message: this.i18nService.t("attachmentSaved"),
});
this.onUploadedAttachment.emit();
this.onUploadedAttachment.emit(this.cipher);
} catch (e) {
this.logService.error(e);
}
@@ -125,7 +126,16 @@ export class AttachmentsComponent implements OnInit {
try {
this.deletePromises[attachment.id] = this.deleteCipherAttachment(attachment.id);
await this.deletePromises[attachment.id];
const updatedCipher = await this.deletePromises[attachment.id];
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const cipher = new Cipher(updatedCipher);
this.cipher = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
this.toastService.showToast({
variant: "success",
title: null,
@@ -140,7 +150,7 @@ export class AttachmentsComponent implements OnInit {
}
this.deletePromises[attachment.id] = null;
this.onDeletedAttachment.emit();
this.onDeletedAttachment.emit(this.cipher);
}
async download(attachment: AttachmentView) {

View File

@@ -1,7 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { BehaviorSubject, firstValueFrom, from, Subject, switchMap, takeUntil } from "rxjs";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -40,7 +41,12 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
constructor(
protected searchService: SearchService,
protected cipherService: CipherService,
) {}
) {
this.cipherService.cipherViews$.pipe(takeUntilDestroyed()).subscribe((ciphers) => {
void this.doSearch(ciphers);
this.loaded = true;
});
}
ngOnInit(): void {
this._searchText$
@@ -117,7 +123,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted;
protected async doSearch(indexedCiphers?: CipherView[]) {
indexedCiphers = indexedCiphers ?? (await this.cipherService.getAllDecrypted());
indexedCiphers = indexedCiphers ?? (await firstValueFrom(this.cipherService.cipherViews$));
const failedCiphers = await firstValueFrom(this.cipherService.failedToDecryptCiphers$);
if (failedCiphers != null && failedCiphers.length > 0) {

View File

@@ -11,7 +11,7 @@ import {
OnInit,
Output,
} from "@angular/core";
import { firstValueFrom, map, Observable } from "rxjs";
import { filter, firstValueFrom, map, Observable } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
@@ -144,11 +144,15 @@ export class ViewComponent implements OnDestroy, OnInit {
async load() {
this.cleanUp();
const cipher = await this.cipherService.get(this.cipherId);
const activeUserId = await firstValueFrom(this.activeUserId$);
this.cipher = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
// Grab individual cipher from `cipherViews$` for the most up-to-date information
this.cipher = await firstValueFrom(
this.cipherService.cipherViews$.pipe(
map((ciphers) => ciphers.find((c) => c.id === this.cipherId)),
filter((cipher) => !!cipher),
),
);
this.canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId),
);

View File

@@ -8,10 +8,8 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { NewDeviceVerificationNoticeService } from "@bitwarden/vault";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { NewDeviceVerificationNoticeService } from "../../../../vault/src/services/new-device-verification-notice.service";
import { VaultProfileService } from "../services/vault-profile.service";
import { NewDeviceVerificationNoticeGuard } from "./new-device-verification-notice.guard";
@@ -36,7 +34,7 @@ describe("NewDeviceVerificationNoticeGuard", () => {
return Promise.resolve(false);
});
const isSelfHost = jest.fn().mockResolvedValue(false);
const isSelfHost = jest.fn().mockReturnValue(false);
const getProfileTwoFactorEnabled = jest.fn().mockResolvedValue(false);
const policyAppliesToActiveUser$ = jest.fn().mockReturnValue(new BehaviorSubject<boolean>(false));
const noticeState$ = jest.fn().mockReturnValue(new BehaviorSubject(null));
@@ -139,6 +137,12 @@ describe("NewDeviceVerificationNoticeGuard", () => {
expect(await newDeviceGuard()).toBe(true);
});
it("returns `true` when the profile service throws an error", async () => {
getProfileCreationDate.mockRejectedValueOnce(new Error("test"));
expect(await newDeviceGuard()).toBe(true);
});
describe("temp flag", () => {
beforeEach(() => {
getFeatureFlag.mockImplementation((key) => {

View File

@@ -1,6 +1,6 @@
import { inject } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivateFn, Router } from "@angular/router";
import { Observable, firstValueFrom } from "rxjs";
import { firstValueFrom, Observable } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -8,10 +8,8 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { NewDeviceVerificationNoticeService } from "@bitwarden/vault";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { NewDeviceVerificationNoticeService } from "../../../../vault/src/services/new-device-verification-notice.service";
import { VaultProfileService } from "../services/vault-profile.service";
export const NewDeviceVerificationNoticeGuard: CanActivateFn = async (
@@ -47,17 +45,23 @@ export const NewDeviceVerificationNoticeGuard: CanActivateFn = async (
return router.createUrlTree(["/login"]);
}
const has2FAEnabled = await hasATwoFactorProviderEnabled(vaultProfileService, currentAcct.id);
const isSelfHosted = await platformUtilsService.isSelfHost();
const requiresSSO = await isSSORequired(policyService);
const isProfileLessThanWeekOld = await profileIsLessThanWeekOld(
vaultProfileService,
currentAcct.id,
);
try {
const isSelfHosted = platformUtilsService.isSelfHost();
const requiresSSO = await isSSORequired(policyService);
const has2FAEnabled = await hasATwoFactorProviderEnabled(vaultProfileService, currentAcct.id);
const isProfileLessThanWeekOld = await profileIsLessThanWeekOld(
vaultProfileService,
currentAcct.id,
);
// When any of the following are true, the device verification notice is
// not applicable for the user.
if (has2FAEnabled || isSelfHosted || requiresSSO || isProfileLessThanWeekOld) {
// When any of the following are true, the device verification notice is
// not applicable for the user.
if (has2FAEnabled || isSelfHosted || requiresSSO || isProfileLessThanWeekOld) {
return true;
}
} catch {
// Skip showing the notice if there was a problem determining applicability
// The most likely problem to occur is the user not having a network connection
return true;
}

View File

@@ -16,14 +16,11 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state";
import { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "../../abstractions/deprecated-vault-filter.service";
import { DynamicTreeNode } from "../models/dynamic-tree-node.model";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { COLLAPSED_GROUPINGS } from "./../../../../../common/src/vault/services/key-state/collapsed-groupings.state";
const NestingDelimiter = "/";
@Injectable()

View File

@@ -13,7 +13,6 @@
"@bitwarden/generator-history": ["../tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"],
"@bitwarden/importer/core": ["../importer/src"],
"@bitwarden/key-management": ["../key-management/src"],
"@bitwarden/platform": ["../platform/src"],
"@bitwarden/ui-common": ["../ui/common/src"],

View File

@@ -1,5 +1,5 @@
<bit-dialog>
<span bitDialogTitle>{{ "areYouTryingtoLogin" | i18n }}</span>
<span bitDialogTitle>{{ "areYouTryingToAccessYourAccount" | i18n }}</span>
<ng-container bitDialogContent>
<ng-container *ngIf="loading">
<div class="tw-flex tw-items-center tw-justify-center" *ngIf="loading">
@@ -8,7 +8,7 @@
</ng-container>
<ng-container *ngIf="!loading">
<h4 class="tw-mb-3">{{ "logInAttemptBy" | i18n: email }}</h4>
<h4 class="tw-mb-3">{{ "accessAttemptBy" | i18n: email }}</h4>
<div>
<b>{{ "fingerprintPhraseHeader" | i18n }}</b>
<p class="tw-text-code">{{ fingerprintPhrase }}</p>
@@ -35,7 +35,7 @@
[bitAction]="approveLogin"
[disabled]="loading"
>
{{ "confirmLogIn" | i18n }}
{{ "confirmAccess" | i18n }}
</button>
<button
bitButton
@@ -44,7 +44,7 @@
[bitAction]="denyLogin"
[disabled]="loading"
>
{{ "denyLogIn" | i18n }}
{{ "denyAccess" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -85,7 +85,7 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
}
const publicKey = Utils.fromB64ToArray(this.authRequestResponse.publicKey);
this.email = await await firstValueFrom(
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(

View File

@@ -202,7 +202,7 @@ export class LoginDecryptionOptionsComponent implements OnInit {
});
const autoEnrollStatus$ = defer(() =>
this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(),
this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeAccountId),
).pipe(
switchMap((organizationIdentifier) => {
if (organizationIdentifier == undefined) {

View File

@@ -1,6 +1,20 @@
<div class="tw-text-center">
<ng-container *ngIf="flow === Flow.StandardAuthRequest">
<p>{{ "makeSureYourAccountIsUnlockedAndTheFingerprintEtc" | i18n }}</p>
<p *ngIf="clientType !== ClientType.Web">
{{ "notificationSentDevicePart1" | i18n }}
<a
bitLink
linkType="primary"
class="tw-cursor-pointer"
[href]="deviceManagementUrl"
target="_blank"
rel="noreferrer"
>{{ "notificationSentDeviceAnchor" | i18n }}</a
>. {{ "notificationSentDevicePart2" | i18n }}
</p>
<p *ngIf="clientType === ClientType.Web">
{{ "notificationSentDeviceComplete" | i18n }}
</p>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>

View File

@@ -29,6 +29,7 @@ import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -71,6 +72,8 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
protected showResendNotification = false;
protected Flow = Flow;
protected flow = Flow.StandardAuthRequest;
protected webVaultUrl: string;
protected deviceManagementUrl: string;
constructor(
private accountService: AccountService,
@@ -81,6 +84,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private authService: AuthService,
private cryptoFunctionService: CryptoFunctionService,
private deviceTrustService: DeviceTrustServiceAbstraction,
private environmentService: EnvironmentService,
private i18nService: I18nService,
private logService: LogService,
private loginEmailService: LoginEmailServiceAbstraction,
@@ -109,6 +113,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
this.logService.error("Failed to use approved auth request: " + e.message);
});
});
// Get the web vault URL from the environment service
this.environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => {
this.webVaultUrl = env.getWebVaultUrl();
this.deviceManagementUrl = `${this.webVaultUrl}/#/settings/security/device-management`;
});
}
async ngOnInit(): Promise<void> {

View File

@@ -36,6 +36,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import {
AsyncActionsModule,
ButtonModule,
@@ -89,6 +90,7 @@ export class SsoComponent implements OnInit {
protected state: string | undefined;
protected codeChallenge: string | undefined;
protected clientId: SsoClientType | undefined;
protected activeUserId: UserId | undefined;
formPromise: Promise<AuthResult> | undefined;
initiateSsoFormPromise: Promise<SsoPreValidateResponse> | undefined;
@@ -130,6 +132,8 @@ export class SsoComponent implements OnInit {
}
async ngOnInit() {
this.activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const qParams: QueryParams = await firstValueFrom(this.route.queryParams);
// This if statement will pass on the second portion of the SSO flow
@@ -384,7 +388,10 @@ export class SsoComponent implements OnInit {
// - TDE login decryption options component
// - Browser SSO on extension open
// Note: you cannot set this in state before 2FA b/c there won't be an account in state.
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier);
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(
orgSsoIdentifier,
this.activeUserId,
);
// Users enrolled in admin acct recovery can be forced to set a new password after
// having the admin set a temp password for them (affects TDE & standard users)

View File

@@ -120,7 +120,7 @@
<div class="tw-mb-6" *ngIf="sentInitialCode">
{{ "enterVerificationCodeSentToEmail" | i18n }}
<p class="mb-0">
<p class="tw-mb-0">
<button bitLink type="button" linkType="primary" (click)="requestOTP()">
{{ "resendCode" | i18n }}
</button>

View File

@@ -35,6 +35,8 @@ export class FakeAccountService implements AccountService {
activeAccountSubject = new ReplaySubject<Account | null>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountVerifyDevicesSubject = new ReplaySubject<boolean>(1);
private _activeUserId: UserId;
get activeUserId() {
return this._activeUserId;
@@ -42,6 +44,7 @@ export class FakeAccountService implements AccountService {
accounts$ = this.accountsSubject.asObservable();
activeAccount$ = this.activeAccountSubject.asObservable();
accountActivity$ = this.accountActivitySubject.asObservable();
accountVerifyNewDeviceLogin$ = this.accountVerifyDevicesSubject.asObservable();
get sortedUserIds$() {
return this.accountActivity$.pipe(
map((activity) => {
@@ -67,6 +70,11 @@ export class FakeAccountService implements AccountService {
this.activeAccountSubject.next(null);
this.accountActivitySubject.next(accountActivity);
}
setAccountVerifyNewDeviceLogin(userId: UserId, verifyNewDeviceLogin: boolean): Promise<void> {
return this.mock.setAccountVerifyNewDeviceLogin(userId, verifyNewDeviceLogin);
}
setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
this.accountActivitySubject.next({
...this.accountActivitySubject["_buffer"][0],

View File

@@ -0,0 +1,76 @@
import { Matrix } from "./matrix";
class TestObject {
value: number = 0;
constructor() {}
increment() {
this.value++;
}
}
describe("matrix", () => {
it("caches entries in a matrix properly with a single argument", () => {
const mockFunction = jest.fn<TestObject, [arg1: string]>();
const getter = Matrix.autoMockMethod(mockFunction, () => new TestObject());
const obj = getter("test1");
expect(obj.value).toBe(0);
// Change the state of the object
obj.increment();
// Should return the same instance the second time this is called
expect(getter("test1").value).toBe(1);
// Using the getter should not call the mock function
expect(mockFunction).not.toHaveBeenCalled();
const mockedFunctionReturn1 = mockFunction("test1");
expect(mockedFunctionReturn1.value).toBe(1);
// Totally new value
const mockedFunctionReturn2 = mockFunction("test2");
expect(mockedFunctionReturn2.value).toBe(0);
expect(mockFunction).toHaveBeenCalledTimes(2);
});
it("caches entries in matrix properly with multiple arguments", () => {
const mockFunction = jest.fn<TestObject, [arg1: string, arg2: number]>();
const getter = Matrix.autoMockMethod(mockFunction, () => {
return new TestObject();
});
const obj = getter("test1", 4);
expect(obj.value).toBe(0);
obj.increment();
expect(getter("test1", 4).value).toBe(1);
expect(mockFunction("test1", 3).value).toBe(0);
});
it("should give original args in creator even if it has multiple key layers", () => {
const mockFunction = jest.fn<TestObject, [arg1: string, arg2: number, arg3: boolean]>();
let invoked = false;
const getter = Matrix.autoMockMethod(mockFunction, (args) => {
expect(args).toHaveLength(3);
expect(args[0]).toBe("test");
expect(args[1]).toBe(42);
expect(args[2]).toBe(true);
invoked = true;
return new TestObject();
});
getter("test", 42, true);
expect(invoked).toBe(true);
});
});

115
libs/common/spec/matrix.ts Normal file
View File

@@ -0,0 +1,115 @@
type PickFirst<Array> = Array extends [infer First, ...unknown[]] ? First : never;
type MatrixOrValue<Array extends unknown[], Value> = Array extends []
? Value
: Matrix<Array, Value>;
type RemoveFirst<T> = T extends [unknown, ...infer Rest] ? Rest : never;
/**
* A matrix is intended to manage cached values for a set of method arguments.
*/
export class Matrix<TKeys extends unknown[], TValue> {
private map: Map<PickFirst<TKeys>, MatrixOrValue<RemoveFirst<TKeys>, TValue>> = new Map();
/**
* This is especially useful for methods on a service that take inputs but return Observables.
* Generally when interacting with observables in tests, you want to use a simple SubjectLike
* type to back it instead, so that you can easily `next` values to simulate an emission.
*
* @param mockFunction The function to have a Matrix based implementation added to it.
* @param creator The function to use to create the underlying value to return for the given arguments.
* @returns A "getter" function that allows you to retrieve the backing value that is used for the given arguments.
*
* @example
* ```ts
* interface MyService {
* event$(userId: UserId) => Observable<UserEvent>
* }
*
* // Test
* const myService = mock<MyService>();
* const eventGetter = Matrix.autoMockMethod(myService.event$, (userId) => BehaviorSubject<UserEvent>());
*
* eventGetter("userOne").next(new UserEvent());
* eventGetter("userTwo").next(new UserEvent());
* ```
*
* This replaces a more manual way of doing things like:
*
* ```ts
* const myService = mock<MyService>();
* const userOneSubject = new BehaviorSubject<UserEvent>();
* const userTwoSubject = new BehaviorSubject<UserEvent>();
* myService.event$.mockImplementation((userId) => {
* if (userId === "userOne") {
* return userOneSubject;
* } else if (userId === "userTwo") {
* return userTwoSubject;
* }
* return new BehaviorSubject<UserEvent>();
* });
*
* userOneSubject.next(new UserEvent());
* userTwoSubject.next(new UserEvent());
* ```
*/
static autoMockMethod<TReturn, TArgs extends unknown[], TActualReturn extends TReturn>(
mockFunction: jest.Mock<TReturn, TArgs>,
creator: (args: TArgs) => TActualReturn,
): (...args: TArgs) => TActualReturn {
const matrix = new Matrix<TArgs, TActualReturn>();
const getter = (...args: TArgs) => {
return matrix.getOrCreateEntry(args, creator);
};
mockFunction.mockImplementation(getter);
return getter;
}
/**
* Gives the ability to get or create an entry in the matrix via the given args.
*
* @note The args are evaulated using Javascript equality so primivites work best.
*
* @param args The arguments to use to evaluate if an entry in the matrix exists already,
* or a value should be created and stored with those arguments.
* @param creator The function to call with the arguments to build a value.
* @returns The existing entry if one already exists or a new value created with the creator param.
*/
getOrCreateEntry(args: TKeys, creator: (args: TKeys) => TValue): TValue {
if (args.length === 0) {
throw new Error("Matrix is not for you.");
}
if (args.length === 1) {
const arg = args[0] as PickFirst<TKeys>;
if (this.map.has(arg)) {
// Get the cached value
return this.map.get(arg) as TValue;
} else {
const value = creator(args);
// Save the value for the next time
this.map.set(arg, value as MatrixOrValue<RemoveFirst<TKeys>, TValue>);
return value;
}
}
// There are for sure 2 or more args
const [first, ...rest] = args as unknown as [PickFirst<TKeys>, ...RemoveFirst<TKeys>];
let matrix: Matrix<RemoveFirst<TKeys>, TValue> | null = null;
if (this.map.has(first)) {
// We've already created a map for this argument
matrix = this.map.get(first) as Matrix<RemoveFirst<TKeys>, TValue>;
} else {
matrix = new Matrix<RemoveFirst<TKeys>, TValue>();
this.map.set(first, matrix as MatrixOrValue<RemoveFirst<TKeys>, TValue>);
}
return matrix.getOrCreateEntry(rest, () => creator(args));
}
}

View File

@@ -1,8 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
export abstract class NotificationsService {
init: () => Promise<void>;
updateConnection: (sync?: boolean) => Promise<void>;
reconnectFromActivity: () => Promise<void>;
disconnectFromInactivity: () => Promise<void>;
}

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
import { ProviderUpdateRequest } from "../../models/request/provider/provider-update.request";
import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request";
@@ -14,4 +16,12 @@ export class ProviderApiServiceAbstraction {
request: ProviderVerifyRecoverDeleteRequest,
) => Promise<any>;
deleteProvider: (id: string) => Promise<void>;
getProviderAddableOrganizations: (providerId: string) => Promise<AddableOrganizationResponse[]>;
addOrganizationToProvider: (
providerId: string,
request: {
key: string;
organizationId: string;
},
) => Promise<void>;
}

View File

@@ -53,6 +53,7 @@ describe("ORGANIZATIONS state", () => {
accessSecretsManager: false,
limitCollectionCreation: false,
limitCollectionDeletion: false,
limitItemDeletion: false,
allowAdminAccessToAllCollectionItems: false,
familySponsorshipLastSyncDate: new Date(),
userIsManagedByOrganization: false,

View File

@@ -56,6 +56,7 @@ export class OrganizationData {
accessSecretsManager: boolean;
limitCollectionCreation: boolean;
limitCollectionDeletion: boolean;
limitItemDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
@@ -117,6 +118,7 @@ export class OrganizationData {
this.accessSecretsManager = response.accessSecretsManager;
this.limitCollectionCreation = response.limitCollectionCreation;
this.limitCollectionDeletion = response.limitCollectionDeletion;
this.limitItemDeletion = response.limitItemDeletion;
this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
this.useRiskInsights = response.useRiskInsights;

View File

@@ -76,6 +76,12 @@ export class Organization {
/**
* Refers to the ability for an owner/admin to access all collection items, regardless of assigned collections
*/
limitItemDeletion: boolean;
/**
* Refers to the ability to limit delete permission of collection items.
* If set to true, members can only delete items when they have a Can Manage permission over the collection.
* If set to false, members can delete items when they have a Can Manage OR Can Edit permission over the collection.
*/
allowAdminAccessToAllCollectionItems: boolean;
/**
* Indicates if this organization manages the user.
@@ -138,6 +144,7 @@ export class Organization {
this.accessSecretsManager = obj.accessSecretsManager;
this.limitCollectionCreation = obj.limitCollectionCreation;
this.limitCollectionDeletion = obj.limitCollectionDeletion;
this.limitItemDeletion = obj.limitItemDeletion;
this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
this.useRiskInsights = obj.useRiskInsights;

View File

@@ -3,5 +3,6 @@
export class OrganizationCollectionManagementUpdateRequest {
limitCollectionCreation: boolean;
limitCollectionDeletion: boolean;
limitItemDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
}

View File

@@ -0,0 +1,18 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class AddableOrganizationResponse extends BaseResponse {
id: string;
plan: string;
name: string;
seats: number;
disabled: boolean;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("id");
this.plan = this.getResponseProperty("plan");
this.name = this.getResponseProperty("name");
this.seats = this.getResponseProperty("seats");
this.disabled = this.getResponseProperty("disabled");
}
}

View File

@@ -36,6 +36,7 @@ export class OrganizationResponse extends BaseResponse {
maxAutoscaleSmServiceAccounts?: number;
limitCollectionCreation: boolean;
limitCollectionDeletion: boolean;
limitItemDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
useRiskInsights: boolean;
@@ -75,6 +76,7 @@ export class OrganizationResponse extends BaseResponse {
this.maxAutoscaleSmServiceAccounts = this.getResponseProperty("MaxAutoscaleSmServiceAccounts");
this.limitCollectionCreation = this.getResponseProperty("LimitCollectionCreation");
this.limitCollectionDeletion = this.getResponseProperty("LimitCollectionDeletion");
this.limitItemDeletion = this.getResponseProperty("LimitItemDeletion");
this.allowAdminAccessToAllCollectionItems = this.getResponseProperty(
"AllowAdminAccessToAllCollectionItems",
);

View File

@@ -51,6 +51,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
accessSecretsManager: boolean;
limitCollectionCreation: boolean;
limitCollectionDeletion: boolean;
limitItemDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
@@ -114,6 +115,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
this.accessSecretsManager = this.getResponseProperty("AccessSecretsManager");
this.limitCollectionCreation = this.getResponseProperty("LimitCollectionCreation");
this.limitCollectionDeletion = this.getResponseProperty("LimitCollectionDeletion");
this.limitItemDeletion = this.getResponseProperty("LimitItemDeletion");
this.allowAdminAccessToAllCollectionItems = this.getResponseProperty(
"AllowAdminAccessToAllCollectionItems",
);

View File

@@ -1,3 +1,5 @@
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
import { ApiService } from "../../../abstractions/api.service";
import { ProviderApiServiceAbstraction } from "../../abstractions/provider/provider-api.service.abstraction";
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
@@ -44,4 +46,34 @@ export class ProviderApiService implements ProviderApiServiceAbstraction {
async deleteProvider(id: string): Promise<void> {
await this.apiService.send("DELETE", "/providers/" + id, null, true, false);
}
async getProviderAddableOrganizations(
providerId: string,
): Promise<AddableOrganizationResponse[]> {
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/clients/addable",
null,
true,
true,
);
return response.map((data: any) => new AddableOrganizationResponse(data));
}
addOrganizationToProvider(
providerId: string,
request: {
key: string;
organizationId: string;
},
): Promise<void> {
return this.apiService.send(
"POST",
"/providers/" + providerId + "/clients/existing",
request,
true,
false,
);
}
}

View File

@@ -1,6 +1,7 @@
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.request";
import { SetVerifyDevicesRequest } from "../models/request/set-verify-devices.request";
import { Verification } from "../types/verification";
export abstract class AccountApiService {
@@ -18,7 +19,7 @@ export abstract class AccountApiService {
*
* @param request - The request object containing
* information needed to send the verification email, such as the user's email address.
* @returns A promise that resolves to a string tokencontaining the user's encrypted
* @returns A promise that resolves to a string token containing the user's encrypted
* information which must be submitted to complete registration or `null` if
* email verification is enabled (users must get the token by clicking a
* link in the email that will be sent to them).
@@ -33,7 +34,7 @@ export abstract class AccountApiService {
*
* @param request - The request object containing the email verification token and the
* user's email address (which is required to validate the token)
* @returns A promise that resolves when the event is logged on the server succcessfully or a bad
* @returns A promise that resolves when the event is logged on the server successfully or a bad
* request if the token is invalid for any reason.
*/
abstract registerVerificationEmailClicked(
@@ -50,4 +51,15 @@ export abstract class AccountApiService {
* registration process is successfully completed.
*/
abstract registerFinish(request: RegisterFinishRequest): Promise<string>;
/**
* Sets the [dbo].[User].[VerifyDevices] flag to true or false.
*
* @param request - The request object is a SecretVerificationRequest extension
* that also contains the boolean value that the VerifyDevices property is being
* set to.
* @returns A promise that resolves when the process is successfully completed or
* a bad request if secret verification fails.
*/
abstract setVerifyDevices(request: SetVerifyDevicesRequest): Promise<string>;
}

View File

@@ -43,6 +43,8 @@ export abstract class AccountService {
* Observable of the last activity time for each account.
*/
accountActivity$: Observable<Record<UserId, Date>>;
/** Observable of the new device login verification property for the account. */
accountVerifyNewDeviceLogin$: Observable<boolean>;
/** Account list in order of descending recency */
sortedUserIds$: Observable<UserId[]>;
/** Next account that is not the current active account */
@@ -73,6 +75,15 @@ export abstract class AccountService {
* @param emailVerified
*/
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
/**
* updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account.
* @param userId
* @param VerifyNewDeviceLogin
*/
abstract setAccountVerifyNewDeviceLogin(
userId: UserId,
verifyNewDeviceLogin: boolean,
): Promise<void>;
/**
* Updates the `activeAccount$` observable with the new active account.
* @param userId

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { UserId } from "@bitwarden/common/types/guid";
export abstract class SsoLoginServiceAbstraction {
/**
* Gets the code verifier used for SSO.
@@ -74,12 +76,16 @@ export abstract class SsoLoginServiceAbstraction {
* Gets the value of the active user's organization sso identifier.
*
* This should only be used post successful SSO login once the user is initialized.
* @param userId The user id for retrieving the org identifier state.
*/
getActiveUserOrganizationSsoIdentifier: () => Promise<string>;
getActiveUserOrganizationSsoIdentifier: (userId: UserId) => Promise<string>;
/**
* Sets the value of the active user's organization sso identifier.
*
* This should only be used post successful SSO login once the user is initialized.
*/
setActiveUserOrganizationSsoIdentifier: (organizationIdentifier: string) => Promise<void>;
setActiveUserOrganizationSsoIdentifier: (
organizationIdentifier: string,
userId: UserId | undefined,
) => Promise<void>;
}

View File

@@ -0,0 +1,8 @@
import { SecretVerificationRequest } from "./secret-verification.request";
export class SetVerifyDevicesRequest extends SecretVerificationRequest {
/**
* This is the input for a user update that controls [dbo].[Users].[VerifyDevices]
*/
verifyDevices!: boolean;
}

View File

@@ -10,6 +10,7 @@ import { UserVerificationService } from "../abstractions/user-verification/user-
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.request";
import { SetVerifyDevicesRequest } from "../models/request/set-verify-devices.request";
import { Verification } from "../types/verification";
export class AccountApiServiceImplementation implements AccountApiService {
@@ -102,4 +103,21 @@ export class AccountApiServiceImplementation implements AccountApiService {
throw e;
}
}
async setVerifyDevices(request: SetVerifyDevicesRequest): Promise<string> {
try {
const response = await this.apiService.send(
"POST",
"/accounts/verify-devices",
request,
true,
true,
);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
}

View File

@@ -7,7 +7,10 @@ import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { FakeGlobalState } from "../../../spec/fake-state";
import { FakeGlobalStateProvider } from "../../../spec/fake-state-provider";
import {
FakeGlobalStateProvider,
FakeSingleUserStateProvider,
} from "../../../spec/fake-state-provider";
import { trackEmissions } from "../../../spec/utils";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
@@ -19,6 +22,7 @@ import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
ACCOUNT_ACTIVITY,
ACCOUNT_VERIFY_NEW_DEVICE_LOGIN,
AccountServiceImplementation,
} from "./account.service";
@@ -66,6 +70,7 @@ describe("accountService", () => {
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
let globalStateProvider: FakeGlobalStateProvider;
let singleUserStateProvider: FakeSingleUserStateProvider;
let sut: AccountServiceImplementation;
let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>;
let activeAccountIdState: FakeGlobalState<UserId>;
@@ -77,8 +82,14 @@ describe("accountService", () => {
messagingService = mock();
logService = mock();
globalStateProvider = new FakeGlobalStateProvider();
singleUserStateProvider = new FakeSingleUserStateProvider();
sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider);
sut = new AccountServiceImplementation(
messagingService,
logService,
globalStateProvider,
singleUserStateProvider,
);
accountsState = globalStateProvider.getFake(ACCOUNT_ACCOUNTS);
activeAccountIdState = globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID);
@@ -128,6 +139,22 @@ describe("accountService", () => {
});
});
describe("accountsVerifyNewDeviceLogin$", () => {
it("returns expected value", async () => {
// Arrange
const expected = true;
// we need to set this state since it is how we initialize the VerifyNewDeviceLogin$
activeAccountIdState.stateSubject.next(userId);
singleUserStateProvider.getFake(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).nextState(expected);
// Act
const result = await firstValueFrom(sut.accountVerifyNewDeviceLogin$);
// Assert
expect(result).toEqual(expected);
});
});
describe("addAccount", () => {
it("should emit the new account", async () => {
await sut.addAccount(userId, userInfo);
@@ -226,6 +253,33 @@ describe("accountService", () => {
});
});
describe("setAccountVerifyNewDeviceLogin", () => {
const initialState = true;
beforeEach(() => {
activeAccountIdState.stateSubject.next(userId);
singleUserStateProvider
.getFake(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN)
.nextState(initialState);
});
it("should update the VerifyNewDeviceLogin", async () => {
const expected = false;
expect(await firstValueFrom(sut.accountVerifyNewDeviceLogin$)).toEqual(initialState);
await sut.setAccountVerifyNewDeviceLogin(userId, expected);
const currentState = await firstValueFrom(sut.accountVerifyNewDeviceLogin$);
expect(currentState).toEqual(expected);
});
it("should NOT update VerifyNewDeviceLogin when userId is null", async () => {
await sut.setAccountVerifyNewDeviceLogin(null, false);
const currentState = await firstValueFrom(sut.accountVerifyNewDeviceLogin$);
expect(currentState).toEqual(initialState);
});
});
describe("clean", () => {
beforeEach(() => {
accountsState.stateSubject.next({ [userId]: userInfo });

View File

@@ -7,6 +7,7 @@ import {
shareReplay,
combineLatest,
Observable,
switchMap,
filter,
timeout,
of,
@@ -26,6 +27,8 @@ import {
GlobalState,
GlobalStateProvider,
KeyDefinition,
SingleUserStateProvider,
UserKeyDefinition,
} from "../../platform/state";
import { UserId } from "../../types/guid";
@@ -45,6 +48,15 @@ export const ACCOUNT_ACTIVITY = KeyDefinition.record<Date, UserId>(ACCOUNT_DISK,
deserializer: (activity) => new Date(activity),
});
export const ACCOUNT_VERIFY_NEW_DEVICE_LOGIN = new UserKeyDefinition<boolean>(
ACCOUNT_DISK,
"verifyNewDeviceLogin",
{
deserializer: (verifyDevices) => verifyDevices,
clearOn: ["logout"],
},
);
const LOGGED_OUT_INFO: AccountInfo = {
email: "",
emailVerified: false,
@@ -76,6 +88,7 @@ export class AccountServiceImplementation implements InternalAccountService {
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<Account | null>;
accountActivity$: Observable<Record<UserId, Date>>;
accountVerifyNewDeviceLogin$: Observable<boolean>;
sortedUserIds$: Observable<UserId[]>;
nextUpAccount$: Observable<Account>;
@@ -83,6 +96,7 @@ export class AccountServiceImplementation implements InternalAccountService {
private messagingService: MessagingService,
private logService: LogService,
private globalStateProvider: GlobalStateProvider,
private singleUserStateProvider: SingleUserStateProvider,
) {
this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS);
this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID);
@@ -117,6 +131,12 @@ export class AccountServiceImplementation implements InternalAccountService {
return nextId ? { id: nextId, ...accounts[nextId] } : null;
}),
);
this.accountVerifyNewDeviceLogin$ = this.activeAccountIdState.state$.pipe(
switchMap(
(userId) =>
this.singleUserStateProvider.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).state$,
),
);
}
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
@@ -203,6 +223,20 @@ export class AccountServiceImplementation implements InternalAccountService {
);
}
async setAccountVerifyNewDeviceLogin(
userId: UserId,
setVerifyNewDeviceLogin: boolean,
): Promise<void> {
if (!Utils.isGuid(userId)) {
// only store for valid userIds
return;
}
await this.singleUserStateProvider.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).update(() => {
return setVerifyNewDeviceLogin;
});
}
async removeAccountActivity(userId: UserId): Promise<void> {
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
(activity) => {

View File

@@ -368,6 +368,7 @@ describe("KeyConnectorService", () => {
accessSecretsManager: false,
limitCollectionCreation: true,
limitCollectionDeletion: true,
limitItemDeletion: true,
allowAdminAccessToAllCollectionItems: true,
flexibleCollections: false,
object: "profileOrganization",

View File

@@ -0,0 +1,94 @@
import { mock, MockProxy } from "jest-mock-extended";
import {
CODE_VERIFIER,
GLOBAL_ORGANIZATION_SSO_IDENTIFIER,
SSO_EMAIL,
SSO_STATE,
SsoLoginService,
USER_ORGANIZATION_SSO_IDENTIFIER,
} from "@bitwarden/common/auth/services/sso-login.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
describe("SSOLoginService ", () => {
let sut: SsoLoginService;
let accountService: FakeAccountService;
let mockSingleUserStateProvider: FakeStateProvider;
let mockLogService: MockProxy<LogService>;
let userId: UserId;
beforeEach(() => {
jest.clearAllMocks();
userId = Utils.newGuid() as UserId;
accountService = mockAccountServiceWith(userId);
mockSingleUserStateProvider = new FakeStateProvider(accountService);
mockLogService = mock<LogService>();
sut = new SsoLoginService(mockSingleUserStateProvider, mockLogService);
});
it("instantiates", () => {
expect(sut).not.toBeFalsy();
});
it("gets and sets code verifier", async () => {
const codeVerifier = "test-code-verifier";
await sut.setCodeVerifier(codeVerifier);
mockSingleUserStateProvider.getGlobal(CODE_VERIFIER);
const result = await sut.getCodeVerifier();
expect(result).toBe(codeVerifier);
});
it("gets and sets SSO state", async () => {
const ssoState = "test-sso-state";
await sut.setSsoState(ssoState);
mockSingleUserStateProvider.getGlobal(SSO_STATE);
const result = await sut.getSsoState();
expect(result).toBe(ssoState);
});
it("gets and sets organization SSO identifier", async () => {
const orgIdentifier = "test-org-identifier";
await sut.setOrganizationSsoIdentifier(orgIdentifier);
mockSingleUserStateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER);
const result = await sut.getOrganizationSsoIdentifier();
expect(result).toBe(orgIdentifier);
});
it("gets and sets SSO email", async () => {
const email = "test@example.com";
await sut.setSsoEmail(email);
mockSingleUserStateProvider.getGlobal(SSO_EMAIL);
const result = await sut.getSsoEmail();
expect(result).toBe(email);
});
it("gets and sets active user organization SSO identifier", async () => {
const userId = Utils.newGuid() as UserId;
const orgIdentifier = "test-active-org-identifier";
await sut.setActiveUserOrganizationSsoIdentifier(orgIdentifier, userId);
mockSingleUserStateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
const result = await sut.getActiveUserOrganizationSsoIdentifier(userId);
expect(result).toBe(orgIdentifier);
});
it("logs error when setting active user organization SSO identifier with undefined userId", async () => {
const orgIdentifier = "test-active-org-identifier";
await sut.setActiveUserOrganizationSsoIdentifier(orgIdentifier, undefined);
expect(mockLogService.warning).toHaveBeenCalledWith(
"Tried to set a user organization sso identifier with an undefined user id.",
);
});
});

View File

@@ -2,10 +2,13 @@
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
ActiveUserState,
GlobalState,
KeyDefinition,
SingleUserState,
SSO_DISK,
StateProvider,
UserKeyDefinition,
@@ -15,21 +18,21 @@ import { SsoLoginServiceAbstraction } from "../abstractions/sso-login.service.ab
/**
* Uses disk storage so that the code verifier can be persisted across sso redirects.
*/
const CODE_VERIFIER = new KeyDefinition<string>(SSO_DISK, "ssoCodeVerifier", {
export const CODE_VERIFIER = new KeyDefinition<string>(SSO_DISK, "ssoCodeVerifier", {
deserializer: (codeVerifier) => codeVerifier,
});
/**
* Uses disk storage so that the sso state can be persisted across sso redirects.
*/
const SSO_STATE = new KeyDefinition<string>(SSO_DISK, "ssoState", {
export const SSO_STATE = new KeyDefinition<string>(SSO_DISK, "ssoState", {
deserializer: (state) => state,
});
/**
* Uses disk storage so that the organization sso identifier can be persisted across sso redirects.
*/
const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition<string>(
export const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition<string>(
SSO_DISK,
"organizationSsoIdentifier",
{
@@ -41,7 +44,7 @@ const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition<string>(
/**
* Uses disk storage so that the organization sso identifier can be persisted across sso redirects.
*/
const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition<string>(
export const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition<string>(
SSO_DISK,
"organizationSsoIdentifier",
{
@@ -52,7 +55,7 @@ const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition<string>(
/**
* Uses disk storage so that the user's email can be persisted across sso redirects.
*/
const SSO_EMAIL = new KeyDefinition<string>(SSO_DISK, "ssoEmail", {
export const SSO_EMAIL = new KeyDefinition<string>(SSO_DISK, "ssoEmail", {
deserializer: (state) => state,
});
@@ -61,16 +64,15 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
private ssoState: GlobalState<string>;
private orgSsoIdentifierState: GlobalState<string>;
private ssoEmailState: GlobalState<string>;
private activeUserOrgSsoIdentifierState: ActiveUserState<string>;
constructor(private stateProvider: StateProvider) {
constructor(
private stateProvider: StateProvider,
private logService: LogService,
) {
this.codeVerifierState = this.stateProvider.getGlobal(CODE_VERIFIER);
this.ssoState = this.stateProvider.getGlobal(SSO_STATE);
this.orgSsoIdentifierState = this.stateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER);
this.ssoEmailState = this.stateProvider.getGlobal(SSO_EMAIL);
this.activeUserOrgSsoIdentifierState = this.stateProvider.getActive(
USER_ORGANIZATION_SSO_IDENTIFIER,
);
}
getCodeVerifier(): Promise<string> {
@@ -105,11 +107,24 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
await this.ssoEmailState.update((_) => email);
}
getActiveUserOrganizationSsoIdentifier(): Promise<string> {
return firstValueFrom(this.activeUserOrgSsoIdentifierState.state$);
getActiveUserOrganizationSsoIdentifier(userId: UserId): Promise<string> {
return firstValueFrom(this.userOrgSsoIdentifierState(userId).state$);
}
async setActiveUserOrganizationSsoIdentifier(organizationIdentifier: string): Promise<void> {
await this.activeUserOrgSsoIdentifierState.update((_) => organizationIdentifier);
async setActiveUserOrganizationSsoIdentifier(
organizationIdentifier: string,
userId: UserId | undefined,
): Promise<void> {
if (userId === undefined) {
this.logService.warning(
"Tried to set a user organization sso identifier with an undefined user id.",
);
return;
}
await this.userOrgSsoIdentifierState(userId).update((_) => organizationIdentifier);
}
private userOrgSsoIdentifierState(userId: UserId): SingleUserState<string> {
return this.stateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
}
}

View File

@@ -9,6 +9,7 @@ export enum FeatureFlag {
AccountDeprovisioning = "pm-10308-account-deprovisioning",
VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint",
PM14505AdminConsoleIntegrationPage = "pm-14505-admin-console-integration-page",
LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission",
/* Autofill */
BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain",
@@ -23,8 +24,12 @@ export enum FeatureFlag {
NotificationRefresh = "notification-refresh",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
/* Tools */
ItemShare = "item-share",
GeneratorToolsModernization = "generator-tools-modernization",
CriticalApps = "pm-14466-risk-insights-critical-application",
EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section",
ExtensionRefresh = "extension-refresh",
PersistPopupView = "persist-popup-view",
@@ -37,7 +42,6 @@ export enum FeatureFlag {
UserKeyRotationV2 = "userkey-rotation-v2",
CipherKeyEncryption = "cipher-key-encryption",
PM11901_RefactorSelfHostingLicenseUploader = "PM-11901-refactor-self-hosting-license-uploader",
CriticalApps = "pm-14466-risk-insights-critical-application",
TrialPaymentOptional = "PM-8163-trial-payment",
SecurityTasks = "security-tasks",
NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss",
@@ -48,6 +52,7 @@ export enum FeatureFlag {
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
NewDeviceVerification = "new-device-verification",
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -67,6 +72,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AccountDeprovisioning]: FALSE,
[FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE,
[FeatureFlag.PM14505AdminConsoleIntegrationPage]: FALSE,
[FeatureFlag.LimitItemDeletion]: FALSE,
/* Autofill */
[FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE,
@@ -81,8 +87,12 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.NotificationRefresh]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
/* Tools */
[FeatureFlag.ItemShare]: FALSE,
[FeatureFlag.GeneratorToolsModernization]: FALSE,
[FeatureFlag.CriticalApps]: FALSE,
[FeatureFlag.EnableRiskInsightsNotifications]: FALSE,
[FeatureFlag.AC1795_UpdatedSubscriptionStatusSection]: FALSE,
[FeatureFlag.ExtensionRefresh]: FALSE,
[FeatureFlag.PersistPopupView]: FALSE,
@@ -95,7 +105,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.UserKeyRotationV2]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader]: FALSE,
[FeatureFlag.CriticalApps]: FALSE,
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
[FeatureFlag.NewDeviceVerificationTemporaryDismiss]: FALSE,
@@ -106,6 +115,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
[FeatureFlag.NewDeviceVerification]: FALSE,
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -0,0 +1,13 @@
/**
* The preferred push technology of the server.
*/
export enum PushTechnology {
/**
* Indicates that we should use SignalR over web sockets to receive push notifications from the server.
*/
SignalR = 0,
/**
* Indicatates that we should use WebPush to receive push notifications from the server.
*/
WebPush = 1,
}

View File

@@ -21,6 +21,7 @@ export class ProfileResponse extends BaseResponse {
securityStamp: string;
forcePasswordReset: boolean;
usesKeyConnector: boolean;
verifyDevices: boolean;
organizations: ProfileOrganizationResponse[] = [];
providers: ProfileProviderResponse[] = [];
providerOrganizations: ProfileProviderOrganizationResponse[] = [];
@@ -42,6 +43,7 @@ export class ProfileResponse extends BaseResponse {
this.securityStamp = this.getResponseProperty("SecurityStamp");
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset") ?? false;
this.usesKeyConnector = this.getResponseProperty("UsesKeyConnector") ?? false;
this.verifyDevices = this.getResponseProperty("VerifyDevices") ?? true;
const organizations = this.getResponseProperty("Organizations");
if (organizations != null) {

View File

@@ -3,6 +3,7 @@
import { Jsonify } from "type-fest";
import { AllowedFeatureFlagTypes } from "../../../enums/feature-flag.enum";
import { PushTechnology } from "../../../enums/push-technology.enum";
import {
ServerConfigData,
ThirdPartyServerConfigData,
@@ -10,6 +11,11 @@ import {
} from "../../models/data/server-config.data";
import { ServerSettings } from "../../models/domain/server-settings";
type PushConfig =
| { pushTechnology: PushTechnology.SignalR }
| { pushTechnology: PushTechnology.WebPush; vapidPublicKey: string }
| undefined;
const dayInMilliseconds = 24 * 3600 * 1000;
export class ServerConfig {
@@ -19,6 +25,7 @@ export class ServerConfig {
environment?: EnvironmentServerConfigData;
utcDate: Date;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
push: PushConfig;
settings: ServerSettings;
constructor(serverConfigData: ServerConfigData) {
@@ -28,6 +35,15 @@ export class ServerConfig {
this.utcDate = new Date(serverConfigData.utcDate);
this.environment = serverConfigData.environment;
this.featureStates = serverConfigData.featureStates;
this.push =
serverConfigData.push == null
? {
pushTechnology: PushTechnology.SignalR,
}
: {
pushTechnology: serverConfigData.push.pushTechnology,
vapidPublicKey: serverConfigData.push.vapidPublicKey,
};
this.settings = serverConfigData.settings;
if (this.server?.name == null && this.server?.url == null) {

View File

@@ -3,6 +3,7 @@ import { Observable } from "rxjs";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { UserId } from "../../../types/guid";
import { Rc } from "../../misc/reference-counting/rc";
export abstract class SdkService {
/**
@@ -27,5 +28,5 @@ export abstract class SdkService {
*
* @param userId
*/
abstract userClient$(userId: UserId): Observable<BitwardenClient | undefined>;
abstract userClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined>;
}

View File

@@ -0,0 +1,93 @@
// Temporary workaround for Symbol.dispose
// remove when https://github.com/jestjs/jest/issues/14874 is resolved and *released*
const disposeSymbol: unique symbol = Symbol("Symbol.dispose");
const asyncDisposeSymbol: unique symbol = Symbol("Symbol.asyncDispose");
(Symbol as any).asyncDispose ??= asyncDisposeSymbol as unknown as SymbolConstructor["asyncDispose"];
(Symbol as any).dispose ??= disposeSymbol as unknown as SymbolConstructor["dispose"];
// Import needs to be after the workaround
import { Rc } from "./rc";
export class FreeableTestValue {
isFreed = false;
free() {
this.isFreed = true;
}
}
describe("Rc", () => {
let value: FreeableTestValue;
let rc: Rc<FreeableTestValue>;
beforeEach(() => {
value = new FreeableTestValue();
rc = new Rc(value);
});
it("should increase refCount when taken", () => {
rc.take();
expect(rc["refCount"]).toBe(1);
});
it("should return value on take", () => {
// eslint-disable-next-line @bitwarden/platform/required-using
const reference = rc.take();
expect(reference.value).toBe(value);
});
it("should decrease refCount when disposing reference", () => {
// eslint-disable-next-line @bitwarden/platform/required-using
const reference = rc.take();
reference[Symbol.dispose]();
expect(rc["refCount"]).toBe(0);
});
it("should automatically decrease refCount when reference goes out of scope", () => {
{
// eslint-disable-next-line @typescript-eslint/no-unused-vars
using reference = rc.take();
}
expect(rc["refCount"]).toBe(0);
});
it("should not free value when refCount reaches 0 if not marked for disposal", () => {
// eslint-disable-next-line @bitwarden/platform/required-using
const reference = rc.take();
reference[Symbol.dispose]();
expect(value.isFreed).toBe(false);
});
it("should free value when refCount reaches 0 and rc is marked for disposal", () => {
// eslint-disable-next-line @bitwarden/platform/required-using
const reference = rc.take();
rc.markForDisposal();
reference[Symbol.dispose]();
expect(value.isFreed).toBe(true);
});
it("should free value when marked for disposal if refCount is 0", () => {
// eslint-disable-next-line @bitwarden/platform/required-using
const reference = rc.take();
reference[Symbol.dispose]();
rc.markForDisposal();
expect(value.isFreed).toBe(true);
});
it("should throw error when trying to take a disposed reference", () => {
rc.markForDisposal();
expect(() => rc.take()).toThrow();
});
});

View File

@@ -0,0 +1,76 @@
import { UsingRequired } from "../using-required";
export type Freeable = { free: () => void };
/**
* Reference counted disposable value.
* This class is used to manage the lifetime of a value that needs to be
* freed of at a specific time but might still be in-use when that happens.
*/
export class Rc<T extends Freeable> {
private markedForDisposal = false;
private refCount = 0;
private value: T;
constructor(value: T) {
this.value = value;
}
/**
* Use this function when you want to use the underlying object.
* This will guarantee that you have a reference to the object
* and that it won't be freed until your reference goes out of scope.
*
* This function must be used with the `using` keyword.
*
* @example
* ```typescript
* function someFunction(rc: Rc<SomeValue>) {
* using reference = rc.take();
* reference.value.doSomething();
* // reference is automatically disposed here
* }
* ```
*
* @returns The value.
*/
take(): Ref<T> {
if (this.markedForDisposal) {
throw new Error("Cannot take a reference to a value marked for disposal");
}
this.refCount++;
return new Ref(() => this.release(), this.value);
}
/**
* Mark this Rc for disposal. When the refCount reaches 0, the value
* will be freed.
*/
markForDisposal() {
this.markedForDisposal = true;
this.freeIfPossible();
}
private release() {
this.refCount--;
this.freeIfPossible();
}
private freeIfPossible() {
if (this.refCount === 0 && this.markedForDisposal) {
this.value.free();
}
}
}
export class Ref<T extends Freeable> implements UsingRequired {
constructor(
private readonly release: () => void,
readonly value: T,
) {}
[Symbol.dispose]() {
this.release();
}
}

View File

@@ -0,0 +1,48 @@
import { ObservableInput, OperatorFunction, switchMap } from "rxjs";
/**
* Indicates that the given set of actions is not supported and there is
* not anything the user can do to make it supported. The reason property
* should contain a documented and machine readable string so more in
* depth details can be shown to the user.
*/
export type NotSupported = { type: "not-supported"; reason: string };
/**
* Indicates that the given set of actions does not currently work but
* could be supported if configuration, either inside Bitwarden or outside,
* is done. The reason property should contain a documented and
* machine readable string so further instruction can be supplied to the caller.
*/
export type NeedsConfiguration = { type: "needs-configuration"; reason: string };
/**
* Indicates that the actions in the service property are supported.
*/
export type Supported<T> = { type: "supported"; service: T };
/**
* A type encapsulating the status of support for a service.
*/
export type SupportStatus<T> = Supported<T> | NeedsConfiguration | NotSupported;
/**
* Projects each source value to one of the given projects defined in `selectors`.
*
* @param selectors.supported The function to run when the given item reports that it is supported
* @param selectors.notSupported The function to run when the given item reports that it is either not-supported
* or needs-configuration.
* @returns A function that returns an Observable that emits the result of one of the given projection functions.
*/
export function supportSwitch<TService, TSupported, TNotSupported>(selectors: {
supported: (service: TService, index: number) => ObservableInput<TSupported>;
notSupported: (reason: string, index: number) => ObservableInput<TNotSupported>;
}): OperatorFunction<SupportStatus<TService>, TSupported | TNotSupported> {
return switchMap((supportStatus, index) => {
if (supportStatus.type === "supported") {
return selectors.supported(supportStatus.service, index);
}
return selectors.notSupported(supportStatus.reason, index);
});
}

View File

@@ -0,0 +1,11 @@
export type Disposable = { [Symbol.dispose]: () => void };
/**
* Types implementing this type must be used together with the `using` keyword
*
* @example using ref = rc.take();
*/
// We want to use `interface` here because it creates a separate type.
// Type aliasing would not expose `UsingRequired` to the linter.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface UsingRequired extends Disposable {}

View File

@@ -1,3 +1,4 @@
import { PushTechnology } from "../../../enums/push-technology.enum";
import { Region } from "../../abstractions/environment.service";
import {
@@ -29,6 +30,9 @@ describe("ServerConfigData", () => {
},
utcDate: "2020-01-01T00:00:00.000Z",
featureStates: { feature: "state" },
push: {
pushTechnology: PushTechnology.SignalR,
},
};
const serverConfigData = ServerConfigData.fromJSON(json);

View File

@@ -9,6 +9,7 @@ import {
ServerConfigResponse,
ThirdPartyServerConfigResponse,
EnvironmentServerConfigResponse,
PushSettingsConfigResponse,
} from "../response/server-config.response";
export class ServerConfigData {
@@ -18,6 +19,7 @@ export class ServerConfigData {
environment?: EnvironmentServerConfigData;
utcDate: string;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
push: PushSettingsConfigData;
settings: ServerSettings;
constructor(serverConfigResponse: Partial<ServerConfigResponse>) {
@@ -32,6 +34,9 @@ export class ServerConfigData {
: null;
this.featureStates = serverConfigResponse?.featureStates;
this.settings = new ServerSettings(serverConfigResponse.settings);
this.push = serverConfigResponse?.push
? new PushSettingsConfigData(serverConfigResponse.push)
: null;
}
static fromJSON(obj: Jsonify<ServerConfigData>): ServerConfigData {
@@ -42,6 +47,20 @@ export class ServerConfigData {
}
}
export class PushSettingsConfigData {
pushTechnology: number;
vapidPublicKey?: string;
constructor(response: Partial<PushSettingsConfigResponse>) {
this.pushTechnology = response.pushTechnology;
this.vapidPublicKey = response.vapidPublicKey;
}
static fromJSON(obj: Jsonify<PushSettingsConfigData>): PushSettingsConfigData {
return Object.assign(new PushSettingsConfigData({}), obj);
}
}
export class ThirdPartyServerConfigData {
name: string;
url: string;

View File

@@ -11,6 +11,7 @@ export class ServerConfigResponse extends BaseResponse {
server: ThirdPartyServerConfigResponse;
environment: EnvironmentServerConfigResponse;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
push: PushSettingsConfigResponse;
settings: ServerSettings;
constructor(response: any) {
@@ -25,10 +26,27 @@ export class ServerConfigResponse extends BaseResponse {
this.server = new ThirdPartyServerConfigResponse(this.getResponseProperty("Server"));
this.environment = new EnvironmentServerConfigResponse(this.getResponseProperty("Environment"));
this.featureStates = this.getResponseProperty("FeatureStates");
this.push = new PushSettingsConfigResponse(this.getResponseProperty("Push"));
this.settings = new ServerSettings(this.getResponseProperty("Settings"));
}
}
export class PushSettingsConfigResponse extends BaseResponse {
pushTechnology: number;
vapidPublicKey: string;
constructor(data: any = null) {
super(data);
if (data == null) {
return;
}
this.pushTechnology = this.getResponseProperty("PushTechnology");
this.vapidPublicKey = this.getResponseProperty("VapidPublicKey");
}
}
export class EnvironmentServerConfigResponse extends BaseResponse {
cloudRegion: Region;
vault: string;

View File

@@ -0,0 +1 @@
export { NotificationsService } from "./notifications.service";

View File

@@ -0,0 +1,316 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, Subject } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { awaitAsync } from "../../../../spec";
import { Matrix } from "../../../../spec/matrix";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { NotificationType } from "../../../enums";
import { NotificationResponse } from "../../../models/response/notification.response";
import { UserId } from "../../../types/guid";
import { AppIdService } from "../../abstractions/app-id.service";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { LogService } from "../../abstractions/log.service";
import { MessageSender } from "../../messaging";
import { SupportStatus } from "../../misc/support-status";
import { SyncService } from "../../sync";
import {
DefaultNotificationsService,
DISABLED_NOTIFICATIONS_URL,
} from "./default-notifications.service";
import { SignalRConnectionService, SignalRNotification } from "./signalr-connection.service";
import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service";
import { WorkerWebPushConnectionService } from "./worker-webpush-connection.service";
describe("NotificationsService", () => {
let syncService: MockProxy<SyncService>;
let appIdService: MockProxy<AppIdService>;
let environmentService: MockProxy<EnvironmentService>;
let logoutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason]>;
let messagingService: MockProxy<MessageSender>;
let accountService: MockProxy<AccountService>;
let signalRNotificationConnectionService: MockProxy<SignalRConnectionService>;
let authService: MockProxy<AuthService>;
let webPushNotificationConnectionService: MockProxy<WebPushConnectionService>;
let activeAccount: BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>;
let environment: BehaviorSubject<ObservedValueOf<EnvironmentService["environment$"]>>;
let authStatusGetter: (userId: UserId) => BehaviorSubject<AuthenticationStatus>;
let webPushSupportGetter: (userId: UserId) => BehaviorSubject<SupportStatus<WebPushConnector>>;
let signalrNotificationGetter: (
userId: UserId,
notificationsUrl: string,
) => Subject<SignalRNotification>;
let sut: DefaultNotificationsService;
beforeEach(() => {
syncService = mock<SyncService>();
appIdService = mock<AppIdService>();
environmentService = mock<EnvironmentService>();
logoutCallback = jest.fn<Promise<void>, [logoutReason: LogoutReason]>();
messagingService = mock<MessageSender>();
accountService = mock<AccountService>();
signalRNotificationConnectionService = mock<SignalRConnectionService>();
authService = mock<AuthService>();
webPushNotificationConnectionService = mock<WorkerWebPushConnectionService>();
activeAccount = new BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>(null);
accountService.activeAccount$ = activeAccount.asObservable();
environment = new BehaviorSubject<ObservedValueOf<EnvironmentService["environment$"]>>({
getNotificationsUrl: () => "https://notifications.bitwarden.com",
} as Environment);
environmentService.environment$ = environment;
authStatusGetter = Matrix.autoMockMethod(
authService.authStatusFor$,
() => new BehaviorSubject<AuthenticationStatus>(AuthenticationStatus.LoggedOut),
);
webPushSupportGetter = Matrix.autoMockMethod(
webPushNotificationConnectionService.supportStatus$,
() =>
new BehaviorSubject<SupportStatus<WebPushConnector>>({
type: "not-supported",
reason: "test",
}),
);
signalrNotificationGetter = Matrix.autoMockMethod(
signalRNotificationConnectionService.connect$,
() => new Subject<SignalRNotification>(),
);
sut = new DefaultNotificationsService(
mock<LogService>(),
syncService,
appIdService,
environmentService,
logoutCallback,
messagingService,
accountService,
signalRNotificationConnectionService,
authService,
webPushNotificationConnectionService,
);
});
const mockUser1 = "user1" as UserId;
const mockUser2 = "user2" as UserId;
function emitActiveUser(userId: UserId) {
if (userId == null) {
activeAccount.next(null);
} else {
activeAccount.next({ id: userId, email: "email", name: "Test Name", emailVerified: true });
}
}
function emitNotificationUrl(url: string) {
environment.next({
getNotificationsUrl: () => url,
} as Environment);
}
const expectNotification = (
notification: readonly [NotificationResponse, UserId],
expectedUser: UserId,
expectedType: NotificationType,
) => {
const [actualNotification, actualUser] = notification;
expect(actualUser).toBe(expectedUser);
expect(actualNotification.type).toBe(expectedType);
};
it("emits notifications through WebPush when supported", async () => {
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2)));
emitActiveUser(mockUser1);
emitNotificationUrl("http://test.example.com");
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
const webPush = mock<WebPushConnector>();
const webPushSubject = new Subject<NotificationResponse>();
webPush.notifications$ = webPushSubject;
webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush });
webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncFolderCreate }));
webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncFolderDelete }));
const notifications = await notificationsPromise;
expectNotification(notifications[0], mockUser1, NotificationType.SyncFolderCreate);
expectNotification(notifications[1], mockUser1, NotificationType.SyncFolderDelete);
});
it("switches to SignalR when web push is not supported.", async () => {
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2)));
emitActiveUser(mockUser1);
emitNotificationUrl("http://test.example.com");
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
const webPush = mock<WebPushConnector>();
const webPushSubject = new Subject<NotificationResponse>();
webPush.notifications$ = webPushSubject;
webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush });
webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncFolderCreate }));
emitActiveUser(mockUser2);
authStatusGetter(mockUser2).next(AuthenticationStatus.Unlocked);
// Second user does not support web push
webPushSupportGetter(mockUser2).next({ type: "not-supported", reason: "test" });
signalrNotificationGetter(mockUser2, "http://test.example.com").next({
type: "ReceiveMessage",
message: new NotificationResponse({ type: NotificationType.SyncCipherUpdate }),
});
const notifications = await notificationsPromise;
expectNotification(notifications[0], mockUser1, NotificationType.SyncFolderCreate);
expectNotification(notifications[1], mockUser2, NotificationType.SyncCipherUpdate);
});
it("switches to WebPush when it becomes supported.", async () => {
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2)));
emitActiveUser(mockUser1);
emitNotificationUrl("http://test.example.com");
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" });
signalrNotificationGetter(mockUser1, "http://test.example.com").next({
type: "ReceiveMessage",
message: new NotificationResponse({ type: NotificationType.AuthRequest }),
});
const webPush = mock<WebPushConnector>();
const webPushSubject = new Subject<NotificationResponse>();
webPush.notifications$ = webPushSubject;
webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush });
webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncLoginDelete }));
const notifications = await notificationsPromise;
expectNotification(notifications[0], mockUser1, NotificationType.AuthRequest);
expectNotification(notifications[1], mockUser1, NotificationType.SyncLoginDelete);
});
it("does not emit SignalR heartbeats", async () => {
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(1)));
emitActiveUser(mockUser1);
emitNotificationUrl("http://test.example.com");
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" });
signalrNotificationGetter(mockUser1, "http://test.example.com").next({ type: "Heartbeat" });
signalrNotificationGetter(mockUser1, "http://test.example.com").next({
type: "ReceiveMessage",
message: new NotificationResponse({ type: NotificationType.AuthRequestResponse }),
});
const notifications = await notificationsPromise;
expectNotification(notifications[0], mockUser1, NotificationType.AuthRequestResponse);
});
it.each([
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked },
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked },
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked },
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Unlocked },
])(
"does not re-connect when the user transitions from $initialStatus to $updatedStatus",
async ({ initialStatus, updatedStatus }) => {
emitActiveUser(mockUser1);
emitNotificationUrl("http://test.example.com");
authStatusGetter(mockUser1).next(initialStatus);
webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" });
const notificationsSubscriptions = sut.notifications$.subscribe();
await awaitAsync(1);
authStatusGetter(mockUser1).next(updatedStatus);
await awaitAsync(1);
expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledTimes(1);
expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledWith(
mockUser1,
"http://test.example.com",
);
notificationsSubscriptions.unsubscribe();
},
);
it.each([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked])(
"connects when a user transitions from logged out to %s",
async (newStatus: AuthenticationStatus) => {
emitActiveUser(mockUser1);
emitNotificationUrl("http://test.example.com");
authStatusGetter(mockUser1).next(AuthenticationStatus.LoggedOut);
webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" });
const notificationsSubscriptions = sut.notifications$.subscribe();
await awaitAsync(1);
authStatusGetter(mockUser1).next(newStatus);
await awaitAsync(1);
expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledTimes(1);
expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledWith(
mockUser1,
"http://test.example.com",
);
notificationsSubscriptions.unsubscribe();
},
);
it("does not connect to any notification stream when notifications are disabled through special url", () => {
const subscription = sut.notifications$.subscribe();
emitActiveUser(mockUser1);
emitNotificationUrl(DISABLED_NOTIFICATIONS_URL);
expect(signalRNotificationConnectionService.connect$).not.toHaveBeenCalled();
expect(webPushNotificationConnectionService.supportStatus$).not.toHaveBeenCalled();
subscription.unsubscribe();
});
it("does not connect to any notification stream when there is no active user", () => {
const subscription = sut.notifications$.subscribe();
emitActiveUser(null);
expect(signalRNotificationConnectionService.connect$).not.toHaveBeenCalled();
expect(webPushNotificationConnectionService.supportStatus$).not.toHaveBeenCalled();
subscription.unsubscribe();
});
it("does not reconnect if the same notification url is emitted", async () => {
const subscription = sut.notifications$.subscribe();
emitActiveUser(mockUser1);
emitNotificationUrl("http://test.example.com");
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
await awaitAsync(1);
expect(webPushNotificationConnectionService.supportStatus$).toHaveBeenCalledTimes(1);
emitNotificationUrl("http://test.example.com");
await awaitAsync(1);
expect(webPushNotificationConnectionService.supportStatus$).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
});
});

View File

@@ -0,0 +1,238 @@
import {
BehaviorSubject,
catchError,
distinctUntilChanged,
EMPTY,
filter,
map,
mergeMap,
Observable,
switchMap,
} from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { NotificationType } from "../../../enums";
import {
NotificationResponse,
SyncCipherNotification,
SyncFolderNotification,
SyncSendNotification,
} from "../../../models/response/notification.response";
import { UserId } from "../../../types/guid";
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
import { AppIdService } from "../../abstractions/app-id.service";
import { EnvironmentService } from "../../abstractions/environment.service";
import { LogService } from "../../abstractions/log.service";
import { MessagingService } from "../../abstractions/messaging.service";
import { supportSwitch } from "../../misc/support-status";
import { NotificationsService as NotificationsServiceAbstraction } from "../notifications.service";
import { ReceiveMessage, SignalRConnectionService } from "./signalr-connection.service";
import { WebPushConnectionService } from "./webpush-connection.service";
export const DISABLED_NOTIFICATIONS_URL = "http://-";
export class DefaultNotificationsService implements NotificationsServiceAbstraction {
notifications$: Observable<readonly [NotificationResponse, UserId]>;
private activitySubject = new BehaviorSubject<"active" | "inactive">("active");
constructor(
private readonly logService: LogService,
private syncService: SyncService,
private appIdService: AppIdService,
private environmentService: EnvironmentService,
private logoutCallback: (logoutReason: LogoutReason, userId: UserId) => Promise<void>,
private messagingService: MessagingService,
private readonly accountService: AccountService,
private readonly signalRConnectionService: SignalRConnectionService,
private readonly authService: AuthService,
private readonly webPushConnectionService: WebPushConnectionService,
) {
this.notifications$ = this.accountService.activeAccount$.pipe(
map((account) => account?.id),
distinctUntilChanged(),
switchMap((activeAccountId) => {
if (activeAccountId == null) {
// We don't emit notifications for inactive accounts currently
return EMPTY;
}
return this.userNotifications$(activeAccountId).pipe(
map((notification) => [notification, activeAccountId] as const),
);
}),
);
}
/**
* Retrieves a stream of push notifications for the given user.
* @param userId The user id of the user to get the push notifications for.
*/
private userNotifications$(userId: UserId) {
return this.environmentService.environment$.pipe(
map((env) => env.getNotificationsUrl()),
distinctUntilChanged(),
switchMap((notificationsUrl) => {
if (notificationsUrl === DISABLED_NOTIFICATIONS_URL) {
return EMPTY;
}
return this.userNotificationsHelper$(userId, notificationsUrl);
}),
);
}
private userNotificationsHelper$(userId: UserId, notificationsUrl: string) {
return this.hasAccessToken$(userId).pipe(
switchMap((hasAccessToken) => {
if (!hasAccessToken) {
return EMPTY;
}
return this.activitySubject;
}),
switchMap((activityStatus) => {
if (activityStatus === "inactive") {
return EMPTY;
}
return this.webPushConnectionService.supportStatus$(userId);
}),
supportSwitch({
supported: (service) =>
service.notifications$.pipe(
catchError((err: unknown) => {
this.logService.warning("Issue with web push, falling back to SignalR", err);
return this.connectSignalR$(userId, notificationsUrl);
}),
),
notSupported: () => this.connectSignalR$(userId, notificationsUrl),
}),
);
}
private connectSignalR$(userId: UserId, notificationsUrl: string) {
return this.signalRConnectionService.connect$(userId, notificationsUrl).pipe(
filter((n) => n.type === "ReceiveMessage"),
map((n) => (n as ReceiveMessage).message),
);
}
private hasAccessToken$(userId: UserId) {
return this.authService.authStatusFor$(userId).pipe(
map(
(authStatus) =>
authStatus === AuthenticationStatus.Locked ||
authStatus === AuthenticationStatus.Unlocked,
),
distinctUntilChanged(),
);
}
private async processNotification(notification: NotificationResponse, userId: UserId) {
const appId = await this.appIdService.getAppId();
if (notification == null || notification.contextId === appId) {
return;
}
const payloadUserId = notification.payload?.userId || notification.payload?.UserId;
if (payloadUserId != null && payloadUserId !== userId) {
return;
}
switch (notification.type) {
case NotificationType.SyncCipherCreate:
case NotificationType.SyncCipherUpdate:
await this.syncService.syncUpsertCipher(
notification.payload as SyncCipherNotification,
notification.type === NotificationType.SyncCipherUpdate,
);
break;
case NotificationType.SyncCipherDelete:
case NotificationType.SyncLoginDelete:
await this.syncService.syncDeleteCipher(notification.payload as SyncCipherNotification);
break;
case NotificationType.SyncFolderCreate:
case NotificationType.SyncFolderUpdate:
await this.syncService.syncUpsertFolder(
notification.payload as SyncFolderNotification,
notification.type === NotificationType.SyncFolderUpdate,
userId,
);
break;
case NotificationType.SyncFolderDelete:
await this.syncService.syncDeleteFolder(
notification.payload as SyncFolderNotification,
userId,
);
break;
case NotificationType.SyncVault:
case NotificationType.SyncCiphers:
case NotificationType.SyncSettings:
await this.syncService.fullSync(false);
break;
case NotificationType.SyncOrganizations:
// An organization update may not have bumped the user's account revision date, so force a sync
await this.syncService.fullSync(true);
break;
case NotificationType.SyncOrgKeys:
await this.syncService.fullSync(true);
this.activitySubject.next("inactive"); // Force a disconnect
this.activitySubject.next("active"); // Allow a reconnect
break;
case NotificationType.LogOut:
this.logService.info("[Notifications Service] Received logout notification");
await this.logoutCallback("logoutNotification", userId);
break;
case NotificationType.SyncSendCreate:
case NotificationType.SyncSendUpdate:
await this.syncService.syncUpsertSend(
notification.payload as SyncSendNotification,
notification.type === NotificationType.SyncSendUpdate,
);
break;
case NotificationType.SyncSendDelete:
await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
break;
case NotificationType.AuthRequest:
{
this.messagingService.send("openLoginApproval", {
notificationId: notification.payload.id,
});
}
break;
case NotificationType.SyncOrganizationStatusChanged:
await this.syncService.fullSync(true);
break;
case NotificationType.SyncOrganizationCollectionSettingChanged:
await this.syncService.fullSync(true);
break;
default:
break;
}
}
startListening() {
return this.notifications$
.pipe(
mergeMap(async ([notification, userId]) => this.processNotification(notification, userId)),
)
.subscribe({
error: (e: unknown) => this.logService.warning("Error in notifications$ observable", e),
});
}
reconnectFromActivity(): void {
this.activitySubject.next("active");
}
disconnectFromInactivity(): void {
this.activitySubject.next("inactive");
}
}

View File

@@ -0,0 +1,8 @@
export * from "./worker-webpush-connection.service";
export * from "./signalr-connection.service";
export * from "./default-notifications.service";
export * from "./noop-notifications.service";
export * from "./unsupported-webpush-connection.service";
export * from "./webpush-connection.service";
export * from "./websocket-webpush-connection.service";
export * from "./web-push-notifications-api.service";

View File

@@ -0,0 +1,23 @@
import { Subscription } from "rxjs";
import { LogService } from "../../abstractions/log.service";
import { NotificationsService } from "../notifications.service";
export class NoopNotificationsService implements NotificationsService {
constructor(private logService: LogService) {}
startListening(): Subscription {
this.logService.info(
"Initializing no-op notification service, no push notifications will be received",
);
return Subscription.EMPTY;
}
reconnectFromActivity(): void {
this.logService.info("Reconnecting notification service from activity");
}
disconnectFromInactivity(): void {
this.logService.info("Disconnecting notification service from inactivity");
}
}

View File

@@ -0,0 +1,125 @@
import {
HttpTransportType,
HubConnectionBuilder,
HubConnectionState,
ILogger,
LogLevel,
} from "@microsoft/signalr";
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
import { Observable, Subscription } from "rxjs";
import { ApiService } from "../../../abstractions/api.service";
import { NotificationResponse } from "../../../models/response/notification.response";
import { UserId } from "../../../types/guid";
import { LogService } from "../../abstractions/log.service";
// 2 Minutes
const MIN_RECONNECT_TIME = 2 * 60 * 1000;
// 5 Minutes
const MAX_RECONNECT_TIME = 5 * 60 * 1000;
export type Heartbeat = { type: "Heartbeat" };
export type ReceiveMessage = { type: "ReceiveMessage"; message: NotificationResponse };
export type SignalRNotification = Heartbeat | ReceiveMessage;
class SignalRLogger implements ILogger {
constructor(private readonly logService: LogService) {}
log(logLevel: LogLevel, message: string): void {
switch (logLevel) {
case LogLevel.Critical:
this.logService.error(message);
break;
case LogLevel.Error:
this.logService.error(message);
break;
case LogLevel.Warning:
this.logService.warning(message);
break;
case LogLevel.Information:
this.logService.info(message);
break;
case LogLevel.Debug:
this.logService.debug(message);
break;
}
}
}
export class SignalRConnectionService {
constructor(
private readonly apiService: ApiService,
private readonly logService: LogService,
) {}
connect$(userId: UserId, notificationsUrl: string) {
return new Observable<SignalRNotification>((subsciber) => {
const connection = new HubConnectionBuilder()
.withUrl(notificationsUrl + "/hub", {
accessTokenFactory: () => this.apiService.getActiveBearerToken(),
skipNegotiation: true,
transport: HttpTransportType.WebSockets,
})
.withHubProtocol(new MessagePackHubProtocol())
.configureLogging(new SignalRLogger(this.logService))
.build();
connection.on("ReceiveMessage", (data: any) => {
subsciber.next({ type: "ReceiveMessage", message: new NotificationResponse(data) });
});
connection.on("Heartbeat", () => {
subsciber.next({ type: "Heartbeat" });
});
let reconnectSubscription: Subscription | null = null;
// Create schedule reconnect function
const scheduleReconnect = (): Subscription => {
if (
connection == null ||
connection.state !== HubConnectionState.Disconnected ||
(reconnectSubscription != null && !reconnectSubscription.closed)
) {
return Subscription.EMPTY;
}
const randomTime = this.random();
const timeoutHandler = setTimeout(() => {
connection
.start()
.then(() => (reconnectSubscription = null))
.catch(() => {
reconnectSubscription = scheduleReconnect();
});
}, randomTime);
return new Subscription(() => clearTimeout(timeoutHandler));
};
connection.onclose((error) => {
reconnectSubscription = scheduleReconnect();
});
// Start connection
connection.start().catch(() => {
reconnectSubscription = scheduleReconnect();
});
return () => {
connection?.stop().catch((error) => {
this.logService.error("Error while stopping SignalR connection", error);
// TODO: Does calling stop call `onclose`?
reconnectSubscription?.unsubscribe();
});
};
});
}
private random() {
return (
Math.floor(Math.random() * (MAX_RECONNECT_TIME - MIN_RECONNECT_TIME + 1)) + MIN_RECONNECT_TIME
);
}
}

View File

@@ -0,0 +1,15 @@
import { Observable, of } from "rxjs";
import { UserId } from "../../../types/guid";
import { SupportStatus } from "../../misc/support-status";
import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service";
/**
* An implementation of {@see WebPushConnectionService} for clients that do not have support for WebPush
*/
export class UnsupportedWebPushConnectionService implements WebPushConnectionService {
supportStatus$(userId: UserId): Observable<SupportStatus<WebPushConnector>> {
return of({ type: "not-supported", reason: "client-not-supported" });
}
}

View File

@@ -0,0 +1,25 @@
import { ApiService } from "../../../abstractions/api.service";
import { AppIdService } from "../../abstractions/app-id.service";
import { WebPushRequest } from "./web-push.request";
export class WebPushNotificationsApiService {
constructor(
private readonly apiService: ApiService,
private readonly appIdService: AppIdService,
) {}
/**
* Posts a device-user association to the server and ensures it's installed for push notifications
*/
async putSubscription(pushSubscription: PushSubscriptionJSON): Promise<void> {
const request = WebPushRequest.from(pushSubscription);
await this.apiService.send(
"POST",
`/devices/identifier/${await this.appIdService.getAppId()}/web-push-auth`,
request,
true,
false,
);
}
}

View File

@@ -0,0 +1,13 @@
export class WebPushRequest {
endpoint: string | undefined;
p256dh: string | undefined;
auth: string | undefined;
static from(pushSubscription: PushSubscriptionJSON): WebPushRequest {
const result = new WebPushRequest();
result.endpoint = pushSubscription.endpoint;
result.p256dh = pushSubscription.keys?.p256dh;
result.auth = pushSubscription.keys?.auth;
return result;
}
}

View File

@@ -0,0 +1,13 @@
import { Observable } from "rxjs";
import { NotificationResponse } from "../../../models/response/notification.response";
import { UserId } from "../../../types/guid";
import { SupportStatus } from "../../misc/support-status";
export interface WebPushConnector {
notifications$: Observable<NotificationResponse>;
}
export abstract class WebPushConnectionService {
abstract supportStatus$(userId: UserId): Observable<SupportStatus<WebPushConnector>>;
}

View File

@@ -0,0 +1,12 @@
import { Observable, of } from "rxjs";
import { UserId } from "../../../types/guid";
import { SupportStatus } from "../../misc/support-status";
import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service";
export class WebSocketWebPushConnectionService implements WebPushConnectionService {
supportStatus$(userId: UserId): Observable<SupportStatus<WebPushConnector>> {
return of({ type: "not-supported", reason: "work-in-progress" });
}
}

View File

@@ -0,0 +1,168 @@
import {
concat,
concatMap,
defer,
distinctUntilChanged,
fromEvent,
map,
Observable,
Subject,
Subscription,
switchMap,
} from "rxjs";
import { PushTechnology } from "../../../enums/push-technology.enum";
import { NotificationResponse } from "../../../models/response/notification.response";
import { UserId } from "../../../types/guid";
import { ConfigService } from "../../abstractions/config/config.service";
import { SupportStatus } from "../../misc/support-status";
import { Utils } from "../../misc/utils";
import { WebPushNotificationsApiService } from "./web-push-notifications-api.service";
import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service";
// Ref: https://w3c.github.io/push-api/#the-pushsubscriptionchange-event
interface PushSubscriptionChangeEvent {
readonly newSubscription?: PushSubscription;
readonly oldSubscription?: PushSubscription;
}
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/PushMessageData
interface PushMessageData {
json(): any;
}
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/PushEvent
interface PushEvent {
data: PushMessageData;
}
/**
* An implementation for connecting to web push based notifications running in a Worker.
*/
export class WorkerWebPushConnectionService implements WebPushConnectionService {
private pushEvent = new Subject<PushEvent>();
private pushChangeEvent = new Subject<PushSubscriptionChangeEvent>();
constructor(
private readonly configService: ConfigService,
private readonly webPushApiService: WebPushNotificationsApiService,
private readonly serviceWorkerRegistration: ServiceWorkerRegistration,
) {}
start(): Subscription {
const subscription = new Subscription(() => {
this.pushEvent.complete();
this.pushChangeEvent.complete();
this.pushEvent = new Subject<PushEvent>();
this.pushChangeEvent = new Subject<PushSubscriptionChangeEvent>();
});
const pushEventSubscription = fromEvent<PushEvent>(self, "push").subscribe(this.pushEvent);
const pushChangeEventSubscription = fromEvent<PushSubscriptionChangeEvent>(
self,
"pushsubscriptionchange",
).subscribe(this.pushChangeEvent);
subscription.add(pushEventSubscription);
subscription.add(pushChangeEventSubscription);
return subscription;
}
supportStatus$(userId: UserId): Observable<SupportStatus<WebPushConnector>> {
// Check the server config to see if it supports sending WebPush notifications
// FIXME: get config of server for the specified userId, once ConfigService supports it
return this.configService.serverConfig$.pipe(
map((config) =>
config?.push?.pushTechnology === PushTechnology.WebPush ? config.push.vapidPublicKey : null,
),
// No need to re-emit when there is new server config if the vapidPublicKey is still there and the exact same
distinctUntilChanged(),
map((publicKey) => {
if (publicKey == null) {
return {
type: "not-supported",
reason: "server-not-configured",
} satisfies SupportStatus<WebPushConnector>;
}
return {
type: "supported",
service: new MyWebPushConnector(
publicKey,
userId,
this.webPushApiService,
this.serviceWorkerRegistration,
this.pushEvent,
this.pushChangeEvent,
),
} satisfies SupportStatus<WebPushConnector>;
}),
);
}
}
class MyWebPushConnector implements WebPushConnector {
notifications$: Observable<NotificationResponse>;
constructor(
private readonly vapidPublicKey: string,
private readonly userId: UserId,
private readonly webPushApiService: WebPushNotificationsApiService,
private readonly serviceWorkerRegistration: ServiceWorkerRegistration,
private readonly pushEvent$: Observable<PushEvent>,
private readonly pushChangeEvent$: Observable<PushSubscriptionChangeEvent>,
) {
this.notifications$ = this.getOrCreateSubscription$(this.vapidPublicKey).pipe(
concatMap((subscription) => {
return defer(() => {
if (subscription == null) {
throw new Error("Expected a non-null subscription.");
}
return this.webPushApiService.putSubscription(subscription.toJSON());
}).pipe(
switchMap(() => this.pushEvent$),
map((e) => new NotificationResponse(e.data.json().data)),
);
}),
);
}
private async pushManagerSubscribe(key: string) {
return await this.serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: key,
});
}
private getOrCreateSubscription$(key: string) {
return concat(
defer(async () => {
const existingSubscription =
await this.serviceWorkerRegistration.pushManager.getSubscription();
if (existingSubscription == null) {
return await this.pushManagerSubscribe(key);
}
const subscriptionKey = Utils.fromBufferToUrlB64(
// REASON: `Utils.fromBufferToUrlB64` handles null by returning null back to it.
// its annotation should be updated and then this assertion can be removed.
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
existingSubscription.options?.applicationServerKey!,
);
if (subscriptionKey !== key) {
// There is a subscription, but it's not for the current server, unsubscribe and then make a new one
await existingSubscription.unsubscribe();
return await this.pushManagerSubscribe(key);
}
return existingSubscription;
}),
this.pushChangeEvent$.pipe(map((event) => event.newSubscription)),
);
}
}

View File

@@ -0,0 +1,18 @@
import { Subscription } from "rxjs";
/**
* A service offering abilities to interact with push notifications from the server.
*/
export abstract class NotificationsService {
/**
* Starts automatic listening and processing of notifications, should only be called once per application,
* or you will risk notifications being processed multiple times.
*/
abstract startListening(): Subscription;
// TODO: Delete this method in favor of an `ActivityService` that notifications can depend on.
// https://bitwarden.atlassian.net/browse/PM-14264
abstract reconnectFromActivity(): void;
// TODO: Delete this method in favor of an `ActivityService` that notifications can depend on.
// https://bitwarden.atlassian.net/browse/PM-14264
abstract disconnectFromInactivity(): void;
}

View File

@@ -1,28 +0,0 @@
import { NotificationsService as NotificationsServiceAbstraction } from "../../abstractions/notifications.service";
import { LogService } from "../abstractions/log.service";
export class NoopNotificationsService implements NotificationsServiceAbstraction {
constructor(private logService: LogService) {}
init(): Promise<void> {
this.logService.info(
"Initializing no-op notification service, no push notifications will be received",
);
return Promise.resolve();
}
updateConnection(sync?: boolean): Promise<void> {
this.logService.info("Updating notification service connection");
return Promise.resolve();
}
reconnectFromActivity(): Promise<void> {
this.logService.info("Reconnecting notification service from activity");
return Promise.resolve();
}
disconnectFromInactivity(): Promise<void> {
this.logService.info("Disconnecting notification service from inactivity");
return Promise.resolve();
}
}

View File

@@ -10,6 +10,7 @@ import { UserKey } from "../../../types/key";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
import { Rc } from "../../misc/reference-counting/rc";
import { EncryptedString } from "../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
@@ -75,15 +76,14 @@ describe("DefaultSdkService", () => {
});
it("creates an SDK client when called the first time", async () => {
const result = await firstValueFrom(service.userClient$(userId));
await firstValueFrom(service.userClient$(userId));
expect(result).toBe(mockClient);
expect(sdkClientFactory.createSdkClient).toHaveBeenCalled();
});
it("does not create an SDK client when called the second time with same userId", async () => {
const subject_1 = new BehaviorSubject(undefined);
const subject_2 = new BehaviorSubject(undefined);
const subject_1 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subject_2 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
// Use subjects to ensure the subscription is kept alive
service.userClient$(userId).subscribe(subject_1);
@@ -92,14 +92,14 @@ describe("DefaultSdkService", () => {
// Wait for the next tick to ensure all async operations are done
await new Promise(process.nextTick);
expect(subject_1.value).toBe(mockClient);
expect(subject_2.value).toBe(mockClient);
expect(subject_1.value.take().value).toBe(mockClient);
expect(subject_2.value.take().value).toBe(mockClient);
expect(sdkClientFactory.createSdkClient).toHaveBeenCalledTimes(1);
});
it("destroys the SDK client when all subscriptions are closed", async () => {
const subject_1 = new BehaviorSubject(undefined);
const subject_2 = new BehaviorSubject(undefined);
const subject_1 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subject_2 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subscription_1 = service.userClient$(userId).subscribe(subject_1);
const subscription_2 = service.userClient$(userId).subscribe(subject_2);
await new Promise(process.nextTick);
@@ -107,6 +107,7 @@ describe("DefaultSdkService", () => {
subscription_1.unsubscribe();
subscription_2.unsubscribe();
await new Promise(process.nextTick);
expect(mockClient.free).toHaveBeenCalledTimes(1);
});
@@ -114,7 +115,7 @@ describe("DefaultSdkService", () => {
const userKey$ = new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey);
keyService.userKey$.calledWith(userId).mockReturnValue(userKey$);
const subject = new BehaviorSubject(undefined);
const subject = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
service.userClient$(userId).subscribe(subject);
await new Promise(process.nextTick);

View File

@@ -30,10 +30,11 @@ import { PlatformUtilsService } from "../../abstractions/platform-utils.service"
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
import { SdkService } from "../../abstractions/sdk/sdk.service";
import { compareValues } from "../../misc/compare-values";
import { Rc } from "../../misc/reference-counting/rc";
import { EncryptedString } from "../../models/domain/enc-string";
export class DefaultSdkService implements SdkService {
private sdkClientCache = new Map<UserId, Observable<BitwardenClient>>();
private sdkClientCache = new Map<UserId, Observable<Rc<BitwardenClient>>>();
client$ = this.environmentService.environment$.pipe(
concatMap(async (env) => {
@@ -58,7 +59,7 @@ export class DefaultSdkService implements SdkService {
private userAgent: string = null,
) {}
userClient$(userId: UserId): Observable<BitwardenClient | undefined> {
userClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined> {
// TODO: Figure out what happens when the user logs out
if (this.sdkClientCache.has(userId)) {
return this.sdkClientCache.get(userId);
@@ -88,32 +89,31 @@ export class DefaultSdkService implements SdkService {
// switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value.
switchMap(([env, account, kdfParams, privateKey, userKey, orgKeys]) => {
// Create our own observable to be able to implement clean-up logic
return new Observable<BitwardenClient>((subscriber) => {
let client: BitwardenClient;
return new Observable<Rc<BitwardenClient>>((subscriber) => {
const createAndInitializeClient = async () => {
if (privateKey == null || userKey == null) {
return undefined;
}
const settings = this.toSettings(env);
client = await this.sdkClientFactory.createSdkClient(settings, LogLevel.Info);
const client = await this.sdkClientFactory.createSdkClient(settings, LogLevel.Info);
await this.initializeClient(client, account, kdfParams, privateKey, userKey, orgKeys);
return client;
};
let client: Rc<BitwardenClient>;
createAndInitializeClient()
.then((c) => {
client = c;
subscriber.next(c);
client = c === undefined ? undefined : new Rc(c);
subscriber.next(client);
})
.catch((e) => {
subscriber.error(e);
});
return () => client?.free();
return () => client?.markForDisposal();
});
}),
tap({

View File

@@ -9,7 +9,7 @@ import { StateUpdateOptions } from "./state-update-options";
export interface GlobalState<T> {
/**
* Method for allowing you to manipulate state in an additive way.
* @param configureState callback for how you want manipulate this section of state
* @param configureState callback for how you want to manipulate this section of state
* @param options Defaults given by @see {module:state-update-options#DEFAULT_OPTIONS}
* @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true
* @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null

View File

@@ -16,6 +16,7 @@ export interface UserState<T> {
}
export const activeMarker: unique symbol = Symbol("active");
export interface ActiveUserState<T> extends UserState<T> {
readonly [activeMarker]: true;
@@ -32,7 +33,7 @@ export interface ActiveUserState<T> extends UserState<T> {
* @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true
* @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null
* @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set.
*
* @returns A promise that must be awaited before your next action to ensure the update has been written to state.
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
*/
@@ -41,6 +42,7 @@ export interface ActiveUserState<T> extends UserState<T> {
options?: StateUpdateOptions<T, TCombine>,
) => Promise<[UserId, T]>;
}
export interface SingleUserState<T> extends UserState<T> {
readonly userId: UserId;
@@ -51,7 +53,7 @@ export interface SingleUserState<T> extends UserState<T> {
* @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true
* @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null
* @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set.
*
* @returns A promise that must be awaited before your next action to ensure the update has been written to state.
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
*/

View File

@@ -197,6 +197,7 @@ export class DefaultSyncService extends CoreSyncService {
await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor);
await this.tokenService.setSecurityStamp(response.securityStamp, response.id);
await this.accountService.setAccountEmailVerified(response.id, response.emailVerified);
await this.accountService.setAccountVerifyNewDeviceLogin(response.id, response.verifyDevices);
await this.billingAccountProfileStateService.setHasPremium(
response.premiumPersonally,

View File

@@ -702,7 +702,7 @@ export class ApiService implements ApiServiceAbstraction {
}
deleteCipherAttachment(id: string, attachmentId: string): Promise<any> {
return this.send("DELETE", "/ciphers/" + id + "/attachment/" + attachmentId, null, true, false);
return this.send("DELETE", "/ciphers/" + id + "/attachment/" + attachmentId, null, true, true);
}
deleteCipherAttachmentAdmin(id: string, attachmentId: string): Promise<any> {

View File

@@ -1,280 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as signalR from "@microsoft/signalr";
import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack";
import { firstValueFrom, Subscription } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { ApiService } from "../abstractions/api.service";
import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service";
import { AuthService } from "../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../auth/enums/authentication-status";
import { NotificationType } from "../enums";
import {
NotificationResponse,
SyncCipherNotification,
SyncFolderNotification,
SyncSendNotification,
} from "../models/response/notification.response";
import { AppIdService } from "../platform/abstractions/app-id.service";
import { EnvironmentService } from "../platform/abstractions/environment.service";
import { LogService } from "../platform/abstractions/log.service";
import { MessagingService } from "../platform/abstractions/messaging.service";
import { StateService } from "../platform/abstractions/state.service";
import { ScheduledTaskNames } from "../platform/scheduling/scheduled-task-name.enum";
import { TaskSchedulerService } from "../platform/scheduling/task-scheduler.service";
import { SyncService } from "../vault/abstractions/sync/sync.service.abstraction";
export class NotificationsService implements NotificationsServiceAbstraction {
private signalrConnection: signalR.HubConnection;
private url: string;
private connected = false;
private inited = false;
private inactive = false;
private reconnectTimerSubscription: Subscription;
private isSyncingOnReconnect = true;
constructor(
private logService: LogService,
private syncService: SyncService,
private appIdService: AppIdService,
private apiService: ApiService,
private environmentService: EnvironmentService,
private logoutCallback: (logoutReason: LogoutReason) => Promise<void>,
private stateService: StateService,
private authService: AuthService,
private messagingService: MessagingService,
private taskSchedulerService: TaskSchedulerService,
) {
this.taskSchedulerService.registerTaskHandler(
ScheduledTaskNames.notificationsReconnectTimeout,
() => this.reconnect(this.isSyncingOnReconnect),
);
this.environmentService.environment$.subscribe(() => {
if (!this.inited) {
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.init();
});
}
async init(): Promise<void> {
this.inited = false;
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
// Set notifications server URL to `https://-` to effectively disable communication
// with the notifications server from the client app
if (this.url === "https://-") {
return;
}
if (this.signalrConnection != null) {
this.signalrConnection.off("ReceiveMessage");
this.signalrConnection.off("Heartbeat");
await this.signalrConnection.stop();
this.connected = false;
this.signalrConnection = null;
}
this.signalrConnection = new signalR.HubConnectionBuilder()
.withUrl(this.url + "/hub", {
accessTokenFactory: () => this.apiService.getActiveBearerToken(),
skipNegotiation: true,
transport: signalR.HttpTransportType.WebSockets,
})
.withHubProtocol(new signalRMsgPack.MessagePackHubProtocol() as signalR.IHubProtocol)
// .configureLogging(signalR.LogLevel.Trace)
.build();
this.signalrConnection.on("ReceiveMessage", (data: any) =>
this.processNotification(new NotificationResponse(data)),
);
// eslint-disable-next-line
this.signalrConnection.on("Heartbeat", (data: any) => {
/*console.log('Heartbeat!');*/
});
this.signalrConnection.onclose(() => {
this.connected = false;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.reconnect(true);
});
this.inited = true;
if (await this.isAuthedAndUnlocked()) {
await this.reconnect(false);
}
}
async updateConnection(sync = false): Promise<void> {
if (!this.inited) {
return;
}
try {
if (await this.isAuthedAndUnlocked()) {
await this.reconnect(sync);
} else {
await this.signalrConnection.stop();
}
} catch (e) {
this.logService.error(e.toString());
}
}
async reconnectFromActivity(): Promise<void> {
this.inactive = false;
if (this.inited && !this.connected) {
await this.reconnect(true);
}
}
async disconnectFromInactivity(): Promise<void> {
this.inactive = true;
if (this.inited && this.connected) {
await this.signalrConnection.stop();
}
}
private async processNotification(notification: NotificationResponse) {
const appId = await this.appIdService.getAppId();
if (notification == null || notification.contextId === appId) {
return;
}
const isAuthenticated = await this.stateService.getIsAuthenticated();
const payloadUserId = notification.payload.userId || notification.payload.UserId;
const myUserId = await this.stateService.getUserId();
if (isAuthenticated && payloadUserId != null && payloadUserId !== myUserId) {
return;
}
switch (notification.type) {
case NotificationType.SyncCipherCreate:
case NotificationType.SyncCipherUpdate:
await this.syncService.syncUpsertCipher(
notification.payload as SyncCipherNotification,
notification.type === NotificationType.SyncCipherUpdate,
);
break;
case NotificationType.SyncCipherDelete:
case NotificationType.SyncLoginDelete:
await this.syncService.syncDeleteCipher(notification.payload as SyncCipherNotification);
break;
case NotificationType.SyncFolderCreate:
case NotificationType.SyncFolderUpdate:
await this.syncService.syncUpsertFolder(
notification.payload as SyncFolderNotification,
notification.type === NotificationType.SyncFolderUpdate,
payloadUserId,
);
break;
case NotificationType.SyncFolderDelete:
await this.syncService.syncDeleteFolder(
notification.payload as SyncFolderNotification,
payloadUserId,
);
break;
case NotificationType.SyncVault:
case NotificationType.SyncCiphers:
case NotificationType.SyncSettings:
if (isAuthenticated) {
await this.syncService.fullSync(false);
}
break;
case NotificationType.SyncOrganizations:
if (isAuthenticated) {
// An organization update may not have bumped the user's account revision date, so force a sync
await this.syncService.fullSync(true);
}
break;
case NotificationType.SyncOrgKeys:
if (isAuthenticated) {
await this.syncService.fullSync(true);
// Stop so a reconnect can be made
await this.signalrConnection.stop();
}
break;
case NotificationType.LogOut:
if (isAuthenticated) {
this.logService.info("[Notifications Service] Received logout notification");
// 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.logoutCallback("logoutNotification");
}
break;
case NotificationType.SyncSendCreate:
case NotificationType.SyncSendUpdate:
await this.syncService.syncUpsertSend(
notification.payload as SyncSendNotification,
notification.type === NotificationType.SyncSendUpdate,
);
break;
case NotificationType.SyncSendDelete:
await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
break;
case NotificationType.AuthRequest:
{
this.messagingService.send("openLoginApproval", {
notificationId: notification.payload.id,
});
}
break;
case NotificationType.SyncOrganizationStatusChanged:
if (isAuthenticated) {
await this.syncService.fullSync(true);
}
break;
case NotificationType.SyncOrganizationCollectionSettingChanged:
if (isAuthenticated) {
await this.syncService.fullSync(true);
}
break;
default:
break;
}
}
private async reconnect(sync: boolean) {
this.reconnectTimerSubscription?.unsubscribe();
if (this.connected || !this.inited || this.inactive) {
return;
}
const authedAndUnlocked = await this.isAuthedAndUnlocked();
if (!authedAndUnlocked) {
return;
}
try {
await this.signalrConnection.start();
this.connected = true;
if (sync) {
await this.syncService.fullSync(false);
}
} catch (e) {
this.logService.error(e);
}
if (!this.connected) {
this.isSyncingOnReconnect = sync;
this.reconnectTimerSubscription = this.taskSchedulerService.setTimeout(
ScheduledTaskNames.notificationsReconnectTimeout,
this.random(120000, 300000),
);
}
}
private async isAuthedAndUnlocked() {
const authStatus = await this.authService.getAuthStatus();
return authStatus >= AuthenticationStatus.Unlocked;
}
private random(min: number, max: number) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
}

View File

@@ -1,24 +0,0 @@
{
"overrides": [
{
"files": ["*"],
"rules": {
"import/no-restricted-paths": [
"error",
{
"basePath": "libs/common/src/state-migrations",
"zones": [
{
"target": "./",
"from": "../",
// Relative to from, not basePath
"except": ["state-migrations"],
"message": "State migrations should rarely import from the greater codebase. If you need to import from another location, take into account the likelihood of change in that code and consider copying to the migration instead."
}
]
}
]
}
}
]
}

View File

@@ -154,8 +154,8 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
delete: (id: string | string[]) => Promise<any>;
deleteWithServer: (id: string, asAdmin?: boolean) => Promise<any>;
deleteManyWithServer: (ids: string[], asAdmin?: boolean) => Promise<any>;
deleteAttachment: (id: string, attachmentId: string) => Promise<void>;
deleteAttachmentWithServer: (id: string, attachmentId: string) => Promise<void>;
deleteAttachment: (id: string, revisionDate: string, attachmentId: string) => Promise<CipherData>;
deleteAttachmentWithServer: (id: string, attachmentId: string) => Promise<CipherData>;
sortCiphersByLastUsed: (a: CipherView, b: CipherView) => number;
sortCiphersByLastUsedThenName: (a: CipherView, b: CipherView) => number;
getLocaleSortingFunction: () => (a: CipherView, b: CipherView) => number;

View File

@@ -1,8 +1,7 @@
import { mock, MockProxy } from "jest-mock-extended";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../../key-management/src/abstractions/key.service";
import { KeyService } from "@bitwarden/key-management";
import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string";

View File

@@ -1,9 +1,8 @@
import { mock } from "jest-mock-extended";
import { Jsonify } from "type-fest";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../../key-management/src/abstractions/key.service";
import { KeyService } from "@bitwarden/key-management";
import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils";
import { UriMatchStrategy } from "../../../models/domain/domain-service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";

View File

@@ -142,6 +142,13 @@ export class CipherView implements View, InitializerMetadata {
);
}
get canAssignToCollections(): boolean {
if (this.organizationId == null) {
return true;
}
return this.edit && this.viewPassword;
}
/**
* Determines if the cipher can be launched in a new browser tab.
*/

View File

@@ -1,12 +1,8 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, map, of } from "rxjs";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import {
CipherDecryptionKeys,
KeyService,
} from "../../../../key-management/src/abstractions/key.service";
import { CipherDecryptionKeys, KeyService } from "@bitwarden/key-management";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { makeStaticByteArray } from "../../../spec/utils";

View File

@@ -14,9 +14,8 @@ import {
} from "rxjs";
import { SemVer } from "semver";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { KeyService } from "@bitwarden/key-management";
import { ApiService } from "../../abstractions/api.service";
import { SearchService } from "../../abstractions/search.service";
import { AccountService } from "../../auth/abstractions/account.service";
@@ -1078,7 +1077,11 @@ export class CipherService implements CipherServiceAbstraction {
await this.delete(ids);
}
async deleteAttachment(id: string, attachmentId: string): Promise<void> {
async deleteAttachment(
id: string,
revisionDate: string,
attachmentId: string,
): Promise<CipherData> {
let ciphers = await firstValueFrom(this.ciphers$);
const cipherId = id as CipherId;
// eslint-disable-next-line
@@ -1092,6 +1095,10 @@ export class CipherService implements CipherServiceAbstraction {
}
}
// Deleting the cipher updates the revision date on the server,
// Update the stored `revisionDate` to match
ciphers[cipherId].revisionDate = revisionDate;
await this.clearCache();
await this.encryptedCiphersState.update(() => {
if (ciphers == null) {
@@ -1099,15 +1106,20 @@ export class CipherService implements CipherServiceAbstraction {
}
return ciphers;
});
return ciphers[cipherId];
}
async deleteAttachmentWithServer(id: string, attachmentId: string): Promise<void> {
async deleteAttachmentWithServer(id: string, attachmentId: string): Promise<CipherData> {
let cipherResponse = null;
try {
await this.apiService.deleteCipherAttachment(id, attachmentId);
cipherResponse = await this.apiService.deleteCipherAttachment(id, attachmentId);
} catch (e) {
return Promise.reject((e as ErrorResponse).getSingleMessage());
}
await this.deleteAttachment(id, attachmentId);
const cipherData = CipherData.fromJSON(cipherResponse?.cipher);
return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId);
}
sortCiphersByLastUsed(a: CipherView, b: CipherView): number {

View File

@@ -1,9 +1,8 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../../key-management/src/abstractions/key.service";
import { KeyService } from "@bitwarden/key-management";
import { makeEncString } from "../../../../spec";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec/fake-account-service";
import { FakeSingleUserState } from "../../../../spec/fake-state";

View File

@@ -2,11 +2,10 @@
// @ts-strict-ignore
import { Observable, Subject, firstValueFrom, map, shareReplay, switchMap, merge } from "rxjs";
import { KeyService } from "@bitwarden/key-management";
import { EncryptService } from ".././../../platform/abstractions/encrypt.service";
import { Utils } from ".././../../platform/misc/utils";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "../../../../../key-management/src/abstractions/key.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { StateProvider } from "../../../platform/state";

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { NgIf, NgClass } from "@angular/common";
import { NgClass } from "@angular/common";
import { Component, Input, OnChanges } from "@angular/core";
import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser";
@@ -18,9 +18,11 @@ const SizeClasses: Record<SizeTypes, string[]> = {
@Component({
selector: "bit-avatar",
template: `<img *ngIf="src" [src]="src" title="{{ title || text }}" [ngClass]="classList" />`,
template: `@if (src) {
<img [src]="src" title="{{ title || text }}" [ngClass]="classList" />
}`,
standalone: true,
imports: [NgIf, NgClass],
imports: [NgClass],
})
export class AvatarComponent implements OnChanges {
@Input() border = false;

View File

@@ -1,11 +1,15 @@
<div class="tw-inline-flex tw-flex-wrap tw-gap-2">
<ng-container *ngFor="let item of filteredItems; let last = last">
@for (item of filteredItems; track item; let last = $last) {
<span bitBadge [variant]="variant" [truncate]="truncate">
{{ item }}
</span>
<span class="tw-sr-only" *ngIf="!last || isFiltered">, </span>
</ng-container>
<span *ngIf="isFiltered" bitBadge [variant]="variant">
{{ "plusNMore" | i18n: (items.length - filteredItems.length).toString() }}
</span>
@if (!last || isFiltered) {
<span class="tw-sr-only">, </span>
}
}
@if (isFiltered) {
<span bitBadge [variant]="variant">
{{ "plusNMore" | i18n: (items.length - filteredItems.length).toString() }}
</span>
}
</div>

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, Input, OnChanges } from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -11,7 +11,7 @@ import { BadgeModule, BadgeVariant } from "../badge";
selector: "bit-badge-list",
templateUrl: "badge-list.component.html",
standalone: true,
imports: [CommonModule, BadgeModule, I18nPipe],
imports: [BadgeModule, I18nPipe],
})
export class BadgeListComponent implements OnChanges {
private _maxItems: number;

View File

@@ -4,21 +4,24 @@
[attr.role]="useAlertRole ? 'status' : null"
[attr.aria-live]="useAlertRole ? 'polite' : null"
>
<i class="bwi tw-align-middle tw-text-base" [ngClass]="icon" *ngIf="icon" aria-hidden="true"></i>
@if (icon) {
<i class="bwi tw-align-middle tw-text-base" [ngClass]="icon" aria-hidden="true"></i>
}
<!-- Overriding focus-visible color for link buttons for a11y against colored background -->
<span class="tw-grow tw-text-base [&>button[bitlink]:focus-visible:before]:!tw-ring-text-main">
<ng-content></ng-content>
</span>
<!-- Overriding hover and focus-visible colors for a11y against colored background -->
<button
*ngIf="showClose"
class="hover:tw-border-text-main focus-visible:before:tw-ring-text-main"
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
(click)="onClose.emit()"
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
></button>
@if (showClose) {
<button
class="hover:tw-border-text-main focus-visible:before:tw-ring-text-main"
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
(click)="onClose.emit()"
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
></button>
}
</div>

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