diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts index e5c09db6428..fb636ecaf6d 100644 --- a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts @@ -1,9 +1,10 @@ import { CommonModule, Location } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { Subject, firstValueFrom, map, of, startWith, switchMap, takeUntil } from "rxjs"; +import { Subject, firstValueFrom, map, of, startWith, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { LockService } from "@bitwarden/auth/common"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -70,6 +71,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private authService: AuthService, private configService: ConfigService, + private lockService: LockService, ) {} get accountLimit() { @@ -131,26 +133,8 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { async lockAll() { this.loading = true; - this.availableAccounts$ - .pipe( - map((accounts) => - accounts - .filter((account) => account.id !== this.specialAddAccountId) - .sort((a, b) => (a.isActive ? -1 : b.isActive ? 1 : 0)) // Log out of the active account first - .map((account) => account.id), - ), - switchMap(async (accountIds) => { - if (accountIds.length === 0) { - return; - } - - // Must lock active (first) account first, then order doesn't matter - await this.vaultTimeoutService.lock(accountIds.shift()); - await Promise.all(accountIds.map((id) => this.vaultTimeoutService.lock(id))); - }), - takeUntil(this.destroy$), - ) - .subscribe(() => this.router.navigate(["lock"])); + await this.lockService.lockAll(); + await this.router.navigate(["lock"]); } async logOut(userId: UserId) { diff --git a/apps/browser/src/auth/popup/accounts/foreground-lock.service.ts b/apps/browser/src/auth/popup/accounts/foreground-lock.service.ts new file mode 100644 index 00000000000..20a52a90d8b --- /dev/null +++ b/apps/browser/src/auth/popup/accounts/foreground-lock.service.ts @@ -0,0 +1,32 @@ +import { filter, firstValueFrom } from "rxjs"; + +import { LockService } from "@bitwarden/auth/common"; +import { + CommandDefinition, + MessageListener, + MessageSender, +} from "@bitwarden/common/platform/messaging"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +const LOCK_ALL_FINISHED = new CommandDefinition<{ requestId: string }>("lockAllFinished"); +const LOCK_ALL = new CommandDefinition<{ requestId: string }>("lockAll"); + +export class ForegroundLockService implements LockService { + constructor( + private readonly messageSender: MessageSender, + private readonly messageListener: MessageListener, + ) {} + + async lockAll(): Promise { + const requestId = Utils.newGuid(); + const finishMessage = firstValueFrom( + this.messageListener + .messages$(LOCK_ALL_FINISHED) + .pipe(filter((m) => m.requestId === requestId)), + ); + + this.messageSender.send(LOCK_ALL, { requestId }); + + await finishMessage; + } +} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 1cb615fe067..9579fb2be83 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -9,6 +9,7 @@ import { AuthRequestService, LoginEmailServiceAbstraction, LogoutReason, + DefaultLockService, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -1065,6 +1066,9 @@ export default class MainBackground { this.scriptInjectorService, this.configService, ); + + const lockService = new DefaultLockService(this.accountService, this.vaultTimeoutService); + this.runtimeBackground = new RuntimeBackground( this, this.autofillService, @@ -1079,6 +1083,7 @@ export default class MainBackground { this.fido2Background, messageListener, this.accountService, + lockService, ); this.nativeMessagingBackground = new NativeMessagingBackground( this.cryptoService, diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 424449f0b65..1ec7edcc30c 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -1,5 +1,6 @@ import { firstValueFrom, map, mergeMap, of, switchMap } from "rxjs"; +import { LockService } from "@bitwarden/auth/common"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AutofillOverlayVisibility, ExtensionCommand } from "@bitwarden/common/autofill/constants"; @@ -48,6 +49,7 @@ export default class RuntimeBackground { private fido2Background: Fido2Background, private messageListener: MessageListener, private accountService: AccountService, + private readonly lockService: LockService, ) { // onInstalled listener must be wired up before anything else, so we do it in the ctor chrome.runtime.onInstalled.addListener((details: any) => { @@ -245,6 +247,12 @@ export default class RuntimeBackground { case "lockVault": await this.main.vaultTimeoutService.lock(msg.userId); break; + case "lockAll": + { + await this.lockService.lockAll(); + this.messagingService.send("lockAllFinished", { requestId: msg.requestId }); + } + break; case "logout": await this.main.logout(msg.expired, msg.userId); break; diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 098c6eb91ce..efbe9ce6bf5 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -17,7 +17,7 @@ import { } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; -import { PinServiceAbstraction } from "@bitwarden/auth/common"; +import { LockService, PinServiceAbstraction } from "@bitwarden/auth/common"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; @@ -91,6 +91,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; +import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service"; import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; import AutofillService from "../../autofill/services/autofill.service"; @@ -560,6 +561,11 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionAnonLayoutWrapperDataService, deps: [], }), + safeProvider({ + provide: LockService, + useClass: ForegroundLockService, + deps: [MessageSender, MessageListener], + }), ]; @NgModule({ diff --git a/libs/auth/src/common/services/accounts/lock.service.ts b/libs/auth/src/common/services/accounts/lock.service.ts new file mode 100644 index 00000000000..334a795f7bc --- /dev/null +++ b/libs/auth/src/common/services/accounts/lock.service.ts @@ -0,0 +1,49 @@ +import { combineLatest, firstValueFrom, map } from "rxjs"; + +import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +export abstract class LockService { + /** + * Locks all accounts. + */ + abstract lockAll(): Promise; +} + +export class DefaultLockService implements LockService { + constructor( + private readonly accountService: AccountService, + private readonly vaultTimeoutService: VaultTimeoutService, + ) {} + + async lockAll() { + const accounts = await firstValueFrom( + combineLatest([this.accountService.activeAccount$, this.accountService.accounts$]).pipe( + map(([activeAccount, accounts]) => { + const otherAccounts = Object.keys(accounts) as UserId[]; + + if (activeAccount == null) { + return { activeAccount: null, otherAccounts: otherAccounts }; + } + + return { + activeAccount: activeAccount.id, + otherAccounts: otherAccounts.filter((accountId) => accountId !== activeAccount.id), + }; + }), + ), + ); + + for (const otherAccount of accounts.otherAccounts) { + await this.vaultTimeoutService.lock(otherAccount); + } + + // Do the active account last in case we ever try to route the user on lock + // that way this whole operation will be complete before that routing + // could take place. + if (accounts.activeAccount != null) { + await this.vaultTimeoutService.lock(accounts.activeAccount); + } + } +} diff --git a/libs/auth/src/common/services/accounts/lock.services.spec.ts b/libs/auth/src/common/services/accounts/lock.services.spec.ts new file mode 100644 index 00000000000..eecc3dd787f --- /dev/null +++ b/libs/auth/src/common/services/accounts/lock.services.spec.ts @@ -0,0 +1,43 @@ +import { mock } from "jest-mock-extended"; + +import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; +import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { DefaultLockService } from "./lock.service"; + +describe("DefaultLockService", () => { + const mockUser1 = "user1" as UserId; + const mockUser2 = "user2" as UserId; + const mockUser3 = "user3" as UserId; + + const accountService = mockAccountServiceWith(mockUser1); + const vaultTimeoutService = mock(); + + const sut = new DefaultLockService(accountService, vaultTimeoutService); + describe("lockAll", () => { + it("locks the active account last", async () => { + await accountService.addAccount(mockUser2, { + name: "name2", + email: "email2@example.com", + emailVerified: false, + }); + + await accountService.addAccount(mockUser3, { + name: "name3", + email: "email3@example.com", + emailVerified: false, + }); + + await sut.lockAll(); + + expect(vaultTimeoutService.lock).toHaveBeenCalledTimes(3); + // Non-Active users should be called first + expect(vaultTimeoutService.lock).toHaveBeenNthCalledWith(1, mockUser2); + expect(vaultTimeoutService.lock).toHaveBeenNthCalledWith(2, mockUser3); + + // Active user should be called last + expect(vaultTimeoutService.lock).toHaveBeenNthCalledWith(3, mockUser1); + }); + }); +}); diff --git a/libs/auth/src/common/services/index.ts b/libs/auth/src/common/services/index.ts index eb4ec39ce7b..3a8df057796 100644 --- a/libs/auth/src/common/services/index.ts +++ b/libs/auth/src/common/services/index.ts @@ -4,3 +4,4 @@ export * from "./login-strategies/login-strategy.service"; export * from "./user-decryption-options/user-decryption-options.service"; export * from "./auth-request/auth-request.service"; export * from "./register-route.service"; +export * from "./accounts/lock.service";