diff --git a/apps/browser/src/auth/background/service-factories/token-service.factory.ts b/apps/browser/src/auth/background/service-factories/token-service.factory.ts index 476b8e2d78..25c30460f0 100644 --- a/apps/browser/src/auth/background/service-factories/token-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/token-service.factory.ts @@ -7,13 +7,29 @@ import { factory, } from "../../../platform/background/service-factories/factory-options"; import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../../platform/background/service-factories/state-service.factory"; + GlobalStateProviderInitOptions, + globalStateProviderFactory, +} from "../../../platform/background/service-factories/global-state-provider.factory"; +import { + PlatformUtilsServiceInitOptions, + platformUtilsServiceFactory, +} from "../../../platform/background/service-factories/platform-utils-service.factory"; +import { + SingleUserStateProviderInitOptions, + singleUserStateProviderFactory, +} from "../../../platform/background/service-factories/single-user-state-provider.factory"; +import { + SecureStorageServiceInitOptions, + secureStorageServiceFactory, +} from "../../../platform/background/service-factories/storage-service.factory"; type TokenServiceFactoryOptions = FactoryOptions; -export type TokenServiceInitOptions = TokenServiceFactoryOptions & StateServiceInitOptions; +export type TokenServiceInitOptions = TokenServiceFactoryOptions & + SingleUserStateProviderInitOptions & + GlobalStateProviderInitOptions & + PlatformUtilsServiceInitOptions & + SecureStorageServiceInitOptions; export function tokenServiceFactory( cache: { tokenService?: AbstractTokenService } & CachedServices, @@ -23,6 +39,12 @@ export function tokenServiceFactory( cache, "tokenService", opts, - async () => new TokenService(await stateServiceFactory(cache, opts)), + async () => + new TokenService( + await singleUserStateProviderFactory(cache, opts), + await globalStateProviderFactory(cache, opts), + (await platformUtilsServiceFactory(cache, opts)).supportsSecureStorage(), + await secureStorageServiceFactory(cache, opts), + ), ); } diff --git a/apps/browser/src/auth/popup/login.component.ts b/apps/browser/src/auth/popup/login.component.ts index 857dae6630..c1dd952658 100644 --- a/apps/browser/src/auth/popup/login.component.ts +++ b/apps/browser/src/auth/popup/login.component.ts @@ -91,6 +91,8 @@ export class LoginComponent extends BaseLoginComponent { } async launchSsoBrowser() { + // Save off email for SSO + await this.ssoLoginService.setSsoEmail(this.formGroup.value.email); await this.loginService.saveEmailSettings(); // Generate necessary sso params const passwordOptions: any = { diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index 998b5c7258..b7e26be4a9 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -184,6 +184,11 @@ export class MainContextMenuHandler { stateServiceOptions: { stateFactory: stateFactory, }, + platformUtilsServiceOptions: { + clipboardWriteCallback: () => Promise.resolve(), + biometricCallback: () => Promise.resolve(false), + win: self, + }, }; return new MainContextMenuHandler( diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 34a5f9e1fd..23f415fc41 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -427,6 +427,21 @@ export default class MainBackground { ); this.biometricStateService = new DefaultBiometricStateService(this.stateProvider); + this.userNotificationSettingsService = new UserNotificationSettingsService(this.stateProvider); + this.platformUtilsService = new BackgroundPlatformUtilsService( + this.messagingService, + (clipboardValue, clearMs) => this.clearClipboard(clipboardValue, clearMs), + async () => this.biometricUnlock(), + self, + ); + + this.tokenService = new TokenService( + this.singleUserStateProvider, + this.globalStateProvider, + this.platformUtilsService.supportsSecureStorage(), + this.secureStorageService, + ); + const migrationRunner = new MigrationRunner( this.storageService, this.logService, @@ -441,15 +456,9 @@ export default class MainBackground { new StateFactory(GlobalState, Account), this.accountService, this.environmentService, + this.tokenService, migrationRunner, ); - this.userNotificationSettingsService = new UserNotificationSettingsService(this.stateProvider); - this.platformUtilsService = new BackgroundPlatformUtilsService( - this.messagingService, - (clipboardValue, clearMs) => this.clearClipboard(clipboardValue, clearMs), - async () => this.biometricUnlock(), - self, - ); const themeStateService = new DefaultThemeStateService(this.globalStateProvider); @@ -465,13 +474,14 @@ export default class MainBackground { this.stateProvider, this.biometricStateService, ); - this.tokenService = new TokenService(this.stateService); + this.appIdService = new AppIdService(this.globalStateProvider); this.apiService = new ApiService( this.tokenService, this.platformUtilsService, this.environmentService, this.appIdService, + this.stateService, (expired: boolean) => this.logout(expired), ); this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); 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 649fe1f7fe..57cd500441 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 @@ -20,6 +20,7 @@ import { PlatformUtilsServiceInitOptions, platformUtilsServiceFactory, } from "./platform-utils-service.factory"; +import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; type ApiServiceFactoryOptions = FactoryOptions & { apiServiceOptions: { @@ -32,7 +33,8 @@ export type ApiServiceInitOptions = ApiServiceFactoryOptions & TokenServiceInitOptions & PlatformUtilsServiceInitOptions & EnvironmentServiceInitOptions & - AppIdServiceInitOptions; + AppIdServiceInitOptions & + StateServiceInitOptions; export function apiServiceFactory( cache: { apiService?: AbstractApiService } & CachedServices, @@ -48,6 +50,7 @@ export function apiServiceFactory( await platformUtilsServiceFactory(cache, opts), await environmentServiceFactory(cache, opts), await appIdServiceFactory(cache, opts), + await stateServiceFactory(cache, opts), opts.apiServiceOptions.logoutCallback, opts.apiServiceOptions.customUserAgent, ), diff --git a/apps/browser/src/platform/background/service-factories/state-service.factory.ts b/apps/browser/src/platform/background/service-factories/state-service.factory.ts index 8bcb65a320..20a9ac074a 100644 --- a/apps/browser/src/platform/background/service-factories/state-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/state-service.factory.ts @@ -5,6 +5,10 @@ import { accountServiceFactory, AccountServiceInitOptions, } from "../../../auth/background/service-factories/account-service.factory"; +import { + tokenServiceFactory, + TokenServiceInitOptions, +} from "../../../auth/background/service-factories/token-service.factory"; import { Account } from "../../../models/account"; import { BrowserStateService } from "../../services/browser-state.service"; @@ -38,6 +42,7 @@ export type StateServiceInitOptions = StateServiceFactoryOptions & LogServiceInitOptions & AccountServiceInitOptions & EnvironmentServiceInitOptions & + TokenServiceInitOptions & MigrationRunnerInitOptions; export async function stateServiceFactory( @@ -57,6 +62,7 @@ export async function stateServiceFactory( opts.stateServiceOptions.stateFactory, await accountServiceFactory(cache, opts), await environmentServiceFactory(cache, opts), + await tokenServiceFactory(cache, opts), await migrationRunnerFactory(cache, opts), opts.stateServiceOptions.useAccountCache, ), diff --git a/apps/browser/src/platform/services/browser-state.service.spec.ts b/apps/browser/src/platform/services/browser-state.service.spec.ts index b5d1b9c38a..3069b8f174 100644 --- a/apps/browser/src/platform/services/browser-state.service.spec.ts +++ b/apps/browser/src/platform/services/browser-state.service.spec.ts @@ -1,5 +1,6 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { @@ -32,6 +33,7 @@ describe("Browser State Service", () => { let stateFactory: MockProxy>; let useAccountCache: boolean; let environmentService: MockProxy; + let tokenService: MockProxy; let migrationRunner: MockProxy; let state: State; @@ -46,6 +48,7 @@ describe("Browser State Service", () => { logService = mock(); stateFactory = mock(); environmentService = mock(); + tokenService = mock(); migrationRunner = mock(); // turn off account cache for tests useAccountCache = false; @@ -77,6 +80,7 @@ describe("Browser State Service", () => { stateFactory, accountService, environmentService, + tokenService, migrationRunner, useAccountCache, ); diff --git a/apps/browser/src/platform/services/browser-state.service.ts b/apps/browser/src/platform/services/browser-state.service.ts index c544915e26..f7ee74be21 100644 --- a/apps/browser/src/platform/services/browser-state.service.ts +++ b/apps/browser/src/platform/services/browser-state.service.ts @@ -1,6 +1,7 @@ import { BehaviorSubject } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { @@ -45,6 +46,7 @@ export class BrowserStateService stateFactory: StateFactory, accountService: AccountService, environmentService: EnvironmentService, + tokenService: TokenService, migrationRunner: MigrationRunner, useAccountCache = true, ) { @@ -56,6 +58,7 @@ export class BrowserStateService stateFactory, accountService, environmentService, + tokenService, migrationRunner, useAccountCache, ); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 5f97b57882..0e9cee5c67 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -233,7 +233,6 @@ function getBgService(service: keyof MainBackground) { deps: [], }, { provide: TotpService, useFactory: getBgService("totpService"), deps: [] }, - { provide: TokenService, useFactory: getBgService("tokenService"), deps: [] }, { provide: I18nServiceAbstraction, useFactory: (globalStateProvider: GlobalStateProvider) => { @@ -445,6 +444,7 @@ function getBgService(service: keyof MainBackground) { logService: LogServiceAbstraction, accountService: AccountServiceAbstraction, environmentService: EnvironmentService, + tokenService: TokenService, migrationRunner: MigrationRunner, ) => { return new BrowserStateService( @@ -455,6 +455,7 @@ function getBgService(service: keyof MainBackground) { new StateFactory(GlobalState, Account), accountService, environmentService, + tokenService, migrationRunner, ); }, @@ -465,6 +466,7 @@ function getBgService(service: keyof MainBackground) { LogServiceAbstraction, AccountServiceAbstraction, EnvironmentService, + TokenService, MigrationRunner, ], }, diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 75e6479dc0..97c0974ac6 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -203,6 +203,7 @@ export class LoginCommand { ssoCodeVerifier, this.ssoRedirectUri, orgIdentifier, + undefined, // email to look up 2FA token not required as CLI can't remember 2FA token twoFactor, ), ); diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index f312c0c37e..8e3b60e270 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -310,6 +310,13 @@ export class Main { this.environmentService = new EnvironmentService(this.stateProvider, this.accountService); + this.tokenService = new TokenService( + this.singleUserStateProvider, + this.globalStateProvider, + this.platformUtilsService.supportsSecureStorage(), + this.secureStorageService, + ); + const migrationRunner = new MigrationRunner( this.storageService, this.logService, @@ -324,6 +331,7 @@ export class Main { new StateFactory(GlobalState, Account), this.accountService, this.environmentService, + this.tokenService, migrationRunner, ); @@ -341,7 +349,6 @@ export class Main { ); this.appIdService = new AppIdService(this.globalStateProvider); - this.tokenService = new TokenService(this.stateService); const customUserAgent = "Bitwarden_CLI/" + @@ -354,6 +361,7 @@ export class Main { this.platformUtilsService, this.environmentService, this.appIdService, + this.stateService, async (expired: boolean) => await this.logout(), customUserAgent, ); diff --git a/apps/cli/src/platform/services/node-api.service.ts b/apps/cli/src/platform/services/node-api.service.ts index d95e9d7d85..9099fd2760 100644 --- a/apps/cli/src/platform/services/node-api.service.ts +++ b/apps/cli/src/platform/services/node-api.service.ts @@ -6,6 +6,7 @@ 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ApiService } from "@bitwarden/common/services/api.service"; (global as any).fetch = fe.default; @@ -20,6 +21,7 @@ export class NodeApiService extends ApiService { platformUtilsService: PlatformUtilsService, environmentService: EnvironmentService, appIdService: AppIdService, + stateService: StateService, logoutCallback: (expired: boolean) => Promise, customUserAgent: string = null, ) { @@ -28,6 +30,7 @@ export class NodeApiService extends ApiService { platformUtilsService, environmentService, appIdService, + stateService, logoutCallback, customUserAgent, ); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 2b103b8d71..3a8457da7a 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -10,6 +10,7 @@ import { OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, WINDOW, + SUPPORTS_SECURE_STORAGE, SYSTEM_THEME_OBSERVABLE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; @@ -18,6 +19,7 @@ import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/adm import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { LoginService } from "@bitwarden/common/auth/services/login.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -54,7 +56,10 @@ import { LoginGuard } from "../../auth/guards/login.guard"; import { Account } from "../../models/account"; import { ElectronCryptoService } from "../../platform/services/electron-crypto.service"; import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service"; -import { ElectronPlatformUtilsService } from "../../platform/services/electron-platform-utils.service"; +import { + ELECTRON_SUPPORTS_SECURE_STORAGE, + ElectronPlatformUtilsService, +} from "../../platform/services/electron-platform-utils.service"; import { ElectronRendererMessagingService } from "../../platform/services/electron-renderer-messaging.service"; import { ElectronRendererSecureStorageService } from "../../platform/services/electron-renderer-secure-storage.service"; import { ElectronRendererStorageService } from "../../platform/services/electron-renderer-storage.service"; @@ -101,6 +106,13 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); useClass: ElectronPlatformUtilsService, deps: [I18nServiceAbstraction, MessagingServiceAbstraction], }, + { + // We manually override the value of SUPPORTS_SECURE_STORAGE here to avoid + // the TokenService having to inject the PlatformUtilsService which introduces a + // circular dependency on Desktop only. + provide: SUPPORTS_SECURE_STORAGE, + useValue: ELECTRON_SUPPORTS_SECURE_STORAGE, + }, { provide: I18nServiceAbstraction, useClass: I18nRendererService, @@ -140,6 +152,7 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); STATE_FACTORY, AccountServiceAbstraction, EnvironmentService, + TokenService, MigrationRunner, STATE_SERVICE_USE_CACHE, ], diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 80dfa04c27..0b92fab894 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2,7 +2,9 @@ import * as path from "path"; import { app } from "electron"; +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"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { DefaultBiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; @@ -36,6 +38,7 @@ import { ClipboardMain } from "./platform/main/clipboard.main"; import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener"; import { MainCryptoFunctionService } from "./platform/main/main-crypto-function.service"; import { ElectronLogMainService } from "./platform/services/electron-log.main.service"; +import { ELECTRON_SUPPORTS_SECURE_STORAGE } from "./platform/services/electron-platform-utils.service"; import { ElectronStateService } from "./platform/services/electron-state.service"; import { ElectronStorageService } from "./platform/services/electron-storage.service"; import { I18nMainService } from "./platform/services/i18n.main.service"; @@ -53,6 +56,7 @@ export class Main { mainCryptoFunctionService: MainCryptoFunctionService; desktopCredentialStorageListener: DesktopCredentialStorageListener; migrationRunner: MigrationRunner; + tokenService: TokenServiceAbstraction; windowMain: WindowMain; messagingMain: MessagingMain; @@ -129,8 +133,13 @@ export class Main { stateEventRegistrarService, ); + const activeUserStateProvider = new DefaultActiveUserStateProvider( + accountService, + singleUserStateProvider, + ); + const stateProvider = new DefaultStateProvider( - new DefaultActiveUserStateProvider(accountService, singleUserStateProvider), + activeUserStateProvider, singleUserStateProvider, globalStateProvider, new DefaultDerivedStateProvider(this.memoryStorageForStateProviders), @@ -138,6 +147,13 @@ export class Main { this.environmentService = new EnvironmentService(stateProvider, accountService); + this.tokenService = new TokenService( + singleUserStateProvider, + globalStateProvider, + ELECTRON_SUPPORTS_SECURE_STORAGE, + this.storageService, + ); + this.migrationRunner = new MigrationRunner( this.storageService, this.logService, @@ -155,6 +171,7 @@ export class Main { new StateFactory(GlobalState, Account), accountService, // will not broadcast logouts. This is a hack until we can remove messaging dependency this.environmentService, + this.tokenService, this.migrationRunner, false, // Do not use disk caching because this will get out of sync with the renderer service ); @@ -176,6 +193,7 @@ export class Main { this.messagingService = new ElectronMainMessagingService(this.windowMain, (message) => { this.messagingMain.onMessage(message); }); + this.powerMonitorMain = new PowerMonitorMain(this.messagingService); this.menuMain = new MenuMain( this.i18nService, diff --git a/apps/desktop/src/platform/services/electron-platform-utils.service.ts b/apps/desktop/src/platform/services/electron-platform-utils.service.ts index 5f117ba678..2d50712dfb 100644 --- a/apps/desktop/src/platform/services/electron-platform-utils.service.ts +++ b/apps/desktop/src/platform/services/electron-platform-utils.service.ts @@ -8,6 +8,8 @@ import { import { ClipboardWriteMessage } from "../types/clipboard"; +export const ELECTRON_SUPPORTS_SECURE_STORAGE = true; + export class ElectronPlatformUtilsService implements PlatformUtilsService { constructor( protected i18nService: I18nService, @@ -142,7 +144,7 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService { } supportsSecureStorage(): boolean { - return true; + return ELECTRON_SUPPORTS_SECURE_STORAGE; } getAutofillKeyboardShortcut(): Promise { diff --git a/apps/web/src/app/core/state/state.service.ts b/apps/web/src/app/core/state/state.service.ts index 1ad3bd25c3..a80384d179 100644 --- a/apps/web/src/app/core/state/state.service.ts +++ b/apps/web/src/app/core/state/state.service.ts @@ -7,6 +7,7 @@ import { STATE_SERVICE_USE_CACHE, } from "@bitwarden/angular/services/injection-tokens"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { @@ -33,6 +34,7 @@ export class StateService extends BaseStateService { @Inject(STATE_FACTORY) stateFactory: StateFactory, accountService: AccountService, environmentService: EnvironmentService, + tokenService: TokenService, migrationRunner: MigrationRunner, @Inject(STATE_SERVICE_USE_CACHE) useAccountCache = true, ) { @@ -44,6 +46,7 @@ export class StateService extends BaseStateService { stateFactory, accountService, environmentService, + tokenService, migrationRunner, useAccountCache, ); diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index 8314bdb2dc..0ed1fd1dfe 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -149,6 +149,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, this.captchaToken, null, ); + this.formPromise = this.loginStrategyService.logIn(credentials); const response = await this.formPromise; this.setFormValues(); @@ -302,6 +303,9 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, async saveEmailSettings() { this.setFormValues(); await this.loginService.saveEmailSettings(); + + // Save off email for SSO + await this.ssoLoginService.setSsoEmail(this.formGroup.value.email); } // Legacy accounts used the master key to encrypt data. Migration is required diff --git a/libs/angular/src/auth/components/sso.component.ts b/libs/angular/src/auth/components/sso.component.ts index 2f50288f04..a5a08f9aef 100644 --- a/libs/angular/src/auth/components/sso.component.ts +++ b/libs/angular/src/auth/components/sso.component.ts @@ -182,11 +182,14 @@ export class SsoComponent { private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise { this.loggingIn = true; try { + const email = await this.ssoLoginService.getSsoEmail(); + const credentials = new SsoLoginCredentials( code, codeVerifier, this.redirectUri, orgSsoIdentifier, + email, ); this.formPromise = this.loginStrategyService.logIn(credentials); const authResult = await this.formPromise; diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 50aafc2326..7d39078797 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -42,6 +42,7 @@ export const LOGOUT_CALLBACK = new SafeInjectionToken< export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promise>( "LOCKED_CALLBACK", ); +export const SUPPORTS_SECURE_STORAGE = new SafeInjectionToken("SUPPORTS_SECURE_STORAGE"); export const LOCALES_DIRECTORY = new SafeInjectionToken("LOCALES_DIRECTORY"); export const SYSTEM_LANGUAGE = new SafeInjectionToken("SYSTEM_LANGUAGE"); export const LOG_MAC_FAILURES = new SafeInjectionToken("LOG_MAC_FAILURES"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c5ab77e77b..58d614bb9c 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -250,6 +250,7 @@ import { SECURE_STORAGE, STATE_FACTORY, STATE_SERVICE_USE_CACHE, + SUPPORTS_SECURE_STORAGE, SYSTEM_LANGUAGE, SYSTEM_THEME_OBSERVABLE, WINDOW, @@ -272,6 +273,12 @@ const typesafeProviders: Array = [ useFactory: (i18nService: I18nServiceAbstraction) => i18nService.translationLocale, deps: [I18nServiceAbstraction], }), + safeProvider({ + provide: SUPPORTS_SECURE_STORAGE, + useFactory: (platformUtilsService: PlatformUtilsServiceAbstraction) => + platformUtilsService.supportsSecureStorage(), + deps: [PlatformUtilsServiceAbstraction], + }), safeProvider({ provide: LOCALES_DIRECTORY, useValue: "./locales", @@ -475,7 +482,12 @@ const typesafeProviders: Array = [ safeProvider({ provide: TokenServiceAbstraction, useClass: TokenService, - deps: [StateServiceAbstraction], + deps: [ + SingleUserStateProvider, + GlobalStateProvider, + SUPPORTS_SECURE_STORAGE, + AbstractStorageService, + ], }), safeProvider({ provide: KeyGenerationServiceAbstraction, @@ -519,6 +531,7 @@ const typesafeProviders: Array = [ PlatformUtilsServiceAbstraction, EnvironmentServiceAbstraction, AppIdServiceAbstraction, + StateServiceAbstraction, LOGOUT_CALLBACK, ], }), @@ -621,6 +634,7 @@ const typesafeProviders: Array = [ STATE_FACTORY, AccountServiceAbstraction, EnvironmentServiceAbstraction, + TokenServiceAbstraction, MigrationRunner, STATE_SERVICE_USE_CACHE, ], diff --git a/libs/auth/src/common/index.ts b/libs/auth/src/common/index.ts index f70f8be215..936666e1a8 100644 --- a/libs/auth/src/common/index.ts +++ b/libs/auth/src/common/index.ts @@ -4,3 +4,4 @@ export * from "./abstractions"; export * from "./models"; export * from "./services"; +export * from "./utilities"; diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts index dd046195aa..6a045a8f62 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts @@ -67,7 +67,7 @@ describe("AuthRequestLoginStrategy", () => { tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); - tokenService.decodeToken.mockResolvedValue({}); + tokenService.decodeAccessToken.mockResolvedValue({}); authRequestLoginStrategy = new AuthRequestLoginStrategy( cache, diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts index acf21219c2..01a2c97077 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts @@ -79,7 +79,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy { credentials.email, credentials.accessCode, null, - await this.buildTwoFactor(credentials.twoFactor), + await this.buildTwoFactor(credentials.twoFactor, credentials.email), await this.buildDeviceRequest(), ); data.tokenRequest.setAuthRequestAccessCode(credentials.authRequestId); diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index 5771cb2543..a9938bd39c 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -14,6 +14,7 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -123,11 +124,12 @@ describe("LoginStrategy", () => { logService = mock(); stateService = mock(); twoFactorService = mock(); + policyService = mock(); passwordStrengthService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); - tokenService.decodeToken.calledWith(accessToken).mockResolvedValue(decodedToken); + tokenService.decodeAccessToken.calledWith(accessToken).mockResolvedValue(decodedToken); // The base class is abstract so we test it via PasswordLoginStrategy passwordLoginStrategy = new PasswordLoginStrategy( @@ -167,8 +169,21 @@ describe("LoginStrategy", () => { const idTokenResponse = identityTokenResponseFactory(); apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + const mockVaultTimeoutAction = VaultTimeoutAction.Lock; + const mockVaultTimeout = 1000; + + stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction); + stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout); + await passwordLoginStrategy.logIn(credentials); + expect(tokenService.setTokens).toHaveBeenCalledWith( + accessToken, + refreshToken, + mockVaultTimeoutAction, + mockVaultTimeout, + ); + expect(stateService.addAccount).toHaveBeenCalledWith( new Account({ profile: { @@ -184,10 +199,6 @@ describe("LoginStrategy", () => { }, tokens: { ...new AccountTokens(), - ...{ - accessToken: accessToken, - refreshToken: refreshToken, - }, }, keys: new AccountKeys(), decryptionOptions: AccountDecryptionOptions.fromResponse(idTokenResponse), @@ -299,6 +310,7 @@ describe("LoginStrategy", () => { expect(stateService.addAccount).not.toHaveBeenCalled(); expect(messagingService.send).not.toHaveBeenCalled(); + expect(tokenService.clearTwoFactorToken).toHaveBeenCalled(); const expected = new AuthResult(); expected.twoFactorProviders = new Map(); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index e6ff1c68a3..c6d441af23 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -16,6 +16,7 @@ import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { ClientType } from "@bitwarden/common/enums"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -49,6 +50,9 @@ export abstract class LoginStrategyData { | SsoTokenRequest | WebAuthnLoginTokenRequest; captchaBypassToken?: string; + + /** User's entered email obtained pre-login. */ + abstract userEnteredEmail?: string; } export abstract class LoginStrategy { @@ -110,21 +114,47 @@ export abstract class LoginStrategy { return new DeviceRequest(appId, this.platformUtilsService); } - protected async buildTwoFactor(userProvidedTwoFactor?: TokenTwoFactorRequest) { + /** + * Builds the TokenTwoFactorRequest to be used within other login strategies token requests + * to the server. + * If the user provided a 2FA token in an already created TokenTwoFactorRequest, it will be used. + * If not, and the user has previously remembered a 2FA token, it will be used. + * If neither of these are true, an empty TokenTwoFactorRequest will be returned. + * @param userProvidedTwoFactor - optional - The 2FA token request provided by the caller + * @param email - optional - ensure that email is provided for any login strategies that support remember 2FA functionality + * @returns a promise which resolves to a TokenTwoFactorRequest to be sent to the server + */ + protected async buildTwoFactor( + userProvidedTwoFactor?: TokenTwoFactorRequest, + email?: string, + ): Promise { if (userProvidedTwoFactor != null) { return userProvidedTwoFactor; } - const storedTwoFactorToken = await this.tokenService.getTwoFactorToken(); - if (storedTwoFactorToken != null) { - return new TokenTwoFactorRequest(TwoFactorProviderType.Remember, storedTwoFactorToken, false); + if (email) { + const storedTwoFactorToken = await this.tokenService.getTwoFactorToken(email); + if (storedTwoFactorToken != null) { + return new TokenTwoFactorRequest( + TwoFactorProviderType.Remember, + storedTwoFactorToken, + false, + ); + } } return new TokenTwoFactorRequest(); } - protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) { - const accountInformation = await this.tokenService.decodeToken(tokenResponse.accessToken); + /** + * Initializes the account with information from the IdTokenResponse after successful login. + * It also sets the access token and refresh token in the token service. + * + * @param {IdentityTokenResponse} tokenResponse - The response from the server containing the identity token. + * @returns {Promise} - A promise that resolves when the account information has been successfully saved. + */ + protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise { + const accountInformation = await this.tokenService.decodeAccessToken(tokenResponse.accessToken); // Must persist existing device key if it exists for trusted device decryption to work // However, we must provide a user id so that the device key can be retrieved @@ -141,6 +171,18 @@ export abstract class LoginStrategy { // If you don't persist existing admin auth requests on login, they will get deleted. const adminAuthRequest = await this.stateService.getAdminAuthRequest({ userId }); + const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction(); + const vaultTimeout = await this.stateService.getVaultTimeout(); + + // set access token and refresh token before account initialization so authN status can be accurate + // User id will be derived from the access token. + await this.tokenService.setTokens( + tokenResponse.accessToken, + tokenResponse.refreshToken, + vaultTimeoutAction as VaultTimeoutAction, + vaultTimeout, + ); + await this.stateService.addAccount( new Account({ profile: { @@ -158,10 +200,6 @@ export abstract class LoginStrategy { }, tokens: { ...new AccountTokens(), - ...{ - accessToken: tokenResponse.accessToken, - refreshToken: tokenResponse.refreshToken, - }, }, keys: accountKeys, decryptionOptions: AccountDecryptionOptions.fromResponse(tokenResponse), @@ -193,7 +231,10 @@ export abstract class LoginStrategy { await this.saveAccountInformation(response); if (response.twoFactorToken != null) { - await this.tokenService.setTwoFactorToken(response); + // note: we can read email from access token b/c it was saved in saveAccountInformation + const userEmail = await this.tokenService.getEmail(); + + await this.tokenService.setTwoFactorToken(userEmail, response.twoFactorToken); } await this.setMasterKey(response); @@ -226,7 +267,18 @@ export abstract class LoginStrategy { } } + /** + * Handles the response from the server when a 2FA is required. + * It clears any existing 2FA token, as it's no longer valid, and sets up the necessary data for the 2FA process. + * + * @param {IdentityTwoFactorResponse} response - The response from the server indicating that 2FA is required. + * @returns {Promise} - A promise that resolves to an AuthResult object + */ private async processTwoFactorResponse(response: IdentityTwoFactorResponse): Promise { + // If we get a 2FA required response, then we should clear the 2FA token + // just in case as it is no longer valid. + await this.clearTwoFactorToken(); + const result = new AuthResult(); result.twoFactorProviders = response.twoFactorProviders2; @@ -237,6 +289,16 @@ export abstract class LoginStrategy { return result; } + /** + * Clears the 2FA token from the token service using the user's email if it exists + */ + private async clearTwoFactorToken() { + const email = this.cache.value.userEnteredEmail; + if (email) { + await this.tokenService.clearTwoFactorToken(email); + } + } + private async processCaptchaResponse(response: IdentityCaptchaResponse): Promise { const result = new AuthResult(); result.captchaSiteKey = response.siteKey; diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index 77ef6792ba..1ab908ac9e 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -81,7 +81,7 @@ describe("PasswordLoginStrategy", () => { passwordStrengthService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); - tokenService.decodeToken.mockResolvedValue({}); + tokenService.decodeAccessToken.mockResolvedValue({}); loginStrategyService.makePreloginKey.mockResolvedValue(masterKey); diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index c12eb28204..2c99c243e0 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -32,6 +32,10 @@ import { LoginStrategy, LoginStrategyData } from "./login.strategy"; export class PasswordLoginStrategyData implements LoginStrategyData { tokenRequest: PasswordTokenRequest; + + /** User's entered email obtained pre-login. Always present in MP login. */ + userEnteredEmail: string; + captchaBypassToken?: string; /** * The local version of the user's master key hash @@ -105,6 +109,7 @@ export class PasswordLoginStrategy extends LoginStrategy { const data = new PasswordLoginStrategyData(); data.masterKey = await this.loginStrategyService.makePreloginKey(masterPassword, email); + data.userEnteredEmail = email; // Hash the password early (before authentication) so we don't persist it in memory in plaintext data.localMasterKeyHash = await this.cryptoService.hashMasterKey( @@ -118,7 +123,7 @@ export class PasswordLoginStrategy extends LoginStrategy { email, masterKeyHash, captchaToken, - await this.buildTwoFactor(twoFactor), + await this.buildTwoFactor(twoFactor, email), await this.buildDeviceRequest(), ); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index b6cf6db58a..9946a6141f 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -71,7 +71,7 @@ describe("SsoLoginStrategy", () => { tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); - tokenService.decodeToken.mockResolvedValue({}); + tokenService.decodeAccessToken.mockResolvedValue({}); ssoLoginStrategy = new SsoLoginStrategy( null, diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index a5ef922204..6b88a92f70 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -29,6 +29,10 @@ import { LoginStrategyData, LoginStrategy } from "./login.strategy"; export class SsoLoginStrategyData implements LoginStrategyData { captchaBypassToken: string; tokenRequest: SsoTokenRequest; + /** + * User's entered email obtained pre-login. Present in most SSO flows, but not CLI + SSO Flow. + */ + userEnteredEmail?: string; /** * User email address. Only available after authentication. */ @@ -105,11 +109,14 @@ export class SsoLoginStrategy extends LoginStrategy { async logIn(credentials: SsoLoginCredentials) { const data = new SsoLoginStrategyData(); data.orgId = credentials.orgId; + + data.userEnteredEmail = credentials.email; + data.tokenRequest = new SsoTokenRequest( credentials.code, credentials.codeVerifier, credentials.redirectUrl, - await this.buildTwoFactor(credentials.twoFactor), + await this.buildTwoFactor(credentials.twoFactor, credentials.email), await this.buildDeviceRequest(), ); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index d50d2883c7..da856a282e 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -4,6 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -59,7 +60,7 @@ describe("UserApiLoginStrategy", () => { appIdService.getAppId.mockResolvedValue(deviceId); tokenService.getTwoFactorToken.mockResolvedValue(null); - tokenService.decodeToken.mockResolvedValue({}); + tokenService.decodeAccessToken.mockResolvedValue({}); apiLogInStrategy = new UserApiLoginStrategy( cache, @@ -101,10 +102,23 @@ describe("UserApiLoginStrategy", () => { it("sets the local environment after a successful login", async () => { apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory()); + const mockVaultTimeoutAction = VaultTimeoutAction.Lock; + const mockVaultTimeout = 60; + stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction); + stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout); + await apiLogInStrategy.logIn(credentials); - expect(stateService.setApiKeyClientId).toHaveBeenCalledWith(apiClientId); - expect(stateService.setApiKeyClientSecret).toHaveBeenCalledWith(apiClientSecret); + expect(tokenService.setClientId).toHaveBeenCalledWith( + apiClientId, + mockVaultTimeoutAction, + mockVaultTimeout, + ); + expect(tokenService.setClientSecret).toHaveBeenCalledWith( + apiClientSecret, + mockVaultTimeoutAction, + mockVaultTimeout, + ); expect(stateService.addAccount).toHaveBeenCalled(); }); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts index a26fb41ae9..68916b6e8e 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts @@ -7,6 +7,7 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service" import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -104,9 +105,21 @@ export class UserApiLoginStrategy extends LoginStrategy { protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) { await super.saveAccountInformation(tokenResponse); + const vaultTimeout = await this.stateService.getVaultTimeout(); + const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction(); + const tokenRequest = this.cache.value.tokenRequest; - await this.stateService.setApiKeyClientId(tokenRequest.clientId); - await this.stateService.setApiKeyClientSecret(tokenRequest.clientSecret); + + await this.tokenService.setClientId( + tokenRequest.clientId, + vaultTimeoutAction as VaultTimeoutAction, + vaultTimeout, + ); + await this.tokenService.setClientSecret( + tokenRequest.clientSecret, + vaultTimeoutAction as VaultTimeoutAction, + vaultTimeout, + ); } exportCache(): CacheData { diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index 17933a3dcb..b7a56e6230 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -71,7 +71,7 @@ describe("WebAuthnLoginStrategy", () => { tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); - tokenService.decodeToken.mockResolvedValue({}); + tokenService.decodeAccessToken.mockResolvedValue({}); webAuthnLoginStrategy = new WebAuthnLoginStrategy( cache, diff --git a/libs/auth/src/common/models/domain/login-credentials.ts b/libs/auth/src/common/models/domain/login-credentials.ts index a56d8e0097..bfe01aea20 100644 --- a/libs/auth/src/common/models/domain/login-credentials.ts +++ b/libs/auth/src/common/models/domain/login-credentials.ts @@ -25,6 +25,11 @@ export class SsoLoginCredentials { public codeVerifier: string, public redirectUrl: string, public orgId: string, + /** + * Optional email address for SSO login. + * Used for looking up 2FA token on clients that support remembering 2FA token. + */ + public email?: string, public twoFactor?: TokenTwoFactorRequest, ) {} } diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts index 21509eb83c..2304dc4d33 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts @@ -114,7 +114,7 @@ describe("LoginStrategyService", () => { token_type: "Bearer", }), ); - tokenService.decodeToken.calledWith("ACCESS_TOKEN").mockResolvedValue({ + tokenService.decodeAccessToken.calledWith("ACCESS_TOKEN").mockResolvedValue({ sub: "USER_ID", name: "NAME", email: "EMAIL", @@ -161,7 +161,7 @@ describe("LoginStrategyService", () => { }), ); - tokenService.decodeToken.calledWith("ACCESS_TOKEN").mockResolvedValue({ + tokenService.decodeAccessToken.calledWith("ACCESS_TOKEN").mockResolvedValue({ sub: "USER_ID", name: "NAME", email: "EMAIL", diff --git a/libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.spec.ts b/libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.spec.ts new file mode 100644 index 0000000000..84778b82f8 --- /dev/null +++ b/libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.spec.ts @@ -0,0 +1,90 @@ +import { DecodedAccessToken } from "@bitwarden/common/auth/services/token.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { decodeJwtTokenToJson } from "./decode-jwt-token-to-json.utility"; + +describe("decodeJwtTokenToJson", () => { + const accessTokenJwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0IiwibmJmIjoxNzA5MzI0MTExLCJpYXQiOjE3MDkzMjQxMTEsImV4cCI6MTcwOTMyNzcxMSwic2NvcGUiOlsiYXBpIiwib2ZmbGluZV9hY2Nlc3MiXSwiYW1yIjpbIkFwcGxpY2F0aW9uIl0sImNsaWVudF9pZCI6IndlYiIsInN1YiI6ImVjZTcwYTEzLTcyMTYtNDNjNC05OTc3LWIxMDMwMTQ2ZTFlNyIsImF1dGhfdGltZSI6MTcwOTMyNDEwNCwiaWRwIjoiYml0d2FyZGVuIiwicHJlbWl1bSI6ZmFsc2UsImVtYWlsIjoiZXhhbXBsZUBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJzc3RhbXAiOiJHWTdKQU82NENLS1RLQkI2WkVBVVlMMldPUVU3QVNUMiIsIm5hbWUiOiJUZXN0IFVzZXIiLCJvcmdvd25lciI6WyI5MmI0OTkwOC1iNTE0LTQ1YTgtYmFkYi1iMTAzMDE0OGZlNTMiLCIzOGVkZTMyMi1iNGI0LTRiZDgtOWUwOS1iMTA3MDExMmRjMTEiLCJiMmQwNzAyOC1hNTgzLTRjM2UtOGQ2MC1iMTA3MDExOThjMjkiLCJiZjkzNGJhMi0wZmQ0LTQ5ZjItYTk1ZS1iMTA3MDExZmM5ZTYiLCJjMGI3Zjc1ZC0wMTVmLTQyYzktYjNhNi1iMTA4MDE3NjA3Y2EiXSwiZGV2aWNlIjoiNGI4NzIzNjctMGRhNi00MWEwLWFkY2ItNzdmMmZlZWZjNGY0IiwianRpIjoiNzUxNjFCRTQxMzFGRjVBMkRFNTExQjhDNEUyRkY4OUEifQ.n7roP8sSbfwcYdvRxZNZds27IK32TW6anorE6BORx_Q"; + + const accessTokenDecoded: DecodedAccessToken = { + iss: "http://localhost", + nbf: 1709324111, + iat: 1709324111, + exp: 1709327711, + scope: ["api", "offline_access"], + amr: ["Application"], + client_id: "web", + sub: "ece70a13-7216-43c4-9977-b1030146e1e7", // user id + auth_time: 1709324104, + idp: "bitwarden", + premium: false, + email: "example@bitwarden.com", + email_verified: false, + sstamp: "GY7JAO64CKKTKBB6ZEAUYL2WOQU7AST2", + name: "Test User", + orgowner: [ + "92b49908-b514-45a8-badb-b1030148fe53", + "38ede322-b4b4-4bd8-9e09-b1070112dc11", + "b2d07028-a583-4c3e-8d60-b10701198c29", + "bf934ba2-0fd4-49f2-a95e-b107011fc9e6", + "c0b7f75d-015f-42c9-b3a6-b108017607ca", + ], + device: "4b872367-0da6-41a0-adcb-77f2feefc4f4", + jti: "75161BE4131FF5A2DE511B8C4E2FF89A", + }; + + it("should decode the JWT token", () => { + // Act + const result = decodeJwtTokenToJson(accessTokenJwt); + + // Assert + expect(result).toEqual(accessTokenDecoded); + }); + + it("should throw an error if the JWT token is null", () => { + // Act && Assert + expect(() => decodeJwtTokenToJson(null)).toThrow("JWT token not found"); + }); + + it("should throw an error if the JWT token is missing 3 parts", () => { + // Act && Assert + expect(() => decodeJwtTokenToJson("invalidToken")).toThrow("JWT must have 3 parts"); + }); + + it("should throw an error if the JWT token payload contains invalid JSON", () => { + // Arrange: Create a token with a valid format but with a payload that's valid Base64 but not valid JSON + const header = btoa(JSON.stringify({ alg: "none" })); + // Create a Base64-encoded string which fails to parse as JSON + const payload = btoa("invalid JSON"); + const signature = "signature"; + const malformedToken = `${header}.${payload}.${signature}`; + + // Act & Assert + expect(() => decodeJwtTokenToJson(malformedToken)).toThrow( + "Cannot parse the token's payload into JSON", + ); + }); + + it("should throw an error if the JWT token cannot be decoded", () => { + // Arrange: Create a token with a valid format + const header = btoa(JSON.stringify({ alg: "none" })); + const payload = "invalidPayloadBecauseWeWillMockTheFailure"; + const signature = "signature"; + const malformedToken = `${header}.${payload}.${signature}`; + + // Mock Utils.fromUrlB64ToUtf8 to throw an error for this specific payload + jest.spyOn(Utils, "fromUrlB64ToUtf8").mockImplementation((input) => { + if (input === payload) { + throw new Error("Mock error"); + } + return input; // Default behavior for other inputs + }); + + // Act & Assert + expect(() => decodeJwtTokenToJson(malformedToken)).toThrow("Cannot decode the token"); + + // Restore original function so other tests are not affected + jest.restoreAllMocks(); + }); +}); diff --git a/libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.ts b/libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.ts new file mode 100644 index 0000000000..717e80b110 --- /dev/null +++ b/libs/auth/src/common/utilities/decode-jwt-token-to-json.utility.ts @@ -0,0 +1,32 @@ +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +export function decodeJwtTokenToJson(jwtToken: string): any { + if (jwtToken == null) { + throw new Error("JWT token not found"); + } + + const parts = jwtToken.split("."); + if (parts.length !== 3) { + throw new Error("JWT must have 3 parts"); + } + + // JWT has 3 parts: header, payload, signature separated by '.' + // So, grab the payload to decode + const encodedPayload = parts[1]; + + let decodedPayloadJSON: string; + try { + // Attempt to decode from URL-safe Base64 to UTF-8 + decodedPayloadJSON = Utils.fromUrlB64ToUtf8(encodedPayload); + } catch (decodingError) { + throw new Error("Cannot decode the token"); + } + + try { + // Attempt to parse the JSON payload + const decodedToken = JSON.parse(decodedPayloadJSON); + return decodedToken; + } catch (jsonError) { + throw new Error("Cannot parse the token's payload into JSON"); + } +} diff --git a/libs/auth/src/common/utilities/index.ts b/libs/auth/src/common/utilities/index.ts new file mode 100644 index 0000000000..0309e37f39 --- /dev/null +++ b/libs/auth/src/common/utilities/index.ts @@ -0,0 +1 @@ +export * from "./decode-jwt-token-to-json.utility"; diff --git a/libs/common/src/auth/abstractions/sso-login.service.abstraction.ts b/libs/common/src/auth/abstractions/sso-login.service.abstraction.ts index 4d73810320..c964c8809c 100644 --- a/libs/common/src/auth/abstractions/sso-login.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/sso-login.service.abstraction.ts @@ -54,6 +54,20 @@ export abstract class SsoLoginServiceAbstraction { * Do not use this value outside of the SSO login flow. */ setOrganizationSsoIdentifier: (organizationIdentifier: string) => Promise; + /** + * Gets the user's email. + * Note: This should only be used during the SSO flow to identify the user that is attempting to log in. + * @returns The user's email. + */ + getSsoEmail: () => Promise; + /** + * Sets the user's email. + * Note: This should only be used during the SSO flow to identify the user that is attempting to log in. + * @param email The user's email. + * @returns A promise that resolves when the email has been set. + * + */ + setSsoEmail: (email: string) => Promise; /** * Gets the value of the active user's organization sso identifier. * diff --git a/libs/common/src/auth/abstractions/token.service.ts b/libs/common/src/auth/abstractions/token.service.ts index 88e6d489b3..d2358314d7 100644 --- a/libs/common/src/auth/abstractions/token.service.ts +++ b/libs/common/src/auth/abstractions/token.service.ts @@ -1,31 +1,208 @@ -import { IdentityTokenResponse } from "../models/response/identity-token.response"; +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { UserId } from "../../types/guid"; +import { DecodedAccessToken } from "../services/token.service"; export abstract class TokenService { + /** + * Sets the access token, refresh token, API Key Client ID, and API Key Client Secret in memory or disk + * based on the given vaultTimeoutAction and vaultTimeout and the derived access token user id. + * Note: for platforms that support secure storage, the access & refresh tokens are stored in secure storage instead of on disk. + * Note 2: this method also enforces always setting the access token and the refresh token together as + * we can retrieve the user id required to set the refresh token from the access token for efficiency. + * @param accessToken The access token to set. + * @param refreshToken The refresh token to set. + * @param clientIdClientSecret The API Key Client ID and Client Secret to set. + * @param vaultTimeoutAction The action to take when the vault times out. + * @param vaultTimeout The timeout for the vault. + * @returns A promise that resolves when the tokens have been set. + */ setTokens: ( accessToken: string, refreshToken: string, - clientIdClientSecret: [string, string], - ) => Promise; - setToken: (token: string) => Promise; - getToken: () => Promise; - setRefreshToken: (refreshToken: string) => Promise; - getRefreshToken: () => Promise; - setClientId: (clientId: string) => Promise; - getClientId: () => Promise; - setClientSecret: (clientSecret: string) => Promise; - getClientSecret: () => Promise; - setTwoFactorToken: (tokenResponse: IdentityTokenResponse) => Promise; - getTwoFactorToken: () => Promise; - clearTwoFactorToken: () => Promise; - clearToken: (userId?: string) => Promise; - decodeToken: (token?: string) => Promise; - getTokenExpirationDate: () => Promise; + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + clientIdClientSecret?: [string, string], + ) => Promise; + + /** + * Clears the access token, refresh token, API Key Client ID, and API Key Client Secret out of memory, disk, and secure storage if supported. + * @param userId The optional user id to clear the tokens for; if not provided, the active user id is used. + * @returns A promise that resolves when the tokens have been cleared. + */ + clearTokens: (userId?: UserId) => Promise; + + /** + * Sets the access token in memory or disk based on the given vaultTimeoutAction and vaultTimeout + * and the user id read off the access token + * Note: for platforms that support secure storage, the access & refresh tokens are stored in secure storage instead of on disk. + * @param accessToken The access token to set. + * @param vaultTimeoutAction The action to take when the vault times out. + * @param vaultTimeout The timeout for the vault. + * @returns A promise that resolves when the access token has been set. + */ + setAccessToken: ( + accessToken: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + ) => Promise; + + // TODO: revisit having this public clear method approach once the state service is fully deprecated. + /** + * Clears the access token for the given user id out of memory, disk, and secure storage if supported. + * @param userId The optional user id to clear the access token for; if not provided, the active user id is used. + * @returns A promise that resolves when the access token has been cleared. + * + * Note: This method is required so that the StateService doesn't have to inject the VaultTimeoutSettingsService to + * pass in the vaultTimeoutAction and vaultTimeout. + * This avoids a circular dependency between the StateService, TokenService, and VaultTimeoutSettingsService. + */ + clearAccessToken: (userId?: UserId) => Promise; + + /** + * 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. + */ + getAccessToken: (userId?: UserId) => Promise; + + /** + * 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. + */ + getRefreshToken: (userId?: UserId) => Promise; + + /** + * Sets the API Key Client ID for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. + * @param clientId The API Key Client ID to set. + * @param vaultTimeoutAction The action to take when the vault times out. + * @param vaultTimeout The timeout for the vault. + * @returns A promise that resolves when the API Key Client ID has been set. + */ + setClientId: ( + clientId: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + userId?: UserId, + ) => Promise; + + /** + * Gets the API Key Client ID for the active user. + * @returns A promise that resolves with the API Key Client ID or undefined + */ + getClientId: (userId?: UserId) => Promise; + + /** + * Sets the API Key Client Secret for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. + * @param clientSecret The API Key Client Secret to set. + * @param vaultTimeoutAction The action to take when the vault times out. + * @param vaultTimeout The timeout for the vault. + * @returns A promise that resolves when the API Key Client Secret has been set. + */ + setClientSecret: ( + clientSecret: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + userId?: UserId, + ) => Promise; + + /** + * Gets the API Key Client Secret for the active user. + * @returns A promise that resolves with the API Key Client Secret or undefined + */ + getClientSecret: (userId?: UserId) => Promise; + + /** + * Sets the two factor token for the given email in global state. + * The two factor token is set when the user checks "remember me" when completing two factor + * authentication and it is used to bypass two factor authentication for a period of time. + * @param email The email to set the two factor token for. + * @param twoFactorToken The two factor token to set. + * @returns A promise that resolves when the two factor token has been set. + */ + setTwoFactorToken: (email: string, twoFactorToken: string) => Promise; + + /** + * Gets the two factor token for the given email. + * @param email The email to get the two factor token for. + * @returns A promise that resolves with the two factor token for the given email or null if it isn't found. + */ + getTwoFactorToken: (email: string) => Promise; + + /** + * Clears the two factor token for the given email out of global state. + * @param email The email to clear the two factor token for. + * @returns A promise that resolves when the two factor token has been cleared. + */ + clearTwoFactorToken: (email: string) => Promise; + + /** + * Decodes the access token. + * @param token The access token to decode. + * @returns A promise that resolves with the decoded access token. + */ + decodeAccessToken: (token?: string) => Promise; + + /** + * Gets the expiration date for the access token. Returns if token can't be decoded or has no expiration + * @returns A promise that resolves with the expiration date for the access token. + */ + getTokenExpirationDate: () => Promise; + + /** + * Calculates the adjusted time in seconds until the access token expires, considering an optional offset. + * + * @param {number} [offsetSeconds=0] Optional seconds to subtract from the remaining time, + * creating a buffer before actual expiration. Useful for preemptive actions + * before token expiry. A value of 0 or omitting this parameter calculates time + * based on the actual expiration. + * @returns {Promise} Promise resolving to the adjusted seconds remaining. + */ tokenSecondsRemaining: (offsetSeconds?: number) => Promise; + + /** + * Checks if the access token needs to be refreshed. + * @param {number} [minutes=5] - Optional number of minutes before the access token expires to consider refreshing it. + * @returns A promise that resolves with a boolean indicating if the access token needs to be refreshed. + */ tokenNeedsRefresh: (minutes?: number) => Promise; - getUserId: () => Promise; + + /** + * Gets the user id for the active user from the access token. + * @returns A promise that resolves with the user id for the active user. + * @deprecated Use AccountService.activeAccount$ instead. + */ + getUserId: () => Promise; + + /** + * Gets the email for the active user from the access token. + * @returns A promise that resolves with the email for the active user. + * @deprecated Use AccountService.activeAccount$ instead. + */ getEmail: () => Promise; + + /** + * Gets the email verified status for the active user from the access token. + * @returns A promise that resolves with the email verified status for the active user. + */ getEmailVerified: () => Promise; + + /** + * Gets the name for the active user from the access token. + * @returns A promise that resolves with the name for the active user. + * @deprecated Use AccountService.activeAccount$ instead. + */ getName: () => Promise; + + /** + * Gets the issuer for the active user from the access token. + * @returns A promise that resolves with the issuer for the active user. + */ getIssuer: () => Promise; + + /** + * Gets whether or not the user authenticated via an external mechanism. + * @returns A promise that resolves with a boolean representing the user's external authN status. + */ getIsExternal: () => Promise; } diff --git a/libs/common/src/auth/services/sso-login.service.ts b/libs/common/src/auth/services/sso-login.service.ts index e693de44fc..99640e1c6c 100644 --- a/libs/common/src/auth/services/sso-login.service.ts +++ b/libs/common/src/auth/services/sso-login.service.ts @@ -7,6 +7,7 @@ import { SSO_DISK, StateProvider, } from "../../platform/state"; +import { SsoLoginServiceAbstraction } from "../abstractions/sso-login.service.abstraction"; /** * Uses disk storage so that the code verifier can be persisted across sso redirects. @@ -33,16 +34,25 @@ const ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition( }, ); -export class SsoLoginService { +/** + * Uses disk storage so that the user's email can be persisted across sso redirects. + */ +const SSO_EMAIL = new KeyDefinition(SSO_DISK, "ssoEmail", { + deserializer: (state) => state, +}); + +export class SsoLoginService implements SsoLoginServiceAbstraction { private codeVerifierState: GlobalState; private ssoState: GlobalState; private orgSsoIdentifierState: GlobalState; + private ssoEmailState: GlobalState; private activeUserOrgSsoIdentifierState: ActiveUserState; constructor(private stateProvider: StateProvider) { this.codeVerifierState = this.stateProvider.getGlobal(CODE_VERIFIER); this.ssoState = this.stateProvider.getGlobal(SSO_STATE); this.orgSsoIdentifierState = this.stateProvider.getGlobal(ORGANIZATION_SSO_IDENTIFIER); + this.ssoEmailState = this.stateProvider.getGlobal(SSO_EMAIL); this.activeUserOrgSsoIdentifierState = this.stateProvider.getActive( ORGANIZATION_SSO_IDENTIFIER, ); @@ -72,6 +82,14 @@ export class SsoLoginService { await this.orgSsoIdentifierState.update((_) => organizationIdentifier); } + getSsoEmail(): Promise { + return firstValueFrom(this.ssoEmailState.state$); + } + + async setSsoEmail(email: string): Promise { + await this.ssoEmailState.update((_) => email); + } + getActiveUserOrganizationSsoIdentifier(): Promise { return firstValueFrom(this.activeUserOrgSsoIdentifierState.state$); } diff --git a/libs/common/src/auth/services/token.service.spec.ts b/libs/common/src/auth/services/token.service.spec.ts new file mode 100644 index 0000000000..a7b953f928 --- /dev/null +++ b/libs/common/src/auth/services/token.service.spec.ts @@ -0,0 +1,2237 @@ +import { mock } from "jest-mock-extended"; + +import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec"; +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { AbstractStorageService } from "../../platform/abstractions/storage.service"; +import { StorageLocation } from "../../platform/enums"; +import { StorageOptions } from "../../platform/models/domain/storage-options"; +import { UserId } from "../../types/guid"; + +import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service"; +import { DecodedAccessToken, TokenService } from "./token.service"; +import { + ACCESS_TOKEN_DISK, + ACCESS_TOKEN_MEMORY, + ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, + API_KEY_CLIENT_ID_DISK, + API_KEY_CLIENT_ID_MEMORY, + API_KEY_CLIENT_SECRET_DISK, + API_KEY_CLIENT_SECRET_MEMORY, + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + REFRESH_TOKEN_DISK, + REFRESH_TOKEN_MEMORY, + REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, +} from "./token.state"; + +describe("TokenService", () => { + let tokenService: TokenService; + let singleUserStateProvider: FakeSingleUserStateProvider; + let globalStateProvider: FakeGlobalStateProvider; + + const secureStorageService = mock(); + + const memoryVaultTimeoutAction = VaultTimeoutAction.LogOut; + const memoryVaultTimeout = 30; + + const diskVaultTimeoutAction = VaultTimeoutAction.Lock; + const diskVaultTimeout: number = null; + + const accessTokenJwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0IiwibmJmIjoxNzA5MzI0MTExLCJpYXQiOjE3MDkzMjQxMTEsImV4cCI6MTcwOTMyNzcxMSwic2NvcGUiOlsiYXBpIiwib2ZmbGluZV9hY2Nlc3MiXSwiYW1yIjpbIkFwcGxpY2F0aW9uIl0sImNsaWVudF9pZCI6IndlYiIsInN1YiI6ImVjZTcwYTEzLTcyMTYtNDNjNC05OTc3LWIxMDMwMTQ2ZTFlNyIsImF1dGhfdGltZSI6MTcwOTMyNDEwNCwiaWRwIjoiYml0d2FyZGVuIiwicHJlbWl1bSI6ZmFsc2UsImVtYWlsIjoiZXhhbXBsZUBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJzc3RhbXAiOiJHWTdKQU82NENLS1RLQkI2WkVBVVlMMldPUVU3QVNUMiIsIm5hbWUiOiJUZXN0IFVzZXIiLCJvcmdvd25lciI6WyI5MmI0OTkwOC1iNTE0LTQ1YTgtYmFkYi1iMTAzMDE0OGZlNTMiLCIzOGVkZTMyMi1iNGI0LTRiZDgtOWUwOS1iMTA3MDExMmRjMTEiLCJiMmQwNzAyOC1hNTgzLTRjM2UtOGQ2MC1iMTA3MDExOThjMjkiLCJiZjkzNGJhMi0wZmQ0LTQ5ZjItYTk1ZS1iMTA3MDExZmM5ZTYiLCJjMGI3Zjc1ZC0wMTVmLTQyYzktYjNhNi1iMTA4MDE3NjA3Y2EiXSwiZGV2aWNlIjoiNGI4NzIzNjctMGRhNi00MWEwLWFkY2ItNzdmMmZlZWZjNGY0IiwianRpIjoiNzUxNjFCRTQxMzFGRjVBMkRFNTExQjhDNEUyRkY4OUEifQ.n7roP8sSbfwcYdvRxZNZds27IK32TW6anorE6BORx_Q"; + + const accessTokenDecoded: DecodedAccessToken = { + iss: "http://localhost", + nbf: 1709324111, + iat: 1709324111, + exp: 1709327711, + scope: ["api", "offline_access"], + amr: ["Application"], + client_id: "web", + sub: "ece70a13-7216-43c4-9977-b1030146e1e7", // user id + auth_time: 1709324104, + idp: "bitwarden", + premium: false, + email: "example@bitwarden.com", + email_verified: false, + sstamp: "GY7JAO64CKKTKBB6ZEAUYL2WOQU7AST2", + name: "Test User", + orgowner: [ + "92b49908-b514-45a8-badb-b1030148fe53", + "38ede322-b4b4-4bd8-9e09-b1070112dc11", + "b2d07028-a583-4c3e-8d60-b10701198c29", + "bf934ba2-0fd4-49f2-a95e-b107011fc9e6", + "c0b7f75d-015f-42c9-b3a6-b108017607ca", + ], + device: "4b872367-0da6-41a0-adcb-77f2feefc4f4", + jti: "75161BE4131FF5A2DE511B8C4E2FF89A", + }; + + const userIdFromAccessToken: UserId = accessTokenDecoded.sub as UserId; + + const secureStorageOptions: StorageOptions = { + storageLocation: StorageLocation.Disk, + useSecureStorage: true, + userId: userIdFromAccessToken, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + singleUserStateProvider = new FakeSingleUserStateProvider(); + globalStateProvider = new FakeGlobalStateProvider(); + + const supportsSecureStorage = false; // default to false; tests will override as needed + tokenService = createTokenService(supportsSecureStorage); + }); + + it("instantiates", () => { + expect(tokenService).not.toBeFalsy(); + }); + + describe("Access Token methods", () => { + const accessTokenPartialSecureStorageKey = `_accessToken`; + const accessTokenSecureStorageKey = `${userIdFromAccessToken}${accessTokenPartialSecureStorageKey}`; + + describe("setAccessToken", () => { + it("should throw an error if the access token is null", async () => { + // Act + const result = tokenService.setAccessToken(null, VaultTimeoutAction.Lock, null); + // Assert + await expect(result).rejects.toThrow("Access token is required."); + }); + + it("should throw an error if an invalid token is passed in", async () => { + // Act + const result = tokenService.setAccessToken("invalidToken", VaultTimeoutAction.Lock, null); + // Assert + await expect(result).rejects.toThrow("JWT must have 3 parts"); + }); + + it("should not throw an error as long as the token is valid", async () => { + // Act + const result = tokenService.setAccessToken(accessTokenJwt, VaultTimeoutAction.Lock, null); + // Assert + await expect(result).resolves.not.toThrow(); + }); + + describe("Memory storage tests", () => { + it("should set the access token in memory", async () => { + // Act + await tokenService.setAccessToken( + accessTokenJwt, + memoryVaultTimeoutAction, + memoryVaultTimeout, + ); + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(accessTokenJwt); + }); + }); + + describe("Disk storage tests (secure storage not supported on platform)", () => { + it("should set the access token in disk", async () => { + // Act + await tokenService.setAccessToken( + accessTokenJwt, + diskVaultTimeoutAction, + diskVaultTimeout, + ); + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(accessTokenJwt); + }); + }); + + describe("Disk storage tests (secure storage supported on platform)", () => { + beforeEach(() => { + const supportsSecureStorage = true; + tokenService = createTokenService(supportsSecureStorage); + }); + + it("should set the access token in secure storage, null out data on disk or in memory, and set a flag to indicate the token has been migrated", async () => { + // Arrange: + + // For testing purposes, let's assume that the access token is already in disk and memory + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Act + await tokenService.setAccessToken( + accessTokenJwt, + diskVaultTimeoutAction, + diskVaultTimeout, + ); + // Assert + + // assert that the access token was set in secure storage + expect(secureStorageService.save).toHaveBeenCalledWith( + accessTokenSecureStorageKey, + accessTokenJwt, + secureStorageOptions, + ); + + // assert data was migrated out of disk and memory + flag was set + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + + expect( + singleUserStateProvider.getFake( + userIdFromAccessToken, + ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, + ).nextMock, + ).toHaveBeenCalledWith(true); + }); + }); + }); + + describe("getAccessToken", () => { + it("should return undefined if no user id is provided and there is no active user in global state", async () => { + // Act + const result = await tokenService.getAccessToken(); + // Assert + expect(result).toBeUndefined(); + }); + + it("should return null if no access token is found in memory, disk, or secure storage", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getAccessToken(); + // Assert + expect(result).toBeNull(); + }); + + describe("Memory storage tests", () => { + it("should get the access token from memory with no user id specified (uses global active user)", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // set disk to undefined + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getAccessToken(); + + // Assert + expect(result).toEqual(accessTokenJwt); + }); + + it("should get the access token from memory for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // set disk to undefined + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Act + const result = await tokenService.getAccessToken(userIdFromAccessToken); + // Assert + expect(result).toEqual(accessTokenJwt); + }); + }); + + describe("Disk storage tests (secure storage not supported on platform)", () => { + it("should get the access token from disk with no user id specified", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getAccessToken(); + // Assert + expect(result).toEqual(accessTokenJwt); + }); + + it("should get the access token from disk for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Act + const result = await tokenService.getAccessToken(userIdFromAccessToken); + // Assert + expect(result).toEqual(accessTokenJwt); + }); + }); + + describe("Disk storage tests (secure storage supported on platform)", () => { + beforeEach(() => { + const supportsSecureStorage = true; + tokenService = createTokenService(supportsSecureStorage); + }); + + it("should get the access token from secure storage when no user id is specified and the migration flag is set to true", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + secureStorageService.get.mockResolvedValue(accessTokenJwt); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // set access token migration flag to true + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, true]); + + // Act + const result = await tokenService.getAccessToken(); + // Assert + expect(result).toEqual(accessTokenJwt); + }); + + it("should get the access token from secure storage when user id is specified and the migration flag set to true", async () => { + // Arrange + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + secureStorageService.get.mockResolvedValue(accessTokenJwt); + + // set access token migration flag to true + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, true]); + + // Act + const result = await tokenService.getAccessToken(userIdFromAccessToken); + // Assert + expect(result).toEqual(accessTokenJwt); + }); + + it("should fallback and get the access token from disk when user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // set access token migration flag to false + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, false]); + + // Act + const result = await tokenService.getAccessToken(userIdFromAccessToken); + + // Assert + expect(result).toEqual(accessTokenJwt); + + // assert that secure storage was not called + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); + + it("should fallback and get the access token from disk when no user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // set access token migration flag to false + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, false]); + + // Act + const result = await tokenService.getAccessToken(); + + // Assert + expect(result).toEqual(accessTokenJwt); + + // assert that secure storage was not called + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); + }); + }); + + describe("clearAccessToken", () => { + it("should throw an error if no user id is provided and there is no active user in global state", async () => { + // Act + // note: don't await here because we want to test the error + const result = tokenService.clearAccessToken(); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot clear access token."); + }); + + describe("Secure storage enabled", () => { + beforeEach(() => { + const supportsSecureStorage = true; + tokenService = createTokenService(supportsSecureStorage); + }); + + it("should clear the access token from all storage locations for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Act + await tokenService.clearAccessToken(userIdFromAccessToken); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(null); + + expect(secureStorageService.remove).toHaveBeenCalledWith( + accessTokenSecureStorageKey, + secureStorageOptions, + ); + }); + + it("should clear the access token from all storage locations for the global active user", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await tokenService.clearAccessToken(); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(null); + + expect(secureStorageService.remove).toHaveBeenCalledWith( + accessTokenSecureStorageKey, + secureStorageOptions, + ); + }); + }); + }); + + describe("decodeAccessToken", () => { + it("should throw an error if no access token provided or retrieved from state", async () => { + // Access + tokenService.getAccessToken = jest.fn().mockResolvedValue(null); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.decodeAccessToken(); + // Assert + await expect(result).rejects.toThrow("Access token not found."); + }); + + it("should decode the access token", async () => { + // Arrange + tokenService.getAccessToken = jest.fn().mockResolvedValue(accessTokenJwt); + + // Act + const result = await tokenService.decodeAccessToken(); + + // Assert + expect(result).toEqual(accessTokenDecoded); + }); + }); + + describe("Data methods", () => { + describe("getTokenExpirationDate", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getTokenExpirationDate(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should return null if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + const result = await tokenService.getTokenExpirationDate(); + + // Assert + expect(result).toBeNull(); + }); + + it("should return null if the decoded access token does not have an expiration date", async () => { + // Arrange + const accessTokenDecodedWithoutExp = { ...accessTokenDecoded }; + delete accessTokenDecodedWithoutExp.exp; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithoutExp); + + // Act + const result = await tokenService.getTokenExpirationDate(); + + // Assert + expect(result).toBeNull(); + }); + + it("should return null if the decoded access token has an non numeric expiration date", async () => { + // Arrange + const accessTokenDecodedWithNonNumericExp = { ...accessTokenDecoded, exp: "non-numeric" }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonNumericExp); + + // Act + const result = await tokenService.getTokenExpirationDate(); + + // Assert + expect(result).toBeNull(); + }); + + it("should return the expiration date of the access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await tokenService.getTokenExpirationDate(); + + // Assert + expect(result).toEqual(new Date(accessTokenDecoded.exp * 1000)); + }); + }); + + describe("tokenSecondsRemaining", () => { + it("should return 0 if the tokenExpirationDate is null", async () => { + // Arrange + tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(null); + + // Act + const result = await tokenService.tokenSecondsRemaining(); + + // Assert + expect(result).toEqual(0); + }); + + it("should return the number of seconds remaining until the token expires", async () => { + // Arrange + // Lock the time to ensure a consistent test environment + // otherwise we have flaky issues with set system time date and the Date.now() call. + const fixedCurrentTime = new Date("2024-03-06T00:00:00Z"); + jest.useFakeTimers().setSystemTime(fixedCurrentTime); + + const nowInSeconds = Math.floor(Date.now() / 1000); + const expirationInSeconds = nowInSeconds + 3600; // token expires in 1 hr + const expectedSecondsRemaining = expirationInSeconds - nowInSeconds; + + const expirationDate = new Date(0); + expirationDate.setUTCSeconds(expirationInSeconds); + tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate); + + // Act + const result = await tokenService.tokenSecondsRemaining(); + + // Assert + expect(result).toEqual(expectedSecondsRemaining); + + // Reset the timers to be the real ones + jest.useRealTimers(); + }); + + it("should return the number of seconds remaining until the token expires, considering an offset", async () => { + // Arrange + // Lock the time to ensure a consistent test environment + // otherwise we have flaky issues with set system time date and the Date.now() call. + const fixedCurrentTime = new Date("2024-03-06T00:00:00Z"); + jest.useFakeTimers().setSystemTime(fixedCurrentTime); + + const nowInSeconds = Math.floor(Date.now() / 1000); + const offsetSeconds = 300; // 5 minute offset + const expirationInSeconds = nowInSeconds + 3600; // token expires in 1 hr + const expectedSecondsRemaining = expirationInSeconds - nowInSeconds - offsetSeconds; // Adjust for offset + + const expirationDate = new Date(0); + expirationDate.setUTCSeconds(expirationInSeconds); + tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate); + + // Act + const result = await tokenService.tokenSecondsRemaining(offsetSeconds); + + // Assert + expect(result).toEqual(expectedSecondsRemaining); + + // Reset the timers to be the real ones + jest.useRealTimers(); + }); + }); + + describe("tokenNeedsRefresh", () => { + it("should return true if token is within the default refresh threshold (5 min)", async () => { + // Arrange + const tokenSecondsRemaining = 60; + tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); + + // Act + const result = await tokenService.tokenNeedsRefresh(); + + // Assert + expect(result).toEqual(true); + }); + + it("should return false if token is outside the default refresh threshold (5 min)", async () => { + // Arrange + const tokenSecondsRemaining = 600; + tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); + + // Act + const result = await tokenService.tokenNeedsRefresh(); + + // Assert + expect(result).toEqual(false); + }); + + it("should return true if token is within the specified refresh threshold", async () => { + // Arrange + const tokenSecondsRemaining = 60; + tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); + + // Act + const result = await tokenService.tokenNeedsRefresh(2); + + // Assert + expect(result).toEqual(true); + }); + + it("should return false if token is outside the specified refresh threshold", async () => { + // Arrange + const tokenSecondsRemaining = 600; + tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); + + // Act + const result = await tokenService.tokenNeedsRefresh(5); + + // Assert + expect(result).toEqual(false); + }); + }); + + describe("getUserId", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getUserId(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should throw an error if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getUserId(); + // Assert + await expect(result).rejects.toThrow("No user id found"); + }); + + it("should throw an error if the decoded access token has a non-string user id", async () => { + // Arrange + const accessTokenDecodedWithNonStringSub = { ...accessTokenDecoded, sub: 123 }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonStringSub); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getUserId(); + // Assert + await expect(result).rejects.toThrow("No user id found"); + }); + + it("should return the user id from the decoded access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await tokenService.getUserId(); + + // Assert + expect(result).toEqual(userIdFromAccessToken); + }); + }); + + describe("getUserIdFromAccessToken", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).getUserIdFromAccessToken(accessTokenJwt); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should throw an error if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).getUserIdFromAccessToken(accessTokenJwt); + // Assert + await expect(result).rejects.toThrow("No user id found"); + }); + + it("should throw an error if the decoded access token has a non-string user id", async () => { + // Arrange + const accessTokenDecodedWithNonStringSub = { ...accessTokenDecoded, sub: 123 }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonStringSub); + + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).getUserIdFromAccessToken(accessTokenJwt); + // Assert + await expect(result).rejects.toThrow("No user id found"); + }); + + it("should return the user id from the decoded access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await (tokenService as any).getUserIdFromAccessToken(accessTokenJwt); + + // Assert + expect(result).toEqual(userIdFromAccessToken); + }); + }); + + describe("getEmail", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getEmail(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should throw an error if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getEmail(); + // Assert + await expect(result).rejects.toThrow("No email found"); + }); + + it("should throw an error if the decoded access token has a non-string email", async () => { + // Arrange + const accessTokenDecodedWithNonStringEmail = { ...accessTokenDecoded, email: 123 }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonStringEmail); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getEmail(); + // Assert + await expect(result).rejects.toThrow("No email found"); + }); + + it("should return the email from the decoded access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await tokenService.getEmail(); + + // Assert + expect(result).toEqual(accessTokenDecoded.email); + }); + }); + + describe("getEmailVerified", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getEmailVerified(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should throw an error if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getEmailVerified(); + // Assert + await expect(result).rejects.toThrow("No email verification found"); + }); + + it("should throw an error if the decoded access token has a non-boolean email_verified", async () => { + // Arrange + const accessTokenDecodedWithNonBooleanEmailVerified = { + ...accessTokenDecoded, + email_verified: 123, + }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonBooleanEmailVerified); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getEmailVerified(); + // Assert + await expect(result).rejects.toThrow("No email verification found"); + }); + + it("should return the email_verified from the decoded access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await tokenService.getEmailVerified(); + + // Assert + expect(result).toEqual(accessTokenDecoded.email_verified); + }); + }); + + describe("getName", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getName(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should return null if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + const result = await tokenService.getName(); + + // Assert + expect(result).toBeNull(); + }); + + it("should return null if the decoded access token has a non-string name", async () => { + // Arrange + const accessTokenDecodedWithNonStringName = { ...accessTokenDecoded, name: 123 }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonStringName); + + // Act + const result = await tokenService.getName(); + + // Assert + expect(result).toBeNull(); + }); + + it("should return the name from the decoded access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await tokenService.getName(); + + // Assert + expect(result).toEqual(accessTokenDecoded.name); + }); + }); + + describe("getIssuer", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getIssuer(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should throw an error if the decoded access token is null", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getIssuer(); + // Assert + await expect(result).rejects.toThrow("No issuer found"); + }); + + it("should throw an error if the decoded access token has a non-string iss", async () => { + // Arrange + const accessTokenDecodedWithNonStringIss = { ...accessTokenDecoded, iss: 123 }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithNonStringIss); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getIssuer(); + // Assert + await expect(result).rejects.toThrow("No issuer found"); + }); + + it("should return the issuer from the decoded access token", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); + + // Act + const result = await tokenService.getIssuer(); + + // Assert + expect(result).toEqual(accessTokenDecoded.iss); + }); + }); + + describe("getIsExternal", () => { + it("should throw an error if the access token cannot be decoded", async () => { + // Arrange + tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); + + // Act + // note: don't await here because we want to test the error + const result = tokenService.getIsExternal(); + // Assert + await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); + }); + + it("should return false if the amr (Authentication Method Reference) claim does not contain 'external'", async () => { + // Arrange + const accessTokenDecodedWithoutExternalAmr = { + ...accessTokenDecoded, + amr: ["not-external"], + }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithoutExternalAmr); + + // Act + const result = await tokenService.getIsExternal(); + + // Assert + expect(result).toEqual(false); + }); + + it("should return true if the amr (Authentication Method Reference) claim contains 'external'", async () => { + // Arrange + const accessTokenDecodedWithExternalAmr = { + ...accessTokenDecoded, + amr: ["external"], + }; + tokenService.decodeAccessToken = jest + .fn() + .mockResolvedValue(accessTokenDecodedWithExternalAmr); + + // Act + const result = await tokenService.getIsExternal(); + + // Assert + expect(result).toEqual(true); + }); + }); + }); + }); + + describe("Refresh Token methods", () => { + const refreshToken = "refreshToken"; + const refreshTokenPartialSecureStorageKey = `_refreshToken`; + const refreshTokenSecureStorageKey = `${userIdFromAccessToken}${refreshTokenPartialSecureStorageKey}`; + + describe("setRefreshToken", () => { + it("should throw an error if no user id is provided", async () => { + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).setRefreshToken( + refreshToken, + VaultTimeoutAction.Lock, + null, + ); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot save refresh token."); + }); + + describe("Memory storage tests", () => { + it("should set the refresh token in memory for the specified user id", async () => { + // Act + await (tokenService as any).setRefreshToken( + refreshToken, + memoryVaultTimeoutAction, + memoryVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(refreshToken); + }); + }); + + describe("Disk storage tests (secure storage not supported on platform)", () => { + it("should set the refresh token in disk for the specified user id", async () => { + // Act + await (tokenService as any).setRefreshToken( + refreshToken, + diskVaultTimeoutAction, + diskVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(refreshToken); + }); + }); + + describe("Disk storage tests (secure storage supported on platform)", () => { + beforeEach(() => { + const supportsSecureStorage = true; + tokenService = createTokenService(supportsSecureStorage); + }); + + it("should set the refresh token in secure storage, null out data on disk or in memory, and set a flag to indicate the token has been migrated for the specified user id", async () => { + // Arrange: + // For testing purposes, let's assume that the token is already in disk and memory + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + // Act + await (tokenService as any).setRefreshToken( + refreshToken, + diskVaultTimeoutAction, + diskVaultTimeout, + userIdFromAccessToken, + ); + // Assert + + // assert that the refresh token was set in secure storage + expect(secureStorageService.save).toHaveBeenCalledWith( + refreshTokenSecureStorageKey, + refreshToken, + secureStorageOptions, + ); + + // assert data was migrated out of disk and memory + flag was set + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + + expect( + singleUserStateProvider.getFake( + userIdFromAccessToken, + REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, + ).nextMock, + ).toHaveBeenCalledWith(true); + }); + }); + }); + + describe("getRefreshToken", () => { + it("should return undefined if no user id is provided and there is no active user in global state", async () => { + // Act + const result = await (tokenService as any).getRefreshToken(); + // Assert + expect(result).toBeUndefined(); + }); + + it("should return null if no refresh token is found in memory, disk, or secure storage", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await (tokenService as any).getRefreshToken(); + // Assert + expect(result).toBeNull(); + }); + + describe("Memory storage tests", () => { + it("should get the refresh token from memory with no user id specified (uses global active user)", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getRefreshToken(); + + // Assert + expect(result).toEqual(refreshToken); + }); + + it("should get the refresh token from memory for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Act + const result = await tokenService.getRefreshToken(userIdFromAccessToken); + // Assert + expect(result).toEqual(refreshToken); + }); + }); + + describe("Disk storage tests (secure storage not supported on platform)", () => { + it("should get the refresh token from disk with no user id specified", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getRefreshToken(); + // Assert + expect(result).toEqual(refreshToken); + }); + + it("should get the refresh token from disk for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + // Act + const result = await tokenService.getRefreshToken(userIdFromAccessToken); + // Assert + expect(result).toEqual(refreshToken); + }); + }); + + describe("Disk storage tests (secure storage supported on platform)", () => { + beforeEach(() => { + const supportsSecureStorage = true; + tokenService = createTokenService(supportsSecureStorage); + }); + + it("should get the refresh token from secure storage when no user id is specified and the migration flag is set to true", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + secureStorageService.get.mockResolvedValue(refreshToken); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // set access token migration flag to true + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, true]); + + // Act + const result = await tokenService.getRefreshToken(); + // Assert + expect(result).toEqual(refreshToken); + }); + + it("should get the refresh token from secure storage when user id is specified and the migration flag set to true", async () => { + // Arrange + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + secureStorageService.get.mockResolvedValue(refreshToken); + + // set access token migration flag to true + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, true]); + + // Act + const result = await tokenService.getRefreshToken(userIdFromAccessToken); + // Assert + expect(result).toEqual(refreshToken); + }); + + it("should fallback and get the refresh token from disk when user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + // set refresh token migration flag to false + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, false]); + + // Act + const result = await tokenService.getRefreshToken(userIdFromAccessToken); + + // Assert + expect(result).toEqual(refreshToken); + + // assert that secure storage was not called + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); + + it("should fallback and get the refresh token from disk when no user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // set access token migration flag to false + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .stateSubject.next([userIdFromAccessToken, false]); + + // Act + const result = await tokenService.getRefreshToken(); + + // Assert + expect(result).toEqual(refreshToken); + + // assert that secure storage was not called + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); + }); + }); + + describe("clearRefreshToken", () => { + it("should throw an error if no user id is provided", async () => { + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).clearRefreshToken(); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot clear refresh token."); + }); + + describe("Secure storage enabled", () => { + beforeEach(() => { + const supportsSecureStorage = true; + tokenService = createTokenService(supportsSecureStorage); + }); + + it("should clear the refresh token from all storage locations for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, refreshToken]); + + // Act + await (tokenService as any).clearRefreshToken(userIdFromAccessToken); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(null); + + expect(secureStorageService.remove).toHaveBeenCalledWith( + refreshTokenSecureStorageKey, + secureStorageOptions, + ); + }); + }); + }); + }); + + describe("Client Id methods", () => { + const clientId = "clientId"; + + describe("setClientId", () => { + it("should throw an error if no user id is provided and there is no active user in global state", async () => { + // Act + // note: don't await here because we want to test the error + const result = tokenService.setClientId(clientId, VaultTimeoutAction.Lock, null); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot save client id."); + }); + + describe("Memory storage tests", () => { + it("should set the client id in memory when there is an active user in global state", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await tokenService.setClientId(clientId, memoryVaultTimeoutAction, memoryVaultTimeout); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .nextMock, + ).toHaveBeenCalledWith(clientId); + }); + + it("should set the client id in memory for the specified user id", async () => { + // Act + await tokenService.setClientId( + clientId, + memoryVaultTimeoutAction, + memoryVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .nextMock, + ).toHaveBeenCalledWith(clientId); + }); + }); + + describe("Disk storage tests", () => { + it("should set the client id in disk when there is an active user in global state", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await tokenService.setClientId(clientId, diskVaultTimeoutAction, diskVaultTimeout); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK).nextMock, + ).toHaveBeenCalledWith(clientId); + }); + + it("should set the client id in disk for the specified user id", async () => { + // Act + await tokenService.setClientId( + clientId, + diskVaultTimeoutAction, + diskVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK).nextMock, + ).toHaveBeenCalledWith(clientId); + }); + }); + }); + + describe("getClientId", () => { + it("should return undefined if no user id is provided and there is no active user in global state", async () => { + // Act + const result = await tokenService.getClientId(); + // Assert + expect(result).toBeUndefined(); + }); + + it("should return null if no client id is found in memory or disk", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getClientId(); + // Assert + expect(result).toBeNull(); + }); + + describe("Memory storage tests", () => { + it("should get the client id from memory with no user id specified (uses global active user)", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientId]); + + // set disk to undefined + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getClientId(); + + // Assert + expect(result).toEqual(clientId); + }); + + it("should get the client id from memory for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientId]); + + // set disk to undefined + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Act + const result = await tokenService.getClientId(userIdFromAccessToken); + // Assert + expect(result).toEqual(clientId); + }); + }); + + describe("Disk storage tests", () => { + it("should get the client id from disk with no user id specified", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) + .stateSubject.next([userIdFromAccessToken, clientId]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getClientId(); + // Assert + expect(result).toEqual(clientId); + }); + + it("should get the client id from disk for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) + .stateSubject.next([userIdFromAccessToken, clientId]); + + // Act + const result = await tokenService.getClientId(userIdFromAccessToken); + // Assert + expect(result).toEqual(clientId); + }); + }); + }); + + describe("clearClientId", () => { + it("should throw an error if no user id is provided and there is no active user in global state", async () => { + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).clearClientId(); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot clear client id."); + }); + + it("should clear the client id from memory and disk for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientId]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) + .stateSubject.next([userIdFromAccessToken, clientId]); + + // Act + await (tokenService as any).clearClientId(userIdFromAccessToken); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK).nextMock, + ).toHaveBeenCalledWith(null); + }); + + it("should clear the client id from memory and disk for the global active user", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientId]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) + .stateSubject.next([userIdFromAccessToken, clientId]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await (tokenService as any).clearClientId(); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK).nextMock, + ).toHaveBeenCalledWith(null); + }); + }); + }); + + describe("Client Secret methods", () => { + const clientSecret = "clientSecret"; + + describe("setClientSecret", () => { + it("should throw an error if no user id is provided and there is no active user in global state", async () => { + // Act + // note: don't await here because we want to test the error + const result = tokenService.setClientSecret(clientSecret, VaultTimeoutAction.Lock, null); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot save client secret."); + }); + + describe("Memory storage tests", () => { + it("should set the client secret in memory when there is an active user in global state", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await tokenService.setClientSecret( + clientSecret, + memoryVaultTimeoutAction, + memoryVaultTimeout, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .nextMock, + ).toHaveBeenCalledWith(clientSecret); + }); + + it("should set the client secret in memory for the specified user id", async () => { + // Act + await tokenService.setClientSecret( + clientSecret, + memoryVaultTimeoutAction, + memoryVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .nextMock, + ).toHaveBeenCalledWith(clientSecret); + }); + }); + + describe("Disk storage tests", () => { + it("should set the client secret in disk when there is an active user in global state", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await tokenService.setClientSecret( + clientSecret, + diskVaultTimeoutAction, + diskVaultTimeout, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .nextMock, + ).toHaveBeenCalledWith(clientSecret); + }); + + it("should set the client secret in disk for the specified user id", async () => { + // Act + await tokenService.setClientSecret( + clientSecret, + diskVaultTimeoutAction, + diskVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .nextMock, + ).toHaveBeenCalledWith(clientSecret); + }); + }); + }); + + describe("getClientSecret", () => { + it("should return undefined if no user id is provided and there is no active user in global state", async () => { + // Act + const result = await tokenService.getClientSecret(); + // Assert + expect(result).toBeUndefined(); + }); + + it("should return null if no client secret is found in memory or disk", async () => { + // Arrange + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getClientSecret(); + // Assert + expect(result).toBeNull(); + }); + + describe("Memory storage tests", () => { + it("should get the client secret from memory with no user id specified (uses global active user)", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + // set disk to undefined + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getClientSecret(); + + // Assert + expect(result).toEqual(clientSecret); + }); + + it("should get the client secret from memory for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + // set disk to undefined + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + // Act + const result = await tokenService.getClientSecret(userIdFromAccessToken); + // Assert + expect(result).toEqual(clientSecret); + }); + }); + + describe("Disk storage tests", () => { + it("should get the client secret from disk with no user id specified", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + const result = await tokenService.getClientSecret(); + // Assert + expect(result).toEqual(clientSecret); + }); + + it("should get the client secret from disk for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + // Act + const result = await tokenService.getClientSecret(userIdFromAccessToken); + // Assert + expect(result).toEqual(clientSecret); + }); + }); + }); + + describe("clearClientSecret", () => { + it("should throw an error if no user id is provided and there is no active user in global state", async () => { + // Act + // note: don't await here because we want to test the error + const result = (tokenService as any).clearClientSecret(); + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot clear client secret."); + }); + + it("should clear the client secret from memory and disk for the specified user id", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + // Act + await (tokenService as any).clearClientSecret(userIdFromAccessToken); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .nextMock, + ).toHaveBeenCalledWith(null); + }); + + it("should clear the client secret from memory and disk for the global active user", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .stateSubject.next([userIdFromAccessToken, clientSecret]); + + // Need to have global active id set to the user id + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + + // Act + await (tokenService as any).clearClientSecret(); + + // Assert + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) + .nextMock, + ).toHaveBeenCalledWith(null); + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) + .nextMock, + ).toHaveBeenCalledWith(null); + }); + }); + }); + + describe("setTokens", () => { + it("should call to set all passed in tokens after deriving user id from the access token", async () => { + // Arrange + const refreshToken = "refreshToken"; + // specific vault timeout actions and vault timeouts don't change this test so values don't matter. + const vaultTimeoutAction = VaultTimeoutAction.Lock; + const vaultTimeout = 30; + const clientId = "clientId"; + const clientSecret = "clientSecret"; + + (tokenService as any)._setAccessToken = jest.fn(); + // any hack allows for mocking private method. + (tokenService as any).setRefreshToken = jest.fn(); + tokenService.setClientId = jest.fn(); + tokenService.setClientSecret = jest.fn(); + + // Act + // Note: passing a valid access token so that a valid user id can be determined from the access token + await tokenService.setTokens(accessTokenJwt, refreshToken, vaultTimeoutAction, vaultTimeout, [ + clientId, + clientSecret, + ]); + + // Assert + expect((tokenService as any)._setAccessToken).toHaveBeenCalledWith( + accessTokenJwt, + vaultTimeoutAction, + vaultTimeout, + userIdFromAccessToken, + ); + + // any hack allows for testing private methods + expect((tokenService as any).setRefreshToken).toHaveBeenCalledWith( + refreshToken, + vaultTimeoutAction, + vaultTimeout, + userIdFromAccessToken, + ); + + expect(tokenService.setClientId).toHaveBeenCalledWith( + clientId, + vaultTimeoutAction, + vaultTimeout, + userIdFromAccessToken, + ); + expect(tokenService.setClientSecret).toHaveBeenCalledWith( + clientSecret, + vaultTimeoutAction, + vaultTimeout, + userIdFromAccessToken, + ); + }); + + it("should not try to set client id and client secret if they are not passed in", async () => { + // Arrange + const refreshToken = "refreshToken"; + const vaultTimeoutAction = VaultTimeoutAction.Lock; + const vaultTimeout = 30; + + (tokenService as any)._setAccessToken = jest.fn(); + (tokenService as any).setRefreshToken = jest.fn(); + tokenService.setClientId = jest.fn(); + tokenService.setClientSecret = jest.fn(); + + // Act + await tokenService.setTokens(accessTokenJwt, refreshToken, vaultTimeoutAction, vaultTimeout); + + // Assert + expect((tokenService as any)._setAccessToken).toHaveBeenCalledWith( + accessTokenJwt, + vaultTimeoutAction, + vaultTimeout, + userIdFromAccessToken, + ); + + // any hack allows for testing private methods + expect((tokenService as any).setRefreshToken).toHaveBeenCalledWith( + refreshToken, + vaultTimeoutAction, + vaultTimeout, + userIdFromAccessToken, + ); + + expect(tokenService.setClientId).not.toHaveBeenCalled(); + expect(tokenService.setClientSecret).not.toHaveBeenCalled(); + }); + + it("should throw an error if the access token is invalid", async () => { + // Arrange + const accessToken = "invalidToken"; + const refreshToken = "refreshToken"; + const vaultTimeoutAction = VaultTimeoutAction.Lock; + const vaultTimeout = 30; + + // Act + const result = tokenService.setTokens( + accessToken, + refreshToken, + vaultTimeoutAction, + vaultTimeout, + ); + + // Assert + await expect(result).rejects.toThrow("JWT must have 3 parts"); + }); + + it("should throw an error if the access token is missing", async () => { + // Arrange + const accessToken: string = null; + const refreshToken = "refreshToken"; + const vaultTimeoutAction = VaultTimeoutAction.Lock; + const vaultTimeout = 30; + + // Act + const result = tokenService.setTokens( + accessToken, + refreshToken, + vaultTimeoutAction, + vaultTimeout, + ); + + // Assert + await expect(result).rejects.toThrow("Access token and refresh token are required."); + }); + + it("should throw an error if the refresh token is missing", async () => { + // Arrange + const accessToken = "accessToken"; + const refreshToken: string = null; + const vaultTimeoutAction = VaultTimeoutAction.Lock; + const vaultTimeout = 30; + + // Act + const result = tokenService.setTokens( + accessToken, + refreshToken, + vaultTimeoutAction, + vaultTimeout, + ); + + // Assert + await expect(result).rejects.toThrow("Access token and refresh token are required."); + }); + }); + + describe("clearTokens", () => { + it("should call to clear all tokens for the specified user id", async () => { + // Arrange + const userId = "userId" as UserId; + + tokenService.clearAccessToken = jest.fn(); + (tokenService as any).clearRefreshToken = jest.fn(); + (tokenService as any).clearClientId = jest.fn(); + (tokenService as any).clearClientSecret = jest.fn(); + + // Act + + await tokenService.clearTokens(userId); + + // Assert + + expect(tokenService.clearAccessToken).toHaveBeenCalledWith(userId); + expect((tokenService as any).clearRefreshToken).toHaveBeenCalledWith(userId); + expect((tokenService as any).clearClientId).toHaveBeenCalledWith(userId); + expect((tokenService as any).clearClientSecret).toHaveBeenCalledWith(userId); + }); + + it("should call to clear all tokens for the active user id", async () => { + // Arrange + const userId = "userId" as UserId; + + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).stateSubject.next(userId); + + tokenService.clearAccessToken = jest.fn(); + (tokenService as any).clearRefreshToken = jest.fn(); + (tokenService as any).clearClientId = jest.fn(); + (tokenService as any).clearClientSecret = jest.fn(); + + // Act + + await tokenService.clearTokens(); + + // Assert + + expect(tokenService.clearAccessToken).toHaveBeenCalledWith(userId); + expect((tokenService as any).clearRefreshToken).toHaveBeenCalledWith(userId); + expect((tokenService as any).clearClientId).toHaveBeenCalledWith(userId); + expect((tokenService as any).clearClientSecret).toHaveBeenCalledWith(userId); + }); + + it("should not call to clear all tokens if no user id is provided and there is no active user in global state", async () => { + // Arrange + tokenService.clearAccessToken = jest.fn(); + (tokenService as any).clearRefreshToken = jest.fn(); + (tokenService as any).clearClientId = jest.fn(); + (tokenService as any).clearClientSecret = jest.fn(); + + // Act + + const result = tokenService.clearTokens(); + + // Assert + await expect(result).rejects.toThrow("User id not found. Cannot clear tokens."); + }); + }); + + describe("Two Factor Token methods", () => { + describe("setTwoFactorToken", () => { + it("should set the email and two factor token when there hasn't been a previous record (initializing the record)", async () => { + // Arrange + const email = "testUser@email.com"; + const twoFactorToken = "twoFactorTokenForTestUser"; + // Act + await tokenService.setTwoFactorToken(email, twoFactorToken); + // Assert + expect( + globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextMock, + ).toHaveBeenCalledWith({ [email]: twoFactorToken }); + }); + + it("should set the email and two factor token when there is an initialized value already (updating the existing record)", async () => { + // Arrange + const email = "testUser@email.com"; + const twoFactorToken = "twoFactorTokenForTestUser"; + const initialTwoFactorTokenRecord: Record = { + otherUser: "otherUserTwoFactorToken", + }; + + globalStateProvider + .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) + .stateSubject.next(initialTwoFactorTokenRecord); + + // Act + await tokenService.setTwoFactorToken(email, twoFactorToken); + + // Assert + expect( + globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextMock, + ).toHaveBeenCalledWith({ [email]: twoFactorToken, ...initialTwoFactorTokenRecord }); + }); + }); + + describe("getTwoFactorToken", () => { + it("should return the two factor token for the given email", async () => { + // Arrange + const email = "testUser"; + const twoFactorToken = "twoFactorTokenForTestUser"; + const initialTwoFactorTokenRecord: Record = { + [email]: twoFactorToken, + }; + + globalStateProvider + .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) + .stateSubject.next(initialTwoFactorTokenRecord); + + // Act + const result = await tokenService.getTwoFactorToken(email); + + // Assert + expect(result).toEqual(twoFactorToken); + }); + + it("should not return the two factor token for an email that doesn't exist", async () => { + // Arrange + const email = "testUser"; + const initialTwoFactorTokenRecord: Record = { + otherUser: "twoFactorTokenForOtherUser", + }; + + globalStateProvider + .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) + .stateSubject.next(initialTwoFactorTokenRecord); + + // Act + const result = await tokenService.getTwoFactorToken(email); + + // Assert + expect(result).toEqual(undefined); + }); + + it("should return null if there is no two factor token record", async () => { + // Arrange + globalStateProvider + .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) + .stateSubject.next(null); + + // Act + const result = await tokenService.getTwoFactorToken("testUser"); + + // Assert + expect(result).toEqual(null); + }); + }); + + describe("clearTwoFactorToken", () => { + it("should clear the two factor token for the given email when a record exists", async () => { + // Arrange + const email = "testUser"; + const twoFactorToken = "twoFactorTokenForTestUser"; + const initialTwoFactorTokenRecord: Record = { + [email]: twoFactorToken, + }; + + globalStateProvider + .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) + .stateSubject.next(initialTwoFactorTokenRecord); + + // Act + await tokenService.clearTwoFactorToken(email); + + // Assert + expect( + globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextMock, + ).toHaveBeenCalledWith({}); + }); + + it("should initialize the record if it doesn't exist and delete the value", async () => { + // Arrange + const email = "testUser"; + + // Act + await tokenService.clearTwoFactorToken(email); + + // Assert + expect( + globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextMock, + ).toHaveBeenCalledWith({}); + }); + }); + }); + + // Helpers + function createTokenService(supportsSecureStorage: boolean) { + return new TokenService( + singleUserStateProvider, + globalStateProvider, + supportsSecureStorage, + secureStorageService, + ); + } +}); diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index b112c7b57d..4e9722614e 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -1,125 +1,629 @@ -import { StateService } from "../../platform/abstractions/state.service"; -import { Utils } from "../../platform/misc/utils"; +import { firstValueFrom } from "rxjs"; + +import { decodeJwtTokenToJson } from "@bitwarden/auth/common"; + +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { AbstractStorageService } from "../../platform/abstractions/storage.service"; +import { StorageLocation } from "../../platform/enums"; +import { StorageOptions } from "../../platform/models/domain/storage-options"; +import { + GlobalState, + GlobalStateProvider, + KeyDefinition, + SingleUserStateProvider, +} from "../../platform/state"; +import { UserId } from "../../types/guid"; import { TokenService as TokenServiceAbstraction } from "../abstractions/token.service"; -import { IdentityTokenResponse } from "../models/response/identity-token.response"; + +import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service"; +import { + ACCESS_TOKEN_DISK, + ACCESS_TOKEN_MEMORY, + ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, + API_KEY_CLIENT_ID_DISK, + API_KEY_CLIENT_ID_MEMORY, + API_KEY_CLIENT_SECRET_DISK, + API_KEY_CLIENT_SECRET_MEMORY, + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + REFRESH_TOKEN_DISK, + REFRESH_TOKEN_MEMORY, + REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, +} from "./token.state"; + +export enum TokenStorageLocation { + Disk = "disk", + SecureStorage = "secureStorage", + Memory = "memory", +} + +/** + * Type representing the structure of a standard Bitwarden decoded access token. + * src: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 + * Note: all claims are technically optional so we must verify their existence before using them. + * Note 2: NumericDate is a number representing a date in seconds since the Unix epoch. + */ +export type DecodedAccessToken = { + /** Issuer - the issuer of the token, typically the URL of the authentication server */ + iss?: string; + + /** Not Before - a timestamp defining when the token starts being valid */ + nbf?: number; + + /** Issued At - a timestamp of when the token was issued */ + iat?: number; + + /** Expiration Time - a NumericDate timestamp of when the token will expire */ + exp?: number; + + /** Scope - the scope of the access request, such as the permissions the token grants */ + scope?: string[]; + + /** Authentication Method Reference - the methods used in the authentication */ + amr?: string[]; + + /** Client ID - the identifier for the client that requested the token */ + client_id?: string; + + /** Subject - the unique identifier for the user */ + sub?: string; + + /** Authentication Time - a timestamp of when the user authentication occurred */ + auth_time?: number; + + /** Identity Provider - the system or service that authenticated the user */ + idp?: string; + + /** Premium - a boolean flag indicating whether the account is premium */ + premium?: boolean; + + /** Email - the user's email address */ + email?: string; + + /** Email Verified - a boolean flag indicating whether the user's email address has been verified */ + email_verified?: boolean; + + /** + * Security Stamp - a unique identifier which invalidates the access token if it changes in the db + * (typically after critical account changes like a password change) + */ + sstamp?: string; + + /** Name - the name of the user */ + name?: string; + + /** Organization Owners - a list of organization owner identifiers */ + orgowner?: string[]; + + /** Device - the identifier of the device used */ + device?: string; + + /** JWT ID - a unique identifier for the JWT */ + jti?: string; +}; export class TokenService implements TokenServiceAbstraction { - static decodeToken(token: string): Promise { - if (token == null) { - throw new Error("Token not provided."); - } + private readonly accessTokenSecureStorageKey: string = "_accessToken"; - const parts = token.split("."); - if (parts.length !== 3) { - throw new Error("JWT must have 3 parts"); - } + private readonly refreshTokenSecureStorageKey: string = "_refreshToken"; - const decoded = Utils.fromUrlB64ToUtf8(parts[1]); - if (decoded == null) { - throw new Error("Cannot decode the token"); - } + private emailTwoFactorTokenRecordGlobalState: GlobalState>; - const decodedToken = JSON.parse(decoded); - return decodedToken; + private activeUserIdGlobalState: GlobalState; + + constructor( + // Note: we cannot use ActiveStateProvider because if we ever want to inject + // this service into the AccountService, we will make a circular dependency + private singleUserStateProvider: SingleUserStateProvider, + private globalStateProvider: GlobalStateProvider, + private readonly platformSupportsSecureStorage: boolean, + private secureStorageService: AbstractStorageService, + ) { + this.initializeState(); } - constructor(private stateService: StateService) {} + private initializeState(): void { + this.emailTwoFactorTokenRecordGlobalState = this.globalStateProvider.get( + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + ); + + this.activeUserIdGlobalState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID); + } async setTokens( accessToken: string, refreshToken: string, - clientIdClientSecret: [string, string], - ): Promise { - await this.setToken(accessToken); - await this.setRefreshToken(refreshToken); + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + clientIdClientSecret?: [string, string], + ): Promise { + if (!accessToken || !refreshToken) { + throw new Error("Access token and refresh token are required."); + } + + // get user id the access token + const userId: UserId = await this.getUserIdFromAccessToken(accessToken); + + if (!userId) { + throw new Error("User id not found. Cannot set tokens."); + } + + await this._setAccessToken(accessToken, vaultTimeoutAction, vaultTimeout, userId); + await this.setRefreshToken(refreshToken, vaultTimeoutAction, vaultTimeout, userId); if (clientIdClientSecret != null) { - await this.setClientId(clientIdClientSecret[0]); - await this.setClientSecret(clientIdClientSecret[1]); + await this.setClientId(clientIdClientSecret[0], vaultTimeoutAction, vaultTimeout, userId); + await this.setClientSecret(clientIdClientSecret[1], vaultTimeoutAction, vaultTimeout, userId); } } - async setClientId(clientId: string): Promise { - return await this.stateService.setApiKeyClientId(clientId); + /** + * Internal helper for set access token which always requires user id. + * This is useful because setTokens always will have a user id from the access token whereas + * the public setAccessToken method does not. + */ + private async _setAccessToken( + accessToken: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + userId: UserId, + ): Promise { + const storageLocation = await this.determineStorageLocation( + vaultTimeoutAction, + vaultTimeout, + true, + ); + + switch (storageLocation) { + case TokenStorageLocation.SecureStorage: + await this.saveStringToSecureStorage(userId, this.accessTokenSecureStorageKey, accessToken); + + // TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408 + // 2024-02-20: Remove access token from memory and disk so that we migrate to secure storage over time. + // Remove these 2 calls to remove the access token from memory and disk after 3 releases. + + await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null); + await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null); + + // Set flag to indicate that the access token has been migrated to secure storage (don't remove this) + await this.setAccessTokenMigratedToSecureStorage(userId); + + return; + case TokenStorageLocation.Disk: + await this.singleUserStateProvider + .get(userId, ACCESS_TOKEN_DISK) + .update((_) => accessToken); + return; + case TokenStorageLocation.Memory: + await this.singleUserStateProvider + .get(userId, ACCESS_TOKEN_MEMORY) + .update((_) => accessToken); + return; + } } - async getClientId(): Promise { - return await this.stateService.getApiKeyClientId(); + async setAccessToken( + accessToken: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + ): Promise { + if (!accessToken) { + throw new Error("Access token is required."); + } + const userId: UserId = await this.getUserIdFromAccessToken(accessToken); + + // If we don't have a user id, we can't save the value + if (!userId) { + throw new Error("User id not found. Cannot save access token."); + } + + await this._setAccessToken(accessToken, vaultTimeoutAction, vaultTimeout, userId); } - async setClientSecret(clientSecret: string): Promise { - return await this.stateService.setApiKeyClientSecret(clientSecret); + async clearAccessToken(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + // If we don't have a user id, we can't clear the value + if (!userId) { + throw new Error("User id not found. Cannot clear access token."); + } + + // TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data. + // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout + // but we can simply clear all locations to avoid the need to require those parameters + + if (this.platformSupportsSecureStorage) { + await this.secureStorageService.remove( + `${userId}${this.accessTokenSecureStorageKey}`, + this.getSecureStorageOptions(userId), + ); + } + + // Platform doesn't support secure storage, so use state provider implementation + await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null); + await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null); } - async getClientSecret(): Promise { - return await this.stateService.getApiKeyClientSecret(); + async getAccessToken(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + if (!userId) { + return undefined; + } + + const accessTokenMigratedToSecureStorage = + await this.getAccessTokenMigratedToSecureStorage(userId); + if (this.platformSupportsSecureStorage && accessTokenMigratedToSecureStorage) { + return await this.getStringFromSecureStorage(userId, this.accessTokenSecureStorageKey); + } + + // Try to get the access token from memory + const accessTokenMemory = await this.getStateValueByUserIdAndKeyDef( + userId, + ACCESS_TOKEN_MEMORY, + ); + + if (accessTokenMemory != null) { + return accessTokenMemory; + } + + // If memory is null, read from disk + return await this.getStateValueByUserIdAndKeyDef(userId, ACCESS_TOKEN_DISK); } - async setToken(token: string): Promise { - await this.stateService.setAccessToken(token); + private async getAccessTokenMigratedToSecureStorage(userId: UserId): Promise { + return await firstValueFrom( + this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE).state$, + ); } - async getToken(): Promise { - return await this.stateService.getAccessToken(); + private async setAccessTokenMigratedToSecureStorage(userId: UserId): Promise { + await this.singleUserStateProvider + .get(userId, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .update((_) => true); } - async setRefreshToken(refreshToken: string): Promise { - return await this.stateService.setRefreshToken(refreshToken); + // Private because we only ever set the refresh token when also setting the access token + // and we need the user id from the access token to save to secure storage + private async setRefreshToken( + refreshToken: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + userId: UserId, + ): Promise { + // If we don't have a user id, we can't save the value + if (!userId) { + throw new Error("User id not found. Cannot save refresh token."); + } + + const storageLocation = await this.determineStorageLocation( + vaultTimeoutAction, + vaultTimeout, + true, + ); + + switch (storageLocation) { + case TokenStorageLocation.SecureStorage: + 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); + + // Set flag to indicate that the refresh token has been migrated to secure storage (don't remove this) + await this.setRefreshTokenMigratedToSecureStorage(userId); + + return; + + case TokenStorageLocation.Disk: + await this.singleUserStateProvider + .get(userId, REFRESH_TOKEN_DISK) + .update((_) => refreshToken); + return; + + case TokenStorageLocation.Memory: + await this.singleUserStateProvider + .get(userId, REFRESH_TOKEN_MEMORY) + .update((_) => refreshToken); + return; + } } - async getRefreshToken(): Promise { - return await this.stateService.getRefreshToken(); + async getRefreshToken(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + if (!userId) { + return undefined; + } + + const refreshTokenMigratedToSecureStorage = + await this.getRefreshTokenMigratedToSecureStorage(userId); + if (this.platformSupportsSecureStorage && refreshTokenMigratedToSecureStorage) { + return await this.getStringFromSecureStorage(userId, this.refreshTokenSecureStorageKey); + } + + // pre-secure storage migration: + // Always read memory first b/c faster + const refreshTokenMemory = await this.getStateValueByUserIdAndKeyDef( + userId, + REFRESH_TOKEN_MEMORY, + ); + + if (refreshTokenMemory != null) { + return refreshTokenMemory; + } + + // if memory is null, read from disk + const refreshTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, REFRESH_TOKEN_DISK); + + if (refreshTokenDisk != null) { + return refreshTokenDisk; + } + + return null; } - async setTwoFactorToken(tokenResponse: IdentityTokenResponse): Promise { - return await this.stateService.setTwoFactorToken(tokenResponse.twoFactorToken); + private async clearRefreshToken(userId: UserId): Promise { + // If we don't have a user id, we can't clear the value + if (!userId) { + throw new Error("User id not found. Cannot clear refresh token."); + } + + // TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data. + // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout + // but we can simply clear all locations to avoid the need to require those parameters + + if (this.platformSupportsSecureStorage) { + await this.secureStorageService.remove( + `${userId}${this.refreshTokenSecureStorageKey}`, + this.getSecureStorageOptions(userId), + ); + } + + // Platform doesn't support secure storage, so use state provider implementation + await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null); + await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null); } - async getTwoFactorToken(): Promise { - return await this.stateService.getTwoFactorToken(); + private async getRefreshTokenMigratedToSecureStorage(userId: UserId): Promise { + return await firstValueFrom( + this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE).state$, + ); } - async clearTwoFactorToken(): Promise { - return await this.stateService.setTwoFactorToken(null); + private async setRefreshTokenMigratedToSecureStorage(userId: UserId): Promise { + await this.singleUserStateProvider + .get(userId, REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE) + .update((_) => true); } - async clearToken(userId?: string): Promise { - await this.stateService.setAccessToken(null, { userId: userId }); - await this.stateService.setRefreshToken(null, { userId: userId }); - await this.stateService.setApiKeyClientId(null, { userId: userId }); - await this.stateService.setApiKeyClientSecret(null, { userId: userId }); + async setClientId( + clientId: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + userId?: UserId, + ): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + // If we don't have a user id, we can't save the value + if (!userId) { + throw new Error("User id not found. Cannot save client id."); + } + + const storageLocation = await this.determineStorageLocation( + vaultTimeoutAction, + vaultTimeout, + false, + ); + + if (storageLocation === TokenStorageLocation.Disk) { + await this.singleUserStateProvider + .get(userId, API_KEY_CLIENT_ID_DISK) + .update((_) => clientId); + } else if (storageLocation === TokenStorageLocation.Memory) { + await this.singleUserStateProvider + .get(userId, API_KEY_CLIENT_ID_MEMORY) + .update((_) => clientId); + } + } + + async getClientId(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + if (!userId) { + return undefined; + } + + // Always read memory first b/c faster + const apiKeyClientIdMemory = await this.getStateValueByUserIdAndKeyDef( + userId, + API_KEY_CLIENT_ID_MEMORY, + ); + + if (apiKeyClientIdMemory != null) { + return apiKeyClientIdMemory; + } + + // if memory is null, read from disk + return await this.getStateValueByUserIdAndKeyDef(userId, API_KEY_CLIENT_ID_DISK); + } + + private async clearClientId(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + // If we don't have a user id, we can't clear the value + if (!userId) { + throw new Error("User id not found. Cannot clear client id."); + } + + // TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data. + // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout + // but we can simply clear both locations to avoid the need to require those parameters + + // Platform doesn't support secure storage, so use state provider implementation + await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_ID_MEMORY).update((_) => null); + await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_ID_DISK).update((_) => null); + } + + async setClientSecret( + clientSecret: string, + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + userId?: UserId, + ): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + if (!userId) { + throw new Error("User id not found. Cannot save client secret."); + } + + const storageLocation = await this.determineStorageLocation( + vaultTimeoutAction, + vaultTimeout, + false, + ); + + if (storageLocation === TokenStorageLocation.Disk) { + await this.singleUserStateProvider + .get(userId, API_KEY_CLIENT_SECRET_DISK) + .update((_) => clientSecret); + } else if (storageLocation === TokenStorageLocation.Memory) { + await this.singleUserStateProvider + .get(userId, API_KEY_CLIENT_SECRET_MEMORY) + .update((_) => clientSecret); + } + } + + async getClientSecret(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + if (!userId) { + return undefined; + } + + // Always read memory first b/c faster + const apiKeyClientSecretMemory = await this.getStateValueByUserIdAndKeyDef( + userId, + API_KEY_CLIENT_SECRET_MEMORY, + ); + + if (apiKeyClientSecretMemory != null) { + return apiKeyClientSecretMemory; + } + + // if memory is null, read from disk + return await this.getStateValueByUserIdAndKeyDef(userId, API_KEY_CLIENT_SECRET_DISK); + } + + private async clearClientSecret(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + // If we don't have a user id, we can't clear the value + if (!userId) { + throw new Error("User id not found. Cannot clear client secret."); + } + + // TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data. + // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout + // but we can simply clear both locations to avoid the need to require those parameters + + // Platform doesn't support secure storage, so use state provider implementation + await this.singleUserStateProvider + .get(userId, API_KEY_CLIENT_SECRET_MEMORY) + .update((_) => null); + await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_SECRET_DISK).update((_) => null); + } + + async setTwoFactorToken(email: string, twoFactorToken: string): Promise { + await this.emailTwoFactorTokenRecordGlobalState.update((emailTwoFactorTokenRecord) => { + emailTwoFactorTokenRecord ??= {}; + + emailTwoFactorTokenRecord[email] = twoFactorToken; + return emailTwoFactorTokenRecord; + }); + } + + async getTwoFactorToken(email: string): Promise { + const emailTwoFactorTokenRecord: Record = await firstValueFrom( + this.emailTwoFactorTokenRecordGlobalState.state$, + ); + + if (!emailTwoFactorTokenRecord) { + return null; + } + + return emailTwoFactorTokenRecord[email]; + } + + async clearTwoFactorToken(email: string): Promise { + await this.emailTwoFactorTokenRecordGlobalState.update((emailTwoFactorTokenRecord) => { + emailTwoFactorTokenRecord ??= {}; + delete emailTwoFactorTokenRecord[email]; + return emailTwoFactorTokenRecord; + }); + } + + async clearTokens(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); + + if (!userId) { + throw new Error("User id not found. Cannot clear tokens."); + } + + await Promise.all([ + this.clearAccessToken(userId), + this.clearRefreshToken(userId), + this.clearClientId(userId), + this.clearClientSecret(userId), + ]); } // jwthelper methods // ref https://github.com/auth0/angular-jwt/blob/master/src/angularJwt/services/jwt.js - async decodeToken(token?: string): Promise { - token = token ?? (await this.stateService.getAccessToken()); + async decodeAccessToken(token?: string): Promise { + token = token ?? (await this.getAccessToken()); if (token == null) { - throw new Error("Token not found."); + throw new Error("Access token not found."); } - return TokenService.decodeToken(token); + return decodeJwtTokenToJson(token) as DecodedAccessToken; } - async getTokenExpirationDate(): Promise { - const decoded = await this.decodeToken(); - if (typeof decoded.exp === "undefined") { + // TODO: PM-6678- tech debt - consider consolidating the return types of all these access + // token data retrieval methods to return null if something goes wrong instead of throwing an error. + + async getTokenExpirationDate(): Promise { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + // per RFC, exp claim is optional but if it exists, it should be a number + if (!decoded || typeof decoded.exp !== "number") { return null; } - const d = new Date(0); // The 0 here is the key, which sets the date to the epoch - d.setUTCSeconds(decoded.exp); - return d; + // The 0 in Date(0) is the key; it sets the date to the epoch + const expirationDate = new Date(0); + expirationDate.setUTCSeconds(decoded.exp); + return expirationDate; } async tokenSecondsRemaining(offsetSeconds = 0): Promise { - const d = await this.getTokenExpirationDate(); - if (d == null) { + const date = await this.getTokenExpirationDate(); + if (date == null) { return 0; } - const msRemaining = d.valueOf() - (new Date().valueOf() + offsetSeconds * 1000); + const msRemaining = date.valueOf() - (new Date().valueOf() + offsetSeconds * 1000); return Math.round(msRemaining / 1000); } @@ -128,54 +632,159 @@ export class TokenService implements TokenServiceAbstraction { return sRemaining < 60 * minutes; } - async getUserId(): Promise { - const decoded = await this.decodeToken(); - if (typeof decoded.sub === "undefined") { + async getUserId(): Promise { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + if (!decoded || typeof decoded.sub !== "string") { throw new Error("No user id found"); } - return decoded.sub as string; + return decoded.sub as UserId; + } + + private async getUserIdFromAccessToken(accessToken: string): Promise { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(accessToken); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + if (!decoded || typeof decoded.sub !== "string") { + throw new Error("No user id found"); + } + + return decoded.sub as UserId; } async getEmail(): Promise { - const decoded = await this.decodeToken(); - if (typeof decoded.email === "undefined") { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + if (!decoded || typeof decoded.email !== "string") { throw new Error("No email found"); } - return decoded.email as string; + return decoded.email; } async getEmailVerified(): Promise { - const decoded = await this.decodeToken(); - if (typeof decoded.email_verified === "undefined") { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + if (!decoded || typeof decoded.email_verified !== "boolean") { throw new Error("No email verification found"); } - return decoded.email_verified as boolean; + return decoded.email_verified; } async getName(): Promise { - const decoded = await this.decodeToken(); - if (typeof decoded.name === "undefined") { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + if (!decoded || typeof decoded.name !== "string") { return null; } - return decoded.name as string; + return decoded.name; } async getIssuer(): Promise { - const decoded = await this.decodeToken(); - if (typeof decoded.iss === "undefined") { + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } + + if (!decoded || typeof decoded.iss !== "string") { throw new Error("No issuer found"); } - return decoded.iss as string; + return decoded.iss; } async getIsExternal(): Promise { - const decoded = await this.decodeToken(); + let decoded: DecodedAccessToken; + try { + decoded = await this.decodeAccessToken(); + } catch (error) { + throw new Error("Failed to decode access token: " + error.message); + } return Array.isArray(decoded.amr) && decoded.amr.includes("external"); } + + private async getStateValueByUserIdAndKeyDef( + userId: UserId, + storageLocation: KeyDefinition, + ): Promise { + // read from single user state provider + return await firstValueFrom(this.singleUserStateProvider.get(userId, storageLocation).state$); + } + + private async determineStorageLocation( + vaultTimeoutAction: VaultTimeoutAction, + vaultTimeout: number | null, + useSecureStorage: boolean, + ): Promise { + if (vaultTimeoutAction === VaultTimeoutAction.LogOut && vaultTimeout != null) { + return TokenStorageLocation.Memory; + } else { + if (useSecureStorage && this.platformSupportsSecureStorage) { + return TokenStorageLocation.SecureStorage; + } + + return TokenStorageLocation.Disk; + } + } + + private async saveStringToSecureStorage( + userId: UserId, + storageKey: string, + value: string, + ): Promise { + await this.secureStorageService.save( + `${userId}${storageKey}`, + value, + this.getSecureStorageOptions(userId), + ); + } + + private async getStringFromSecureStorage( + userId: UserId, + storageKey: string, + ): Promise { + // If we have a user ID, read from secure storage. + return await this.secureStorageService.get( + `${userId}${storageKey}`, + this.getSecureStorageOptions(userId), + ); + } + + private getSecureStorageOptions(userId: UserId): StorageOptions { + return { + storageLocation: StorageLocation.Disk, + useSecureStorage: true, + userId: userId, + }; + } } diff --git a/libs/common/src/auth/services/token.state.spec.ts b/libs/common/src/auth/services/token.state.spec.ts new file mode 100644 index 0000000000..f4089a73fb --- /dev/null +++ b/libs/common/src/auth/services/token.state.spec.ts @@ -0,0 +1,64 @@ +import { KeyDefinition } from "../../platform/state"; + +import { + ACCESS_TOKEN_DISK, + ACCESS_TOKEN_MEMORY, + ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, + API_KEY_CLIENT_ID_DISK, + API_KEY_CLIENT_ID_MEMORY, + API_KEY_CLIENT_SECRET_DISK, + API_KEY_CLIENT_SECRET_MEMORY, + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + REFRESH_TOKEN_DISK, + REFRESH_TOKEN_MEMORY, + REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, +} from "./token.state"; + +describe.each([ + [ACCESS_TOKEN_DISK, "accessTokenDisk"], + [ACCESS_TOKEN_MEMORY, "accessTokenMemory"], + [ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, true], + [REFRESH_TOKEN_DISK, "refreshTokenDisk"], + [REFRESH_TOKEN_MEMORY, "refreshTokenMemory"], + [REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, true], + [EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, { user: "token" }], + [API_KEY_CLIENT_ID_DISK, "apiKeyClientIdDisk"], + [API_KEY_CLIENT_ID_MEMORY, "apiKeyClientIdMemory"], + [API_KEY_CLIENT_SECRET_DISK, "apiKeyClientSecretDisk"], + [API_KEY_CLIENT_SECRET_MEMORY, "apiKeyClientSecretMemory"], +])( + "deserializes state key definitions", + ( + keyDefinition: + | KeyDefinition + | KeyDefinition + | KeyDefinition>, + state: string | boolean | Record, + ) => { + function getTypeDescription(value: any): string { + if (isRecord(value)) { + return "Record"; + } else if (Array.isArray(value)) { + return "array"; + } else if (value === null) { + return "null"; + } + + // Fallback for primitive types + return typeof value; + } + + function isRecord(value: any): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + function testDeserialization(keyDefinition: KeyDefinition, state: T) { + const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state))); + expect(deserialized).toEqual(state); + } + + it(`should deserialize state for KeyDefinition<${getTypeDescription(state)}>: "${keyDefinition.key}"`, () => { + testDeserialization(keyDefinition, state); + }); + }, +); diff --git a/libs/common/src/auth/services/token.state.ts b/libs/common/src/auth/services/token.state.ts new file mode 100644 index 0000000000..022f56f7aa --- /dev/null +++ b/libs/common/src/auth/services/token.state.ts @@ -0,0 +1,65 @@ +import { KeyDefinition, TOKEN_DISK, TOKEN_DISK_LOCAL, TOKEN_MEMORY } from "../../platform/state"; + +export const ACCESS_TOKEN_DISK = new KeyDefinition(TOKEN_DISK, "accessToken", { + deserializer: (accessToken) => accessToken, +}); + +export const ACCESS_TOKEN_MEMORY = new KeyDefinition(TOKEN_MEMORY, "accessToken", { + deserializer: (accessToken) => accessToken, +}); + +export const ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE = new KeyDefinition( + TOKEN_DISK, + "accessTokenMigratedToSecureStorage", + { + deserializer: (accessTokenMigratedToSecureStorage) => accessTokenMigratedToSecureStorage, + }, +); + +export const REFRESH_TOKEN_DISK = new KeyDefinition(TOKEN_DISK, "refreshToken", { + deserializer: (refreshToken) => refreshToken, +}); + +export const REFRESH_TOKEN_MEMORY = new KeyDefinition(TOKEN_MEMORY, "refreshToken", { + deserializer: (refreshToken) => refreshToken, +}); + +export const REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE = new KeyDefinition( + TOKEN_DISK, + "refreshTokenMigratedToSecureStorage", + { + deserializer: (refreshTokenMigratedToSecureStorage) => refreshTokenMigratedToSecureStorage, + }, +); + +export const EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL = KeyDefinition.record( + TOKEN_DISK_LOCAL, + "emailTwoFactorTokenRecord", + { + deserializer: (emailTwoFactorTokenRecord) => emailTwoFactorTokenRecord, + }, +); + +export const API_KEY_CLIENT_ID_DISK = new KeyDefinition(TOKEN_DISK, "apiKeyClientId", { + deserializer: (apiKeyClientId) => apiKeyClientId, +}); + +export const API_KEY_CLIENT_ID_MEMORY = new KeyDefinition(TOKEN_MEMORY, "apiKeyClientId", { + deserializer: (apiKeyClientId) => apiKeyClientId, +}); + +export const API_KEY_CLIENT_SECRET_DISK = new KeyDefinition( + TOKEN_DISK, + "apiKeyClientSecret", + { + deserializer: (apiKeyClientSecret) => apiKeyClientSecret, + }, +); + +export const API_KEY_CLIENT_SECRET_MEMORY = new KeyDefinition( + TOKEN_MEMORY, + "apiKeyClientSecret", + { + deserializer: (apiKeyClientSecret) => apiKeyClientSecret, + }, +); diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 3fc65e4acf..938720daaa 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -52,16 +52,11 @@ export abstract class StateService { clean: (options?: StorageOptions) => Promise; init: (initOptions?: InitOptions) => Promise; - getAccessToken: (options?: StorageOptions) => Promise; - setAccessToken: (value: string, options?: StorageOptions) => Promise; getAddEditCipherInfo: (options?: StorageOptions) => Promise; setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise; getAlwaysShowDock: (options?: StorageOptions) => Promise; setAlwaysShowDock: (value: boolean, options?: StorageOptions) => Promise; - getApiKeyClientId: (options?: StorageOptions) => Promise; - setApiKeyClientId: (value: string, options?: StorageOptions) => Promise; - getApiKeyClientSecret: (options?: StorageOptions) => Promise; - setApiKeyClientSecret: (value: string, options?: StorageOptions) => Promise; + getAutoConfirmFingerPrints: (options?: StorageOptions) => Promise; setAutoConfirmFingerprints: (value: boolean, options?: StorageOptions) => Promise; getBiometricFingerprintValidated: (options?: StorageOptions) => Promise; @@ -332,14 +327,10 @@ export abstract class StateService { * Sets the user's Pin, encrypted by the user key */ setProtectedPin: (value: string, options?: StorageOptions) => Promise; - getRefreshToken: (options?: StorageOptions) => Promise; - setRefreshToken: (value: string, options?: StorageOptions) => Promise; getRememberedEmail: (options?: StorageOptions) => Promise; setRememberedEmail: (value: string, options?: StorageOptions) => Promise; getSecurityStamp: (options?: StorageOptions) => Promise; setSecurityStamp: (value: string, options?: StorageOptions) => Promise; - getTwoFactorToken: (options?: StorageOptions) => Promise; - setTwoFactorToken: (value: string, options?: StorageOptions) => Promise; getUserId: (options?: StorageOptions) => Promise; getUsesKeyConnector: (options?: StorageOptions) => Promise; setUsesKeyConnector: (value: boolean, options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 0c85307032..edb8f87d25 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -112,7 +112,6 @@ export class AccountKeys { masterKeyEncryptedUserKey?: string; deviceKey?: ReturnType; publicKey?: Uint8Array; - apiKeyClientSecret?: string; /** @deprecated July 2023, left for migration purposes*/ cryptoMasterKey?: SymmetricCryptoKey; @@ -167,7 +166,6 @@ export class AccountKeys { } export class AccountProfile { - apiKeyClientId?: string; convertAccountToKeyConnector?: boolean; name?: string; email?: string; @@ -233,8 +231,6 @@ export class AccountSettings { } export class AccountTokens { - accessToken?: string; - refreshToken?: string; securityStamp?: string; static fromJSON(obj: Jsonify): AccountTokens { diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 08c5350d06..0ccd405dd1 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -3,12 +3,12 @@ import { Jsonify, JsonValue } from "type-fest"; import { OrganizationData } from "../../admin-console/models/data/organization.data"; import { AccountService } from "../../auth/abstractions/account.service"; +import { TokenService } from "../../auth/abstractions/token.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; -import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { EventData } from "../../models/data/event.data"; import { WindowState } from "../../models/domain/window-state"; import { GeneratorOptions } from "../../tools/generator/generator-options"; @@ -100,6 +100,7 @@ export class StateService< protected stateFactory: StateFactory, protected accountService: AccountService, protected environmentService: EnvironmentService, + protected tokenService: TokenService, private migrationRunner: MigrationRunner, protected useAccountCache: boolean = true, ) { @@ -190,7 +191,7 @@ export class StateService< // TODO: Temporary update to avoid routing all account status changes through account service for now. // The determination of state should be handled by the various services that control those values. - const token = await this.getAccessToken({ userId: userId }); + const token = await this.tokenService.getAccessToken(userId as UserId); const autoKey = await this.getUserKeyAutoUnlock({ userId: userId }); const accountStatus = token == null @@ -255,18 +256,6 @@ export class StateService< return currentUser as UserId; } - async getAccessToken(options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - return (await this.getAccount(options))?.tokens?.accessToken; - } - - async setAccessToken(value: string, options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - const account = await this.getAccount(options); - account.tokens.accessToken = value; - await this.saveAccount(account, options); - } - async getAddEditCipherInfo(options?: StorageOptions): Promise { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()), @@ -313,30 +302,6 @@ export class StateService< ); } - async getApiKeyClientId(options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - return (await this.getAccount(options))?.profile?.apiKeyClientId; - } - - async setApiKeyClientId(value: string, options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - const account = await this.getAccount(options); - account.profile.apiKeyClientId = value; - await this.saveAccount(account, options); - } - - async getApiKeyClientSecret(options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - return (await this.getAccount(options))?.keys?.apiKeyClientSecret; - } - - async setApiKeyClientSecret(value: string, options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - const account = await this.getAccount(options); - account.keys.apiKeyClientSecret = value; - await this.saveAccount(account, options); - } - async getAutoConfirmFingerPrints(options?: StorageOptions): Promise { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) @@ -1356,7 +1321,10 @@ export class StateService< } async getIsAuthenticated(options?: StorageOptions): Promise { - return (await this.getAccessToken(options)) != null && (await this.getUserId(options)) != null; + return ( + (await this.tokenService.getAccessToken(options?.userId as UserId)) != null && + (await this.getUserId(options)) != null + ); } async getKdfConfig(options?: StorageOptions): Promise { @@ -1672,18 +1640,6 @@ export class StateService< ); } - async getRefreshToken(options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - return (await this.getAccount(options))?.tokens?.refreshToken; - } - - async setRefreshToken(value: string, options?: StorageOptions): Promise { - options = await this.getTimeoutBasedStorageOptions(options); - const account = await this.getAccount(options); - account.tokens.refreshToken = value; - await this.saveAccount(account, options); - } - async getRememberedEmail(options?: StorageOptions): Promise { return ( await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) @@ -1718,23 +1674,6 @@ export class StateService< ); } - async getTwoFactorToken(options?: StorageOptions): Promise { - return ( - await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) - )?.twoFactorToken; - } - - async setTwoFactorToken(value: string, options?: StorageOptions): Promise { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - globals.twoFactorToken = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - } - async getUserId(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) @@ -2041,15 +1980,6 @@ export class StateService< await this.storageService.remove(keys.tempAccountSettings); } - if ( - account.settings.vaultTimeoutAction === VaultTimeoutAction.LogOut && - account.settings.vaultTimeout != null - ) { - account.tokens.accessToken = null; - account.tokens.refreshToken = null; - account.profile.apiKeyClientId = null; - account.keys.apiKeyClientSecret = null; - } await this.saveAccount( account, this.reconcileOptions( @@ -2250,7 +2180,7 @@ export class StateService< } protected async deAuthenticateAccount(userId: string): Promise { - await this.setAccessToken(null, { userId: userId }); + await this.tokenService.clearAccessToken(userId as UserId); await this.setLastActive(null, { userId: userId }); await this.updateState(async (state) => { state.authenticatedAccounts = state.authenticatedAccounts.filter((id) => id !== userId); @@ -2293,16 +2223,6 @@ export class StateService< return newActiveUser; } - private async getTimeoutBasedStorageOptions(options?: StorageOptions): Promise { - const timeoutAction = await this.getVaultTimeoutAction({ userId: options?.userId }); - const timeout = await this.getVaultTimeout({ userId: options?.userId }); - const defaultOptions = - timeoutAction === VaultTimeoutAction.LogOut && timeout != null - ? await this.defaultInMemoryOptions() - : await this.defaultOnDiskOptions(); - return this.reconcileOptions(options, defaultOptions); - } - protected async saveSecureStorageKey( key: string, value: T, diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 86b8dd051c..34b6bb097f 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -28,6 +28,11 @@ export const PROVIDERS_DISK = new StateDefinition("providers", "disk"); export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" }); export const SSO_DISK = new StateDefinition("ssoLogin", "disk"); +export const TOKEN_DISK = new StateDefinition("token", "disk"); +export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", { + web: "disk-local", +}); +export const TOKEN_MEMORY = new StateDefinition("token", "memory"); export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory"); // Autofill diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 336191f3ab..869d45ebff 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -93,6 +93,7 @@ import { SubscriptionResponse } from "../billing/models/response/subscription.re import { TaxInfoResponse } from "../billing/models/response/tax-info.response"; import { TaxRateResponse } from "../billing/models/response/tax-rate.response"; import { DeviceType } from "../enums"; +import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum"; import { CollectionBulkDeleteRequest } from "../models/request/collection-bulk-delete.request"; import { DeleteRecoverRequest } from "../models/request/delete-recover.request"; import { EventRequest } from "../models/request/event.request"; @@ -116,6 +117,7 @@ import { UserKeyResponse } from "../models/response/user-key.response"; import { AppIdService } from "../platform/abstractions/app-id.service"; import { EnvironmentService } from "../platform/abstractions/environment.service"; import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; +import { StateService } from "../platform/abstractions/state.service"; import { Utils } from "../platform/misc/utils"; import { AttachmentRequest } from "../vault/models/request/attachment.request"; import { CipherBulkDeleteRequest } from "../vault/models/request/cipher-bulk-delete.request"; @@ -154,6 +156,7 @@ export class ApiService implements ApiServiceAbstraction { private platformUtilsService: PlatformUtilsService, private environmentService: EnvironmentService, private appIdService: AppIdService, + private stateService: StateService, private logoutCallback: (expired: boolean) => Promise, private customUserAgent: string = null, ) { @@ -224,7 +227,6 @@ export class ApiService implements ApiServiceAbstraction { responseJson.TwoFactorProviders2 && Object.keys(responseJson.TwoFactorProviders2).length ) { - await this.tokenService.clearTwoFactorToken(); return new IdentityTwoFactorResponse(responseJson); } else if ( response.status === 400 && @@ -1578,10 +1580,10 @@ export class ApiService implements ApiServiceAbstraction { // Helpers async getActiveBearerToken(): Promise { - let accessToken = await this.tokenService.getToken(); + let accessToken = await this.tokenService.getAccessToken(); if (await this.tokenService.tokenNeedsRefresh()) { await this.doAuthRefresh(); - accessToken = await this.tokenService.getToken(); + accessToken = await this.tokenService.getAccessToken(); } return accessToken; } @@ -1749,7 +1751,7 @@ export class ApiService implements ApiServiceAbstraction { headers.set("User-Agent", this.customUserAgent); } - const decodedToken = await this.tokenService.decodeToken(); + const decodedToken = await this.tokenService.decodeAccessToken(); const response = await this.fetch( new Request(this.environmentService.getIdentityUrl() + "/connect/token", { body: this.qsStringify({ @@ -1767,10 +1769,15 @@ export class ApiService implements ApiServiceAbstraction { if (response.status === 200) { const responseJson = await response.json(); const tokenResponse = new IdentityTokenResponse(responseJson); + + const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction(); + const vaultTimeout = await this.stateService.getVaultTimeout(); + await this.tokenService.setTokens( tokenResponse.accessToken, tokenResponse.refreshToken, - null, + vaultTimeoutAction as VaultTimeoutAction, + vaultTimeout, ); } else { const error = await this.handleError(response, true, true); @@ -1796,7 +1803,14 @@ export class ApiService implements ApiServiceAbstraction { throw new Error("Invalid response received when refreshing api token"); } - await this.tokenService.setToken(response.accessToken); + const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction(); + const vaultTimeout = await this.stateService.getVaultTimeout(); + + await this.tokenService.setAccessToken( + response.accessToken, + vaultTimeoutAction as VaultTimeoutAction, + vaultTimeout, + ); } async send( diff --git a/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts index 0d0eb508cb..e8897d82b7 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts @@ -29,7 +29,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA async setVaultTimeoutOptions(timeout: number, action: VaultTimeoutAction): Promise { // We swap these tokens from being on disk for lock actions, and in memory for logout actions // Get them here to set them to their new location after changing the timeout action and clearing if needed - const token = await this.tokenService.getToken(); + const accessToken = await this.tokenService.getAccessToken(); const refreshToken = await this.tokenService.getRefreshToken(); const clientId = await this.tokenService.getClientId(); const clientSecret = await this.tokenService.getClientSecret(); @@ -37,21 +37,22 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA await this.stateService.setVaultTimeout(timeout); const currentAction = await this.stateService.getVaultTimeoutAction(); + if ( (timeout != null || timeout === 0) && action === VaultTimeoutAction.LogOut && action !== currentAction ) { // if we have a vault timeout and the action is log out, reset tokens - await this.tokenService.clearToken(); + await this.tokenService.clearTokens(); } await this.stateService.setVaultTimeoutAction(action); - await this.tokenService.setToken(token); - await this.tokenService.setRefreshToken(refreshToken); - await this.tokenService.setClientId(clientId); - await this.tokenService.setClientSecret(clientSecret); + await this.tokenService.setTokens(accessToken, refreshToken, action, timeout, [ + clientId, + clientSecret, + ]); await this.cryptoService.refreshAdditionalKeys(); } diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 77a35ccb87..798af38220 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -24,7 +24,6 @@ import { RevertLastSyncMigrator } from "./migrations/26-revert-move-last-sync-to import { BadgeSettingsMigrator } from "./migrations/27-move-badge-settings-to-state-providers"; import { MoveBiometricUnlockToStateProviders } from "./migrations/28-move-biometric-unlock-to-state-providers"; import { UserNotificationSettingsKeyMigrator } from "./migrations/29-move-user-notification-settings-to-state-provider"; -import { FixPremiumMigrator } from "./migrations/3-fix-premium"; import { PolicyMigrator } from "./migrations/30-move-policy-state-to-state-provider"; import { EnableContextMenuMigrator } from "./migrations/31-move-enable-context-menu-to-autofill-settings-state-provider"; import { PreferredLanguageMigrator } from "./migrations/32-move-preferred-language"; @@ -33,6 +32,7 @@ import { DomainSettingsMigrator } from "./migrations/34-move-domain-settings-to- import { MoveThemeToStateProviderMigrator } from "./migrations/35-move-theme-to-state-providers"; import { VaultSettingsKeyMigrator } from "./migrations/36-move-show-card-and-identity-to-state-provider"; import { AvatarColorMigrator } from "./migrations/37-move-avatar-color-to-state-providers"; +import { TokenServiceStateProviderMigrator } from "./migrations/38-migrate-token-svc-to-state-provider"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; @@ -41,14 +41,13 @@ import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global"; import { MinVersionMigrator } from "./migrations/min-version"; -export const MIN_VERSION = 2; -export const CURRENT_VERSION = 37; +export const MIN_VERSION = 3; +export const CURRENT_VERSION = 38; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { return MigrationBuilder.create() .with(MinVersionMigrator) - .with(FixPremiumMigrator, 2, 3) .with(RemoveEverBeenUnlockedMigrator, 3, 4) .with(AddKeyTypeToOrgKeysMigrator, 4, 5) .with(RemoveLegacyEtmKeyMigrator, 5, 6) @@ -82,7 +81,8 @@ export function createMigrationBuilder() { .with(DomainSettingsMigrator, 33, 34) .with(MoveThemeToStateProviderMigrator, 34, 35) .with(VaultSettingsKeyMigrator, 35, 36) - .with(AvatarColorMigrator, 36, CURRENT_VERSION); + .with(AvatarColorMigrator, 36, 37) + .with(TokenServiceStateProviderMigrator, 37, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts b/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts deleted file mode 100644 index 1ef910d456..0000000000 --- a/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { MockProxy } from "jest-mock-extended"; - -// eslint-disable-next-line import/no-restricted-paths -- Used for testing migration, which requires import -import { TokenService } from "../../auth/services/token.service"; -import { MigrationHelper } from "../migration-helper"; -import { mockMigrationHelper } from "../migration-helper.spec"; - -import { FixPremiumMigrator } from "./3-fix-premium"; - -function migrateExampleJSON() { - return { - global: { - stateVersion: 2, - otherStuff: "otherStuff1", - }, - authenticatedAccounts: [ - "c493ed01-4e08-4e88-abc7-332f380ca760", - "23e61a5f-2ece-4f5e-b499-f0bc489482a9", - ], - "c493ed01-4e08-4e88-abc7-332f380ca760": { - profile: { - otherStuff: "otherStuff2", - hasPremiumPersonally: null as boolean, - }, - tokens: { - otherStuff: "otherStuff3", - accessToken: "accessToken", - }, - otherStuff: "otherStuff4", - }, - "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { - profile: { - otherStuff: "otherStuff5", - hasPremiumPersonally: true, - }, - tokens: { - otherStuff: "otherStuff6", - accessToken: "accessToken", - }, - otherStuff: "otherStuff7", - }, - otherStuff: "otherStuff8", - }; -} - -jest.mock("../../auth/services/token.service", () => ({ - TokenService: { - decodeToken: jest.fn(), - }, -})); - -describe("FixPremiumMigrator", () => { - let helper: MockProxy; - let sut: FixPremiumMigrator; - const decodeTokenSpy = TokenService.decodeToken as jest.Mock; - - beforeEach(() => { - helper = mockMigrationHelper(migrateExampleJSON()); - sut = new FixPremiumMigrator(2, 3); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe("migrate", () => { - it("should migrate hasPremiumPersonally", async () => { - decodeTokenSpy.mockResolvedValueOnce({ premium: true }); - await sut.migrate(helper); - - expect(helper.set).toHaveBeenCalledTimes(1); - expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { - profile: { - otherStuff: "otherStuff2", - hasPremiumPersonally: true, - }, - tokens: { - otherStuff: "otherStuff3", - accessToken: "accessToken", - }, - otherStuff: "otherStuff4", - }); - }); - - it("should not migrate if decode throws", async () => { - decodeTokenSpy.mockRejectedValueOnce(new Error("test")); - await sut.migrate(helper); - - expect(helper.set).not.toHaveBeenCalled(); - }); - - it("should not migrate if decode returns null", async () => { - decodeTokenSpy.mockResolvedValueOnce(null); - await sut.migrate(helper); - - expect(helper.set).not.toHaveBeenCalled(); - }); - }); - - describe("updateVersion", () => { - it("should update version", async () => { - await sut.updateVersion(helper, "up"); - - expect(helper.set).toHaveBeenCalledTimes(1); - expect(helper.set).toHaveBeenCalledWith("global", { - stateVersion: 3, - otherStuff: "otherStuff1", - }); - }); - }); -}); diff --git a/libs/common/src/state-migrations/migrations/3-fix-premium.ts b/libs/common/src/state-migrations/migrations/3-fix-premium.ts deleted file mode 100644 index b6c69a9916..0000000000 --- a/libs/common/src/state-migrations/migrations/3-fix-premium.ts +++ /dev/null @@ -1,48 +0,0 @@ -// eslint-disable-next-line import/no-restricted-paths -- Used for token decoding, which are valid for days. We want the latest -import { TokenService } from "../../auth/services/token.service"; -import { MigrationHelper } from "../migration-helper"; -import { Migrator, IRREVERSIBLE, Direction } from "../migrator"; - -type ExpectedAccountType = { - profile?: { hasPremiumPersonally?: boolean }; - tokens?: { accessToken?: string }; -}; - -export class FixPremiumMigrator extends Migrator<2, 3> { - async migrate(helper: MigrationHelper): Promise { - const accounts = await helper.getAccounts(); - - async function fixPremium(userId: string, account: ExpectedAccountType) { - if (account?.profile?.hasPremiumPersonally === null && account.tokens?.accessToken != null) { - let decodedToken: { premium: boolean }; - try { - decodedToken = await TokenService.decodeToken(account.tokens.accessToken); - } catch { - return; - } - - if (decodedToken?.premium == null) { - return; - } - - account.profile.hasPremiumPersonally = decodedToken?.premium; - return helper.set(userId, account); - } - } - - await Promise.all(accounts.map(({ userId, account }) => fixPremium(userId, account))); - } - - rollback(helper: MigrationHelper): Promise { - throw IRREVERSIBLE; - } - - // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version - // it is nested inside a global object. - override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { - const endVersion = direction === "up" ? this.toVersion : this.fromVersion; - helper.currentVersion = endVersion; - const global: Record = (await helper.get("global")) || {}; - await helper.set("global", { ...global, stateVersion: endVersion }); - } -} diff --git a/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts new file mode 100644 index 0000000000..a5243c261a --- /dev/null +++ b/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts @@ -0,0 +1,258 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + ACCESS_TOKEN_DISK, + REFRESH_TOKEN_DISK, + API_KEY_CLIENT_ID_DISK, + API_KEY_CLIENT_SECRET_DISK, + TokenServiceStateProviderMigrator, +} from "./38-migrate-token-svc-to-state-provider"; + +// Represents data in state service pre-migration +function preMigrationJson() { + return { + global: { + twoFactorToken: "twoFactorToken", + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user1", "user2", "user3"], + user1: { + tokens: { + accessToken: "accessToken", + refreshToken: "refreshToken", + otherStuff: "overStuff2", + }, + profile: { + apiKeyClientId: "apiKeyClientId", + email: "user1Email", + otherStuff: "overStuff3", + }, + keys: { + apiKeyClientSecret: "apiKeyClientSecret", + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }, + user2: { + tokens: { + // no tokens to migrate + otherStuff: "overStuff2", + }, + profile: { + // no apiKeyClientId to migrate + otherStuff: "overStuff3", + email: "user2Email", + }, + keys: { + // no apiKeyClientSecret to migrate + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + // User specific state provider data + // use pattern user_{userId}_{stateDefinitionName}_{keyDefinitionKey} for user data + + // User1 migrated data + user_user1_token_accessToken: "accessToken", + user_user1_token_refreshToken: "refreshToken", + user_user1_token_apiKeyClientId: "apiKeyClientId", + user_user1_token_apiKeyClientSecret: "apiKeyClientSecret", + + // User2 migrated data + user_user2_token_accessToken: null as any, + user_user2_token_refreshToken: null as any, + user_user2_token_apiKeyClientId: null as any, + user_user2_token_apiKeyClientSecret: null as any, + + // Global state provider data + // use pattern global_{stateDefinitionName}_{keyDefinitionKey} for global data + global_tokenDiskLocal_emailTwoFactorTokenRecord: { + user1Email: "twoFactorToken", + user2Email: "twoFactorToken", + }, + + global: { + // no longer has twoFactorToken + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user1", "user2", "user3"], + user1: { + tokens: { + otherStuff: "overStuff2", + }, + profile: { + email: "user1Email", + otherStuff: "overStuff3", + }, + keys: { + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }, + user2: { + tokens: { + otherStuff: "overStuff2", + }, + profile: { + email: "user2Email", + otherStuff: "overStuff3", + }, + keys: { + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +describe("TokenServiceStateProviderMigrator", () => { + let helper: MockProxy; + let sut: TokenServiceStateProviderMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(preMigrationJson(), 37); + sut = new TokenServiceStateProviderMigrator(37, 38); + }); + + it("should remove state service data from all accounts that have it", async () => { + await sut.migrate(helper); + + expect(helper.set).toHaveBeenCalledWith("user1", { + tokens: { + otherStuff: "overStuff2", + }, + profile: { + email: "user1Email", + otherStuff: "overStuff3", + }, + keys: { + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }); + + expect(helper.set).toHaveBeenCalledTimes(2); + expect(helper.set).not.toHaveBeenCalledWith("user2", any()); + expect(helper.set).not.toHaveBeenCalledWith("user3", any()); + }); + + it("should migrate data to state providers for defined accounts that have the data", async () => { + await sut.migrate(helper); + + // Two factor Token Migration + expect(helper.setToGlobal).toHaveBeenLastCalledWith( + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + { + user1Email: "twoFactorToken", + user2Email: "twoFactorToken", + }, + ); + expect(helper.setToGlobal).toHaveBeenCalledTimes(1); + + expect(helper.setToUser).toHaveBeenCalledWith("user1", ACCESS_TOKEN_DISK, "accessToken"); + expect(helper.setToUser).toHaveBeenCalledWith("user1", REFRESH_TOKEN_DISK, "refreshToken"); + expect(helper.setToUser).toHaveBeenCalledWith( + "user1", + API_KEY_CLIENT_ID_DISK, + "apiKeyClientId", + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "user1", + API_KEY_CLIENT_SECRET_DISK, + "apiKeyClientSecret", + ); + + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", ACCESS_TOKEN_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", REFRESH_TOKEN_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", API_KEY_CLIENT_ID_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", API_KEY_CLIENT_SECRET_DISK, any()); + + // Expect that we didn't migrate anything to user 3 + + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", ACCESS_TOKEN_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", REFRESH_TOKEN_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", API_KEY_CLIENT_ID_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", API_KEY_CLIENT_SECRET_DISK, any()); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 38); + sut = new TokenServiceStateProviderMigrator(37, 38); + }); + + it("should null out newly migrated entries in state provider framework", async () => { + await sut.rollback(helper); + + expect(helper.setToGlobal).toHaveBeenCalledWith( + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + null, + ); + + expect(helper.setToUser).toHaveBeenCalledWith("user1", ACCESS_TOKEN_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user1", REFRESH_TOKEN_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user1", API_KEY_CLIENT_ID_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user1", API_KEY_CLIENT_SECRET_DISK, null); + + expect(helper.setToUser).toHaveBeenCalledWith("user2", ACCESS_TOKEN_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user2", REFRESH_TOKEN_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user2", API_KEY_CLIENT_ID_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user2", API_KEY_CLIENT_SECRET_DISK, null); + + expect(helper.setToUser).toHaveBeenCalledWith("user3", ACCESS_TOKEN_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user3", REFRESH_TOKEN_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user3", API_KEY_CLIENT_ID_DISK, null); + expect(helper.setToUser).toHaveBeenCalledWith("user3", API_KEY_CLIENT_SECRET_DISK, null); + }); + + it("should add back data to all accounts that had migrated data (only user 1)", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("user1", { + tokens: { + accessToken: "accessToken", + refreshToken: "refreshToken", + otherStuff: "overStuff2", + }, + profile: { + apiKeyClientId: "apiKeyClientId", + email: "user1Email", + otherStuff: "overStuff3", + }, + keys: { + apiKeyClientSecret: "apiKeyClientSecret", + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }); + }); + + it("should add back the global twoFactorToken", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("global", { + twoFactorToken: "twoFactorToken", + otherStuff: "otherStuff1", + }); + }); + + it("should not add data back if data wasn't migrated or acct doesn't exist", async () => { + await sut.rollback(helper); + + // no data to add back for user2 (acct exists but no migrated data) and user3 (no acct) + expect(helper.set).not.toHaveBeenCalledWith("user2", any()); + expect(helper.set).not.toHaveBeenCalledWith("user3", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts b/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts new file mode 100644 index 0000000000..17753d2187 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts @@ -0,0 +1,231 @@ +import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper"; +import { Migrator } from "../migrator"; + +// Types to represent data as it is stored in JSON +type ExpectedAccountType = { + tokens?: { + accessToken?: string; + refreshToken?: string; + }; + profile?: { + apiKeyClientId?: string; + email?: string; + }; + keys?: { + apiKeyClientSecret?: string; + }; +}; + +type ExpectedGlobalType = { + twoFactorToken?: string; +}; + +export const EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL: KeyDefinitionLike = { + key: "emailTwoFactorTokenRecord", + stateDefinition: { + name: "tokenDiskLocal", + }, +}; + +const TOKEN_STATE_DEF_LIKE: StateDefinitionLike = { + name: "token", +}; + +export const ACCESS_TOKEN_DISK: KeyDefinitionLike = { + key: "accessToken", // matches KeyDefinition.key + stateDefinition: TOKEN_STATE_DEF_LIKE, +}; + +export const REFRESH_TOKEN_DISK: KeyDefinitionLike = { + key: "refreshToken", + stateDefinition: TOKEN_STATE_DEF_LIKE, +}; + +export const API_KEY_CLIENT_ID_DISK: KeyDefinitionLike = { + key: "apiKeyClientId", + stateDefinition: TOKEN_STATE_DEF_LIKE, +}; + +export const API_KEY_CLIENT_SECRET_DISK: KeyDefinitionLike = { + key: "apiKeyClientSecret", + stateDefinition: TOKEN_STATE_DEF_LIKE, +}; + +export class TokenServiceStateProviderMigrator extends Migrator<37, 38> { + async migrate(helper: MigrationHelper): Promise { + // Move global data + const globalData = await helper.get("global"); + + // Create new global record for 2FA token that we can accumulate data in + const emailTwoFactorTokenRecord = {}; + + const accounts = await helper.getAccounts(); + async function migrateAccount( + userId: string, + account: ExpectedAccountType | undefined, + globalTwoFactorToken: string | undefined, + emailTwoFactorTokenRecord: Record, + ): Promise { + let updatedAccount = false; + + // migrate 2FA token from global to user state + // Due to the existing implmentation, n users on the same device share the same global state value for 2FA token. + // So, we will just migrate it to all users to keep it valid for whichever was the user that set it previously. + // Note: don't bother migrating 2FA Token if user account or email is undefined + const email = account?.profile?.email; + if (globalTwoFactorToken != undefined && account != undefined && email != undefined) { + emailTwoFactorTokenRecord[email] = globalTwoFactorToken; + // Note: don't set updatedAccount to true here as we aren't updating + // the legacy user state, just migrating a global state to a new user state + } + + // Migrate access token + const existingAccessToken = account?.tokens?.accessToken; + + if (existingAccessToken != null) { + // Only migrate data that exists + await helper.setToUser(userId, ACCESS_TOKEN_DISK, existingAccessToken); + delete account.tokens.accessToken; + updatedAccount = true; + } + + // Migrate refresh token + const existingRefreshToken = account?.tokens?.refreshToken; + + if (existingRefreshToken != null) { + await helper.setToUser(userId, REFRESH_TOKEN_DISK, existingRefreshToken); + delete account.tokens.refreshToken; + updatedAccount = true; + } + + // Migrate API key client id + const existingApiKeyClientId = account?.profile?.apiKeyClientId; + + if (existingApiKeyClientId != null) { + await helper.setToUser(userId, API_KEY_CLIENT_ID_DISK, existingApiKeyClientId); + delete account.profile.apiKeyClientId; + updatedAccount = true; + } + + // Migrate API key client secret + const existingApiKeyClientSecret = account?.keys?.apiKeyClientSecret; + if (existingApiKeyClientSecret != null) { + await helper.setToUser(userId, API_KEY_CLIENT_SECRET_DISK, existingApiKeyClientSecret); + delete account.keys.apiKeyClientSecret; + updatedAccount = true; + } + + if (updatedAccount) { + // Save the migrated account only if it was updated + await helper.set(userId, account); + } + } + + await Promise.all([ + ...accounts.map(({ userId, account }) => + migrateAccount(userId, account, globalData?.twoFactorToken, emailTwoFactorTokenRecord), + ), + ]); + + // Save the global 2FA token record + await helper.setToGlobal(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, emailTwoFactorTokenRecord); + + // Delete global data + delete globalData?.twoFactorToken; + await helper.set("global", globalData); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + // Since we migrated the global 2FA token to all users, we need to rollback the 2FA token for all users + // but we only need to set it to the global state once + + // Go through accounts and find the first user that has a non-null email and 2FA token + let migratedTwoFactorToken: string | null = null; + for (const { account } of accounts) { + const email = account?.profile?.email; + if (email == null) { + continue; + } + const emailTwoFactorTokenRecord: Record = await helper.getFromGlobal( + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + ); + + migratedTwoFactorToken = emailTwoFactorTokenRecord[email]; + + if (migratedTwoFactorToken != null) { + break; + } + } + + if (migratedTwoFactorToken != null) { + let legacyGlobal = await helper.get("global"); + if (!legacyGlobal) { + legacyGlobal = {}; + } + legacyGlobal.twoFactorToken = migratedTwoFactorToken; + await helper.set("global", legacyGlobal); + } + + // delete global 2FA token record + await helper.setToGlobal(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, null); + + async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise { + let updatedLegacyAccount = false; + + // Rollback access token + const migratedAccessToken = await helper.getFromUser(userId, ACCESS_TOKEN_DISK); + + if (account?.tokens && migratedAccessToken != null) { + account.tokens.accessToken = migratedAccessToken; + updatedLegacyAccount = true; + } + + await helper.setToUser(userId, ACCESS_TOKEN_DISK, null); + + // Rollback refresh token + const migratedRefreshToken = await helper.getFromUser(userId, REFRESH_TOKEN_DISK); + + if (account?.tokens && migratedRefreshToken != null) { + account.tokens.refreshToken = migratedRefreshToken; + updatedLegacyAccount = true; + } + + await helper.setToUser(userId, REFRESH_TOKEN_DISK, null); + + // Rollback API key client id + + const migratedApiKeyClientId = await helper.getFromUser( + userId, + API_KEY_CLIENT_ID_DISK, + ); + + if (account?.profile && migratedApiKeyClientId != null) { + account.profile.apiKeyClientId = migratedApiKeyClientId; + updatedLegacyAccount = true; + } + + await helper.setToUser(userId, API_KEY_CLIENT_ID_DISK, null); + + // Rollback API key client secret + const migratedApiKeyClientSecret = await helper.getFromUser( + userId, + API_KEY_CLIENT_SECRET_DISK, + ); + + if (account?.keys && migratedApiKeyClientSecret != null) { + account.keys.apiKeyClientSecret = migratedApiKeyClientSecret; + updatedLegacyAccount = true; + } + + await helper.setToUser(userId, API_KEY_CLIENT_SECRET_DISK, null); + + if (updatedLegacyAccount) { + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/importer/src/components/lastpass/lastpass-direct-import.service.ts b/libs/importer/src/components/lastpass/lastpass-direct-import.service.ts index 4b002061e0..7d77bbbc86 100644 --- a/libs/importer/src/components/lastpass/lastpass-direct-import.service.ts +++ b/libs/importer/src/components/lastpass/lastpass-direct-import.service.ts @@ -2,7 +2,6 @@ import { Injectable, NgZone } from "@angular/core"; import { OidcClient } from "oidc-client-ts"; import { Subject, firstValueFrom } from "rxjs"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { ClientType } from "@bitwarden/common/enums"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -32,7 +31,6 @@ export class LastPassDirectImportService { ssoImportCallback$ = this._ssoImportCallback$.asObservable(); constructor( - private tokenService: TokenService, private cryptoFunctionService: CryptoFunctionService, private environmentService: EnvironmentService, private appIdService: AppIdService, @@ -44,7 +42,7 @@ export class LastPassDirectImportService { private dialogService: DialogService, private i18nService: I18nService, ) { - this.vault = new Vault(this.cryptoFunctionService, this.tokenService); + this.vault = new Vault(this.cryptoFunctionService); /** TODO: remove this in favor of dedicated service */ this.broadcasterService.subscribe("LastPassDirectImportService", (message: any) => { diff --git a/libs/importer/src/importers/lastpass/access/vault.ts b/libs/importer/src/importers/lastpass/access/vault.ts index 814390f5c8..13b8b62c10 100644 --- a/libs/importer/src/importers/lastpass/access/vault.ts +++ b/libs/importer/src/importers/lastpass/access/vault.ts @@ -1,6 +1,6 @@ import * as papa from "papaparse"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { decodeJwtTokenToJson } from "@bitwarden/auth/common"; import { HttpStatusCode } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -24,10 +24,7 @@ export class Vault { private client: Client; private cryptoUtils: CryptoUtils; - constructor( - private cryptoFunctionService: CryptoFunctionService, - private tokenService: TokenService, - ) { + constructor(private cryptoFunctionService: CryptoFunctionService) { this.cryptoUtils = new CryptoUtils(cryptoFunctionService); const parser = new Parser(cryptoFunctionService, this.cryptoUtils); this.client = new Client(parser, this.cryptoUtils); @@ -212,7 +209,7 @@ export class Vault { } private async getK1FromAccessToken(federatedUser: FederatedUserContext, b64: boolean) { - const decodedAccessToken = await this.tokenService.decodeToken(federatedUser.accessToken); + const decodedAccessToken = decodeJwtTokenToJson(federatedUser.accessToken); const k1 = decodedAccessToken?.LastPassK1 as string; if (k1 != null) { return b64 ? Utils.fromB64ToArray(k1) : Utils.fromByteStringToArray(k1);