diff --git a/apps/browser/src/autofill/clipboard/clear-clipboard.ts b/apps/browser/src/autofill/clipboard/clear-clipboard.ts index f8018bb036..426d653951 100644 --- a/apps/browser/src/autofill/clipboard/clear-clipboard.ts +++ b/apps/browser/src/autofill/clipboard/clear-clipboard.ts @@ -1,11 +1,9 @@ import { BrowserApi } from "../../platform/browser/browser-api"; -export const clearClipboardAlarmName = "clearClipboard"; - export class ClearClipboard { /** We currently rely on an active tab with an injected content script (`../content/misc-utils.ts`) to clear the clipboard via `window.navigator.clipboard.writeText(text)` - + With https://bugs.chromium.org/p/chromium/issues/detail?id=1160302 it was said that service workers, would have access to the clipboard api and then we could migrate to a simpler solution */ diff --git a/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.spec.ts b/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.spec.ts index 522da22924..d0d42cc06f 100644 --- a/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.spec.ts +++ b/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.spec.ts @@ -1,30 +1,45 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, Subscription } from "rxjs"; import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; -import { setAlarmTime } from "../../platform/alarms/alarm-state"; import { BrowserApi } from "../../platform/browser/browser-api"; +import { BrowserTaskSchedulerService } from "../../platform/services/abstractions/browser-task-scheduler.service"; -import { clearClipboardAlarmName } from "./clear-clipboard"; +import { ClearClipboard } from "./clear-clipboard"; import { GeneratePasswordToClipboardCommand } from "./generate-password-to-clipboard-command"; -jest.mock("../../platform/alarms/alarm-state", () => { +jest.mock("rxjs", () => { + const actual = jest.requireActual("rxjs"); return { - setAlarmTime: jest.fn(), + ...actual, + firstValueFrom: jest.fn(), }; }); -const setAlarmTimeMock = setAlarmTime as jest.Mock; - describe("GeneratePasswordToClipboardCommand", () => { let passwordGenerationService: MockProxy; let autofillSettingsService: MockProxy; + let browserTaskSchedulerService: MockProxy; let sut: GeneratePasswordToClipboardCommand; beforeEach(() => { passwordGenerationService = mock(); + autofillSettingsService = mock(); + browserTaskSchedulerService = mock({ + setTimeout: jest.fn((taskName, timeoutInMs) => { + const timeoutHandle = setTimeout(() => { + if (taskName === ScheduledTaskNames.generatePasswordClearClipboardTimeout) { + void ClearClipboard.run(); + } + }, timeoutInMs); + + return new Subscription(() => clearTimeout(timeoutHandle)); + }), + }); passwordGenerationService.getOptions.mockResolvedValue([{ length: 8 }, {} as any]); @@ -35,6 +50,7 @@ describe("GeneratePasswordToClipboardCommand", () => { sut = new GeneratePasswordToClipboardCommand( passwordGenerationService, autofillSettingsService, + browserTaskSchedulerService, ); }); @@ -44,20 +60,24 @@ describe("GeneratePasswordToClipboardCommand", () => { describe("generatePasswordToClipboard", () => { it("has clear clipboard value", async () => { - jest.spyOn(sut as any, "getClearClipboard").mockImplementation(() => 5 * 60); // 5 minutes + jest.useFakeTimers(); + jest.spyOn(ClearClipboard, "run"); + (firstValueFrom as jest.Mock).mockResolvedValue(2 * 60); // 2 minutes await sut.generatePasswordToClipboard({ id: 1 } as any); + jest.advanceTimersByTime(2 * 60 * 1000); expect(jest.spyOn(BrowserApi, "sendTabsMessage")).toHaveBeenCalledTimes(1); - expect(jest.spyOn(BrowserApi, "sendTabsMessage")).toHaveBeenCalledWith(1, { command: "copyText", text: "PASSWORD", }); - - expect(setAlarmTimeMock).toHaveBeenCalledTimes(1); - - expect(setAlarmTimeMock).toHaveBeenCalledWith(clearClipboardAlarmName, expect.any(Number)); + expect(browserTaskSchedulerService.setTimeout).toHaveBeenCalledTimes(1); + expect(browserTaskSchedulerService.setTimeout).toHaveBeenCalledWith( + ScheduledTaskNames.generatePasswordClearClipboardTimeout, + expect.any(Number), + ); + expect(ClearClipboard.run).toHaveBeenCalledTimes(1); }); it("does not have clear clipboard value", async () => { @@ -71,8 +91,7 @@ describe("GeneratePasswordToClipboardCommand", () => { command: "copyText", text: "PASSWORD", }); - - expect(setAlarmTimeMock).not.toHaveBeenCalled(); + expect(browserTaskSchedulerService.setTimeout).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.ts b/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.ts index dadd61fbd1..cf3bc311ae 100644 --- a/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.ts +++ b/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.ts @@ -1,18 +1,25 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Subscription } from "rxjs"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { TaskSchedulerService, ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; -import { setAlarmTime } from "../../platform/alarms/alarm-state"; - -import { clearClipboardAlarmName } from "./clear-clipboard"; +import { ClearClipboard } from "./clear-clipboard"; import { copyToClipboard } from "./copy-to-clipboard-command"; export class GeneratePasswordToClipboardCommand { + private clearClipboardSubscription: Subscription; + constructor( private passwordGenerationService: PasswordGenerationServiceAbstraction, private autofillSettingsService: AutofillSettingsServiceAbstraction, - ) {} + private taskSchedulerService: TaskSchedulerService, + ) { + this.taskSchedulerService.registerTaskHandler( + ScheduledTaskNames.generatePasswordClearClipboardTimeout, + () => ClearClipboard.run(), + ); + } async getClearClipboard() { return await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$); @@ -22,14 +29,18 @@ export class GeneratePasswordToClipboardCommand { const [options] = await this.passwordGenerationService.getOptions(); const password = await this.passwordGenerationService.generatePassword(options); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - copyToClipboard(tab, password); + await copyToClipboard(tab, password); - const clearClipboard = await this.getClearClipboard(); - - if (clearClipboard != null) { - await setAlarmTime(clearClipboardAlarmName, clearClipboard * 1000); + const clearClipboardDelayInSeconds = await this.getClearClipboard(); + if (!clearClipboardDelayInSeconds) { + return; } + + const timeoutInMs = clearClipboardDelayInSeconds * 1000; + this.clearClipboardSubscription?.unsubscribe(); + this.clearClipboardSubscription = this.taskSchedulerService.setTimeout( + ScheduledTaskNames.generatePasswordClearClipboardTimeout, + timeoutInMs, + ); } } diff --git a/apps/browser/src/autofill/spec/testing-utils.ts b/apps/browser/src/autofill/spec/testing-utils.ts index 5b0db5ebd6..ba7a584498 100644 --- a/apps/browser/src/autofill/spec/testing-utils.ts +++ b/apps/browser/src/autofill/spec/testing-utils.ts @@ -1,21 +1,21 @@ import { mock } from "jest-mock-extended"; -function triggerTestFailure() { +export function triggerTestFailure() { expect(true).toBe("Test has failed."); } const scheduler = typeof setImmediate === "function" ? setImmediate : setTimeout; -function flushPromises() { +export function flushPromises() { return new Promise(function (resolve) { scheduler(resolve); }); } -function postWindowMessage(data: any, origin = "https://localhost/", source = window) { +export function postWindowMessage(data: any, origin = "https://localhost/", source = window) { globalThis.dispatchEvent(new MessageEvent("message", { data, origin, source })); } -function sendMockExtensionMessage( +export function sendMockExtensionMessage( message: any, sender?: chrome.runtime.MessageSender, sendResponse?: CallableFunction, @@ -32,7 +32,7 @@ function sendMockExtensionMessage( ); } -function triggerRuntimeOnConnectEvent(port: chrome.runtime.Port) { +export function triggerRuntimeOnConnectEvent(port: chrome.runtime.Port) { (chrome.runtime.onConnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach( (call) => { const callback = call[0]; @@ -41,21 +41,21 @@ function triggerRuntimeOnConnectEvent(port: chrome.runtime.Port) { ); } -function sendPortMessage(port: chrome.runtime.Port, message: any) { +export function sendPortMessage(port: chrome.runtime.Port, message: any) { (port.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { const callback = call[0]; callback(message || {}, port); }); } -function triggerPortOnDisconnectEvent(port: chrome.runtime.Port) { +export function triggerPortOnDisconnectEvent(port: chrome.runtime.Port) { (port.onDisconnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { const callback = call[0]; callback(port); }); } -function triggerWindowOnFocusedChangedEvent(windowId: number) { +export function triggerWindowOnFocusedChangedEvent(windowId: number) { (chrome.windows.onFocusChanged.addListener as unknown as jest.SpyInstance).mock.calls.forEach( (call) => { const callback = call[0]; @@ -64,7 +64,7 @@ function triggerWindowOnFocusedChangedEvent(windowId: number) { ); } -function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.TabActiveInfo) { +export function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.TabActiveInfo) { (chrome.tabs.onActivated.addListener as unknown as jest.SpyInstance).mock.calls.forEach( (call) => { const callback = call[0]; @@ -73,14 +73,14 @@ function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.TabActiveInfo) { ); } -function triggerTabOnReplacedEvent(addedTabId: number, removedTabId: number) { +export function triggerTabOnReplacedEvent(addedTabId: number, removedTabId: number) { (chrome.tabs.onReplaced.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { const callback = call[0]; callback(addedTabId, removedTabId); }); } -function triggerTabOnUpdatedEvent( +export function triggerTabOnUpdatedEvent( tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab, @@ -91,14 +91,21 @@ function triggerTabOnUpdatedEvent( }); } -function triggerTabOnRemovedEvent(tabId: number, removeInfo: chrome.tabs.TabRemoveInfo) { +export function triggerTabOnRemovedEvent(tabId: number, removeInfo: chrome.tabs.TabRemoveInfo) { (chrome.tabs.onRemoved.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { const callback = call[0]; callback(tabId, removeInfo); }); } -function mockQuerySelectorAllDefinedCall() { +export function triggerOnAlarmEvent(alarm: chrome.alarms.Alarm) { + (chrome.alarms.onAlarm.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { + const callback = call[0]; + callback(alarm); + }); +} + +export function mockQuerySelectorAllDefinedCall() { const originalDocumentQuerySelectorAll = document.querySelectorAll; document.querySelectorAll = function (selector: string) { return originalDocumentQuerySelectorAll.call( @@ -125,19 +132,3 @@ function mockQuerySelectorAllDefinedCall() { }, }; } - -export { - triggerTestFailure, - flushPromises, - postWindowMessage, - sendMockExtensionMessage, - triggerRuntimeOnConnectEvent, - sendPortMessage, - triggerPortOnDisconnectEvent, - triggerWindowOnFocusedChangedEvent, - triggerTabOnActivatedEvent, - triggerTabOnReplacedEvent, - triggerTabOnUpdatedEvent, - triggerTabOnRemovedEvent, - mockQuerySelectorAllDefinedCall, -}; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 9809f6a340..35e674cfd1 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -105,6 +105,7 @@ import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize"; import { Account } from "@bitwarden/common/platform/models/domain/account"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; @@ -216,6 +217,7 @@ import { ChromeMessageSender } from "../platform/messaging/chrome-message.sender /* eslint-enable no-restricted-imports */ import { OffscreenDocumentService } from "../platform/offscreen-document/abstractions/offscreen-document"; import { DefaultOffscreenDocumentService } from "../platform/offscreen-document/offscreen-document.service"; +import { BrowserTaskSchedulerService } from "../platform/services/abstractions/browser-task-scheduler.service"; import { BrowserCryptoService } from "../platform/services/browser-crypto.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import BrowserLocalStorageService from "../platform/services/browser-local-storage.service"; @@ -225,6 +227,8 @@ import I18nService from "../platform/services/i18n.service"; import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service"; import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service"; import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; +import { BackgroundTaskSchedulerService } from "../platform/services/task-scheduler/background-task-scheduler.service"; +import { ForegroundTaskSchedulerService } from "../platform/services/task-scheduler/foreground-task-scheduler.service"; import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service"; import { BrowserStorageServiceProvider } from "../platform/storage/browser-storage-service.provider"; import { ForegroundMemoryStorageService } from "../platform/storage/foreground-memory-storage.service"; @@ -322,6 +326,7 @@ export default class MainBackground { activeUserStateProvider: ActiveUserStateProvider; derivedStateProvider: DerivedStateProvider; stateProvider: StateProvider; + taskSchedulerService: BrowserTaskSchedulerService; fido2Background: Fido2BackgroundAbstraction; individualVaultExportService: IndividualVaultExportServiceAbstraction; organizationVaultExportService: OrganizationVaultExportServiceAbstraction; @@ -511,6 +516,14 @@ export default class MainBackground { this.globalStateProvider, this.derivedStateProvider, ); + + this.taskSchedulerService = this.popupOnlyContext + ? new ForegroundTaskSchedulerService(this.logService, this.stateProvider) + : new BackgroundTaskSchedulerService(this.logService, this.stateProvider); + this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.scheduleNextSyncInterval, () => + this.fullSync(), + ); + this.environmentService = new BrowserEnvironmentService( this.logService, this.stateProvider, @@ -779,6 +792,8 @@ export default class MainBackground { this.authService, this.vaultTimeoutSettingsService, this.stateEventRunnerService, + this.taskSchedulerService, + this.logService, lockedCallback, logoutCallback, ); @@ -858,6 +873,7 @@ export default class MainBackground { this.stateProvider, this.logService, this.authService, + this.taskSchedulerService, ); this.eventCollectionService = new EventCollectionService( this.cipherService, @@ -935,6 +951,7 @@ export default class MainBackground { this.stateService, this.authService, this.messagingService, + this.taskSchedulerService, ); this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService); @@ -950,16 +967,17 @@ export default class MainBackground { this.authService, this.vaultSettingsService, this.domainSettingsService, + this.taskSchedulerService, this.logService, ); - const systemUtilsServiceReloadCallback = () => { + const systemUtilsServiceReloadCallback = async () => { const forceWindowReload = this.platformUtilsService.isSafari() || this.platformUtilsService.isFirefox() || this.platformUtilsService.isOpera(); + await this.taskSchedulerService.clearAllScheduledTasks(); BrowserApi.reloadExtension(forceWindowReload ? self : null); - return Promise.resolve(); }; this.systemService = new SystemService( @@ -971,6 +989,7 @@ export default class MainBackground { this.vaultTimeoutSettingsService, this.biometricStateService, this.accountService, + this.taskSchedulerService, ); // Other fields @@ -1184,7 +1203,12 @@ export default class MainBackground { setTimeout(async () => { await this.refreshBadge(); await this.fullSync(true); + await this.taskSchedulerService.setInterval( + ScheduledTaskNames.scheduleNextSyncInterval, + 5 * 60 * 1000, // check every 5 minutes + ); setTimeout(() => this.notificationsService.init(), 2500); + await this.taskSchedulerService.verifyAlarmsState(); resolve(); }, 500); }); @@ -1453,17 +1477,6 @@ export default class MainBackground { if (override || lastSyncAgo >= syncInternal) { await this.syncService.fullSync(override); - this.scheduleNextSync(); - } else { - this.scheduleNextSync(); } } - - private scheduleNextSync() { - if (this.syncTimeout) { - clearTimeout(this.syncTimeout); - } - - this.syncTimeout = setTimeout(async () => await this.fullSync(), 5 * 60 * 1000); // check every 5 minutes - } } diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index b1c51911ec..b9ab9e0dd9 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -59,6 +59,7 @@ "clipboardRead", "clipboardWrite", "idle", + "alarms", "webRequest", "webRequestBlocking", "webNavigation" diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 40060a7fd9..b9eac49764 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -59,6 +59,7 @@ "clipboardRead", "clipboardWrite", "idle", + "alarms", "scripting", "offscreen", "webRequest", diff --git a/apps/browser/src/platform/alarms/alarm-state.ts b/apps/browser/src/platform/alarms/alarm-state.ts deleted file mode 100644 index fa18e26ed1..0000000000 --- a/apps/browser/src/platform/alarms/alarm-state.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { clearClipboardAlarmName } from "../../autofill/clipboard"; -import { BrowserApi } from "../browser/browser-api"; - -export const alarmKeys = [clearClipboardAlarmName] as const; -export type AlarmKeys = (typeof alarmKeys)[number]; - -type AlarmState = { [T in AlarmKeys]: number | undefined }; - -const alarmState: AlarmState = { - clearClipboard: null, - //TODO once implemented vaultTimeout: null; - //TODO once implemented checkNotifications: null; - //TODO once implemented (if necessary) processReload: null; -}; - -/** - * Retrieves the set alarm time (planned execution) for a give an commandName {@link AlarmState} - * @param commandName A command that has been previously registered with {@link AlarmState} - * @returns {Promise} null or Unix epoch timestamp when the alarm action is supposed to execute - * @example - * // getAlarmTime(clearClipboard) - */ -export async function getAlarmTime(commandName: AlarmKeys): Promise { - let alarmTime: number; - if (BrowserApi.isManifestVersion(3)) { - const fromSessionStore = await chrome.storage.session.get(commandName); - alarmTime = fromSessionStore[commandName]; - } else { - alarmTime = alarmState[commandName]; - } - - return alarmTime; -} - -/** - * Registers an action that should execute after the given time has passed - * @param commandName A command that has been previously registered with {@link AlarmState} - * @param delay_ms The number of ms from now in which the command should execute from - * @example - * // setAlarmTime(clearClipboard, 5000) register the clearClipboard action which will execute when at least 5 seconds from now have passed - */ -export async function setAlarmTime(commandName: AlarmKeys, delay_ms: number): Promise { - if (!delay_ms || delay_ms === 0) { - await this.clearAlarmTime(commandName); - return; - } - - const time = Date.now() + delay_ms; - await setAlarmTimeInternal(commandName, time); -} - -/** - * Clears the time currently set for a given command - * @param commandName A command that has been previously registered with {@link AlarmState} - */ -export async function clearAlarmTime(commandName: AlarmKeys): Promise { - await setAlarmTimeInternal(commandName, null); -} - -async function setAlarmTimeInternal(commandName: AlarmKeys, time: number): Promise { - if (BrowserApi.isManifestVersion(3)) { - await chrome.storage.session.set({ [commandName]: time }); - } else { - alarmState[commandName] = time; - } -} diff --git a/apps/browser/src/platform/alarms/on-alarm-listener.ts b/apps/browser/src/platform/alarms/on-alarm-listener.ts deleted file mode 100644 index 274f19f789..0000000000 --- a/apps/browser/src/platform/alarms/on-alarm-listener.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ClearClipboard, clearClipboardAlarmName } from "../../autofill/clipboard"; - -import { alarmKeys, clearAlarmTime, getAlarmTime } from "./alarm-state"; - -export const onAlarmListener = async (alarm: chrome.alarms.Alarm) => { - alarmKeys.forEach(async (key) => { - const executionTime = await getAlarmTime(key); - if (!executionTime) { - return; - } - - const currentDate = Date.now(); - if (executionTime > currentDate) { - return; - } - - await clearAlarmTime(key); - - switch (key) { - case clearClipboardAlarmName: - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - ClearClipboard.run(); - break; - default: - } - }); -}; diff --git a/apps/browser/src/platform/alarms/register-alarms.ts b/apps/browser/src/platform/alarms/register-alarms.ts deleted file mode 100644 index 86b9fb9774..0000000000 --- a/apps/browser/src/platform/alarms/register-alarms.ts +++ /dev/null @@ -1,31 +0,0 @@ -const NUMBER_OF_ALARMS = 6; - -export function registerAlarms() { - alarmsToBeCreated(NUMBER_OF_ALARMS); -} - -/** - * Creates staggered alarms that periodically (1min) raise OnAlarm events. The staggering is calculated based on the number of alarms passed in. - * @param numberOfAlarms Number of named alarms, that shall be registered - * @example - * // alarmsToBeCreated(2) results in 2 alarms separated by 30 seconds - * @example - * // alarmsToBeCreated(4) results in 4 alarms separated by 15 seconds - * @example - * // alarmsToBeCreated(6) results in 6 alarms separated by 10 seconds - * @example - * // alarmsToBeCreated(60) results in 60 alarms separated by 1 second - */ -function alarmsToBeCreated(numberOfAlarms: number): void { - const oneMinuteInMs = 60 * 1000; - const offset = oneMinuteInMs / numberOfAlarms; - - let calculatedWhen: number = Date.now() + offset; - - for (let index = 0; index < numberOfAlarms; index++) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - chrome.alarms.create(`bw_alarm${index}`, { periodInMinutes: 1, when: calculatedWhen }); - calculatedWhen += offset; - } -} diff --git a/apps/browser/src/platform/listeners/on-command-listener.ts b/apps/browser/src/platform/listeners/on-command-listener.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/browser/src/platform/services/abstractions/browser-task-scheduler.service.ts b/apps/browser/src/platform/services/abstractions/browser-task-scheduler.service.ts new file mode 100644 index 0000000000..58c4eb4889 --- /dev/null +++ b/apps/browser/src/platform/services/abstractions/browser-task-scheduler.service.ts @@ -0,0 +1,33 @@ +import { Observable } from "rxjs"; + +import { TaskSchedulerService, ScheduledTaskName } from "@bitwarden/common/platform/scheduling"; + +export const BrowserTaskSchedulerPortName = "browser-task-scheduler-port"; + +export const BrowserTaskSchedulerPortActions = { + setTimeout: "setTimeout", + setInterval: "setInterval", + clearAlarm: "clearAlarm", +} as const; +export type BrowserTaskSchedulerPortAction = keyof typeof BrowserTaskSchedulerPortActions; + +export type BrowserTaskSchedulerPortMessage = { + action: BrowserTaskSchedulerPortAction; + taskName: ScheduledTaskName; + alarmName?: string; + delayInMs?: number; + intervalInMs?: number; +}; + +export type ActiveAlarm = { + alarmName: string; + startTime: number; + createInfo: chrome.alarms.AlarmCreateInfo; +}; + +export abstract class BrowserTaskSchedulerService extends TaskSchedulerService { + activeAlarms$: Observable; + abstract clearAllScheduledTasks(): Promise; + abstract verifyAlarmsState(): Promise; + abstract clearScheduledAlarm(alarmName: string): Promise; +} diff --git a/apps/browser/src/platform/services/task-scheduler/background-task-scheduler.service.spec.ts b/apps/browser/src/platform/services/task-scheduler/background-task-scheduler.service.spec.ts new file mode 100644 index 0000000000..ded57a5e85 --- /dev/null +++ b/apps/browser/src/platform/services/task-scheduler/background-task-scheduler.service.spec.ts @@ -0,0 +1,129 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { Observable } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; +import { GlobalState, StateProvider } from "@bitwarden/common/platform/state"; + +import { createPortSpyMock } from "../../../autofill/spec/autofill-mocks"; +import { + flushPromises, + sendPortMessage, + triggerPortOnDisconnectEvent, + triggerRuntimeOnConnectEvent, +} from "../../../autofill/spec/testing-utils"; +import { + BrowserTaskSchedulerPortActions, + BrowserTaskSchedulerPortName, +} from "../abstractions/browser-task-scheduler.service"; + +import { BackgroundTaskSchedulerService } from "./background-task-scheduler.service"; + +describe("BackgroundTaskSchedulerService", () => { + let logService: MockProxy; + let stateProvider: MockProxy; + let globalStateMock: MockProxy>; + let portMock: chrome.runtime.Port; + let backgroundTaskSchedulerService: BackgroundTaskSchedulerService; + + beforeEach(() => { + logService = mock(); + globalStateMock = mock>({ + state$: mock>(), + update: jest.fn((callback) => callback([], {} as any)), + }); + stateProvider = mock({ + getGlobal: jest.fn(() => globalStateMock), + }); + portMock = createPortSpyMock(BrowserTaskSchedulerPortName); + backgroundTaskSchedulerService = new BackgroundTaskSchedulerService(logService, stateProvider); + jest.spyOn(globalThis, "setTimeout"); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("ports on connect", () => { + it("ignores port connections that do not have the correct task scheduler port name", () => { + const portMockWithDifferentName = createPortSpyMock("different-name"); + triggerRuntimeOnConnectEvent(portMockWithDifferentName); + + expect(portMockWithDifferentName.onMessage.addListener).not.toHaveBeenCalled(); + expect(portMockWithDifferentName.onDisconnect.addListener).not.toHaveBeenCalled(); + }); + + it("sets up onMessage and onDisconnect listeners for connected ports", () => { + triggerRuntimeOnConnectEvent(portMock); + + expect(portMock.onMessage.addListener).toHaveBeenCalled(); + expect(portMock.onDisconnect.addListener).toHaveBeenCalled(); + }); + }); + + describe("ports on disconnect", () => { + it("removes the port from the set of connected ports", () => { + triggerRuntimeOnConnectEvent(portMock); + expect(backgroundTaskSchedulerService["ports"].size).toBe(1); + + triggerPortOnDisconnectEvent(portMock); + expect(backgroundTaskSchedulerService["ports"].size).toBe(0); + expect(portMock.onMessage.removeListener).toHaveBeenCalled(); + expect(portMock.onDisconnect.removeListener).toHaveBeenCalled(); + }); + }); + + describe("port message handlers", () => { + beforeEach(() => { + triggerRuntimeOnConnectEvent(portMock); + backgroundTaskSchedulerService.registerTaskHandler( + ScheduledTaskNames.loginStrategySessionTimeout, + jest.fn(), + ); + }); + + it("sets a setTimeout backup alarm", async () => { + sendPortMessage(portMock, { + action: BrowserTaskSchedulerPortActions.setTimeout, + taskName: ScheduledTaskNames.loginStrategySessionTimeout, + delayInMs: 1000, + }); + await flushPromises(); + + expect(globalThis.setTimeout).toHaveBeenCalled(); + expect(chrome.alarms.create).toHaveBeenCalledWith( + ScheduledTaskNames.loginStrategySessionTimeout, + { delayInMinutes: 0.5 }, + expect.any(Function), + ); + }); + + it("sets a setInterval backup alarm", async () => { + sendPortMessage(portMock, { + action: BrowserTaskSchedulerPortActions.setInterval, + taskName: ScheduledTaskNames.loginStrategySessionTimeout, + intervalInMs: 600000, + }); + await flushPromises(); + + expect(chrome.alarms.create).toHaveBeenCalledWith( + ScheduledTaskNames.loginStrategySessionTimeout, + { delayInMinutes: 10, periodInMinutes: 10 }, + expect.any(Function), + ); + }); + + it("clears a scheduled alarm", async () => { + sendPortMessage(portMock, { + action: BrowserTaskSchedulerPortActions.clearAlarm, + alarmName: ScheduledTaskNames.loginStrategySessionTimeout, + }); + await flushPromises(); + + expect(chrome.alarms.clear).toHaveBeenCalledWith( + ScheduledTaskNames.loginStrategySessionTimeout, + expect.any(Function), + ); + }); + }); +}); diff --git a/apps/browser/src/platform/services/task-scheduler/background-task-scheduler.service.ts b/apps/browser/src/platform/services/task-scheduler/background-task-scheduler.service.ts new file mode 100644 index 0000000000..23b580988f --- /dev/null +++ b/apps/browser/src/platform/services/task-scheduler/background-task-scheduler.service.ts @@ -0,0 +1,75 @@ +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; + +import { BrowserApi } from "../../browser/browser-api"; +import { + BrowserTaskSchedulerPortActions, + BrowserTaskSchedulerPortMessage, + BrowserTaskSchedulerPortName, +} from "../abstractions/browser-task-scheduler.service"; + +import { BrowserTaskSchedulerServiceImplementation } from "./browser-task-scheduler.service"; + +export class BackgroundTaskSchedulerService extends BrowserTaskSchedulerServiceImplementation { + private ports: Set = new Set(); + + constructor(logService: LogService, stateProvider: StateProvider) { + super(logService, stateProvider); + + BrowserApi.addListener(chrome.runtime.onConnect, this.handlePortOnConnect); + } + + /** + * Handles a port connection made from the foreground task scheduler. + * + * @param port - The port that was connected. + */ + private handlePortOnConnect = (port: chrome.runtime.Port) => { + if (port.name !== BrowserTaskSchedulerPortName) { + return; + } + + this.ports.add(port); + port.onMessage.addListener(this.handlePortMessage); + port.onDisconnect.addListener(this.handlePortOnDisconnect); + }; + + /** + * Handles a port disconnection. + * + * @param port - The port that was disconnected. + */ + private handlePortOnDisconnect = (port: chrome.runtime.Port) => { + port.onMessage.removeListener(this.handlePortMessage); + port.onDisconnect.removeListener(this.handlePortOnDisconnect); + this.ports.delete(port); + }; + + /** + * Handles a message from a port. + * + * @param message - The message that was received. + * @param port - The port that sent the message. + */ + private handlePortMessage = ( + message: BrowserTaskSchedulerPortMessage, + port: chrome.runtime.Port, + ) => { + const isTaskSchedulerPort = port.name === BrowserTaskSchedulerPortName; + const { action, taskName, alarmName, delayInMs, intervalInMs } = message; + + if (isTaskSchedulerPort && action === BrowserTaskSchedulerPortActions.setTimeout) { + super.setTimeout(taskName, delayInMs); + return; + } + + if (isTaskSchedulerPort && action === BrowserTaskSchedulerPortActions.setInterval) { + super.setInterval(taskName, intervalInMs); + return; + } + + if (isTaskSchedulerPort && action === BrowserTaskSchedulerPortActions.clearAlarm) { + super.clearScheduledAlarm(alarmName).catch((error) => this.logService.error(error)); + } + }; +} diff --git a/apps/browser/src/platform/services/task-scheduler/browser-task-scheduler.service.spec.ts b/apps/browser/src/platform/services/task-scheduler/browser-task-scheduler.service.spec.ts new file mode 100644 index 0000000000..d72ba94205 --- /dev/null +++ b/apps/browser/src/platform/services/task-scheduler/browser-task-scheduler.service.spec.ts @@ -0,0 +1,463 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, Observable } from "rxjs"; + +import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; +import { GlobalState, StateProvider } from "@bitwarden/common/platform/state"; + +import { flushPromises, triggerOnAlarmEvent } from "../../../autofill/spec/testing-utils"; +import { + ActiveAlarm, + BrowserTaskSchedulerService, +} from "../abstractions/browser-task-scheduler.service"; + +import { BrowserTaskSchedulerServiceImplementation } from "./browser-task-scheduler.service"; + +jest.mock("rxjs", () => { + const actualModule = jest.requireActual("rxjs"); + return { + ...actualModule, + firstValueFrom: jest.fn((state$: BehaviorSubject) => state$.value), + }; +}); + +function setupGlobalBrowserMock(overrides: Partial = {}) { + globalThis.browser.alarms = { + create: jest.fn(), + clear: jest.fn(), + get: jest.fn(), + getAll: jest.fn(), + clearAll: jest.fn(), + onAlarm: { + addListener: jest.fn(), + removeListener: jest.fn(), + hasListener: jest.fn(), + }, + ...overrides, + }; +} + +describe("BrowserTaskSchedulerService", () => { + const callback = jest.fn(); + const delayInMinutes = 2; + let activeAlarmsMock$: BehaviorSubject; + let logService: MockProxy; + let stateProvider: MockProxy; + let globalStateMock: MockProxy>; + let browserTaskSchedulerService: BrowserTaskSchedulerService; + let activeAlarms: ActiveAlarm[] = []; + const eventUploadsIntervalCreateInfo = { periodInMinutes: 5, delayInMinutes: 5 }; + const scheduleNextSyncIntervalCreateInfo = { periodInMinutes: 5, delayInMinutes: 5 }; + + beforeEach(() => { + jest.useFakeTimers(); + activeAlarms = [ + mock({ + alarmName: ScheduledTaskNames.eventUploadsInterval, + createInfo: eventUploadsIntervalCreateInfo, + }), + mock({ + alarmName: ScheduledTaskNames.scheduleNextSyncInterval, + createInfo: scheduleNextSyncIntervalCreateInfo, + }), + mock({ + alarmName: ScheduledTaskNames.fido2ClientAbortTimeout, + startTime: Date.now() - 60001, + createInfo: { delayInMinutes: 1, periodInMinutes: undefined }, + }), + ]; + activeAlarmsMock$ = new BehaviorSubject(activeAlarms); + logService = mock(); + globalStateMock = mock>({ + state$: mock>(), + update: jest.fn((callback) => callback([], {} as any)), + }); + stateProvider = mock({ + getGlobal: jest.fn(() => globalStateMock), + }); + browserTaskSchedulerService = new BrowserTaskSchedulerServiceImplementation( + logService, + stateProvider, + ); + browserTaskSchedulerService.activeAlarms$ = activeAlarmsMock$; + browserTaskSchedulerService.registerTaskHandler( + ScheduledTaskNames.loginStrategySessionTimeout, + callback, + ); + // @ts-expect-error mocking global browser object + // eslint-disable-next-line no-global-assign + globalThis.browser = {}; + chrome.alarms.get = jest.fn().mockImplementation((_name, callback) => callback(undefined)); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useRealTimers(); + + // eslint-disable-next-line no-global-assign + globalThis.browser = undefined; + }); + + describe("setTimeout", () => { + it("triggers an error when setting a timeout for a task that is not registered", async () => { + expect(() => + browserTaskSchedulerService.setTimeout( + ScheduledTaskNames.notificationsReconnectTimeout, + 1000, + ), + ).toThrow( + `Task handler for ${ScheduledTaskNames.notificationsReconnectTimeout} not registered. Unable to schedule task.`, + ); + }); + + it("creates a timeout alarm", async () => { + browserTaskSchedulerService.setTimeout( + ScheduledTaskNames.loginStrategySessionTimeout, + delayInMinutes * 60 * 1000, + ); + await flushPromises(); + + expect(chrome.alarms.create).toHaveBeenCalledWith( + ScheduledTaskNames.loginStrategySessionTimeout, + { delayInMinutes }, + expect.any(Function), + ); + }); + + it("skips creating a duplicate timeout alarm", async () => { + const mockAlarm = mock(); + chrome.alarms.get = jest.fn().mockImplementation((_name, callback) => callback(mockAlarm)); + + browserTaskSchedulerService.setTimeout( + ScheduledTaskNames.loginStrategySessionTimeout, + delayInMinutes * 60 * 1000, + ); + + expect(chrome.alarms.create).not.toHaveBeenCalled(); + }); + + describe("when the task is scheduled to be triggered in less than the minimum possible delay", () => { + const delayInMs = 25000; + + it("sets a timeout using the global setTimeout API", async () => { + jest.spyOn(globalThis, "setTimeout"); + + browserTaskSchedulerService.setTimeout( + ScheduledTaskNames.loginStrategySessionTimeout, + delayInMs, + ); + await flushPromises(); + + expect(globalThis.setTimeout).toHaveBeenCalledWith(expect.any(Function), delayInMs); + }); + + it("sets a fallback alarm", async () => { + const delayInMs = 15000; + browserTaskSchedulerService.setTimeout( + ScheduledTaskNames.loginStrategySessionTimeout, + delayInMs, + ); + await flushPromises(); + + expect(chrome.alarms.create).toHaveBeenCalledWith( + ScheduledTaskNames.loginStrategySessionTimeout, + { delayInMinutes: 0.5 }, + expect.any(Function), + ); + }); + + it("sets the fallback for a minimum of 1 minute if the environment not for Chrome", async () => { + setupGlobalBrowserMock(); + + browserTaskSchedulerService.setTimeout( + ScheduledTaskNames.loginStrategySessionTimeout, + delayInMs, + ); + await flushPromises(); + + expect(browser.alarms.create).toHaveBeenCalledWith( + ScheduledTaskNames.loginStrategySessionTimeout, + { delayInMinutes: 1 }, + ); + }); + + it("clears the fallback alarm when the setTimeout is triggered", async () => { + jest.useFakeTimers(); + + browserTaskSchedulerService.setTimeout( + ScheduledTaskNames.loginStrategySessionTimeout, + delayInMs, + ); + jest.advanceTimersByTime(delayInMs); + await flushPromises(); + + expect(chrome.alarms.clear).toHaveBeenCalledWith( + ScheduledTaskNames.loginStrategySessionTimeout, + expect.any(Function), + ); + }); + }); + + it("returns a subscription that can be used to clear the timeout", () => { + jest.spyOn(globalThis, "clearTimeout"); + + const timeoutSubscription = browserTaskSchedulerService.setTimeout( + ScheduledTaskNames.loginStrategySessionTimeout, + 10000, + ); + + timeoutSubscription.unsubscribe(); + + expect(chrome.alarms.clear).toHaveBeenCalledWith( + ScheduledTaskNames.loginStrategySessionTimeout, + expect.any(Function), + ); + expect(globalThis.clearTimeout).toHaveBeenCalled(); + }); + + it("clears alarms in non-chrome environments", () => { + setupGlobalBrowserMock(); + + const timeoutSubscription = browserTaskSchedulerService.setTimeout( + ScheduledTaskNames.loginStrategySessionTimeout, + 10000, + ); + timeoutSubscription.unsubscribe(); + + expect(browser.alarms.clear).toHaveBeenCalledWith( + ScheduledTaskNames.loginStrategySessionTimeout, + ); + }); + }); + + describe("setInterval", () => { + it("triggers an error when setting an interval for a task that is not registered", async () => { + expect(() => { + browserTaskSchedulerService.setInterval( + ScheduledTaskNames.notificationsReconnectTimeout, + 1000, + ); + }).toThrow( + `Task handler for ${ScheduledTaskNames.notificationsReconnectTimeout} not registered. Unable to schedule task.`, + ); + }); + + describe("setting an interval that is less than 1 minute", () => { + const intervalInMs = 10000; + + it("sets up stepped alarms that trigger behavior after the first minute of setInterval execution", async () => { + browserTaskSchedulerService.setInterval( + ScheduledTaskNames.loginStrategySessionTimeout, + intervalInMs, + ); + await flushPromises(); + + expect(chrome.alarms.create).toHaveBeenCalledWith( + `${ScheduledTaskNames.loginStrategySessionTimeout}__0`, + { periodInMinutes: 0.6666666666666666, delayInMinutes: 0.5 }, + expect.any(Function), + ); + expect(chrome.alarms.create).toHaveBeenCalledWith( + `${ScheduledTaskNames.loginStrategySessionTimeout}__1`, + { periodInMinutes: 0.6666666666666666, delayInMinutes: 0.6666666666666666 }, + expect.any(Function), + ); + expect(chrome.alarms.create).toHaveBeenCalledWith( + `${ScheduledTaskNames.loginStrategySessionTimeout}__2`, + { periodInMinutes: 0.6666666666666666, delayInMinutes: 0.8333333333333333 }, + expect.any(Function), + ); + expect(chrome.alarms.create).toHaveBeenCalledWith( + `${ScheduledTaskNames.loginStrategySessionTimeout}__3`, + { periodInMinutes: 0.6666666666666666, delayInMinutes: 1 }, + expect.any(Function), + ); + }); + + it("sets an interval using the global setInterval API", async () => { + jest.spyOn(globalThis, "setInterval"); + + browserTaskSchedulerService.setInterval( + ScheduledTaskNames.loginStrategySessionTimeout, + intervalInMs, + ); + await flushPromises(); + + expect(globalThis.setInterval).toHaveBeenCalledWith(expect.any(Function), intervalInMs); + }); + + it("clears the global setInterval instance once the interval has elapsed the minimum required delay for an alarm", async () => { + jest.useFakeTimers(); + jest.spyOn(globalThis, "clearInterval"); + + browserTaskSchedulerService.setInterval( + ScheduledTaskNames.loginStrategySessionTimeout, + intervalInMs, + ); + await flushPromises(); + jest.advanceTimersByTime(50000); + + expect(globalThis.clearInterval).toHaveBeenCalledWith(expect.any(Number)); + }); + }); + + it("creates an interval alarm", async () => { + const periodInMinutes = 2; + const initialDelayInMs = 1000; + + browserTaskSchedulerService.setInterval( + ScheduledTaskNames.loginStrategySessionTimeout, + periodInMinutes * 60 * 1000, + initialDelayInMs, + ); + await flushPromises(); + + expect(chrome.alarms.create).toHaveBeenCalledWith( + ScheduledTaskNames.loginStrategySessionTimeout, + { periodInMinutes, delayInMinutes: 0.5 }, + expect.any(Function), + ); + }); + + it("defaults the alarm's delay in minutes to the interval in minutes if the delay is not specified", async () => { + const periodInMinutes = 2; + browserTaskSchedulerService.setInterval( + ScheduledTaskNames.loginStrategySessionTimeout, + periodInMinutes * 60 * 1000, + ); + await flushPromises(); + + expect(chrome.alarms.create).toHaveBeenCalledWith( + ScheduledTaskNames.loginStrategySessionTimeout, + { periodInMinutes, delayInMinutes: periodInMinutes }, + expect.any(Function), + ); + }); + + it("returns a subscription that can be used to clear an interval alarm", () => { + jest.spyOn(globalThis, "clearInterval"); + + const intervalSubscription = browserTaskSchedulerService.setInterval( + ScheduledTaskNames.loginStrategySessionTimeout, + 600000, + ); + + intervalSubscription.unsubscribe(); + + expect(chrome.alarms.clear).toHaveBeenCalledWith( + ScheduledTaskNames.loginStrategySessionTimeout, + expect.any(Function), + ); + expect(globalThis.clearInterval).not.toHaveBeenCalled(); + }); + + it("returns a subscription that can be used to clear all stepped interval alarms", () => { + jest.spyOn(globalThis, "clearInterval"); + + const intervalSubscription = browserTaskSchedulerService.setInterval( + ScheduledTaskNames.loginStrategySessionTimeout, + 10000, + ); + + intervalSubscription.unsubscribe(); + + expect(chrome.alarms.clear).toHaveBeenCalledWith( + `${ScheduledTaskNames.loginStrategySessionTimeout}__0`, + expect.any(Function), + ); + expect(chrome.alarms.clear).toHaveBeenCalledWith( + `${ScheduledTaskNames.loginStrategySessionTimeout}__1`, + expect.any(Function), + ); + expect(chrome.alarms.clear).toHaveBeenCalledWith( + `${ScheduledTaskNames.loginStrategySessionTimeout}__2`, + expect.any(Function), + ); + expect(chrome.alarms.clear).toHaveBeenCalledWith( + `${ScheduledTaskNames.loginStrategySessionTimeout}__3`, + expect.any(Function), + ); + expect(globalThis.clearInterval).toHaveBeenCalled(); + }); + }); + + describe("verifyAlarmsState", () => { + it("skips recovering a scheduled task if an existing alarm for the task is present", async () => { + chrome.alarms.get = jest + .fn() + .mockImplementation((_name, callback) => callback(mock())); + + await browserTaskSchedulerService.verifyAlarmsState(); + + expect(chrome.alarms.create).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + }); + + describe("extension alarm is not set", () => { + it("triggers the task when the task should have triggered", async () => { + const fido2Callback = jest.fn(); + browserTaskSchedulerService.registerTaskHandler( + ScheduledTaskNames.fido2ClientAbortTimeout, + fido2Callback, + ); + + await browserTaskSchedulerService.verifyAlarmsState(); + + expect(fido2Callback).toHaveBeenCalled(); + }); + + it("schedules an alarm for the task when it has not yet triggered ", async () => { + const syncCallback = jest.fn(); + browserTaskSchedulerService.registerTaskHandler( + ScheduledTaskNames.scheduleNextSyncInterval, + syncCallback, + ); + + await browserTaskSchedulerService.verifyAlarmsState(); + + expect(chrome.alarms.create).toHaveBeenCalledWith( + ScheduledTaskNames.scheduleNextSyncInterval, + scheduleNextSyncIntervalCreateInfo, + expect.any(Function), + ); + }); + }); + }); + + describe("triggering a task", () => { + it("triggers a task when an onAlarm event is triggered", () => { + const alarm = mock({ + name: ScheduledTaskNames.loginStrategySessionTimeout, + }); + + triggerOnAlarmEvent(alarm); + + expect(callback).toHaveBeenCalled(); + }); + }); + + describe("clearAllScheduledTasks", () => { + it("clears all scheduled tasks and extension alarms", async () => { + // @ts-expect-error mocking global state update method + globalStateMock.update = jest.fn((callback) => { + const stateValue = callback([], {} as any); + activeAlarmsMock$.next(stateValue); + return stateValue; + }); + + await browserTaskSchedulerService.clearAllScheduledTasks(); + + expect(chrome.alarms.clearAll).toHaveBeenCalled(); + expect(activeAlarmsMock$.value).toEqual([]); + }); + + it("clears all extension alarms within a non Chrome environment", async () => { + setupGlobalBrowserMock(); + + await browserTaskSchedulerService.clearAllScheduledTasks(); + + expect(browser.alarms.clearAll).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/platform/services/task-scheduler/browser-task-scheduler.service.ts b/apps/browser/src/platform/services/task-scheduler/browser-task-scheduler.service.ts new file mode 100644 index 0000000000..187742f589 --- /dev/null +++ b/apps/browser/src/platform/services/task-scheduler/browser-task-scheduler.service.ts @@ -0,0 +1,427 @@ +import { firstValueFrom, map, Observable, Subscription } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { + DefaultTaskSchedulerService, + ScheduledTaskName, +} from "@bitwarden/common/platform/scheduling"; +import { + TASK_SCHEDULER_DISK, + GlobalState, + KeyDefinition, + StateProvider, +} from "@bitwarden/common/platform/state"; + +import { BrowserApi } from "../../browser/browser-api"; +import { + ActiveAlarm, + BrowserTaskSchedulerService, +} from "../abstractions/browser-task-scheduler.service"; + +const ACTIVE_ALARMS = new KeyDefinition(TASK_SCHEDULER_DISK, "activeAlarms", { + deserializer: (value: ActiveAlarm[]) => value ?? [], +}); + +export class BrowserTaskSchedulerServiceImplementation + extends DefaultTaskSchedulerService + implements BrowserTaskSchedulerService +{ + private activeAlarmsState: GlobalState; + readonly activeAlarms$: Observable; + + constructor( + logService: LogService, + private stateProvider: StateProvider, + ) { + super(logService); + + this.activeAlarmsState = this.stateProvider.getGlobal(ACTIVE_ALARMS); + this.activeAlarms$ = this.activeAlarmsState.state$.pipe( + map((activeAlarms) => activeAlarms ?? []), + ); + + this.setupOnAlarmListener(); + } + + /** + * Sets a timeout to execute a callback after a delay. If the delay is less + * than 1 minute, it will use the global setTimeout. Otherwise, it will + * create a browser extension alarm to handle the delay. + * + * @param taskName - The name of the task, used in defining the alarm. + * @param delayInMs - The delay in milliseconds. + */ + setTimeout(taskName: ScheduledTaskName, delayInMs: number): Subscription { + let timeoutHandle: number | NodeJS.Timeout; + this.validateRegisteredTask(taskName); + + const delayInMinutes = delayInMs / 1000 / 60; + this.scheduleAlarm(taskName, { + delayInMinutes: this.getUpperBoundDelayInMinutes(delayInMinutes), + }).catch((error) => this.logService.error("Failed to schedule alarm", error)); + + // If the delay is less than a minute, we want to attempt to trigger the task through a setTimeout. + // The alarm previously scheduled will be used as a backup in case the setTimeout fails. + if (delayInMinutes < this.getUpperBoundDelayInMinutes(delayInMinutes)) { + timeoutHandle = globalThis.setTimeout(async () => { + await this.clearScheduledAlarm(taskName); + await this.triggerTask(taskName); + }, delayInMs); + } + + return new Subscription(() => { + if (timeoutHandle) { + globalThis.clearTimeout(timeoutHandle); + } + this.clearScheduledAlarm(taskName).catch((error) => + this.logService.error("Failed to clear alarm", error), + ); + }); + } + + /** + * Sets an interval to execute a callback at each interval. If the interval is + * less than 1 minute, it will use the global setInterval. Otherwise, it will + * create a browser extension alarm to handle the interval. + * + * @param taskName - The name of the task, used in defining the alarm. + * @param intervalInMs - The interval in milliseconds. + * @param initialDelayInMs - The initial delay in milliseconds. + */ + setInterval( + taskName: ScheduledTaskName, + intervalInMs: number, + initialDelayInMs?: number, + ): Subscription { + this.validateRegisteredTask(taskName); + + const intervalInMinutes = intervalInMs / 1000 / 60; + const initialDelayInMinutes = initialDelayInMs + ? initialDelayInMs / 1000 / 60 + : intervalInMinutes; + + if (intervalInMinutes < this.getUpperBoundDelayInMinutes(intervalInMinutes)) { + return this.setupSteppedIntervalAlarms(taskName, intervalInMs); + } + + this.scheduleAlarm(taskName, { + periodInMinutes: this.getUpperBoundDelayInMinutes(intervalInMinutes), + delayInMinutes: this.getUpperBoundDelayInMinutes(initialDelayInMinutes), + }).catch((error) => this.logService.error("Failed to schedule alarm", error)); + + return new Subscription(() => + this.clearScheduledAlarm(taskName).catch((error) => + this.logService.error("Failed to clear alarm", error), + ), + ); + } + + /** + * Used in cases where the interval is less than 1 minute. This method will set up a setInterval + * to initialize expected recurring behavior, then create a series of alarms to handle the + * expected scheduled task through the alarms api. This is necessary because the alarms + * api does not support intervals less than 1 minute. + * + * @param taskName - The name of the task + * @param intervalInMs - The interval in milliseconds. + */ + private setupSteppedIntervalAlarms( + taskName: ScheduledTaskName, + intervalInMs: number, + ): Subscription { + const alarmMinDelayInMinutes = this.getAlarmMinDelayInMinutes(); + const intervalInMinutes = intervalInMs / 1000 / 60; + const numberOfAlarmsToCreate = Math.ceil(Math.ceil(1 / intervalInMinutes) / 2) + 1; + const steppedAlarmPeriodInMinutes = alarmMinDelayInMinutes + intervalInMinutes; + const steppedAlarmNames: string[] = []; + for (let alarmIndex = 0; alarmIndex < numberOfAlarmsToCreate; alarmIndex++) { + const steppedAlarmName = `${taskName}__${alarmIndex}`; + steppedAlarmNames.push(steppedAlarmName); + + const delayInMinutes = this.getUpperBoundDelayInMinutes( + alarmMinDelayInMinutes + intervalInMinutes * alarmIndex, + ); + + this.clearScheduledAlarm(steppedAlarmName) + .then(() => + this.scheduleAlarm(steppedAlarmName, { + periodInMinutes: steppedAlarmPeriodInMinutes, + delayInMinutes, + }).catch((error) => this.logService.error("Failed to schedule alarm", error)), + ) + .catch((error) => this.logService.error("Failed to clear alarm", error)); + } + + let elapsedMs = 0; + const intervalHandle: number | NodeJS.Timeout = globalThis.setInterval(async () => { + elapsedMs += intervalInMs; + const elapsedMinutes = elapsedMs / 1000 / 60; + + if (elapsedMinutes >= alarmMinDelayInMinutes) { + globalThis.clearInterval(intervalHandle); + return; + } + + await this.triggerTask(taskName, intervalInMinutes); + }, intervalInMs); + + return new Subscription(() => { + if (intervalHandle) { + globalThis.clearInterval(intervalHandle); + } + steppedAlarmNames.forEach((alarmName) => + this.clearScheduledAlarm(alarmName).catch((error) => + this.logService.error("Failed to clear alarm", error), + ), + ); + }); + } + + /** + * Clears all scheduled tasks by clearing all browser extension + * alarms and resetting the active alarms state. + */ + async clearAllScheduledTasks(): Promise { + await this.clearAllAlarms(); + await this.updateActiveAlarms([]); + } + + /** + * Verifies the state of the active alarms by checking if + * any alarms have been missed or need to be created. + */ + async verifyAlarmsState(): Promise { + const currentTime = Date.now(); + const activeAlarms = await this.getActiveAlarms(); + + for (const alarm of activeAlarms) { + const { alarmName, startTime, createInfo } = alarm; + const existingAlarm = await this.getAlarm(alarmName); + if (existingAlarm) { + continue; + } + + const shouldAlarmHaveBeenTriggered = createInfo.when && createInfo.when < currentTime; + const hasSetTimeoutAlarmExceededDelay = + !createInfo.periodInMinutes && + createInfo.delayInMinutes && + startTime + createInfo.delayInMinutes * 60 * 1000 < currentTime; + if (shouldAlarmHaveBeenTriggered || hasSetTimeoutAlarmExceededDelay) { + await this.triggerTask(alarmName); + continue; + } + + this.scheduleAlarm(alarmName, createInfo).catch((error) => + this.logService.error("Failed to schedule alarm", error), + ); + } + } + + /** + * Creates a browser extension alarm with the given name and create info. + * + * @param alarmName - The name of the alarm. + * @param createInfo - The alarm create info. + */ + private async scheduleAlarm( + alarmName: string, + createInfo: chrome.alarms.AlarmCreateInfo, + ): Promise { + const existingAlarm = await this.getAlarm(alarmName); + if (existingAlarm) { + this.logService.debug(`Alarm ${alarmName} already exists. Skipping creation.`); + return; + } + + await this.createAlarm(alarmName, createInfo); + await this.setActiveAlarm(alarmName, createInfo); + } + + /** + * Gets the active alarms from state. + */ + private async getActiveAlarms(): Promise { + return await firstValueFrom(this.activeAlarms$); + } + + /** + * Sets an active alarm in state. + * + * @param alarmName - The name of the active alarm to set. + * @param createInfo - The creation info of the active alarm. + */ + private async setActiveAlarm( + alarmName: string, + createInfo: chrome.alarms.AlarmCreateInfo, + ): Promise { + const activeAlarms = await this.getActiveAlarms(); + const filteredAlarms = activeAlarms.filter((alarm) => alarm.alarmName !== alarmName); + filteredAlarms.push({ + alarmName, + startTime: Date.now(), + createInfo, + }); + await this.updateActiveAlarms(filteredAlarms); + } + + /** + * Deletes an active alarm from state. + * + * @param alarmName - The name of the active alarm to delete. + */ + private async deleteActiveAlarm(alarmName: string): Promise { + const activeAlarms = await this.getActiveAlarms(); + const filteredAlarms = activeAlarms.filter((alarm) => alarm.alarmName !== alarmName); + await this.updateActiveAlarms(filteredAlarms || []); + } + + /** + * Clears a scheduled alarm by its name and deletes it from the active alarms state. + * + * @param alarmName - The name of the alarm to clear. + */ + async clearScheduledAlarm(alarmName: string): Promise { + const wasCleared = await this.clearAlarm(alarmName); + if (wasCleared) { + await this.deleteActiveAlarm(alarmName); + } + } + + /** + * Updates the active alarms state with the given alarms. + * + * @param alarms - The alarms to update the state with. + */ + private async updateActiveAlarms(alarms: ActiveAlarm[]): Promise { + await this.activeAlarmsState.update(() => alarms); + } + + /** + * Sets up the on alarm listener to handle alarms. + */ + private setupOnAlarmListener(): void { + BrowserApi.addListener(chrome.alarms.onAlarm, this.handleOnAlarm); + } + + /** + * Handles on alarm events, triggering the alarm if a handler exists. + * + * @param alarm - The alarm to handle. + */ + private handleOnAlarm = async (alarm: chrome.alarms.Alarm): Promise => { + const { name, periodInMinutes } = alarm; + await this.triggerTask(name, periodInMinutes); + }; + + /** + * Triggers an alarm by calling its handler and + * deleting it if it is a one-time alarm. + * + * @param alarmName - The name of the alarm to trigger. + * @param periodInMinutes - The period in minutes of an interval alarm. + */ + protected async triggerTask(alarmName: string, periodInMinutes?: number): Promise { + const taskName = this.getTaskFromAlarmName(alarmName); + const handler = this.taskHandlers.get(taskName); + if (!periodInMinutes) { + await this.deleteActiveAlarm(alarmName); + } + + if (handler) { + handler(); + } + } + + /** + * Parses and returns the task name from an alarm name. + * + * @param alarmName - The alarm name to parse. + */ + protected getTaskFromAlarmName(alarmName: string): ScheduledTaskName { + return alarmName.split("__")[0] as ScheduledTaskName; + } + + /** + * Clears a new alarm with the given name and create info. Returns a promise + * that indicates when the alarm has been cleared successfully. + * + * @param alarmName - The name of the alarm to create. + */ + private async clearAlarm(alarmName: string): Promise { + if (this.isNonChromeEnvironment()) { + return browser.alarms.clear(alarmName); + } + + return new Promise((resolve) => chrome.alarms.clear(alarmName, resolve)); + } + + /** + * Clears all alarms that have been set by the extension. Returns a promise + * that indicates when all alarms have been cleared successfully. + */ + private clearAllAlarms(): Promise { + if (this.isNonChromeEnvironment()) { + return browser.alarms.clearAll(); + } + + return new Promise((resolve) => chrome.alarms.clearAll(resolve)); + } + + /** + * Creates a new alarm with the given name and create info. + * + * @param alarmName - The name of the alarm to create. + * @param createInfo - The creation info for the alarm. + */ + private async createAlarm( + alarmName: string, + createInfo: chrome.alarms.AlarmCreateInfo, + ): Promise { + if (this.isNonChromeEnvironment()) { + return browser.alarms.create(alarmName, createInfo); + } + + return new Promise((resolve) => chrome.alarms.create(alarmName, createInfo, resolve)); + } + + /** + * Gets the alarm with the given name. + * + * @param alarmName - The name of the alarm to get. + */ + private getAlarm(alarmName: string): Promise { + if (this.isNonChromeEnvironment()) { + return browser.alarms.get(alarmName); + } + + return new Promise((resolve) => chrome.alarms.get(alarmName, resolve)); + } + + /** + * Checks if the environment is a non-Chrome environment. This is used to determine + * if the browser alarms API should be used in place of the chrome alarms API. This + * is necessary because the `chrome` polyfill that Mozilla implements does not allow + * passing the callback parameter in the same way most `chrome.alarm` api calls allow. + */ + private isNonChromeEnvironment(): boolean { + return typeof browser !== "undefined" && !!browser.alarms; + } + + /** + * Gets the minimum delay in minutes for an alarm. This is used to ensure that the + * delay is at least 1 minute in non-Chrome environments. In Chrome environments, the + * delay can be as low as 0.5 minutes. + */ + private getAlarmMinDelayInMinutes(): number { + return this.isNonChromeEnvironment() ? 1 : 0.5; + } + + /** + * Gets the upper bound delay in minutes for a given delay in minutes. + * + * @param delayInMinutes - The delay in minutes. + */ + private getUpperBoundDelayInMinutes(delayInMinutes: number): number { + return Math.max(this.getAlarmMinDelayInMinutes(), delayInMinutes); + } +} diff --git a/apps/browser/src/platform/services/task-scheduler/foreground-task-scheduler.service.spec.ts b/apps/browser/src/platform/services/task-scheduler/foreground-task-scheduler.service.spec.ts new file mode 100644 index 0000000000..e0ee49c5fa --- /dev/null +++ b/apps/browser/src/platform/services/task-scheduler/foreground-task-scheduler.service.spec.ts @@ -0,0 +1,79 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { Observable } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; +import { GlobalState, StateProvider } from "@bitwarden/common/platform/state"; + +import { createPortSpyMock } from "../../../autofill/spec/autofill-mocks"; +import { flushPromises } from "../../../autofill/spec/testing-utils"; +import { + BrowserTaskSchedulerPortActions, + BrowserTaskSchedulerPortName, +} from "../abstractions/browser-task-scheduler.service"; + +import { ForegroundTaskSchedulerService } from "./foreground-task-scheduler.service"; + +describe("ForegroundTaskSchedulerService", () => { + let logService: MockProxy; + let stateProvider: MockProxy; + let globalStateMock: MockProxy>; + let portMock: chrome.runtime.Port; + let foregroundTaskSchedulerService: ForegroundTaskSchedulerService; + + beforeEach(() => { + logService = mock(); + globalStateMock = mock>({ + state$: mock>(), + update: jest.fn((callback) => callback([], {} as any)), + }); + stateProvider = mock({ + getGlobal: jest.fn(() => globalStateMock), + }); + portMock = createPortSpyMock(BrowserTaskSchedulerPortName); + foregroundTaskSchedulerService = new ForegroundTaskSchedulerService(logService, stateProvider); + foregroundTaskSchedulerService["port"] = portMock; + foregroundTaskSchedulerService.registerTaskHandler( + ScheduledTaskNames.loginStrategySessionTimeout, + jest.fn(), + ); + jest.spyOn(globalThis, "setTimeout"); + jest.spyOn(globalThis, "setInterval"); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("sets a timeout for a task and sends a message to the background to set up a backup timeout alarm", async () => { + foregroundTaskSchedulerService.setTimeout(ScheduledTaskNames.loginStrategySessionTimeout, 1000); + await flushPromises(); + + expect(globalThis.setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); + expect(chrome.alarms.create).toHaveBeenCalledWith( + "loginStrategySessionTimeout", + { delayInMinutes: 0.5 }, + expect.any(Function), + ); + expect(portMock.postMessage).toHaveBeenCalledWith({ + action: BrowserTaskSchedulerPortActions.setTimeout, + taskName: ScheduledTaskNames.loginStrategySessionTimeout, + delayInMs: 1000, + }); + }); + + it("sets an interval for a task and sends a message to the background to set up a backup interval alarm", async () => { + foregroundTaskSchedulerService.setInterval( + ScheduledTaskNames.loginStrategySessionTimeout, + 1000, + ); + await flushPromises(); + + expect(globalThis.setInterval).toHaveBeenCalledWith(expect.any(Function), 1000); + expect(portMock.postMessage).toHaveBeenCalledWith({ + action: BrowserTaskSchedulerPortActions.setInterval, + taskName: ScheduledTaskNames.loginStrategySessionTimeout, + intervalInMs: 1000, + }); + }); +}); diff --git a/apps/browser/src/platform/services/task-scheduler/foreground-task-scheduler.service.ts b/apps/browser/src/platform/services/task-scheduler/foreground-task-scheduler.service.ts new file mode 100644 index 0000000000..af4d56aa62 --- /dev/null +++ b/apps/browser/src/platform/services/task-scheduler/foreground-task-scheduler.service.ts @@ -0,0 +1,71 @@ +import { Subscription } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ScheduledTaskName } from "@bitwarden/common/platform/scheduling"; +import { StateProvider } from "@bitwarden/common/platform/state"; + +import { + BrowserTaskSchedulerPortActions, + BrowserTaskSchedulerPortMessage, + BrowserTaskSchedulerPortName, +} from "../abstractions/browser-task-scheduler.service"; + +import { BrowserTaskSchedulerServiceImplementation } from "./browser-task-scheduler.service"; + +export class ForegroundTaskSchedulerService extends BrowserTaskSchedulerServiceImplementation { + private port: chrome.runtime.Port; + + constructor(logService: LogService, stateProvider: StateProvider) { + super(logService, stateProvider); + + this.port = chrome.runtime.connect({ name: BrowserTaskSchedulerPortName }); + } + + /** + * Sends a port message to the background to set up a fallback timeout. Also sets a timeout locally. + * This is done to ensure that the timeout triggers even if the popup is closed. + * + * @param taskName - The name of the task. + * @param delayInMs - The delay in milliseconds. + */ + setTimeout(taskName: ScheduledTaskName, delayInMs: number): Subscription { + this.sendPortMessage({ + action: BrowserTaskSchedulerPortActions.setTimeout, + taskName, + delayInMs, + }); + + return super.setTimeout(taskName, delayInMs); + } + + /** + * Sends a port message to the background to set up a fallback interval. Also sets an interval locally. + * This is done to ensure that the interval triggers even if the popup is closed. + * + * @param taskName - The name of the task. + * @param intervalInMs - The interval in milliseconds. + * @param initialDelayInMs - The initial delay in milliseconds. + */ + setInterval( + taskName: ScheduledTaskName, + intervalInMs: number, + initialDelayInMs?: number, + ): Subscription { + this.sendPortMessage({ + action: BrowserTaskSchedulerPortActions.setInterval, + taskName, + intervalInMs, + }); + + return super.setInterval(taskName, intervalInMs, initialDelayInMs); + } + + /** + * Sends a message to the background task scheduler. + * + * @param message - The message to send. + */ + private sendPortMessage(message: BrowserTaskSchedulerPortMessage) { + this.port.postMessage(message); + } +} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index b083c2f4c8..c102f461a6 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -61,6 +61,7 @@ import { BiometricStateService } from "@bitwarden/common/platform/biometrics/bio import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency injection import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; +import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; @@ -102,6 +103,7 @@ import BrowserLocalStorageService from "../../platform/services/browser-local-st import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service"; import I18nService from "../../platform/services/i18n.service"; import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service"; +import { ForegroundTaskSchedulerService } from "../../platform/services/task-scheduler/foreground-task-scheduler.service"; import { BrowserStorageServiceProvider } from "../../platform/storage/browser-storage-service.provider"; import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging"; @@ -516,6 +518,15 @@ const safeProviders: SafeProvider[] = [ useClass: Fido2UserVerificationService, deps: [PasswordRepromptService, UserVerificationService, DialogService], }), + safeProvider({ + provide: TaskSchedulerService, + useExisting: ForegroundTaskSchedulerService, + }), + safeProvider({ + provide: ForegroundTaskSchedulerService, + useFactory: getBgService("taskSchedulerService"), + deps: [], + }), ]; @NgModule({ diff --git a/apps/browser/src/services/vault-timeout/vault-timeout.service.ts b/apps/browser/src/services/vault-timeout/vault-timeout.service.ts index 9e9a24fb9c..e0b9db5422 100644 --- a/apps/browser/src/services/vault-timeout/vault-timeout.service.ts +++ b/apps/browser/src/services/vault-timeout/vault-timeout.service.ts @@ -4,16 +4,13 @@ import { SafariApp } from "../../browser/safariApp"; export default class VaultTimeoutService extends BaseVaultTimeoutService { startCheck() { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.checkVaultTimeout(); if (this.platformUtilsService.isSafari()) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.checkSafari(); - } else { - setInterval(() => this.checkVaultTimeout(), 10 * 1000); // check every 10 seconds + this.checkVaultTimeout().catch((error) => this.logService.error(error)); + this.checkSafari().catch((error) => this.logService.error(error)); + return; } + + super.startCheck(); } // This is a work-around to safari adding an arbitrary delay to setTimeout and diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts index 16ebdcbc60..2c358b62c4 100644 --- a/apps/browser/test.setup.ts +++ b/apps/browser/test.setup.ts @@ -143,6 +143,18 @@ const webNavigation = { }, }; +const alarms = { + clear: jest.fn().mockImplementation((_name, callback) => callback(true)), + clearAll: jest.fn().mockImplementation((callback) => callback(true)), + create: jest.fn().mockImplementation((_name, _createInfo, callback) => callback()), + get: jest.fn().mockImplementation((_name, callback) => callback(null)), + getAll: jest.fn().mockImplementation((callback) => callback([])), + onAlarm: { + addListener: jest.fn(), + removeListener: jest.fn(), + }, +}; + // set chrome global.chrome = { i18n, @@ -158,4 +170,5 @@ global.chrome = { offscreen, permissions, webNavigation, + alarms, } as any; diff --git a/apps/cli/src/service-container.ts b/apps/cli/src/service-container.ts index b352af7de7..3d53013ef0 100644 --- a/apps/cli/src/service-container.ts +++ b/apps/cli/src/service-container.ts @@ -65,6 +65,10 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory import { MessageSender } from "@bitwarden/common/platform/messaging"; import { Account } from "@bitwarden/common/platform/models/domain/account"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; +import { + TaskSchedulerService, + DefaultTaskSchedulerService, +} from "@bitwarden/common/platform/scheduling"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; @@ -239,6 +243,7 @@ export class ServiceContainer { providerApiService: ProviderApiServiceAbstraction; userAutoUnlockKeyService: UserAutoUnlockKeyService; kdfConfigService: KdfConfigServiceAbstraction; + taskSchedulerService: TaskSchedulerService; constructor() { let p = null; @@ -543,6 +548,7 @@ export class ServiceContainer { this.stateProvider, ); + this.taskSchedulerService = new DefaultTaskSchedulerService(this.logService); this.loginStrategyService = new LoginStrategyService( this.accountService, this.masterPasswordService, @@ -568,6 +574,7 @@ export class ServiceContainer { this.billingAccountProfileStateService, this.vaultTimeoutSettingsService, this.kdfConfigService, + this.taskSchedulerService, ); this.authService = new AuthService( @@ -642,6 +649,8 @@ export class ServiceContainer { this.authService, this.vaultTimeoutSettingsService, this.stateEventRunnerService, + this.taskSchedulerService, + this.logService, lockedCallback, null, ); @@ -724,6 +733,7 @@ export class ServiceContainer { this.stateProvider, this.logService, this.authService, + this.taskSchedulerService, ); this.eventCollectionService = new EventCollectionService( diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index dfea2e6f27..c0b4bf4eb1 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -45,6 +45,7 @@ import { BiometricStateService } from "@bitwarden/common/platform/biometrics/bio import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency injection import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; +import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { SystemService } from "@bitwarden/common/platform/services/system.service"; import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state"; @@ -177,6 +178,7 @@ const safeProviders: SafeProvider[] = [ VaultTimeoutSettingsService, BiometricStateService, AccountServiceAbstraction, + TaskSchedulerService, ], }), safeProvider({ diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 619155c941..fa53246062 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -157,6 +157,10 @@ import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/inter import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags"; import { Account } from "@bitwarden/common/platform/models/domain/account"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; +import { + TaskSchedulerService, + DefaultTaskSchedulerService, +} from "@bitwarden/common/platform/scheduling"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; @@ -409,6 +413,7 @@ const safeProviders: SafeProvider[] = [ BillingAccountProfileStateService, VaultTimeoutSettingsServiceAbstraction, KdfConfigServiceAbstraction, + TaskSchedulerService, ], }), safeProvider({ @@ -714,6 +719,8 @@ const safeProviders: SafeProvider[] = [ AuthServiceAbstraction, VaultTimeoutSettingsServiceAbstraction, StateEventRunnerService, + TaskSchedulerService, + LogService, LOCKED_CALLBACK, LOGOUT_CALLBACK, ], @@ -812,6 +819,7 @@ const safeProviders: SafeProvider[] = [ StateServiceAbstraction, AuthServiceAbstraction, MessagingServiceAbstraction, + TaskSchedulerService, ], }), safeProvider({ @@ -827,7 +835,13 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: EventUploadServiceAbstraction, useClass: EventUploadService, - deps: [ApiServiceAbstraction, StateProvider, LogService, AuthServiceAbstraction], + deps: [ + ApiServiceAbstraction, + StateProvider, + LogService, + AuthServiceAbstraction, + TaskSchedulerService, + ], }), safeProvider({ provide: EventCollectionServiceAbstraction, @@ -1215,6 +1229,11 @@ const safeProviders: SafeProvider[] = [ new SubjectMessageSender(subject), deps: [INTRAPROCESS_MESSAGING_SUBJECT], }), + safeProvider({ + provide: TaskSchedulerService, + useClass: DefaultTaskSchedulerService, + deps: [LogService], + }), safeProvider({ provide: ProviderApiServiceAbstraction, useClass: ProviderApiService, 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 f0a8d81bea..778ad7c74c 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 @@ -27,6 +27,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { KdfType } from "@bitwarden/common/platform/enums"; +import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; import { FakeAccountService, FakeGlobalState, @@ -72,6 +73,7 @@ describe("LoginStrategyService", () => { let billingAccountProfileStateService: MockProxy; let vaultTimeoutSettingsService: MockProxy; let kdfConfigService: MockProxy; + let taskSchedulerService: MockProxy; let stateProvider: FakeGlobalStateProvider; let loginStrategyCacheExpirationState: FakeGlobalState; @@ -103,6 +105,7 @@ describe("LoginStrategyService", () => { stateProvider = new FakeGlobalStateProvider(); vaultTimeoutSettingsService = mock(); kdfConfigService = mock(); + taskSchedulerService = mock(); sut = new LoginStrategyService( accountService, @@ -129,6 +132,7 @@ describe("LoginStrategyService", () => { billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, + taskSchedulerService, ); loginStrategyCacheExpirationState = stateProvider.getFake(CACHE_EXPIRATION_KEY); diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts index 7169fd69e9..67bcdc3658 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts @@ -5,6 +5,7 @@ import { map, Observable, shareReplay, + Subscription, } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -37,6 +38,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { KdfType } from "@bitwarden/common/platform/enums/kdf-type.enum"; +import { TaskSchedulerService, ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; import { GlobalState, GlobalStateProvider } from "@bitwarden/common/platform/state"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/src/auth/abstractions/device-trust.service.abstraction"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; @@ -69,7 +71,7 @@ import { const sessionTimeoutLength = 2 * 60 * 1000; // 2 minutes export class LoginStrategyService implements LoginStrategyServiceAbstraction { - private sessionTimeout: unknown; + private sessionTimeoutSubscription: Subscription; private currentAuthnTypeState: GlobalState; private loginStrategyCacheState: GlobalState; private loginStrategyCacheExpirationState: GlobalState; @@ -111,6 +113,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { protected billingAccountProfileStateService: BillingAccountProfileStateService, protected vaultTimeoutSettingsService: VaultTimeoutSettingsService, protected kdfConfigService: KdfConfigService, + protected taskSchedulerService: TaskSchedulerService, ) { this.currentAuthnTypeState = this.stateProvider.get(CURRENT_LOGIN_STRATEGY_KEY); this.loginStrategyCacheState = this.stateProvider.get(CACHE_KEY); @@ -118,6 +121,10 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.authRequestPushNotificationState = this.stateProvider.get( AUTH_REQUEST_PUSH_NOTIFICATION_KEY, ); + this.taskSchedulerService.registerTaskHandler( + ScheduledTaskNames.loginStrategySessionTimeout, + () => this.clearCache(), + ); this.currentAuthType$ = this.currentAuthnTypeState.state$; this.loginStrategy$ = this.currentAuthnTypeState.state$.pipe( @@ -268,15 +275,23 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { private async startSessionTimeout(): Promise { await this.clearSessionTimeout(); + + // This Login Strategy Cache Expiration State value set here is used to clear the cache on re-init + // of the application in the case where the timeout is terminated due to a closure of the application + // window. The browser extension popup in particular is susceptible to this concern, as the user + // is almost always likely to close the popup window before the session timeout is reached. await this.loginStrategyCacheExpirationState.update( (_) => new Date(Date.now() + sessionTimeoutLength), ); - this.sessionTimeout = setTimeout(() => this.clearCache(), sessionTimeoutLength); + this.sessionTimeoutSubscription = this.taskSchedulerService.setTimeout( + ScheduledTaskNames.loginStrategySessionTimeout, + sessionTimeoutLength, + ); } private async clearSessionTimeout(): Promise { await this.loginStrategyCacheExpirationState.update((_) => null); - this.sessionTimeout = null; + this.sessionTimeoutSubscription?.unsubscribe(); } private async isSessionValid(): Promise { @@ -284,6 +299,9 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { if (cache == null) { return false; } + + // If the Login Strategy Cache Expiration State value is less than the current + // datetime stamp, then the cache is invalid and should be cleared. const expiration = await firstValueFrom(this.loginStrategyCacheExpirationState.state$); if (expiration != null && expiration < new Date()) { await this.clearCache(); diff --git a/libs/common/src/platform/scheduling/default-task-scheduler.service.spec.ts b/libs/common/src/platform/scheduling/default-task-scheduler.service.spec.ts new file mode 100644 index 0000000000..ec66947f0e --- /dev/null +++ b/libs/common/src/platform/scheduling/default-task-scheduler.service.spec.ts @@ -0,0 +1,123 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { LogService } from "../abstractions/log.service"; +import { ScheduledTaskNames } from "../scheduling/scheduled-task-name.enum"; + +import { DefaultTaskSchedulerService } from "./default-task-scheduler.service"; + +describe("DefaultTaskSchedulerService", () => { + const callback = jest.fn(); + const delayInMs = 1000; + const intervalInMs = 1100; + let logService: MockProxy; + let taskSchedulerService: DefaultTaskSchedulerService; + + beforeEach(() => { + jest.useFakeTimers(); + logService = mock(); + taskSchedulerService = new DefaultTaskSchedulerService(logService); + taskSchedulerService.registerTaskHandler( + ScheduledTaskNames.loginStrategySessionTimeout, + callback, + ); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.clearAllMocks(); + }); + + it("triggers an error when setting a timeout for a task that is not registered", async () => { + expect(() => + taskSchedulerService.setTimeout(ScheduledTaskNames.notificationsReconnectTimeout, 1000), + ).toThrow( + `Task handler for ${ScheduledTaskNames.notificationsReconnectTimeout} not registered. Unable to schedule task.`, + ); + }); + + it("triggers an error when setting an interval for a task that is not registered", async () => { + expect(() => + taskSchedulerService.setInterval(ScheduledTaskNames.notificationsReconnectTimeout, 1000), + ).toThrow( + `Task handler for ${ScheduledTaskNames.notificationsReconnectTimeout} not registered. Unable to schedule task.`, + ); + }); + + it("overrides the handler for a previously registered task and provides a warning about the task registration", () => { + taskSchedulerService.registerTaskHandler( + ScheduledTaskNames.loginStrategySessionTimeout, + callback, + ); + + expect(logService.warning).toHaveBeenCalledWith( + `Task handler for ${ScheduledTaskNames.loginStrategySessionTimeout} already exists. Overwriting.`, + ); + expect( + taskSchedulerService["taskHandlers"].get(ScheduledTaskNames.loginStrategySessionTimeout), + ).toBeDefined(); + }); + + it("sets a timeout and returns the timeout id", () => { + const timeoutId = taskSchedulerService.setTimeout( + ScheduledTaskNames.loginStrategySessionTimeout, + delayInMs, + ); + + expect(timeoutId).toBeDefined(); + expect(callback).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(delayInMs); + + expect(callback).toHaveBeenCalled(); + }); + + it("sets an interval timeout and results the interval id", () => { + const intervalId = taskSchedulerService.setInterval( + ScheduledTaskNames.loginStrategySessionTimeout, + intervalInMs, + ); + + expect(intervalId).toBeDefined(); + expect(callback).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(intervalInMs); + + expect(callback).toHaveBeenCalled(); + + jest.advanceTimersByTime(intervalInMs); + + expect(callback).toHaveBeenCalledTimes(2); + }); + + it("clears scheduled tasks using the timeout id", () => { + const timeoutHandle = taskSchedulerService.setTimeout( + ScheduledTaskNames.loginStrategySessionTimeout, + delayInMs, + ); + + expect(timeoutHandle).toBeDefined(); + expect(callback).not.toHaveBeenCalled(); + + timeoutHandle.unsubscribe(); + + jest.advanceTimersByTime(delayInMs); + + expect(callback).not.toHaveBeenCalled(); + }); + + it("clears scheduled tasks using the interval id", () => { + const intervalHandle = taskSchedulerService.setInterval( + ScheduledTaskNames.loginStrategySessionTimeout, + intervalInMs, + ); + + expect(intervalHandle).toBeDefined(); + expect(callback).not.toHaveBeenCalled(); + + intervalHandle.unsubscribe(); + + jest.advanceTimersByTime(intervalInMs); + + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/common/src/platform/scheduling/default-task-scheduler.service.ts b/libs/common/src/platform/scheduling/default-task-scheduler.service.ts new file mode 100644 index 0000000000..4de2faec64 --- /dev/null +++ b/libs/common/src/platform/scheduling/default-task-scheduler.service.ts @@ -0,0 +1,97 @@ +import { Subscription } from "rxjs"; + +import { LogService } from "../abstractions/log.service"; +import { ScheduledTaskName } from "../scheduling/scheduled-task-name.enum"; +import { TaskSchedulerService } from "../scheduling/task-scheduler.service"; + +export class DefaultTaskSchedulerService extends TaskSchedulerService { + constructor(protected logService: LogService) { + super(); + + this.taskHandlers = new Map(); + } + + /** + * Sets a timeout and returns the timeout id. + * + * @param taskName - The name of the task. Unused in the base implementation. + * @param delayInMs - The delay in milliseconds. + */ + setTimeout(taskName: ScheduledTaskName, delayInMs: number): Subscription { + this.validateRegisteredTask(taskName); + + const timeoutHandle = globalThis.setTimeout(() => this.triggerTask(taskName), delayInMs); + return new Subscription(() => globalThis.clearTimeout(timeoutHandle)); + } + + /** + * Sets an interval and returns the interval id. + * + * @param taskName - The name of the task. Unused in the base implementation. + * @param intervalInMs - The interval in milliseconds. + * @param _initialDelayInMs - The initial delay in milliseconds. Unused in the base implementation. + */ + setInterval( + taskName: ScheduledTaskName, + intervalInMs: number, + _initialDelayInMs?: number, + ): Subscription { + this.validateRegisteredTask(taskName); + + const intervalHandle = globalThis.setInterval(() => this.triggerTask(taskName), intervalInMs); + + return new Subscription(() => globalThis.clearInterval(intervalHandle)); + } + + /** + * Registers a task handler. + * + * @param taskName - The name of the task. + * @param handler - The task handler. + */ + registerTaskHandler(taskName: ScheduledTaskName, handler: () => void) { + const existingHandler = this.taskHandlers.get(taskName); + if (existingHandler) { + this.logService.warning(`Task handler for ${taskName} already exists. Overwriting.`); + this.unregisterTaskHandler(taskName); + } + + this.taskHandlers.set(taskName, handler); + } + + /** + * Unregisters a task handler. + * + * @param taskName - The name of the task. + */ + unregisterTaskHandler(taskName: ScheduledTaskName) { + this.taskHandlers.delete(taskName); + } + + /** + * Triggers a task. + * + * @param taskName - The name of the task. + * @param _periodInMinutes - The period in minutes. Unused in the base implementation. + */ + protected async triggerTask( + taskName: ScheduledTaskName, + _periodInMinutes?: number, + ): Promise { + const handler = this.taskHandlers.get(taskName); + if (handler) { + handler(); + } + } + + /** + * Validates that a task handler is registered. + * + * @param taskName - The name of the task. + */ + protected validateRegisteredTask(taskName: ScheduledTaskName): void { + if (!this.taskHandlers.has(taskName)) { + throw new Error(`Task handler for ${taskName} not registered. Unable to schedule task.`); + } + } +} diff --git a/libs/common/src/platform/scheduling/index.ts b/libs/common/src/platform/scheduling/index.ts new file mode 100644 index 0000000000..e5f10ca3ba --- /dev/null +++ b/libs/common/src/platform/scheduling/index.ts @@ -0,0 +1,3 @@ +export { TaskSchedulerService } from "./task-scheduler.service"; +export { DefaultTaskSchedulerService } from "./default-task-scheduler.service"; +export { ScheduledTaskNames, ScheduledTaskName } from "./scheduled-task-name.enum"; diff --git a/libs/common/src/platform/scheduling/scheduled-task-name.enum.ts b/libs/common/src/platform/scheduling/scheduled-task-name.enum.ts new file mode 100644 index 0000000000..2c0ffc87eb --- /dev/null +++ b/libs/common/src/platform/scheduling/scheduled-task-name.enum.ts @@ -0,0 +1,12 @@ +export const ScheduledTaskNames = { + generatePasswordClearClipboardTimeout: "generatePasswordClearClipboardTimeout", + systemClearClipboardTimeout: "systemClearClipboardTimeout", + loginStrategySessionTimeout: "loginStrategySessionTimeout", + notificationsReconnectTimeout: "notificationsReconnectTimeout", + fido2ClientAbortTimeout: "fido2ClientAbortTimeout", + scheduleNextSyncInterval: "scheduleNextSyncInterval", + eventUploadsInterval: "eventUploadsInterval", + vaultTimeoutCheckInterval: "vaultTimeoutCheckInterval", +} as const; + +export type ScheduledTaskName = (typeof ScheduledTaskNames)[keyof typeof ScheduledTaskNames]; diff --git a/libs/common/src/platform/scheduling/task-scheduler.service.ts b/libs/common/src/platform/scheduling/task-scheduler.service.ts new file mode 100644 index 0000000000..57e5291f7c --- /dev/null +++ b/libs/common/src/platform/scheduling/task-scheduler.service.ts @@ -0,0 +1,16 @@ +import { Subscription } from "rxjs"; + +import { ScheduledTaskName } from "./scheduled-task-name.enum"; + +export abstract class TaskSchedulerService { + protected taskHandlers: Map void>; + abstract setTimeout(taskName: ScheduledTaskName, delayInMs: number): Subscription; + abstract setInterval( + taskName: ScheduledTaskName, + intervalInMs: number, + initialDelayInMs?: number, + ): Subscription; + abstract registerTaskHandler(taskName: ScheduledTaskName, handler: () => void): void; + abstract unregisterTaskHandler(taskName: ScheduledTaskName): void; + protected abstract triggerTask(taskName: ScheduledTaskName, periodInMinutes?: number): void; +} diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts index 597f2d8f32..aac447e033 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts @@ -4,6 +4,7 @@ import { of } from "rxjs"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; +import { Utils } from "../../../platform/misc/utils"; import { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service"; import { ConfigService } from "../../abstractions/config/config.service"; import { @@ -17,7 +18,7 @@ import { CreateCredentialParams, FallbackRequestedError, } from "../../abstractions/fido2/fido2-client.service.abstraction"; -import { Utils } from "../../misc/utils"; +import { TaskSchedulerService } from "../../scheduling/task-scheduler.service"; import * as DomainUtils from "./domain-utils"; import { Fido2AuthenticatorService } from "./fido2-authenticator.service"; @@ -35,6 +36,7 @@ describe("FidoAuthenticatorService", () => { let authService!: MockProxy; let vaultSettingsService: MockProxy; let domainSettingsService: MockProxy; + let taskSchedulerService: MockProxy; let client!: Fido2ClientService; let tab!: chrome.tabs.Tab; let isValidRpId!: jest.SpyInstance; @@ -45,6 +47,7 @@ describe("FidoAuthenticatorService", () => { authService = mock(); vaultSettingsService = mock(); domainSettingsService = mock(); + taskSchedulerService = mock(); isValidRpId = jest.spyOn(DomainUtils, "isValidRpId"); @@ -54,6 +57,7 @@ describe("FidoAuthenticatorService", () => { authService, vaultSettingsService, domainSettingsService, + taskSchedulerService, ); configService.serverConfig$ = of({ environment: { vault: VaultUrl } } as any); vaultSettingsService.enablePasskeys$ = of(true); diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.ts b/libs/common/src/platform/services/fido2/fido2-client.service.ts index d22b91fda0..b384fce1f1 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Subscription } from "rxjs"; import { parse } from "tldts"; import { AuthService } from "../../../auth/abstractions/auth.service"; @@ -27,6 +27,8 @@ import { } from "../../abstractions/fido2/fido2-client.service.abstraction"; import { LogService } from "../../abstractions/log.service"; import { Utils } from "../../misc/utils"; +import { ScheduledTaskNames } from "../../scheduling/scheduled-task-name.enum"; +import { TaskSchedulerService } from "../../scheduling/task-scheduler.service"; import { isValidRpId } from "./domain-utils"; import { Fido2Utils } from "./fido2-utils"; @@ -38,14 +40,33 @@ import { Fido2Utils } from "./fido2-utils"; * It is highly recommended that the W3C specification is used a reference when reading this code. */ export class Fido2ClientService implements Fido2ClientServiceAbstraction { + private timeoutAbortController: AbortController; + private readonly TIMEOUTS = { + NO_VERIFICATION: { + DEFAULT: 120000, + MIN: 30000, + MAX: 180000, + }, + WITH_VERIFICATION: { + DEFAULT: 300000, + MIN: 30000, + MAX: 600000, + }, + }; + constructor( private authenticator: Fido2AuthenticatorService, private configService: ConfigService, private authService: AuthService, private vaultSettingsService: VaultSettingsService, private domainSettingsService: DomainSettingsService, + private taskSchedulerService: TaskSchedulerService, private logService?: LogService, - ) {} + ) { + this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.fido2ClientAbortTimeout, () => + this.timeoutAbortController?.abort(), + ); + } async isFido2FeatureEnabled(hostname: string, origin: string): Promise { const isUserLoggedIn = @@ -161,7 +182,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { this.logService?.info(`[Fido2Client] Aborted with AbortController`); throw new DOMException("The operation either timed out or was not allowed.", "AbortError"); } - const timeout = setAbortTimeout( + const timeoutSubscription = this.setAbortTimeout( abortController, params.authenticatorSelection?.userVerification, params.timeout, @@ -210,7 +231,8 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { }; } - clearTimeout(timeout); + timeoutSubscription?.unsubscribe(); + return { credentialId: Fido2Utils.bufferToString(makeCredentialResult.credentialId), attestationObject: Fido2Utils.bufferToString(makeCredentialResult.attestationObject), @@ -273,7 +295,11 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { throw new DOMException("The operation either timed out or was not allowed.", "AbortError"); } - const timeout = setAbortTimeout(abortController, params.userVerification, params.timeout); + const timeoutSubscription = this.setAbortTimeout( + abortController, + params.userVerification, + params.timeout, + ); let getAssertionResult; try { @@ -310,7 +336,8 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { this.logService?.info(`[Fido2Client] Aborted with AbortController`); throw new DOMException("The operation either timed out or was not allowed.", "AbortError"); } - clearTimeout(timeout); + + timeoutSubscription?.unsubscribe(); return { authenticatorData: Fido2Utils.bufferToString(getAssertionResult.authenticatorData), @@ -323,43 +350,29 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { signature: Fido2Utils.bufferToString(getAssertionResult.signature), }; } -} -const TIMEOUTS = { - NO_VERIFICATION: { - DEFAULT: 120000, - MIN: 30000, - MAX: 180000, - }, - WITH_VERIFICATION: { - DEFAULT: 300000, - MIN: 30000, - MAX: 600000, - }, -}; + private setAbortTimeout = ( + abortController: AbortController, + userVerification?: UserVerification, + timeout?: number, + ): Subscription => { + let clampedTimeout: number; -function setAbortTimeout( - abortController: AbortController, - userVerification?: UserVerification, - timeout?: number, -): number { - let clampedTimeout: number; + const { WITH_VERIFICATION, NO_VERIFICATION } = this.TIMEOUTS; + if (userVerification === "required") { + timeout = timeout ?? WITH_VERIFICATION.DEFAULT; + clampedTimeout = Math.max(WITH_VERIFICATION.MIN, Math.min(timeout, WITH_VERIFICATION.MAX)); + } else { + timeout = timeout ?? NO_VERIFICATION.DEFAULT; + clampedTimeout = Math.max(NO_VERIFICATION.MIN, Math.min(timeout, NO_VERIFICATION.MAX)); + } - if (userVerification === "required") { - timeout = timeout ?? TIMEOUTS.WITH_VERIFICATION.DEFAULT; - clampedTimeout = Math.max( - TIMEOUTS.WITH_VERIFICATION.MIN, - Math.min(timeout, TIMEOUTS.WITH_VERIFICATION.MAX), + this.timeoutAbortController = abortController; + return this.taskSchedulerService.setTimeout( + ScheduledTaskNames.fido2ClientAbortTimeout, + clampedTimeout, ); - } else { - timeout = timeout ?? TIMEOUTS.NO_VERIFICATION.DEFAULT; - clampedTimeout = Math.max( - TIMEOUTS.NO_VERIFICATION.MIN, - Math.min(timeout, TIMEOUTS.NO_VERIFICATION.MAX), - ); - } - - return self.setTimeout(() => abortController.abort(), clampedTimeout); + }; } /** diff --git a/libs/common/src/platform/services/system.service.ts b/libs/common/src/platform/services/system.service.ts index a3927a3fb8..382b3bf8e8 100644 --- a/libs/common/src/platform/services/system.service.ts +++ b/libs/common/src/platform/services/system.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, map, timeout } from "rxjs"; +import { firstValueFrom, map, Subscription, timeout } from "rxjs"; import { PinServiceAbstraction } from "../../../../auth/src/common/abstractions"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; @@ -13,10 +13,12 @@ import { PlatformUtilsService } from "../abstractions/platform-utils.service"; import { SystemService as SystemServiceAbstraction } from "../abstractions/system.service"; import { BiometricStateService } from "../biometrics/biometric-state.service"; import { Utils } from "../misc/utils"; +import { ScheduledTaskNames } from "../scheduling/scheduled-task-name.enum"; +import { TaskSchedulerService } from "../scheduling/task-scheduler.service"; export class SystemService implements SystemServiceAbstraction { private reloadInterval: any = null; - private clearClipboardTimeout: any = null; + private clearClipboardTimeoutSubscription: Subscription; private clearClipboardTimeoutFunction: () => Promise = null; constructor( @@ -28,7 +30,13 @@ export class SystemService implements SystemServiceAbstraction { private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private biometricStateService: BiometricStateService, private accountService: AccountService, - ) {} + private taskSchedulerService: TaskSchedulerService, + ) { + this.taskSchedulerService.registerTaskHandler( + ScheduledTaskNames.systemClearClipboardTimeout, + () => this.clearPendingClipboard(), + ); + } async startProcessReload(authService: AuthService): Promise { const accounts = await firstValueFrom(this.accountService.accounts$); @@ -111,25 +119,22 @@ export class SystemService implements SystemServiceAbstraction { } async clearClipboard(clipboardValue: string, timeoutMs: number = null): Promise { - if (this.clearClipboardTimeout != null) { - clearTimeout(this.clearClipboardTimeout); - this.clearClipboardTimeout = null; - } + this.clearClipboardTimeoutSubscription?.unsubscribe(); if (Utils.isNullOrWhitespace(clipboardValue)) { return; } - const clearClipboardDelay = await firstValueFrom( - this.autofillSettingsService.clearClipboardDelay$, - ); - - if (clearClipboardDelay == null) { - return; + let taskTimeoutInMs = timeoutMs; + if (!taskTimeoutInMs) { + const clearClipboardDelayInSeconds = await firstValueFrom( + this.autofillSettingsService.clearClipboardDelay$, + ); + taskTimeoutInMs = clearClipboardDelayInSeconds ? clearClipboardDelayInSeconds * 1000 : null; } - if (timeoutMs == null) { - timeoutMs = clearClipboardDelay * 1000; + if (!taskTimeoutInMs) { + return; } this.clearClipboardTimeoutFunction = async () => { @@ -139,9 +144,10 @@ export class SystemService implements SystemServiceAbstraction { } }; - this.clearClipboardTimeout = setTimeout(async () => { - await this.clearPendingClipboard(); - }, timeoutMs); + this.clearClipboardTimeoutSubscription = this.taskSchedulerService.setTimeout( + ScheduledTaskNames.systemClearClipboardTimeout, + taskTimeoutInMs, + ); } async clearPendingClipboard() { diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 53e9ca9fb6..0b55e7be77 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -112,6 +112,7 @@ export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk"); export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory"); export const THEMING_DISK = new StateDefinition("theming", "disk", { web: "disk-local" }); export const TRANSLATION_DISK = new StateDefinition("translation", "disk", { web: "disk-local" }); +export const TASK_SCHEDULER_DISK = new StateDefinition("taskScheduler", "disk"); // Secrets Manager diff --git a/libs/common/src/services/event/event-upload.service.ts b/libs/common/src/services/event/event-upload.service.ts index c87d3b2024..faac95c4d6 100644 --- a/libs/common/src/services/event/event-upload.service.ts +++ b/libs/common/src/services/event/event-upload.service.ts @@ -7,6 +7,8 @@ import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { EventData } from "../../models/data/event.data"; import { EventRequest } from "../../models/request/event.request"; import { LogService } from "../../platform/abstractions/log.service"; +import { ScheduledTaskNames } from "../../platform/scheduling/scheduled-task-name.enum"; +import { TaskSchedulerService } from "../../platform/scheduling/task-scheduler.service"; import { StateProvider } from "../../platform/state"; import { UserId } from "../../types/guid"; @@ -19,7 +21,12 @@ export class EventUploadService implements EventUploadServiceAbstraction { private stateProvider: StateProvider, private logService: LogService, private authService: AuthService, - ) {} + private taskSchedulerService: TaskSchedulerService, + ) { + this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.eventUploadsInterval, () => + this.uploadEvents(), + ); + } init(checkOnInterval: boolean) { if (this.inited) { @@ -28,10 +35,11 @@ export class EventUploadService implements EventUploadServiceAbstraction { this.inited = true; if (checkOnInterval) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.uploadEvents(); - setInterval(() => this.uploadEvents(), 60 * 1000); // check every 60 seconds + void this.uploadEvents(); + this.taskSchedulerService.setInterval( + ScheduledTaskNames.eventUploadsInterval, + 60 * 1000, // check every 60 seconds + ); } } diff --git a/libs/common/src/services/notifications.service.ts b/libs/common/src/services/notifications.service.ts index d5c7170e23..8e6a664a0a 100644 --- a/libs/common/src/services/notifications.service.ts +++ b/libs/common/src/services/notifications.service.ts @@ -1,6 +1,6 @@ import * as signalR from "@microsoft/signalr"; import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Subscription } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; @@ -20,6 +20,8 @@ import { EnvironmentService } from "../platform/abstractions/environment.service import { LogService } from "../platform/abstractions/log.service"; import { MessagingService } from "../platform/abstractions/messaging.service"; import { StateService } from "../platform/abstractions/state.service"; +import { ScheduledTaskNames } from "../platform/scheduling/scheduled-task-name.enum"; +import { TaskSchedulerService } from "../platform/scheduling/task-scheduler.service"; import { SyncService } from "../vault/abstractions/sync/sync.service.abstraction"; export class NotificationsService implements NotificationsServiceAbstraction { @@ -28,7 +30,8 @@ export class NotificationsService implements NotificationsServiceAbstraction { private connected = false; private inited = false; private inactive = false; - private reconnectTimer: any = null; + private reconnectTimerSubscription: Subscription; + private isSyncingOnReconnect = true; constructor( private logService: LogService, @@ -40,7 +43,12 @@ export class NotificationsService implements NotificationsServiceAbstraction { private stateService: StateService, private authService: AuthService, private messagingService: MessagingService, + private taskSchedulerService: TaskSchedulerService, ) { + this.taskSchedulerService.registerTaskHandler( + ScheduledTaskNames.notificationsReconnectTimeout, + () => this.reconnect(this.isSyncingOnReconnect), + ); this.environmentService.environment$.subscribe(() => { if (!this.inited) { return; @@ -213,10 +221,8 @@ export class NotificationsService implements NotificationsServiceAbstraction { } private async reconnect(sync: boolean) { - if (this.reconnectTimer != null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } + this.reconnectTimerSubscription?.unsubscribe(); + if (this.connected || !this.inited || this.inactive) { return; } @@ -236,7 +242,11 @@ export class NotificationsService implements NotificationsServiceAbstraction { } if (!this.connected) { - this.reconnectTimer = setTimeout(() => this.reconnect(sync), this.random(120000, 300000)); + this.isSyncingOnReconnect = sync; + this.reconnectTimerSubscription = this.taskSchedulerService.setTimeout( + ScheduledTaskNames.notificationsReconnectTimeout, + this.random(120000, 300000), + ); } } diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts index 6a8071af0c..487a2578b5 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts @@ -2,6 +2,8 @@ import { MockProxy, any, mock } from "jest-mock-extended"; import { BehaviorSubject, from, of } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { SearchService } from "../../abstractions/search.service"; @@ -37,6 +39,8 @@ describe("VaultTimeoutService", () => { let authService: MockProxy; let vaultTimeoutSettingsService: MockProxy; let stateEventRunnerService: MockProxy; + let taskSchedulerService: MockProxy; + let logService: MockProxy; let lockedCallback: jest.Mock, [userId: string]>; let loggedOutCallback: jest.Mock, [logoutReason: LogoutReason, userId?: string]>; @@ -60,6 +64,8 @@ describe("VaultTimeoutService", () => { authService = mock(); vaultTimeoutSettingsService = mock(); stateEventRunnerService = mock(); + taskSchedulerService = mock(); + logService = mock(); lockedCallback = jest.fn(); loggedOutCallback = jest.fn(); @@ -85,6 +91,8 @@ describe("VaultTimeoutService", () => { authService, vaultTimeoutSettingsService, stateEventRunnerService, + taskSchedulerService, + logService, lockedCallback, loggedOutCallback, ); diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.ts index 8d8ecd68a5..d9efef44f4 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.ts @@ -1,6 +1,8 @@ import { combineLatest, filter, firstValueFrom, map, switchMap, timeout } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { TaskSchedulerService, ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; import { SearchService } from "../../abstractions/search.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; @@ -35,12 +37,19 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { private authService: AuthService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private stateEventRunnerService: StateEventRunnerService, + private taskSchedulerService: TaskSchedulerService, + protected logService: LogService, private lockedCallback: (userId?: string) => Promise = null, private loggedOutCallback: ( logoutReason: LogoutReason, userId?: string, ) => Promise = null, - ) {} + ) { + this.taskSchedulerService.registerTaskHandler( + ScheduledTaskNames.vaultTimeoutCheckInterval, + () => this.checkVaultTimeout(), + ); + } async init(checkOnInterval: boolean) { if (this.inited) { @@ -54,10 +63,11 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { } startCheck() { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.checkVaultTimeout(); - setInterval(() => this.checkVaultTimeout(), 10 * 1000); // check every 10 seconds + this.checkVaultTimeout().catch((error) => this.logService.error(error)); + this.taskSchedulerService.setInterval( + ScheduledTaskNames.vaultTimeoutCheckInterval, + 10 * 1000, // check every 10 seconds + ); } async checkVaultTimeout(): Promise {