Auth - PM-7392 & PM-7436 - Token Service - Desktop - Add disk fallback for secure storage failures (#8913)

* PM-7392 - EncryptSvc - add new method for detecting if a simple string is an enc string.

* PM-7392 - TokenSvc - add checks when setting and retrieving the access token to improve handling around the access token encryption.

* PM-7392 - (1) Clean up token svc (2) export access token key type for use in tests.

* PM-7392 - Get token svc tests passing; WIP more tests to come for new scenarios.

* PM-7392 - Access token secure storage to disk fallback WIP but mostly functional besides weird logout behavior.

* PM-7392 - Clean up unnecessary comment

* PM-7392 - TokenSvc - refresh token disk storage fallback

* PM-7392 - Fix token service tests in prep for adding tests for new scenarios.

* PM-7392 - TokenSvc tests - Test new setRefreshToken scenarios

* PM-7392 - TokenSvc - getRefreshToken should return null or a value - not undefined.

* PM-7392 - Fix test name.

* PM-7392 - TokenSvc tests - clean up test names that reference removed refresh token migrated flag.

* PM-7392 - getRefreshToken tests done.

* PM-7392 - Fix error quote

* PM-7392 - TokenSvc tests - setAccessToken new scenarios tested.

* PM-7392 - TokenSvc - getAccessToken - if secure storage errors add error to log.

* PM-7392 - TokenSvc tests - getAccessToken - all new scenarios tested

* PM-7392 - EncryptSvc - test new stringIsEncString method

* PM-7392 - Main.ts - fix circ dep issue.

* PM-7392 - Main.ts - remove comment.

* PM-7392 - Don't re-invent the wheel and simply use existing isSerializedEncString static method.

* PM-7392 - Enc String - (1) Add handling for Nan in parseEncryptedString (2) Added null handling to isSerializedEncString. (3) Plan to remove encrypt service implementation

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>

* PM-7392 - Remove encrypt service method

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>

* PM-7392 - Actually fix circ dep issues with Justin. Ty!

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>

* PM-7392 - TokenSvc - update to use EncString instead of EncryptSvc + fix tests.

* PM-7392 - TokenSvc - (1) Remove test code (2) Refactor decryptAccessToken method to accept access token key and error on failure to pass required decryption key to method.

* PM-7392 - Per PR feedback and discussion, do not log the user out if hte refresh token cannot be found. This will allow users to continue to use the app until their access token expires and we will error on trying to refresh it. The app will then still work on a fresh login for 55 min.

* PM-7392 - API service - update doAuthRefresh error to clarify which token cannot be refreshed.

* PM-7392 - Fix SetRefreshToken case where a null input would incorrectly trigger a fallback to disk.

* PM-7392 - If the access token cannot be refreshed due to a missing refresh token or API keys, then surface an error to the user and log it so it isn't a silent failure + we get a log.

* PM-7392  - Fix CLI build errors

* PM-7392 - Per PR feedback, add missing tests (thank you Jake for writing these!)

Co-authored-by: Jake Fink <jfink@bitwarden.com>

* PM-7392 - Per PR feedback, update incorrect comment from 3 releases to 3 months.

* PM-7392 - Per PR feedback, remove links.

* PM-7392 - Per PR feedback, move tests to existing describe.

* PM-7392 - Per PR feedback, adjust all test names to match naming convention.

* PM-7392 - ApiService - refreshIdentityToken - log error before swallowing it so we have a record of it.

* PM-7392 - Fix copy for errorRefreshingAccessToken

* PM-7392 - Per PR feedback, move error handling toast responsibility to client specific app component logic reached via messaging.

* PM-7392 - Swap logout reason from enum to type.

* PM-7392 - ApiService - Stop using messaging to trigger toast to let user know about refresh access token errors; replace with client specific callback logic.

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Co-authored-by: Matt Gibson <mgibson@bitwarden.com>

* PM-7392 - Per PR feedback, adjust enc string changes and tests.

* PM-7392 - Rename file to be type from enum

* PM-7392 - ToastService - we need to await the activeToast.onHidden observable so return the activeToast from the showToast.

* PM-7392 - Desktop AppComp - cleanup messaging

* PM-7392 - Move Logout reason custom type to auth/common

* PM-7392 - WIP - Enhancing logout callback to consider the logout reason + move show toast logic into logout callback

* PM-7392 - Logout callback should simply pass along the LogoutReason instead of handling it - let each client's message listener handle it.

* PM-7392 - More replacements of expired with logoutReason

* PM-7392 - More expired to logoutReason replacements

* PM-7392 - Build new handlers for displaying the logout reason for desktop & web.

* PM-7392 - Revert ToastService changes

* PM-7392 - TokenSvc - Replace messageSender with logout callback per PR feedback.

* PM-7392 - Desktop App comp - replace toast usage with simple dialog to guarantee users will see the reason for them being logged out.

* PM-7392 - Web app comp - fix issue

* PM-7392 - Desktop App comp - don't show cancel btn on simple dialogs.

* PM-7392 - Desktop App comp - Don't open n simple dialogs.

* PM-7392 - Fix browser build

* PM-7392 - Remove logout reason from CLI as each logout call handles messaging on its own.

* PM-7392 - Previously, if a security stamp was invalid, the session was marked as expired. Restore that functionality.

* PM-7392 - Update sync service logoutCallback to include optional user id.

* PM-7392 - Clean up web app comp

* PM-7392 - Web - app comp - only handle actually possible web logout scenarios.

* PM-7392 - Browser Popup app comp - restore done logging out message functionality + add new default logout message

* PM-7392 - Add optional user id to logout callbacks.

* PM-7392 - Main.background.ts - add clarifying comment.

* PM-7392 - Per feedback, use danger simple dialog type for error.

* PM-7392 - Browser Popup - add comment clarifying expectation of seeing toasts.

* PM-7392 - Consolidate invalidSecurityStamp error handling

* PM-7392 - Per PR feedback, REFRESH_ACCESS_TOKEN_ERROR_CALLBACK can be completely sync. + Refactor to method in main.background.

* PM-7392 - Per PR feedback, use a named callback for refreshAccessTokenErrorCallback in CLI

* PM-7392 - Add TODO

* PM-7392 - Re-apply bw.ts changes to new service-container.

* PM-7392 - TokenSvc - tweak error message.

* PM-7392 - Fix test

* PM-7392 - Clean up merge conflict where I duplicated dependencies.

* PM-7392 - Per discussion with product, change default logout toast to be info

* PM-7392 - After merge, add new logout reason to sync service.

* PM-7392 - Remove default logout message per discussion with product since it isn't really visible on desktop or browser.

* PM-7392 - address PR feedback.

---------

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Co-authored-by: Jake Fink <jfink@bitwarden.com>
Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
This commit is contained in:
Jared Snider 2024-06-03 12:36:45 -04:00 committed by GitHub
parent 010b55d39d
commit f691854387
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 991 additions and 255 deletions

View File

@ -599,6 +599,9 @@
"loggedOut": {
"message": "Logged out"
},
"loggedOutDesc": {
"message": "You have been logged out of your account."
},
"loginExpired": {
"message": "Your login session has expired."
},
@ -1744,6 +1747,12 @@
"ok": {
"message": "Ok"
},
"errorRefreshingAccessToken":{
"message": "Access Token Refresh Error"
},
"errorRefreshingAccessTokenDesc":{
"message": "No refresh token or API keys found. Please try logging out and logging back in."
},
"desktopSyncVerificationTitle": {
"message": "Desktop sync verification"
},

View File

@ -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";
@ -375,8 +376,17 @@ 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);
const refreshAccessTokenErrorCallback = () => {
// Send toast to popup
this.messagingService.send("showToast", {
type: "error",
title: this.i18nService.t("errorRefreshingAccessToken"),
message: this.i18nService.t("errorRefreshingAccessTokenDesc"),
});
};
const isDev = process.env.ENV === "development";
this.logService = new ConsoleLogService(isDev);
@ -523,6 +533,7 @@ export default class MainBackground {
this.keyGenerationService,
this.encryptService,
this.logService,
logoutCallback,
);
const migrationRunner = new MigrationRunner(
@ -608,9 +619,12 @@ export default class MainBackground {
this.platformUtilsService,
this.environmentService,
this.appIdService,
refreshAccessTokenErrorCallback,
this.logService,
(logoutReason: LogoutReason, userId?: UserId) => this.logout(logoutReason, userId),
this.vaultTimeoutSettingsService,
(expired: boolean) => this.logout(expired),
);
this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider);
this.fileUploadService = new FileUploadService(this.logService);
this.cipherFileUploadService = new CipherFileUploadService(
@ -1283,7 +1297,7 @@ export default class MainBackground {
}
}
async logout(expired: boolean, userId?: UserId) {
async logout(logoutReason: LogoutReason, userId?: UserId) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(
map((a) => a?.id),
@ -1349,7 +1363,7 @@ export default class MainBackground {
await logoutPromise;
this.messagingService.send("doneLoggingOut", {
expired: expired,
logoutReason: logoutReason,
userId: userBeingLoggedOut,
});

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angula
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs";
import { LogoutReason } 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";
@ -10,7 +11,12 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
import {
DialogService,
SimpleDialogOptions,
ToastOptions,
ToastService,
} from "@bitwarden/components";
import { BrowserApi } from "../platform/browser/browser-api";
import { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
@ -83,13 +89,10 @@ export class AppComponent implements OnInit, OnDestroy {
.pipe(
tap((msg: any) => {
if (msg.command === "doneLoggingOut") {
// TODO: PM-8544 - why do we call logout in the popup after receiving the doneLoggingOut message? Hasn't this already completeted logout?
this.authService.logOut(async () => {
if (msg.expired) {
this.toastService.showToast({
variant: "warning",
title: this.i18nService.t("loggedOut"),
message: this.i18nService.t("loginExpired"),
});
if (msg.logoutReason) {
await this.displayLogoutReason(msg.logoutReason);
}
});
this.changeDetectorRef.detectChanges();
@ -233,4 +236,23 @@ export class AppComponent implements OnInit, OnDestroy {
this.browserSendStateService.setBrowserSendTypeComponentState(null),
]);
}
// Displaying toasts isn't super useful on the popup due to the reloads we do.
// However, it is visible for a moment on the FF sidebar logout.
private async displayLogoutReason(logoutReason: LogoutReason) {
let toastOptions: ToastOptions;
switch (logoutReason) {
case "invalidSecurityStamp":
case "sessionExpired": {
toastOptions = {
variant: "warning",
title: this.i18nService.t("loggedOut"),
message: this.i18nService.t("loginExpired"),
};
break;
}
}
this.toastService.showToast(toastOptions);
}
}

View File

@ -6,6 +6,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ApiService } from "@bitwarden/common/services/api.service";
@ -21,8 +22,10 @@ export class NodeApiService extends ApiService {
platformUtilsService: PlatformUtilsService,
environmentService: EnvironmentService,
appIdService: AppIdService,
refreshAccessTokenErrorCallback: () => Promise<void>,
logService: LogService,
logoutCallback: () => Promise<void>,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
logoutCallback: (expired: boolean) => Promise<void>,
customUserAgent: string = null,
) {
super(
@ -30,8 +33,10 @@ export class NodeApiService extends ApiService {
platformUtilsService,
environmentService,
appIdService,
vaultTimeoutSettingsService,
refreshAccessTokenErrorCallback,
logService,
logoutCallback,
vaultTimeoutSettingsService,
customUserAgent,
);
}

View File

@ -255,6 +255,8 @@ export class ServiceContainer {
p = path.join(process.env.HOME, ".config/Bitwarden CLI");
}
const logoutCallback = async () => await this.logout();
this.platformUtilsService = new CliPlatformUtilsService(ClientType.Cli, packageJson);
this.logService = new ConsoleLogService(
this.platformUtilsService.isDev(),
@ -337,6 +339,7 @@ export class ServiceContainer {
this.keyGenerationService,
this.encryptService,
this.logService,
logoutCallback,
);
const migrationRunner = new MigrationRunner(
@ -421,13 +424,19 @@ export class ServiceContainer {
VaultTimeoutStringType.Never, // default vault timeout
);
const refreshAccessTokenErrorCallback = () => {
throw new Error("Refresh Access token error");
};
this.apiService = new NodeApiService(
this.tokenService,
this.platformUtilsService,
this.environmentService,
this.appIdService,
refreshAccessTokenErrorCallback,
this.logService,
logoutCallback,
this.vaultTimeoutSettingsService,
async (expired: boolean) => await this.logout(),
customUserAgent,
);
@ -485,7 +494,7 @@ export class ServiceContainer {
this.logService,
this.organizationService,
this.keyGenerationService,
async (expired: boolean) => await this.logout(),
logoutCallback,
this.stateProvider,
);
@ -660,7 +669,7 @@ export class ServiceContainer {
this.sendApiService,
this.userDecryptionOptionsService,
this.avatarService,
async (expired: boolean) => await this.logout(),
logoutCallback,
this.billingAccountProfileStateService,
this.tokenService,
this.authService,

View File

@ -1,3 +1,4 @@
import { DialogRef } from "@angular/cdk/dialog";
import {
Component,
NgZone,
@ -13,6 +14,7 @@ import { filter, firstValueFrom, map, Subject, takeUntil, timeout } from "rxjs";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { FingerprintDialogComponent } from "@bitwarden/auth/angular";
import { LogoutReason } from "@bitwarden/auth/common";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
@ -48,7 +50,7 @@ import { CollectionService } from "@bitwarden/common/vault/abstractions/collecti
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { DialogService, ToastService } from "@bitwarden/components";
import { DialogService, ToastOptions, ToastService } from "@bitwarden/components";
import { DeleteAccountComponent } from "../auth/delete-account.component";
import { LoginApprovalComponent } from "../auth/login/login-approval.component";
@ -108,6 +110,7 @@ export class AppComponent implements OnInit, OnDestroy {
private idleTimer: number = null;
private isIdle = false;
private activeUserId: UserId = null;
private activeSimpleDialog: DialogRef<boolean> = null;
private destroy$ = new Subject<void>();
@ -207,7 +210,7 @@ export class AppComponent implements OnInit, OnDestroy {
break;
case "logout":
this.loading = message.userId == null || message.userId === this.activeUserId;
await this.logOut(!!message.expired, message.userId);
await this.logOut(message.logoutReason, message.userId);
this.loading = false;
break;
case "lockVault":
@ -545,9 +548,73 @@ export class AppComponent implements OnInit, OnDestroy {
this.messagingService.send("updateAppMenu", { updateRequest: updateRequest });
}
private async displayLogoutReason(logoutReason: LogoutReason) {
let toastOptions: ToastOptions;
switch (logoutReason) {
case "invalidSecurityStamp":
case "sessionExpired": {
toastOptions = {
variant: "warning",
title: this.i18nService.t("loggedOut"),
message: this.i18nService.t("loginExpired"),
};
break;
}
// We don't expect these scenarios to be common, but we want the user to
// understand why they are being logged out before a process reload.
case "accessTokenUnableToBeDecrypted": {
// Don't create multiple dialogs if this fires multiple times
if (this.activeSimpleDialog) {
// Let the caller of this function listen for the dialog to close
return firstValueFrom(this.activeSimpleDialog.closed);
}
this.activeSimpleDialog = this.dialogService.openSimpleDialogRef({
title: { key: "loggedOut" },
content: { key: "accessTokenUnableToBeDecrypted" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "danger",
});
await firstValueFrom(this.activeSimpleDialog.closed);
this.activeSimpleDialog = null;
break;
}
case "refreshTokenSecureStorageRetrievalFailure": {
// Don't create multiple dialogs if this fires multiple times
if (this.activeSimpleDialog) {
// Let the caller of this function listen for the dialog to close
return firstValueFrom(this.activeSimpleDialog.closed);
}
this.activeSimpleDialog = this.dialogService.openSimpleDialogRef({
title: { key: "loggedOut" },
content: { key: "refreshTokenSecureStorageRetrievalFailure" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "danger",
});
await firstValueFrom(this.activeSimpleDialog.closed);
this.activeSimpleDialog = null;
break;
}
}
if (toastOptions) {
this.toastService.showToast(toastOptions);
}
}
// 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(expired: boolean, userId: UserId) {
private async logOut(logoutReason: LogoutReason, userId: UserId) {
await this.displayLogoutReason(logoutReason);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
@ -620,15 +687,7 @@ export class AppComponent implements OnInit, OnDestroy {
// This must come last otherwise the logout will prematurely trigger
// a process reload before all the state service user data can be cleaned up
if (userBeingLoggedOut === activeUserId) {
this.authService.logOut(async () => {
if (expired) {
this.platformUtilsService.showToast(
"warning",
this.i18nService.t("loggedOut"),
this.i18nService.t("loginExpired"),
);
}
});
this.authService.logOut(async () => {});
}
}
@ -710,7 +769,7 @@ export class AppComponent implements OnInit, OnDestroy {
// 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
options[1] === "logOut"
? this.logOut(false, userId as UserId)
? this.logOut("vaultTimeout", userId as UserId)
: await this.vaultTimeoutService.lock(userId);
}
}

View File

@ -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."
},
@ -1212,6 +1215,12 @@
}
}
},
"errorRefreshingAccessToken":{
"message": "Access Token Refresh Error"
},
"errorRefreshingAccessTokenDesc":{
"message": "No refresh token or API keys found. Please try logging out and logging back in."
},
"help": {
"message": "Help"
},
@ -2474,6 +2483,12 @@
"important": {
"message": "Important:"
},
"accessTokenUnableToBeDecrypted": {
"message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue."
},
"refreshTokenSecureStorageRetrievalFailure": {
"message": "You have been logged out because your refresh token could not be retrieved. Please log in again to resolve this issue."
},
"masterPasswordHint": {
"message": "Your master password cannot be recovered if you forget it!"
},

View File

@ -3,6 +3,7 @@ import * as path from "path";
import { app } from "electron";
import { Subject, firstValueFrom } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { TokenService } from "@bitwarden/common/auth/services/token.service";
@ -31,6 +32,7 @@ import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state
import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider";
import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service";
import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service";
import { UserId } from "@bitwarden/common/types/guid";
/* eslint-enable import/no-restricted-paths */
import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service";
@ -182,6 +184,7 @@ export class Main {
this.keyGenerationService,
this.encryptService,
this.logService,
async (logoutReason: LogoutReason, userId?: UserId) => {},
);
this.migrationRunner = new MigrationRunner(
@ -207,11 +210,9 @@ export class Main {
);
this.desktopSettingsService = new DesktopSettingsService(stateProvider);
const biometricStateService = new DefaultBiometricStateService(stateProvider);
this.windowMain = new WindowMain(
this.stateService,
biometricStateService,
this.logService,
this.storageService,

View File

@ -6,7 +6,6 @@ import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "elect
import { firstValueFrom } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
@ -38,7 +37,6 @@ export class WindowMain {
readonly defaultHeight = 600;
constructor(
private stateService: StateService,
private biometricStateService: BiometricStateService,
private logService: LogService,
private storageService: AbstractStorageService,

View File

@ -14,6 +14,7 @@ import {
timer,
} from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
@ -40,7 +41,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { DialogService, ToastOptions, ToastService } from "@bitwarden/components";
import { PolicyListService } from "./admin-console/core/policy-list.service";
import {
@ -148,7 +149,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.router.navigate(["/"]);
break;
case "logout":
await this.logOut(!!message.expired, message.redirect);
await this.logOut(message.logoutReason, message.redirect);
break;
case "lockVault":
await this.vaultTimeoutService.lock();
@ -278,7 +279,34 @@ export class AppComponent implements OnDestroy, OnInit {
this.destroy$.complete();
}
private async logOut(expired: boolean, redirect = true) {
private async displayLogoutReason(logoutReason: LogoutReason) {
let toastOptions: ToastOptions;
switch (logoutReason) {
case "invalidSecurityStamp":
case "sessionExpired": {
toastOptions = {
variant: "warning",
title: this.i18nService.t("loggedOut"),
message: this.i18nService.t("loginExpired"),
};
break;
}
default: {
toastOptions = {
variant: "info",
title: this.i18nService.t("loggedOut"),
message: this.i18nService.t("loggedOutDesc"),
};
break;
}
}
this.toastService.showToast(toastOptions);
}
private async logOut(logoutReason: LogoutReason, redirect = true) {
await this.displayLogoutReason(logoutReason);
await this.eventUploadService.uploadEvents();
const userId = (await this.stateService.getUserId()) as UserId;
@ -308,14 +336,6 @@ export class AppComponent implements OnDestroy, OnInit {
await this.searchService.clearIndex();
this.authService.logOut(async () => {
if (expired) {
this.platformUtilsService.showToast(
"warning",
this.i18nService.t("loggedOut"),
this.i18nService.t("loginExpired"),
);
}
await this.stateService.clean({ userId: userId });
await this.accountService.clean(userId);

View File

@ -587,6 +587,9 @@
"loggedOut": {
"message": "Logged out"
},
"loggedOutDesc": {
"message": "You have been logged out of your account."
},
"loginExpired": {
"message": "Your login session has expired."
},
@ -1050,6 +1053,12 @@
"copyUuid": {
"message": "Copy UUID"
},
"errorRefreshingAccessToken":{
"message": "Access Token Refresh Error"
},
"errorRefreshingAccessTokenDesc":{
"message": "No refresh token or API keys found. Please try logging out and logging back in."
},
"warning": {
"message": "Warning"
},

View File

@ -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,
@ -36,7 +37,7 @@ export const MEMORY_STORAGE = new SafeInjectionToken<AbstractStorageService>("ME
export const SECURE_STORAGE = new SafeInjectionToken<AbstractStorageService>("SECURE_STORAGE");
export const STATE_FACTORY = new SafeInjectionToken<StateFactory>("STATE_FACTORY");
export const LOGOUT_CALLBACK = new SafeInjectionToken<
(expired: boolean, userId?: string) => Promise<void>
(logoutReason: LogoutReason, userId?: string) => Promise<void>
>("LOGOUT_CALLBACK");
export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promise<void>>(
"LOCKED_CALLBACK",
@ -53,3 +54,7 @@ export const INTRAPROCESS_MESSAGING_SUBJECT = new SafeInjectionToken<
Subject<Message<Record<string, unknown>>>
>("INTRAPROCESS_MESSAGING_SUBJECT");
export const CLIENT_TYPE = new SafeInjectionToken<ClientType>("CLIENT_TYPE");
export const REFRESH_ACCESS_TOKEN_ERROR_CALLBACK = new SafeInjectionToken<() => void>(
"REFRESH_ACCESS_TOKEN_ERROR_CALLBACK",
);

View File

@ -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";
@ -238,6 +239,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 {
ImportApiService,
ImportApiServiceAbstraction,
@ -281,6 +283,7 @@ import {
DEFAULT_VAULT_TIMEOUT,
INTRAPROCESS_MESSAGING_SUBJECT,
CLIENT_TYPE,
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
} from "./injection-tokens";
import { ModalService } from "./modal.service";
@ -322,8 +325,12 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: LOGOUT_CALLBACK,
useFactory:
(messagingService: MessagingServiceAbstraction) => (expired: boolean, userId?: string) =>
Promise.resolve(messagingService.send("logout", { expired: expired, userId: userId })),
(messagingService: MessagingServiceAbstraction) =>
async (logoutReason: LogoutReason, userId?: string) => {
return Promise.resolve(
messagingService.send("logout", { logoutReason: logoutReason, userId: userId }),
);
},
deps: [MessagingServiceAbstraction],
}),
safeProvider({
@ -532,6 +539,7 @@ const safeProviders: SafeProvider[] = [
KeyGenerationServiceAbstraction,
EncryptService,
LogService,
LOGOUT_CALLBACK,
],
}),
safeProvider({
@ -585,6 +593,17 @@ const safeProviders: SafeProvider[] = [
StateProvider,
],
}),
safeProvider({
provide: REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
useFactory: (toastService: ToastService, i18nService: I18nServiceAbstraction) => () => {
toastService.showToast({
variant: "error",
title: i18nService.t("errorRefreshingAccessToken"),
message: i18nService.t("errorRefreshingAccessTokenDesc"),
});
},
deps: [ToastService, I18nServiceAbstraction],
}),
safeProvider({
provide: ApiServiceAbstraction,
useClass: ApiService,
@ -593,8 +612,10 @@ const safeProviders: SafeProvider[] = [
PlatformUtilsServiceAbstraction,
EnvironmentService,
AppIdServiceAbstraction,
VaultTimeoutSettingsServiceAbstraction,
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
LogService,
LOGOUT_CALLBACK,
VaultTimeoutSettingsServiceAbstraction,
],
}),
safeProvider({

View File

@ -3,5 +3,6 @@
*/
export * from "./abstractions";
export * from "./models";
export * from "./types";
export * from "./services";
export * from "./utilities";

View File

@ -0,0 +1 @@
export * from "./logout-reason.type";

View File

@ -0,0 +1,10 @@
export type LogoutReason =
| "invalidGrantError"
| "vaultTimeout"
| "invalidSecurityStamp"
| "logoutNotification"
| "keyConnectorError"
| "sessionExpired"
| "accessTokenUnableToBeDecrypted"
| "refreshTokenSecureStorageRetrievalFailure"
| "accountDeleted";

View File

@ -70,16 +70,16 @@ export abstract class TokenService {
/**
* Gets the access token
* @param userId - The optional user id to get the access token for; if not provided, the active user is used.
* @returns A promise that resolves with the access token or undefined.
* @returns A promise that resolves with the access token or null.
*/
getAccessToken: (userId?: UserId) => Promise<string | undefined>;
getAccessToken: (userId?: UserId) => Promise<string | null>;
/**
* Gets the refresh token.
* @param userId - The optional user id to get the refresh token for; if not provided, the active user is used.
* @returns A promise that resolves with the refresh token or undefined.
* @returns A promise that resolves with the refresh token or null.
*/
getRefreshToken: (userId?: UserId) => Promise<string | undefined>;
getRefreshToken: (userId?: UserId) => Promise<string | null>;
/**
* Sets the API Key Client ID for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout.

View File

@ -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<void>,
private logoutCallback: (logoutReason: LogoutReason, userId?: string) => Promise<void>,
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");
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
import { Observable, combineLatest, firstValueFrom, map } from "rxjs";
import { Opaque } from "type-fest";
import { decodeJwtTokenToJson } from "@bitwarden/auth/common";
import { LogoutReason, decodeJwtTokenToJson } from "@bitwarden/auth/common";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
@ -111,7 +111,7 @@ export type DecodedAccessToken = {
* A symmetric key for encrypting the access token before the token is stored on disk.
* This key should be stored in secure storage.
* */
type AccessTokenKey = Opaque<SymmetricCryptoKey, "AccessTokenKey">;
export type AccessTokenKey = Opaque<SymmetricCryptoKey, "AccessTokenKey">;
export class TokenService implements TokenServiceAbstraction {
private readonly accessTokenKeySecureStorageKey: string = "_accessTokenKey";
@ -132,6 +132,7 @@ export class TokenService implements TokenServiceAbstraction {
private keyGenerationService: KeyGenerationService,
private encryptService: EncryptService,
private logService: LogService,
private logoutCallback: (logoutReason: LogoutReason, userId?: string) => Promise<void>,
) {
this.initializeState();
}
@ -145,10 +146,6 @@ export class TokenService implements TokenServiceAbstraction {
]).pipe(map(([disk, memory]) => Boolean(disk || memory)));
}
// pivoting to an approach where we create a symmetric key we store in secure storage
// which is used to protect the data before persisting to disk.
// We will also use the same symmetric key to decrypt the data when reading from disk.
private initializeState(): void {
this.emailTwoFactorTokenRecordGlobalState = this.globalStateProvider.get(
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
@ -218,6 +215,14 @@ export class TokenService implements TokenServiceAbstraction {
this.getSecureStorageOptions(userId),
);
// We are having intermittent issues with access token keys not saving into secure storage on windows 10/11.
// So, let's add a check to ensure we can read the value after writing it.
const accessTokenKey = await this.getAccessTokenKey(userId);
if (!accessTokenKey) {
throw new Error("New Access token key unable to be retrieved from secure storage.");
}
return newAccessTokenKey;
}
@ -238,6 +243,8 @@ export class TokenService implements TokenServiceAbstraction {
}
// First see if we have an accessTokenKey in secure storage and return it if we do
// Note: retrieving/saving data from/to secure storage on linux will throw if the
// distro doesn't have a secure storage provider
let accessTokenKey: AccessTokenKey = await this.getAccessTokenKey(userId);
if (!accessTokenKey) {
@ -255,15 +262,13 @@ export class TokenService implements TokenServiceAbstraction {
}
private async decryptAccessToken(
accessTokenKey: AccessTokenKey,
encryptedAccessToken: EncString,
userId: UserId,
): Promise<string | null> {
const accessTokenKey = await this.getAccessTokenKey(userId);
if (!accessTokenKey) {
// If we don't have an accessTokenKey, then that means we don't have an access token as it hasn't been set yet
// and we have to return null here to properly indicate the user isn't logged in.
return null;
throw new Error(
"decryptAccessToken: Access token key required. Cannot decrypt access token.",
);
}
const decryptedAccessToken = await this.encryptService.decryptToUtf8(
@ -297,17 +302,32 @@ export class TokenService implements TokenServiceAbstraction {
// store the access token directly. Instead, we encrypt with accessTokenKey and store that
// in secure storage.
const encryptedAccessToken: EncString = await this.encryptAccessToken(accessToken, userId);
try {
const encryptedAccessToken: EncString = await this.encryptAccessToken(
accessToken,
userId,
);
// Save the encrypted access token to disk
await this.singleUserStateProvider
.get(userId, ACCESS_TOKEN_DISK)
.update((_) => encryptedAccessToken.encryptedString);
// Save the encrypted access token to disk
await this.singleUserStateProvider
.get(userId, ACCESS_TOKEN_DISK)
.update((_) => encryptedAccessToken.encryptedString);
// TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408
// 2024-02-20: Remove access token from memory so that we migrate to encrypt the access token over time.
// Remove this call to remove the access token from memory after 3 releases.
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
// TODO: PM-6408
// 2024-02-20: Remove access token from memory so that we migrate to encrypt the access token over time.
// Remove this call to remove the access token from memory after 3 months.
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
} catch (error) {
this.logService.error(
`SetAccessToken: storing encrypted access token in secure storage failed. Falling back to disk storage.`,
error,
);
// Fall back to disk storage for unecrypted access token
await this.singleUserStateProvider
.get(userId, ACCESS_TOKEN_DISK)
.update((_) => accessToken);
}
return;
}
@ -376,11 +396,11 @@ export class TokenService implements TokenServiceAbstraction {
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
}
async getAccessToken(userId?: UserId): Promise<string | undefined> {
async getAccessToken(userId?: UserId): Promise<string | null> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
return undefined;
return null;
}
// Try to get the access token from memory
@ -399,10 +419,41 @@ export class TokenService implements TokenServiceAbstraction {
}
if (this.platformSupportsSecureStorage) {
const accessTokenKey = await this.getAccessTokenKey(userId);
let accessTokenKey: AccessTokenKey;
try {
accessTokenKey = await this.getAccessTokenKey(userId);
} catch (error) {
if (EncString.isSerializedEncString(accessTokenDisk)) {
this.logService.error(
"Access token key retrieval failed. Unable to decrypt encrypted access token. Logging user out.",
error,
);
await this.logoutCallback("accessTokenUnableToBeDecrypted", userId);
return null;
}
// If the access token key is not found, but the access token is unencrypted then
// this indicates that this is the pre-migration state where the access token
// was stored unencrypted on disk. We can return the access token as is.
// Note: this is likely to only be hit for linux users who don't
// have a secure storage provider configured.
return accessTokenDisk;
}
if (!accessTokenKey) {
// We know this is an unencrypted access token because we don't have an access token key
if (EncString.isSerializedEncString(accessTokenDisk)) {
// The access token is encrypted but we don't have the key to decrypt it for
// whatever reason so we have to log the user out.
this.logService.error(
"Access token key not found to decrypt encrypted access token. Logging user out.",
);
await this.logoutCallback("accessTokenUnableToBeDecrypted", userId);
return null;
}
// We know this is an unencrypted access token
return accessTokenDisk;
}
@ -410,17 +461,18 @@ export class TokenService implements TokenServiceAbstraction {
const encryptedAccessTokenEncString = new EncString(accessTokenDisk as EncryptedString);
const decryptedAccessToken = await this.decryptAccessToken(
accessTokenKey,
encryptedAccessTokenEncString,
userId,
);
return decryptedAccessToken;
} catch (error) {
// If an error occurs during decryption, return null for logout.
// If an error occurs during decryption, logout and then return null.
// We don't try to recover here since we'd like to know
// if access token and key are getting out of sync.
this.logService.error(
`Failed to decrypt access token: ${error?.message ?? "Unknown error."}`,
);
this.logService.error(`Failed to decrypt access token`, error);
await this.logoutCallback("accessTokenUnableToBeDecrypted", userId);
return null;
}
}
@ -456,21 +508,49 @@ export class TokenService implements TokenServiceAbstraction {
);
switch (storageLocation) {
case TokenStorageLocation.SecureStorage:
await this.saveStringToSecureStorage(
userId,
this.refreshTokenSecureStorageKey,
refreshToken,
);
case TokenStorageLocation.SecureStorage: {
try {
await this.saveStringToSecureStorage(
userId,
this.refreshTokenSecureStorageKey,
refreshToken,
);
// TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408
// 2024-02-20: Remove refresh token from memory and disk so that we migrate to secure storage over time.
// Remove these 2 calls to remove the refresh token from memory and disk after 3 releases.
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
// Check if the refresh token was able to be saved to secure storage by reading it
// immediately after setting it. This is needed due to intermittent silent failures on Windows 10/11.
const refreshTokenSecureStorage = await this.getStringFromSecureStorage(
userId,
this.refreshTokenSecureStorageKey,
);
// Only throw if the refresh token was not saved to secure storage
// If we only check for a nullish value out of secure storage without considering the input value,
// then we would end up falling back to disk storage if the input value was null.
if (refreshToken !== null && !refreshTokenSecureStorage) {
throw new Error("Refresh token failed to save to secure storage.");
}
// TODO: PM-6408
// 2024-02-20: Remove refresh token from memory and disk so that we migrate to secure storage over time.
// Remove these 2 calls to remove the refresh token from memory and disk after 3 months.
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
} catch (error) {
// This case could be hit for both Linux users who don't have secure storage configured
// or for Windows users who have intermittent issues with secure storage.
this.logService.error(
`SetRefreshToken: storing refresh token in secure storage failed. Falling back to disk storage.`,
error,
);
// Fall back to disk storage for refresh token
await this.singleUserStateProvider
.get(userId, REFRESH_TOKEN_DISK)
.update((_) => refreshToken);
}
return;
}
case TokenStorageLocation.Disk:
await this.singleUserStateProvider
.get(userId, REFRESH_TOKEN_DISK)
@ -485,11 +565,11 @@ export class TokenService implements TokenServiceAbstraction {
}
}
async getRefreshToken(userId?: UserId): Promise<string | undefined> {
async getRefreshToken(userId?: UserId): Promise<string | null> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
return undefined;
return null;
}
// pre-secure storage migration:
@ -507,17 +587,30 @@ export class TokenService implements TokenServiceAbstraction {
const refreshTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, REFRESH_TOKEN_DISK);
if (refreshTokenDisk != null) {
// This handles the scenario pre-secure storage migration where the refresh token was stored on disk.
return refreshTokenDisk;
}
if (this.platformSupportsSecureStorage) {
const refreshTokenSecureStorage = await this.getStringFromSecureStorage(
userId,
this.refreshTokenSecureStorageKey,
);
try {
const refreshTokenSecureStorage = await this.getStringFromSecureStorage(
userId,
this.refreshTokenSecureStorageKey,
);
if (refreshTokenSecureStorage != null) {
return refreshTokenSecureStorage;
if (refreshTokenSecureStorage != null) {
return refreshTokenSecureStorage;
}
this.logService.error(
"Refresh token not found in secure storage. Access token will fail to refresh upon expiration or manual refresh.",
);
} catch (error) {
// This case will be hit for Linux users who don't have secure storage configured.
this.logService.error(`Failed to retrieve refresh token from secure storage`, error);
await this.logoutCallback("refreshTokenSecureStorageRetrievalFailure", userId);
}
}

View File

@ -179,6 +179,43 @@ describe("EncString", () => {
it("is false if invalid", () => {
expect(EncString.isSerializedEncString("2.iv|data")).toBe(false);
});
it("should return false if a null string is passed in", () => {
// Act
const result = EncString.isSerializedEncString(null);
// Assert
expect(result).toBe(false);
});
it("should return false if an error is thrown while parsing the string", () => {
// Arrange
const value = "invalid.value";
// Act
const result = EncString.isSerializedEncString(value);
// Assert
expect(result).toBe(false);
});
describe("Access Token Parsing Tests", () => {
const encryptedAccessToken =
"2.rFNYSTJoljn8h6GOSNVYdQ==|4dIp7ONJzC+Kx1ClA+1aIAb7EqCQ4OjnADCYdCPg7BKkdheG+yM62ZiONFk+S6at84M+RnGWWO04aIjinTdJhlhyUmszePNATxIfX60Y+bFKQhlMuCtZpYdEmQDzXVgT43YRbf/6NnN9WzhefLqeMiocwoIJTEpLptb+Zcm7T3MJpkX4dR9w5LUOxUTNFEGd5PlWaI8FBavOkNsrzY5skRK70pvFABET5IDeRlKhi8NwbzvTzkO3SisLRzih+djiz5nEZf0+ujeGAp6P+o7l0mB0sXVsNJzcuE4S9QtHLnx31N6z3mQm5pOgP4EmEOdRIcQGc1p7dL1vXcXtaTJLtfKXoJjJbYT3wplnY9Pf8+2FVxdbM3bRB2yVsnEzgLcf9UchKThQSdOy8+5TO/prDbUt5mDpO4GmRltom5ncda8yJaD3Hw1DO7fa0Xh+kfeByxb1AwBC+GTPfqmo5uqr0J4dZsf9cGlPMTElwR3GYmD60OcQ6iDX36CZZjqqJqBwKSpepDXV39p9G347e6YAAvJenLDKtdjgfWXCMXbkwETbMgYooFDRd60KYsGIXV16UwzJSvczgTY2d+hYb2Cl0lClequaiwcRxLVtW2xau6qoEPjTqJjJi9I0Cs2WNL4LRH96Ir14a3bEtnTvkO1NjN+bQNon+KksaP2BqTbuiAfZbBP/cL4S1Oew4G00PSLZUGV5S1BI0ooJy6e2NLQJlYqfCeKM6RgpvgfOiXlZddVgkkB6lohLjyVvcSZNuKPjs1wZMZ9C76bKb6o39NFK8G3/YScELFf9gkueWjmhcjrs22+xNDn5rxXeedwIkVW9UJVNLc//eGxLfp70y8fNDcyTPRN1UUpqT8+wSz+9ZHl4DLUK0DE2jIveEDke8vi4MK/XLMC/c50rr1NCEuVy6iA3nwiOzVo/GNfeKTpzMcR/D9A0gxkC9GyZ3riSsMQsGNXhZCZLdsFYp0gLiiJxVilMUfyTWaygsNm87GPY3ep3GEHcq/pCuxrpLQQYT3V1j95WJvFxb8dSLiPHb8STR0GOZhe7SquI5LIRmYCFTo+3VBnItYeuin9i2xCIqWz886xIyllHN2BIPILbA1lCOsCsz1BRRGNqtLvmTeVRO8iujsHWBJicVgSI7/dgSJwcdOv2t4TIVtnN1hJkQnz+HZcJ2FYK/VWlo4UQYYoML52sBd1sSz/n8/8hrO2N4X9frHHNCrcxeoyChTKo2cm4rAxHylLbCZYvGt/KIW9x3AFkPBMr7tAc3yq98J0Crna8ukXc3F3uGb5QXLnBi//3zBDN6RCv7ByaFW5G0I+pglBegzeFBqKH8xwfy76B2e2VLFF8rz/r/wQzlumGFypsRhAoGxrkZyzjec/k+RNR0arf7TTX7ymC1cueTnItRDx89veW6WLlF53NpAGqC8GJSp4T2FGIIk01y29j6Ji7GOlQ8BUbyLWYjMfHf3khRzAfr6UC2QgVvKWQTKET4Y/b1nZCnwxeW8wC80GHtYGuarsU+KlsEw4242cjyIN1GobrWaA2GTOedQDEMWUA64McAw5fAvMEEao5DM7i57tMzJHeKfruyMuXYQkBca094vmATjJ/T+kIrWGIcmxCT/Fp2SW1hcxr6Ciwuog84LVfbVlUl2MAj3eC/xqL/5HP6Q3ObD0ld444GV+HSrQUqfIvEIn9gFmalW6TGugyhfROACCogoXbeIr1AyMUNDnl4EWlPl6u7SQvPX+itKyq4qhaK2J0W6f7ElLVQ5GbC2uwARuhXOi7mqEZ5FP0V675C5NPZOl2ZEd6BhmuyhGkmQEtEvw0DCKnbKM7bKMk90Y599DSnuEna4BNFBVjJ7k+BuNhXUKO+iNcDZT0pCQhOKRVLWsaqVff3BsuQ4zMEOVnccJwwAVipwSRyxZi8bF+Wyun6BVI8pz1CBvRMy+6ifmIq2awEL8NnV65hF2jyZDEVwsnrvCyT7MlM8l5C3MhqH/MgMcKqOsUz+P6Jv5sBi4WvojsaHzqxQ6miBHpHhGDpYH5K53LVs36henB/tOUTcg5ZnO4ZM67jjB7Oz7to+QnJsldp5Bdwvi1XD/4jeh/Llezu5/KwwytSHnZG1z6dZA7B8rKwnI+yN2Qnfi70h68jzGZ1xCOFPz9KMorNKP3XLw8x2g9H6lEBXdV95uc/TNw+WTJbvKRawns/DZhM1u/g13lU6JG19cht3dh/DlKRcJpj1AdOAxPiUubTSkhBmdwRj2BTTHrVlF3/9ladTP4s4f6Zj9TtQvR9CREVe7CboGflxDYC+Jww3PU50XLmxQjkuV5MkDAmBVcyFCFOcHhDRoxet4FX9ec0wjNeDpYtkI8B/qUS1Rp+is1jOxr4/ni|pabwMkF/SdYKdDlow4uKxaObrAP0urmv7N7fA9bedec=";
const standardAccessToken =
"eyJhbGciOiJSUzI1NiIsImtpZCI6IkY5NjBFQzY4RThEMTBDMUEzNEE0OUYwODkwQkExQkExMDk4QUIzMjFSUzI1NiIsIng1dCI6Ii1XRHNhT2pSREJvMHBKOElrTG9ib1FtS3N5RSIsInR5cCI6ImF0K2p3dCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0IiwibmJmIjoxNzE0NjAwNzI3LCJpYXQiOjE3MTQ2MDA3MjcsImV4cCI6MTcxNDYwNDMyNywic2NvcGUiOlsiYXBpIiwib2ZmbGluZV9hY2Nlc3MiXSwiYW1yIjpbIkFwcGxpY2F0aW9uIl0sImNsaWVudF9pZCI6ImRlc2t0b3AiLCJzdWIiOiJlY2U3MGExMy03MjE2LTQzYzQtOTk3Ny1iMTAzMDE0NmUxZTciLCJhdXRoX3RpbWUiOjE3MTQ2MDA3MjcsImlkcCI6ImJpdHdhcmRlbiIsInByZW1pdW0iOmZhbHNlLCJlbWFpbCI6ImpzbmlkZXJcdTAwMkJsb2NhbEBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJzc3RhbXAiOiJHWTdKQU82NENLS1RLQkI2WkVBVVlMMldPUVU3QVNUMiIsIm5hbWUiOiJKYXJlZCBTbmlkZXIgMSIsIm9yZ293bmVyIjpbIjkyYjQ5OTA4LWI1MTQtNDVhOC1iYWRiLWIxMDMwMTQ4ZmU1MyIsIjM4ZWRlMzIyLWI0YjQtNGJkOC05ZTA5LWIxMDcwMTEyZGMxMSIsImIyZDA3MDI4LWE1ODMtNGMzZS04ZDYwLWIxMDcwMTE5OGMyOSIsImJmOTM0YmEyLTBmZDQtNDlmMi1hOTVlLWIxMDcwMTFmYzllNiIsImMwYjdmNzVkLTAxNWYtNDJjOS1iM2E2LWIxMDgwMTc2MDdjYSJdLCJkZXZpY2UiOiIwYmQzZWFmZC0yYjE3LTRiNGItYmUzNS1kMjIxNTE5MTA1ZmUiLCJqdGkiOiI0MEVGNjlEQ0QyNkI4MERDMkJFQ0UwODZDOTIxNDg5OSJ9.pRaZphZ8pygx3gHMdsKnCHWSBFAvm6gJ5aCLKbXIfx6Zc-LtQ_CkjO17rQaXlE4MwDt_n_wMzA38SDG2WzwjJrF3rWziPJrOMEdMGXLvVHyqxh0gcIiAQXZMYq0PdCYPBSDmsRZUZqg5BXFb9ylZjC0-m-EqDgl-i6OfxaHTPBCosX4_fr4bcyZtAaoaSeY4ZWf_1T8HrEzTlEyYKepHFzWdG3e4pJKHfs4sNGfs0uiW1awMqtRIPYI1n1F--oF5Hkm6jUJOdtrCKU0mKntyF4v7YRxgXdxUDqKw08nkk11vdPFVG87kWhR6ARYBWDp4AASy66YewqGhX7BNaekSTqK7DKxzQ9Adiv4XvmNEz3JO8tQrEFfE_mz9-WZiS6PlUipCxW-UtFp093_gHZh9_xgbuTdaO1u5_8Y42v0V_69v9WgzCGQGEWZ3PPaJsARGDO7FVKdPxP2S2lWIu22gydNHhfDZrOpBGHD1FpByfd5DbhKk0JdhHEPObs8RwNEweK-jlKmQpc_8bnhXFRUeMFrDL2Q2pNrYcDOpF1crIePPcWBk2_YdcWTqYVnGewT0toJ8sGlaAuAe6uOUZkBG3sxkOttkLYQtkxJYqt54gjazJ0N0GxAc0UBUDt0JnuLqk-cuxXiQO2_vHomTf7dilPq8fvqffrtrISxDVZenceg";
it("should return false if a non-encrypted string is passed in", () => {
// Act
const result = EncString.isSerializedEncString(standardAccessToken);
// Assert
expect(result).toBe(false);
});
it("should return true if an encrypted string is passed in", () => {
// Act
const result = EncString.isSerializedEncString(encryptedAccessToken);
// Assert
expect(result).toBe(true);
});
});
});
it("valid", () => {

View File

@ -76,6 +76,7 @@ export class EncString implements Encrypted {
}
const { encType, encPieces } = EncString.parseEncryptedString(this.encryptedString);
this.encryptionType = encType;
if (encPieces.length !== EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE[encType]) {
@ -120,7 +121,7 @@ export class EncString implements Encrypted {
encType = parseInt(headerPieces[0], null);
encPieces = headerPieces[1].split("|");
} catch (e) {
return;
return { encType: NaN, encPieces: [] };
}
} else {
encPieces = encryptedString.split("|");
@ -137,8 +138,16 @@ export class EncString implements Encrypted {
}
static isSerializedEncString(s: string): boolean {
if (s == null) {
return false;
}
const { encType, encPieces } = this.parseEncryptedString(s);
if (isNaN(encType) || encPieces.length === 0) {
return false;
}
return EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE[encType] === encPieces.length;
}

View File

@ -1,5 +1,7 @@
import { firstValueFrom } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service";
import { VaultTimeoutSettingsService } from "../abstractions/vault-timeout/vault-timeout-settings.service";
import { OrganizationConnectionType } from "../admin-console/enums";
@ -116,6 +118,7 @@ import { ProfileResponse } from "../models/response/profile.response";
import { UserKeyResponse } from "../models/response/user-key.response";
import { AppIdService } from "../platform/abstractions/app-id.service";
import { EnvironmentService } from "../platform/abstractions/environment.service";
import { LogService } from "../platform/abstractions/log.service";
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
import { Utils } from "../platform/misc/utils";
import { UserId } from "../types/guid";
@ -157,8 +160,10 @@ export class ApiService implements ApiServiceAbstraction {
private platformUtilsService: PlatformUtilsService,
private environmentService: EnvironmentService,
private appIdService: AppIdService,
private refreshAccessTokenErrorCallback: () => void,
private logService: LogService,
private logoutCallback: (logoutReason: LogoutReason) => Promise<void>,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private logoutCallback: (expired: boolean) => Promise<void>,
private customUserAgent: string = null,
) {
this.device = platformUtilsService.getDevice();
@ -247,7 +252,8 @@ export class ApiService implements ApiServiceAbstraction {
try {
await this.doAuthRefresh();
} catch (e) {
return Promise.reject(null);
this.logService.error("Error refreshing access token: ", e);
throw e;
}
}
@ -1720,7 +1726,9 @@ export class ApiService implements ApiServiceAbstraction {
return this.doApiTokenRefresh();
}
throw new Error("Cannot refresh token, no refresh token or api keys are stored");
this.refreshAccessTokenErrorCallback();
throw new Error("Cannot refresh access token, no refresh token or api keys are stored.");
}
protected async doRefreshToken(): Promise<void> {
@ -1905,7 +1913,7 @@ export class ApiService implements ApiServiceAbstraction {
responseJson != null &&
responseJson.error === "invalid_grant")
) {
await this.logoutCallback(true);
await this.logoutCallback("invalidGrantError");
return null;
}
}

View File

@ -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<void>,
private logoutCallback: (logoutReason: LogoutReason) => Promise<void>,
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:

View File

@ -1,6 +1,8 @@
import { MockProxy, any, mock } from "jest-mock-extended";
import { BehaviorSubject, from, of } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { SearchService } from "../../abstractions/search.service";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
@ -36,7 +38,7 @@ describe("VaultTimeoutService", () => {
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>;
let loggedOutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: string]>;
let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>;
let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>;
@ -190,7 +192,7 @@ describe("VaultTimeoutService", () => {
};
const expectUserToHaveLoggedOut = (userId: string) => {
expect(loggedOutCallback).toHaveBeenCalledWith(false, userId);
expect(loggedOutCallback).toHaveBeenCalledWith("vaultTimeout", userId);
};
const expectNoAction = (userId: string) => {

View File

@ -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<void> = null,
private loggedOutCallback: (expired: boolean, userId?: string) => Promise<void> = null,
private loggedOutCallback: (
logoutReason: LogoutReason,
userId?: string,
) => Promise<void> = null,
) {}
async init(checkOnInterval: boolean) {
@ -145,7 +150,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
async logOut(userId?: string): Promise<void> {
if (this.loggedOutCallback != null) {
await this.loggedOutCallback(false, userId);
await this.loggedOutCallback("vaultTimeout", userId);
}
}

View File

@ -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";
@ -32,6 +32,7 @@ import { SendData } from "../../../tools/send/models/data/send.data";
import { SendResponse } from "../../../tools/send/models/response/send.response";
import { SendApiService } from "../../../tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "../../../tools/send/services/send.service.abstraction";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { FolderApiServiceAbstraction } from "../../../vault/abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
@ -65,7 +66,7 @@ export class SyncService extends CoreSyncService {
sendApiService: SendApiService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private avatarService: AvatarService,
private logoutCallback: (expired: boolean) => Promise<void>,
private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise<void>,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private tokenService: TokenService,
authService: AuthService,
@ -147,7 +148,7 @@ export class SyncService extends CoreSyncService {
const response = await this.apiService.getAccountRevisionDate();
if (response < 0 && this.logoutCallback) {
// Account was deleted, log out now
await this.logoutCallback(false);
await this.logoutCallback("accountDeleted");
}
if (new Date(response) <= lastSync) {
@ -160,7 +161,7 @@ export class SyncService extends CoreSyncService {
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");

View File

@ -18,7 +18,7 @@ export type ToastOptions = {
export class ToastService {
constructor(private toastrService: ToastrService) {}
showToast(options: ToastOptions) {
showToast(options: ToastOptions): void {
const toastrConfig: Partial<IndividualConfig> = {
payload: {
message: options.message,