diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index f168826ac9..041f6fb8ed 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -551,6 +551,9 @@ "loggedOut": { "message": "Logged out" }, + "loggedOutDesc": { + "message": "You have been logged out of your account." + }, "loginExpired": { "message": "Your login session has expired." }, diff --git a/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts b/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts index c602acadae..f3edf30bbf 100644 --- a/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts @@ -1,3 +1,4 @@ +import { LogoutReason } from "@bitwarden/auth/common"; import { KeyConnectorService as AbstractKeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; @@ -40,7 +41,7 @@ import { TokenServiceInitOptions, tokenServiceFactory } from "./token-service.fa type KeyConnectorServiceFactoryOptions = FactoryOptions & { keyConnectorServiceOptions: { - logoutCallback: (expired: boolean, userId?: string) => Promise; + logoutCallback: (logoutReason: LogoutReason, userId?: string) => Promise; }; }; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index cebbd049d0..9c5f7ff334 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -9,6 +9,7 @@ import { AuthRequestService, LoginEmailServiceAbstraction, LoginEmailService, + LogoutReason, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -372,8 +373,8 @@ export default class MainBackground { } }; - const logoutCallback = async (expired: boolean, userId?: UserId) => - await this.logout(expired, userId); + const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) => + await this.logout(logoutReason, userId); this.logService = new ConsoleLogService(false); this.cryptoFunctionService = new WebCryptoFunctionService(self); @@ -579,7 +580,7 @@ export default class MainBackground { return Promise.resolve(); }, this.logService, - (expired: boolean) => this.logout(expired), + (logoutReason: LogoutReason) => this.logout(logoutReason), ); this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); @@ -1209,7 +1210,8 @@ export default class MainBackground { } } - async logout(expired: boolean, userId?: UserId) { + // TODO: figure out what this should look like + async logout(logoutReason: LogoutReason, userId?: UserId) { const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe( map((a) => a?.id), @@ -1279,7 +1281,6 @@ export default class MainBackground { this.messagingService.send("switchAccountFinish"); } else { this.messagingService.send("doneLoggingOut", { - expired: expired, userId: userBeingLoggedOut, }); } diff --git a/apps/browser/src/platform/background/service-factories/api-service.factory.ts b/apps/browser/src/platform/background/service-factories/api-service.factory.ts index 22b8c08cd5..3a892cd26d 100644 --- a/apps/browser/src/platform/background/service-factories/api-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/api-service.factory.ts @@ -1,3 +1,4 @@ +import { LogoutReason } from "@bitwarden/auth/common"; import { ApiService as AbstractApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/services/api.service"; @@ -26,7 +27,7 @@ import { stateServiceFactory, StateServiceInitOptions } from "./state-service.fa type ApiServiceFactoryOptions = FactoryOptions & { apiServiceOptions: { refreshAccessTokenErrorCallback?: () => Promise; - logoutCallback: (expired: boolean) => Promise; + logoutCallback: (logoutReason: LogoutReason) => Promise; customUserAgent?: string; }; }; diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 25fac44450..0d8a0ec190 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -84,14 +84,6 @@ export class AppComponent implements OnInit, OnDestroy { tap((msg: any) => { if (msg.command === "doneLoggingOut") { this.authService.logOut(async () => { - if (msg.expired) { - this.toastService.showToast({ - variant: "warning", - title: this.i18nService.t("loggedOut"), - message: this.i18nService.t("loginExpired"), - }); - } - // 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(["home"]); diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f3a9fd5d4a..5dbe7401ac 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -743,6 +743,9 @@ "loggedOut": { "message": "Logged out" }, + "loggedOutDesc": { + "message": "You have been logged out of your account." + }, "loginExpired": { "message": "Your login session has expired." }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index e2b2e00d0c..a57f02e66b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -584,6 +584,9 @@ "loggedOut": { "message": "Logged out" }, + "loggedOutDesc": { + "message": "You have been logged out of your account." + }, "loginExpired": { "message": "Your login session has expired." }, diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 02a086da41..6e63f9b548 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -1,6 +1,7 @@ import { InjectionToken } from "@angular/core"; import { Observable, Subject } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; import { ClientType } from "@bitwarden/common/enums"; import { AbstractStorageService, @@ -35,7 +36,7 @@ export const MEMORY_STORAGE = new SafeInjectionToken("ME export const SECURE_STORAGE = new SafeInjectionToken("SECURE_STORAGE"); export const STATE_FACTORY = new SafeInjectionToken("STATE_FACTORY"); export const LOGOUT_CALLBACK = new SafeInjectionToken< - (expired: boolean, userId?: string) => Promise + (logoutReason: LogoutReason, userId?: string) => Promise >("LOGOUT_CALLBACK"); export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promise>( "LOCKED_CALLBACK", diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6c3ec3fbcc..d0b4f25277 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1,5 +1,5 @@ import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core"; -import { Subject } from "rxjs"; +import { firstValueFrom, Subject } from "rxjs"; import { AuthRequestServiceAbstraction, @@ -13,6 +13,7 @@ import { InternalUserDecryptionOptionsServiceAbstraction, UserDecryptionOptionsService, UserDecryptionOptionsServiceAbstraction, + LogoutReason, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -117,6 +118,7 @@ import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/bill import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { PaymentMethodWarningsService } from "@bitwarden/common/billing/services/payment-method-warnings.service"; +import { ClientType } from "@bitwarden/common/enums"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; @@ -234,7 +236,7 @@ import { SyncNotifierService } from "@bitwarden/common/vault/services/sync/sync- import { SyncService } from "@bitwarden/common/vault/services/sync/sync.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service"; -import { ToastService } from "@bitwarden/components"; +import { ToastOptions, ToastService } from "@bitwarden/components"; import { ImportApiService, ImportApiServiceAbstraction, @@ -319,9 +321,65 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: LOGOUT_CALLBACK, useFactory: - (messagingService: MessagingServiceAbstraction) => (expired: boolean, userId?: string) => - Promise.resolve(messagingService.send("logout", { expired: expired, userId: userId })), - deps: [MessagingServiceAbstraction], + ( + messagingService: MessagingServiceAbstraction, + toastService: ToastService, + i18nService: I18nServiceAbstraction, + platformUtilsService: PlatformUtilsServiceAbstraction, + ) => + async (logoutReason: LogoutReason, userId?: string) => { + const isDesktop = platformUtilsService.getClientType() === ClientType.Desktop; + + let toastOptions: ToastOptions; + switch (logoutReason) { + case "sessionExpired": { + toastOptions = { + variant: "warning", + title: i18nService.t("loggedOut"), + message: i18nService.t("loginExpired"), + }; + break; + } + case "accessTokenUnableToBeDecrypted": { + toastOptions = { + variant: "error", + title: i18nService.t("loggedOut"), + message: i18nService.t("accessTokenUnableToBeDecrypted"), + }; + break; + } + case "refreshTokenSecureStorageRetrievalFailure": { + toastOptions = { + variant: "error", + title: i18nService.t("loggedOut"), + message: i18nService.t("refreshTokenSecureStorageRetrievalFailure"), + }; + break; + } + default: { + toastOptions = { + variant: "error", + title: i18nService.t("loggedOut"), + message: i18nService.t("loggedOutDesc"), + }; + break; + } + } + + const activeToast = toastService.showToast(toastOptions); + if (isDesktop) { + // Since desktop has process reload on logout, we need to wait for the toast to be hidden before triggering the logout. + await firstValueFrom(activeToast.onHidden); + } + + return Promise.resolve(messagingService.send("logout", { userId: userId })); + }, + deps: [ + MessagingServiceAbstraction, + ToastService, + I18nServiceAbstraction, + PlatformUtilsServiceAbstraction, + ], }), safeProvider({ provide: LOCKED_CALLBACK, diff --git a/libs/auth/src/common/types/logout-reason.type.ts b/libs/auth/src/common/types/logout-reason.type.ts index 91cc1ce6ae..e9a1adc922 100644 --- a/libs/auth/src/common/types/logout-reason.type.ts +++ b/libs/auth/src/common/types/logout-reason.type.ts @@ -1,4 +1,9 @@ export type LogoutReason = + | "invalidGrantError" + | "vaultTimeout" + | "invalidSecurityStamp" + | "logoutNotification" + | "keyConnectorError" | "sessionExpired" | "accessTokenUnableToBeDecrypted" | "refreshTokenSecureStorageRetrievalFailure"; diff --git a/libs/common/src/auth/services/key-connector.service.ts b/libs/common/src/auth/services/key-connector.service.ts index 65d1030bd3..6b81844afb 100644 --- a/libs/common/src/auth/services/key-connector.service.ts +++ b/libs/common/src/auth/services/key-connector.service.ts @@ -1,5 +1,7 @@ import { firstValueFrom } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; + import { ApiService } from "../../abstractions/api.service"; import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserType } from "../../admin-console/enums"; @@ -57,7 +59,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { private logService: LogService, private organizationService: OrganizationService, private keyGenerationService: KeyGenerationService, - private logoutCallback: (expired: boolean, userId?: string) => Promise, + private logoutCallback: (logoutReason: LogoutReason, userId?: string) => Promise, private stateProvider: StateProvider, ) { this.usesKeyConnectorState = this.stateProvider.getActive(USES_KEY_CONNECTOR); @@ -192,7 +194,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { if (this.logoutCallback != null) { // 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(false); + this.logoutCallback("keyConnectorError"); } throw new Error("Key Connector error"); } diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 04eac5d191..2121580eab 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1,5 +1,7 @@ import { firstValueFrom } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; + import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service"; import { OrganizationConnectionType } from "../admin-console/enums"; import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request"; @@ -160,7 +162,7 @@ export class ApiService implements ApiServiceAbstraction { private stateService: StateService, private refreshAccessTokenErrorCallback: () => Promise, private logService: LogService, - private logoutCallback: (expired: boolean) => Promise, + private logoutCallback: (logoutReason: LogoutReason) => Promise, private customUserAgent: string = null, ) { this.device = platformUtilsService.getDevice(); @@ -1888,7 +1890,7 @@ export class ApiService implements ApiServiceAbstraction { responseJson != null && responseJson.error === "invalid_grant") ) { - await this.logoutCallback(true); + await this.logoutCallback("invalidGrantError"); return null; } } diff --git a/libs/common/src/services/notifications.service.ts b/libs/common/src/services/notifications.service.ts index 7dc54b849f..cae6fedbb8 100644 --- a/libs/common/src/services/notifications.service.ts +++ b/libs/common/src/services/notifications.service.ts @@ -2,6 +2,8 @@ import * as signalR from "@microsoft/signalr"; import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack"; import { firstValueFrom } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; + import { AuthRequestServiceAbstraction } from "../../../auth/src/common/abstractions"; import { ApiService } from "../abstractions/api.service"; import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service"; @@ -36,7 +38,7 @@ export class NotificationsService implements NotificationsServiceAbstraction { private appIdService: AppIdService, private apiService: ApiService, private environmentService: EnvironmentService, - private logoutCallback: (expired: boolean) => Promise, + private logoutCallback: (logoutReason: LogoutReason) => Promise, private stateService: StateService, private authService: AuthService, private authRequestService: AuthRequestServiceAbstraction, @@ -188,7 +190,7 @@ export class NotificationsService implements NotificationsServiceAbstraction { if (isAuthenticated) { // 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(true); + this.logoutCallback("logoutNotification"); } break; case NotificationType.SyncSendCreate: diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.ts index a75fb6d4c4..a782c444c7 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.ts @@ -1,5 +1,7 @@ import { combineLatest, filter, firstValueFrom, map, switchMap, timeout } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; + import { SearchService } from "../../abstractions/search.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout.service"; @@ -34,7 +36,10 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private stateEventRunnerService: StateEventRunnerService, private lockedCallback: (userId?: string) => Promise = null, - private loggedOutCallback: (expired: boolean, userId?: string) => Promise = null, + private loggedOutCallback: ( + logoutReason: LogoutReason, + userId?: string, + ) => Promise = null, ) {} async init(checkOnInterval: boolean) { @@ -145,7 +150,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { async logOut(userId?: string): Promise { if (this.loggedOutCallback != null) { - await this.loggedOutCallback(false, userId); + await this.loggedOutCallback("vaultTimeout", userId); } } diff --git a/libs/common/src/vault/services/sync/sync.service.ts b/libs/common/src/vault/services/sync/sync.service.ts index 793bcf2437..d3f99675bf 100644 --- a/libs/common/src/vault/services/sync/sync.service.ts +++ b/libs/common/src/vault/services/sync/sync.service.ts @@ -1,6 +1,6 @@ import { firstValueFrom } from "rxjs"; -import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; +import { LogoutReason, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "../../../abstractions/api.service"; import { InternalOrganizationServiceAbstraction } from "../../../admin-console/abstractions/organization/organization.service.abstraction"; @@ -71,7 +71,7 @@ export class SyncService implements SyncServiceAbstraction { private sendApiService: SendApiService, private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, private avatarService: AvatarService, - private logoutCallback: (expired: boolean) => Promise, + private logoutCallback: (logoutReason: LogoutReason) => Promise, private billingAccountProfileStateService: BillingAccountProfileStateService, private tokenService: TokenService, ) {} @@ -313,7 +313,7 @@ export class SyncService implements SyncServiceAbstraction { const stamp = await this.tokenService.getSecurityStamp(response.id); if (stamp != null && stamp !== response.securityStamp) { if (this.logoutCallback != null) { - await this.logoutCallback(true); + await this.logoutCallback("invalidSecurityStamp"); } throw new Error("Stamp has changed");