1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

fix(Multi-Account-Logout: [Auth/PM-19555] Fix multi account logout on lock screens not redirecting properly (#14630)

* PM-19555 - LogoutService - build abstraction, default, and extension service and register with service modules

* PM-19555 - Lock Comp - use logoutService

* PM-19555 - LoginDecryptionOptions - Use logout service which removed need for extension-login-decryption-options.service

* PM-19555 - AccountSwitcher logic update - (1) Use logout service + redirect guard routing (2) Remove logout method from account switcher service (3) use new NewActiveUser type

* PM-19555 - Extension - Acct Switcher comp - clean up TODOs

* PM-19555 - Add TODOs for remaining tech debt

* PM-19555 - Add tests for new logout services.

* PM-19555 - Extension - LoginInitiated - show acct switcher b/c user is AuthN

* PM-19555 - Add TODO to replace LogoutCallback with LogoutService

* PM-19555 WIP

* PM-19555 - Extension App Comp - account switching to account in TDE locked state works now.

* PM-19555 - Extension App Comp - add docs

* PM-19555 - Extension App Comp - add early return

* PM-19555 - Desktop App Comp - add handling for TDE lock case to switch account logic.

* PM-19555 - Extension - Account Component - if account unlocked go to vault

* PM-19555 - Per PR feedback, clean up unnecessary nullish coalescing operator.

* PM-19555 - Extension - AppComponent - fix everHadUserKey merge issue

* PM-19555 - PR feedback - refactor switchAccount and locked message handling on browser & desktop to require user id. I audited all callsites for both to ensure this *shouldn't* error.
This commit is contained in:
Jared Snider
2025-06-13 13:22:04 -04:00
committed by GitHub
parent b6f402faa8
commit bfb0b874ed
23 changed files with 334 additions and 211 deletions

View File

@@ -4,7 +4,7 @@ import { Router } from "@angular/router";
import { Subject, firstValueFrom, map, of, startWith, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LockService } from "@bitwarden/auth/common";
import { LockService, LogoutService } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -69,6 +69,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private authService: AuthService,
private lockService: LockService,
private logoutService: LogoutService,
) {}
get accountLimit() {
@@ -140,12 +141,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
});
if (confirmed) {
const result = await this.accountSwitcherService.logoutAccount(userId);
// unlocked logout responses need to be navigated out of the account switcher.
// other responses will be handled by background and app.component
if (result?.status === AuthenticationStatus.Unlocked) {
this.location.back();
}
await this.logoutService.logout(userId);
// navigate to root so redirect guard can properly route next active user or null user to correct page
await this.router.navigate(["/"]);
}
this.loading = false;
}

View File

@@ -1,7 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule, Location } from "@angular/common";
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -23,7 +24,7 @@ export class AccountComponent {
constructor(
private accountSwitcherService: AccountSwitcherService,
private location: Location,
private router: Router,
private i18nService: I18nService,
private logService: LogService,
private biometricsService: BiometricsService,
@@ -44,8 +45,8 @@ export class AccountComponent {
// Navigate out of account switching for unlocked accounts
// locked or logged out account statuses are handled by background and app.component
if (result?.status === AuthenticationStatus.Unlocked) {
this.location.back();
if (result?.authenticationStatus === AuthenticationStatus.Unlocked) {
await this.router.navigate(["vault"]);
await this.biometricsService.setShouldAutopromptNow(false);
} else {
await this.biometricsService.setShouldAutopromptNow(true);

View File

@@ -207,35 +207,4 @@ describe("AccountSwitcherService", () => {
expect(removeListenerSpy).toBeCalledTimes(1);
});
});
describe("logout", () => {
const userId1 = "1" as UserId;
const userId2 = "2" as UserId;
it("initiates logout", async () => {
let listener: (
message: { command: string; userId: UserId; status: AuthenticationStatus },
sender: unknown,
sendResponse: unknown,
) => void;
jest.spyOn(chrome.runtime.onMessage, "addListener").mockImplementation((addedListener) => {
listener = addedListener;
});
const removeListenerSpy = jest.spyOn(chrome.runtime.onMessage, "removeListener");
const logoutPromise = accountSwitcherService.logoutAccount(userId1);
listener(
{ command: "switchAccountFinish", userId: userId2, status: AuthenticationStatus.Unlocked },
undefined,
undefined,
);
const result = await logoutPromise;
expect(messagingService.send).toHaveBeenCalledWith("logout", { userId: userId1 });
expect(result).toEqual({ newUserId: userId2, status: AuthenticationStatus.Unlocked });
expect(removeListenerSpy).toBeCalledTimes(1);
});
});
});

View File

@@ -12,6 +12,7 @@ import {
timeout,
} from "rxjs";
import { NewActiveUser } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
@@ -43,7 +44,7 @@ export class AccountSwitcherService {
SPECIAL_ADD_ACCOUNT_ID = "addAccount";
availableAccounts$: Observable<AvailableAccount[]>;
switchAccountFinished$: Observable<{ userId: UserId; status: AuthenticationStatus }>;
switchAccountFinished$: Observable<NewActiveUser | null>;
constructor(
private accountService: AccountService,
@@ -118,7 +119,7 @@ export class AccountSwitcherService {
[message: { command: string; userId: UserId; status: AuthenticationStatus }]
>(chrome.runtime.onMessage).pipe(
filter(([message]) => message.command === "switchAccountFinish"),
map(([message]) => ({ userId: message.userId, status: message.status })),
map(([message]) => ({ userId: message.userId, authenticationStatus: message.status })),
);
}
@@ -143,29 +144,9 @@ export class AccountSwitcherService {
return await switchAccountFinishedPromise;
}
/**
*
* @param userId the user id to logout
* @returns the userId and status of the that has been switch to due to the logout. null on errors.
*/
async logoutAccount(
userId: UserId,
): Promise<{ newUserId: UserId; status: AuthenticationStatus } | null> {
// logout creates an account switch to the next up user, which may be null
const switchPromise = this.listenForSwitchAccountFinish(null);
await this.messagingService.send("logout", { userId });
// wait for account switch to happen, the result will be the new user id and status
const result = await switchPromise;
return { newUserId: result.userId, status: result.status };
}
// Listens for the switchAccountFinish message and returns the userId from the message
// Optionally filters switchAccountFinish to an expected userId
private listenForSwitchAccountFinish(
expectedUserId: UserId | null,
): Promise<{ userId: UserId; status: AuthenticationStatus } | null> {
listenForSwitchAccountFinish(expectedUserId: UserId | null): Promise<NewActiveUser | null> {
return firstValueFrom(
this.switchAccountFinished$.pipe(
filter(({ userId }) => (expectedUserId ? userId === expectedUserId : true)),

View File

@@ -1,64 +0,0 @@
import { Router } from "@angular/router";
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { postLogoutMessageListener$ } from "../utils/post-logout-message-listener";
import { ExtensionLoginDecryptionOptionsService } from "./extension-login-decryption-options.service";
// Mock the module providing postLogoutMessageListener$
jest.mock("../utils/post-logout-message-listener", () => {
return {
postLogoutMessageListener$: new BehaviorSubject<string>(""), // Replace with mock subject
};
});
describe("ExtensionLoginDecryptionOptionsService", () => {
let service: ExtensionLoginDecryptionOptionsService;
let messagingService: MockProxy<MessagingService>;
let router: MockProxy<Router>;
let postLogoutMessageSubject: BehaviorSubject<string>;
beforeEach(() => {
messagingService = mock<MessagingService>();
router = mock<Router>();
// Cast postLogoutMessageListener$ to BehaviorSubject for dynamic control
postLogoutMessageSubject = postLogoutMessageListener$ as BehaviorSubject<string>;
service = new ExtensionLoginDecryptionOptionsService(messagingService, router);
});
it("should instantiate the service", () => {
expect(service).not.toBeFalsy();
});
describe("logOut()", () => {
it("should send a logout message", async () => {
postLogoutMessageSubject.next("switchAccountFinish");
await service.logOut();
expect(messagingService.send).toHaveBeenCalledWith("logout");
});
it("should navigate to root on 'switchAccountFinish'", async () => {
postLogoutMessageSubject.next("switchAccountFinish");
await service.logOut();
expect(router.navigate).toHaveBeenCalledWith(["/"]);
});
it("should not navigate for 'doneLoggingOut'", async () => {
postLogoutMessageSubject.next("doneLoggingOut");
await service.logOut();
expect(router.navigate).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,39 +0,0 @@
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import {
DefaultLoginDecryptionOptionsService,
LoginDecryptionOptionsService,
} from "@bitwarden/auth/angular";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { postLogoutMessageListener$ } from "../utils/post-logout-message-listener";
export class ExtensionLoginDecryptionOptionsService
extends DefaultLoginDecryptionOptionsService
implements LoginDecryptionOptionsService
{
constructor(
protected messagingService: MessagingService,
private router: Router,
) {
super(messagingService);
}
override async logOut(): Promise<void> {
// start listening for "switchAccountFinish" or "doneLoggingOut"
const messagePromise = firstValueFrom(postLogoutMessageListener$);
// 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
super.logOut();
// wait for messages
const command = await messagePromise;
// doneLoggingOut already has a message handler that will navigate us
if (command === "switchAccountFinish") {
await this.router.navigate(["/"]);
}
}
}

View File

@@ -0,0 +1,101 @@
import { MockProxy, mock } from "jest-mock-extended";
import { LogoutReason, LogoutService } from "@bitwarden/auth/common";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
import { AccountSwitcherService } from "../account-switching/services/account-switcher.service";
import { ExtensionLogoutService } from "./extension-logout.service";
describe("ExtensionLogoutService", () => {
let logoutService: LogoutService;
let messagingService: MockProxy<MessagingService>;
let accountSwitcherService: MockProxy<AccountSwitcherService>;
let primaryUserId: UserId;
let secondaryUserId: UserId;
let logoutReason: LogoutReason;
beforeEach(() => {
primaryUserId = "1" as UserId;
secondaryUserId = "2" as UserId;
logoutReason = "vaultTimeout";
messagingService = mock<MessagingService>();
accountSwitcherService = mock<AccountSwitcherService>();
logoutService = new ExtensionLogoutService(messagingService, accountSwitcherService);
});
it("instantiates", () => {
expect(logoutService).not.toBeFalsy();
});
describe("logout", () => {
describe("No new active user", () => {
beforeEach(() => {
accountSwitcherService.listenForSwitchAccountFinish.mockResolvedValue(null);
});
it("sends logout message without a logout reason when not provided", async () => {
const result = await logoutService.logout(primaryUserId);
expect(accountSwitcherService.listenForSwitchAccountFinish).toHaveBeenCalledTimes(1);
expect(messagingService.send).toHaveBeenCalledWith("logout", { userId: primaryUserId });
expect(result).toBeUndefined();
});
it("sends logout message with a logout reason when provided", async () => {
const result = await logoutService.logout(primaryUserId, logoutReason);
expect(accountSwitcherService.listenForSwitchAccountFinish).toHaveBeenCalledTimes(1);
expect(messagingService.send).toHaveBeenCalledWith("logout", {
userId: primaryUserId,
logoutReason,
});
expect(result).toBeUndefined();
});
});
describe("New active user", () => {
const newActiveUserAuthenticationStatus = AuthenticationStatus.Unlocked;
beforeEach(() => {
accountSwitcherService.listenForSwitchAccountFinish.mockResolvedValue({
userId: secondaryUserId,
authenticationStatus: newActiveUserAuthenticationStatus,
});
});
it("sends logout message without a logout reason when not provided and returns the new active user", async () => {
const result = await logoutService.logout(primaryUserId);
expect(accountSwitcherService.listenForSwitchAccountFinish).toHaveBeenCalledTimes(1);
expect(messagingService.send).toHaveBeenCalledWith("logout", { userId: primaryUserId });
expect(result).toEqual({
userId: secondaryUserId,
authenticationStatus: newActiveUserAuthenticationStatus,
});
});
it("sends logout message with a logout reason when provided and returns the new active user", async () => {
const result = await logoutService.logout(primaryUserId, logoutReason);
expect(accountSwitcherService.listenForSwitchAccountFinish).toHaveBeenCalledTimes(1);
expect(messagingService.send).toHaveBeenCalledWith("logout", {
userId: primaryUserId,
logoutReason,
});
expect(result).toEqual({
userId: secondaryUserId,
authenticationStatus: newActiveUserAuthenticationStatus,
});
});
});
});
});

View File

@@ -0,0 +1,39 @@
import {
DefaultLogoutService,
LogoutReason,
LogoutService,
NewActiveUser,
} from "@bitwarden/auth/common";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
import { AccountSwitcherService } from "../account-switching/services/account-switcher.service";
export class ExtensionLogoutService extends DefaultLogoutService implements LogoutService {
constructor(
protected messagingService: MessagingService,
private accountSwitcherService: AccountSwitcherService,
) {
super(messagingService);
}
override async logout(
userId: UserId,
logoutReason?: LogoutReason,
): Promise<NewActiveUser | undefined> {
// logout can result in an account switch to the next up user
const accountSwitchFinishPromise =
this.accountSwitcherService.listenForSwitchAccountFinish(null);
// send the logout message
this.messagingService.send("logout", { userId, logoutReason });
// wait for the account switch to finish
const result = await accountSwitchFinishPromise;
if (result) {
return { userId: result.userId, authenticationStatus: result.authenticationStatus };
}
// if there is no account switch, return undefined
return undefined;
}
}

View File

@@ -1539,6 +1539,7 @@ export default class MainBackground {
}
}
// TODO: PM-21212 - consolidate the logic of this method into the new ExtensionLogoutService
async logout(logoutReason: LogoutReason, userId?: UserId) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(

View File

@@ -496,7 +496,8 @@ const routes: Routes = [
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
showAcctSwitcher: true,
} satisfies ExtensionAnonLayoutWrapperData,
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
{

View File

@@ -11,16 +11,17 @@ import {
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs";
import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap, map } from "rxjs";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n";
import { LogoutReason } from "@bitwarden/auth/common";
import { LogoutReason, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.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";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
@@ -32,7 +33,7 @@ import {
ToastOptions,
ToastService,
} from "@bitwarden/components";
import { BiometricsService, BiometricStateService } from "@bitwarden/key-management";
import { BiometricsService, BiometricStateService, KeyService } from "@bitwarden/key-management";
import { PopupCompactModeService } from "../platform/popup/layout/popup-compact-mode.service";
import { PopupSizeService } from "../platform/popup/layout/popup-size.service";
@@ -82,9 +83,12 @@ export class AppComponent implements OnInit, OnDestroy {
private biometricStateService: BiometricStateService,
private biometricsService: BiometricsService,
private deviceTrustToastService: DeviceTrustToastService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private keyService: KeyService,
private readonly destoryRef: DestroyRef,
private readonly documentLangSetter: DocumentLangSetter,
private popupSizeService: PopupSizeService,
private logService: LogService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
@@ -137,14 +141,38 @@ export class AppComponent implements OnInit, OnDestroy {
this.changeDetectorRef.detectChanges();
} else if (msg.command === "authBlocked" || msg.command === "goHome") {
await this.router.navigate(["login"]);
} else if (
msg.command === "locked" &&
(msg.userId == null || msg.userId == this.activeUserId)
) {
} else if (msg.command === "locked") {
if (msg.userId == null) {
this.logService.error("'locked' message received without userId.");
return;
}
if (msg.userId !== this.activeUserId) {
this.logService.error(
`'locked' message received with userId ${msg.userId} but active userId is ${this.activeUserId}.`,
);
return;
}
await this.biometricsService.setShouldAutopromptNow(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.router.navigate(["lock"]);
// When user is locked, normally we can just send them the lock screen.
// However, for account switching scenarios, we need to consider the TDE lock state.
const tdeEnabled = await firstValueFrom(
this.userDecryptionOptionsService
.userDecryptionOptionsById$(msg.userId)
.pipe(map((decryptionOptions) => decryptionOptions?.trustedDeviceOption != null)),
);
const everHadUserKey = await firstValueFrom(
this.keyService.everHadUserKey$(msg.userId),
);
if (tdeEnabled && !everHadUserKey) {
await this.router.navigate(["login-initiated"]);
return;
}
await this.router.navigate(["lock"]);
} else if (msg.command === "showDialog") {
// 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

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core";
import { Router } from "@angular/router";
import { merge, of, Subject } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
@@ -25,7 +24,6 @@ import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.
import {
AnonLayoutWrapperDataService,
LoginComponentService,
LoginDecryptionOptionsService,
TwoFactorAuthComponentService,
TwoFactorAuthEmailComponentService,
TwoFactorAuthDuoComponentService,
@@ -37,6 +35,7 @@ import {
LoginEmailService,
PinServiceAbstraction,
SsoUrlService,
LogoutService,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
@@ -137,11 +136,12 @@ import {
SshImportPromptService,
} from "@bitwarden/vault";
import { AccountSwitcherService } from "../../auth/popup/account-switching/services/account-switcher.service";
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 { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service";
import { ExtensionSsoComponentService } from "../../auth/popup/login/extension-sso-component.service";
import { ExtensionLoginDecryptionOptionsService } from "../../auth/popup/login-decryption-options/extension-login-decryption-options.service";
import { ExtensionLogoutService } from "../../auth/popup/logout/extension-logout.service";
import { ExtensionTwoFactorAuthComponentService } from "../../auth/services/extension-two-factor-auth-component.service";
import { ExtensionTwoFactorAuthDuoComponentService } from "../../auth/services/extension-two-factor-auth-duo-component.service";
import { ExtensionTwoFactorAuthEmailComponentService } from "../../auth/services/extension-two-factor-auth-email-component.service";
@@ -642,6 +642,11 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionAnonLayoutWrapperDataService,
deps: [],
}),
safeProvider({
provide: LogoutService,
useClass: ExtensionLogoutService,
deps: [MessagingServiceAbstraction, AccountSwitcherService],
}),
safeProvider({
provide: CompactModeService,
useExisting: PopupCompactModeService,
@@ -652,11 +657,6 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionSsoComponentService,
deps: [SyncService, AuthService, EnvironmentService, I18nServiceAbstraction, LogService],
}),
safeProvider({
provide: LoginDecryptionOptionsService,
useClass: ExtensionLoginDecryptionOptionsService,
deps: [MessagingServiceAbstraction, Router],
}),
safeProvider({
provide: SshImportPromptService,
useClass: DefaultSshImportPromptService,

View File

@@ -29,7 +29,11 @@ import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular";
import { DESKTOP_SSO_CALLBACK, LogoutReason } from "@bitwarden/auth/common";
import {
DESKTOP_SSO_CALLBACK,
LogoutReason,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -165,6 +169,7 @@ export class AppComponent implements OnInit, OnDestroy {
private accountService: AccountService,
private organizationService: OrganizationService,
private deviceTrustToastService: DeviceTrustToastService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private readonly destroyRef: DestroyRef,
private readonly documentLangSetter: DocumentLangSetter,
) {
@@ -416,15 +421,35 @@ export class AppComponent implements OnInit, OnDestroy {
this.router.navigate(["/remove-password"]);
break;
case "switchAccount": {
if (message.userId != null) {
await this.accountService.switchAccount(message.userId);
if (message.userId == null) {
this.logService.error("'switchAccount' message received without userId.");
return;
}
await this.accountService.switchAccount(message.userId);
const locked =
(await this.authService.getAuthStatus(message.userId)) ===
AuthenticationStatus.Locked;
if (locked) {
this.modalService.closeAll();
await this.router.navigate(["lock"]);
// We only have to handle TDE lock on "switchAccount" message scenarios but not normal
// lock scenarios since the user will have always decrypted the vault at least once in those cases.
const tdeEnabled = await firstValueFrom(
this.userDecryptionOptionsService
.userDecryptionOptionsById$(message.userId)
.pipe(map((decryptionOptions) => decryptionOptions?.trustedDeviceOption != null)),
);
const everHadUserKey = await firstValueFrom(
this.keyService.everHadUserKey$(message.userId),
);
if (tdeEnabled && !everHadUserKey) {
await this.router.navigate(["login-initiated"]);
} else {
await this.router.navigate(["lock"]);
}
} else {
this.messagingService.send("unlocked");
this.loading = true;
@@ -600,6 +625,8 @@ export class AppComponent implements OnInit, OnDestroy {
}
}
// TODO: PM-21212 - consolidate the logic of this method into the new LogoutService
// (requires creating a desktop specific implementation of the LogoutService)
// Even though the userId parameter is no longer optional doesn't mean a message couldn't be
// passing null-ish values to us.
private async logOut(logoutReason: LogoutReason, userId: UserId) {

View File

@@ -42,6 +42,7 @@ import {
AuthRequestServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLoginSuccessHandlerService,
DefaultLogoutService,
InternalUserDecryptionOptionsServiceAbstraction,
LoginApprovalComponentServiceAbstraction,
LoginEmailService,
@@ -50,6 +51,7 @@ import {
LoginStrategyServiceAbstraction,
LoginSuccessHandlerService,
LogoutReason,
LogoutService,
PinService,
PinServiceAbstraction,
UserDecryptionOptionsService,
@@ -405,6 +407,7 @@ const safeProviders: SafeProvider[] = [
provide: STATE_FACTORY,
useValue: new StateFactory(GlobalState, Account),
}),
// TODO: PM-21212 - Deprecate LogoutCallback in favor of LogoutService
safeProvider({
provide: LOGOUT_CALLBACK,
useFactory:
@@ -1540,6 +1543,11 @@ const safeProviders: SafeProvider[] = [
useClass: MasterPasswordApiService,
deps: [ApiServiceAbstraction, LogService],
}),
safeProvider({
provide: LogoutService,
useClass: DefaultLogoutService,
deps: [MessagingServiceAbstraction],
}),
safeProvider({
provide: DocumentLangSetter,
useClass: DocumentLangSetter,

View File

@@ -10,6 +10,7 @@ import { catchError, defer, firstValueFrom, from, map, of, switchMap, throwError
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
LoginEmailServiceAbstraction,
LogoutService,
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
@@ -109,6 +110,7 @@ export class LoginDecryptionOptionsComponent implements OnInit {
private toastService: ToastService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private validationService: ValidationService,
private logoutService: LogoutService,
) {
this.clientType = this.platformUtilsService.getClientType();
}
@@ -156,19 +158,17 @@ export class LoginDecryptionOptionsComponent implements OnInit {
}
private async handleMissingEmail() {
// TODO: PM-15174 - the solution for this bug will allow us to show the toast on app re-init after
// the user has been logged out and the process reload has occurred.
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("activeUserEmailNotFoundLoggingYouOut"),
});
setTimeout(async () => {
// We can't simply redirect to `/login` because the user is authed and the unauthGuard
// will prevent navigation. We must logout the user first via messagingService, which
// redirects to `/`, which will be handled by the redirectGuard to navigate the user to `/login`.
// The timeout just gives the user a chance to see the error toast before process reload runs on logout.
await this.loginDecryptionOptionsService.logOut();
}, 5000);
await this.logoutService.logout(this.activeAccountId);
// navigate to root so redirect guard can properly route next active user or null user to correct page
await this.router.navigate(["/"]);
}
private observeAndPersistRememberDeviceValueChanges() {
@@ -312,7 +312,9 @@ export class LoginDecryptionOptionsComponent implements OnInit {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (confirmed) {
this.messagingService.send("logout", { userId: userId });
await this.logoutService.logout(userId);
// navigate to root so redirect guard can properly route next active user or null user to correct page
await this.router.navigate(["/"]);
}
}
}

View File

@@ -3,8 +3,4 @@ export abstract class LoginDecryptionOptionsService {
* Handles client-specific logic that runs after a user was successfully created
*/
abstract handleCreateUserSuccess(): Promise<void | null>;
/**
* Logs the user out
*/
abstract logOut(): Promise<void>;
}

View File

@@ -6,3 +6,4 @@ export * from "./user-decryption-options.service.abstraction";
export * from "./auth-request.service.abstraction";
export * from "./login-approval-component.service.abstraction";
export * from "./login-success-handler.service";
export * from "./logout.service";

View File

@@ -0,0 +1,19 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { UserId } from "@bitwarden/common/types/guid";
import { LogoutReason } from "../types";
export interface NewActiveUser {
userId: UserId;
authenticationStatus: AuthenticationStatus;
}
export abstract class LogoutService {
/**
* Logs out the user.
* @param userId The user id.
* @param logoutReason The optional reason for logging out.
* @returns The new active user or undefined if there isn't a new active account.
*/
abstract logout(userId: UserId, logoutReason?: LogoutReason): Promise<NewActiveUser | undefined>;
}

View File

@@ -7,3 +7,4 @@ export * from "./auth-request/auth-request-api.service";
export * from "./accounts/lock.service";
export * from "./login-success-handler/default-login-success-handler.service";
export * from "./sso-redirect/sso-url.service";
export * from "./logout/default-logout.service";

View File

@@ -0,0 +1,40 @@
import { MockProxy, mock } from "jest-mock-extended";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
import { LogoutService } from "../../abstractions";
import { LogoutReason } from "../../types";
import { DefaultLogoutService } from "./default-logout.service";
describe("DefaultLogoutService", () => {
let logoutService: LogoutService;
let messagingService: MockProxy<MessagingService>;
beforeEach(() => {
messagingService = mock<MessagingService>();
logoutService = new DefaultLogoutService(messagingService);
});
it("instantiates", () => {
expect(logoutService).not.toBeFalsy();
});
describe("logout", () => {
it("sends logout message without a logout reason when not provided", async () => {
const userId = "1" as UserId;
await logoutService.logout(userId);
expect(messagingService.send).toHaveBeenCalledWith("logout", { userId });
});
it("sends logout message with a logout reason when provided", async () => {
const userId = "1" as UserId;
const logoutReason: LogoutReason = "vaultTimeout";
await logoutService.logout(userId, logoutReason);
expect(messagingService.send).toHaveBeenCalledWith("logout", { userId, logoutReason });
});
});
});

View File

@@ -0,0 +1,14 @@
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
import { LogoutService, NewActiveUser } from "../../abstractions/logout.service";
import { LogoutReason } from "../../types";
export class DefaultLogoutService implements LogoutService {
constructor(protected messagingService: MessagingService) {}
async logout(userId: UserId, logoutReason?: LogoutReason): Promise<NewActiveUser | undefined> {
this.messagingService.send("logout", { userId, logoutReason });
return undefined;
}
}

View File

@@ -89,9 +89,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
) {
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
map((options) => {
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
// eslint-disable-next-line no-constant-binary-expression
return options?.trustedDeviceOption != null ?? false;
return options?.trustedDeviceOption != null;
}),
);
}
@@ -99,9 +97,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
supportsDeviceTrustByUserId$(userId: UserId): Observable<boolean> {
return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId).pipe(
map((options) => {
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
// eslint-disable-next-line no-constant-binary-expression
return options?.trustedDeviceOption != null ?? false;
return options?.trustedDeviceOption != null;
}),
);
}

View File

@@ -17,7 +17,7 @@ import {
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { LogoutService, PinServiceAbstraction } from "@bitwarden/auth/common";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -156,6 +156,7 @@ export class LockComponent implements OnInit, OnDestroy {
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
private biometricService: BiometricsService,
private logoutService: LogoutService,
private lockComponentService: LockComponentService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
@@ -353,7 +354,9 @@ export class LockComponent implements OnInit, OnDestroy {
});
if (confirmed && this.activeAccount != null) {
this.messagingService.send("logout", { userId: this.activeAccount.id });
await this.logoutService.logout(this.activeAccount.id);
// navigate to root so redirect guard can properly route next active user or null user to correct page
await this.router.navigate(["/"]);
}
}