[PM-6426] Create TaskSchedulerService and update long lived timeouts in the extension to leverage the new service (#8566)

* [PM-6426] Create TaskSchedulerService and update usage of long lived timeouts

* [PM-6426] Implementing nextSync timeout using TaskScheduler

* [PM-6426] Implementing systemClearClipboard using TaskScheduler

* [PM-6426] Fixing race condition with setting/unsetting active alarms

* [PM-6426] Implementing clear clipboard call on generatePasswordToClipboard with the TaskSchedulerService

* [PM-6426] Implementing abortTimeout for Fido2ClientService using TaskSchedulerService

* [PM-6426] Implementing reconnect timer timeout for NotificationService using the TaskSchedulerService

* [PM-6426] Implementing reconnect timer timeout for NotificationService using the TaskSchedulerService

* [PM-6426] Implementing sessionTimeout for LoginStrategyService using TaskSchedulerService

* [PM-6426] Implementing eventUploadInterval using TaskScheduler

* [PM-6426] Adding jest tests for the base TaskSchedulerService class

* [PM-6426] Updating jest tests for GeneratePasswordToClipboardCommand

* [PM-6426] Setting up the full sync process as an interval rather than a timeout

* [PM-6426] Renaming the scheduleNextSync alarm name

* [PM-6426] Fixing dependency references in services.module.ts

* [PM-6426] Adding jest tests for added BrowserApi methods

* [PM-6426] Refactoring small detail for how we identify the clear clipboard timeout in SystemService

* [PM-6426] Ensuring that we await clearing an established scheduled task for the notification service

* [PM-6426] Changing the name of the state definition for the TaskScheduler

* [PM-6426] Implementing jest tests for the BrowserTaskSchedulerService

* [PM-6426] Implementing jest tests for the BrowserTaskSchedulerService

* [PM-6426] Adding jest tests for the base TaskSchedulerService class

* [PM-6426] Finalizing jest tests for BrowserTaskScheduler class

* [PM-6426] Finalizing documentation on BrowserTaskSchedulerService

* [PM-6426] Fixing jest test for LoginStrategyService

* [PM-6426] Implementing compatibility for the browser.alarms api

* [PM-6426] Fixing how we check for the browser alarms api

* [PM-6426] Adding jest tests to the BrowserApi implementation

* [PM-6426] Aligning the implementation with our code guidelines for Angular components

* [PM-6426] Fixing jest tests and lint errors

* [PM-6426] Moving alarms api calls out of BrowserApi and structuring them within the BrowserTaskSchedulerService

* [PM-6426] Reworking implementation to register handlers separately from the call to those handlers

* [PM-6426] Adjusting how we register the fullSync scheduled task

* [PM-6426] Implementing approach for incorporating the user UUID when setting task handlers

* [PM-6426] Attempting to re-work implementation to facilitate userId-spcific alarms

* [PM-6426] Refactoring smaller details of the implementation

* [PM-6426] Working through the details of the implementation and setting up final refinments

* [PM-6426] Fixing some issues surrounding duplicate alarms triggering

* [PM-6426] Adjusting name for generate password to clipboard command task name

* [PM-6426] Fixing generate password to clipboard command jest tests

* [PM-6426] Working through jest tests and implementing a method to guard against setting a task without having a registered callback

* [PM-6426] Working through jest tests and implementing a method to guard against setting a task without having a registered callback

* [PM-6426] Implementing methodology for having a fallback to setTimeout if the browser context is lost in some manner

* [PM-6426] Working through jest tests

* [PM-6426] Working through jest tests

* [PM-6426] Working through jest tests

* [PM-6426] Working through jest tests

* [PM-6426] Finalizing stepped setInterval implementation

* [PM-6426] Implementing Jest tests for DefaultTaskSchedulerService

* [PM-6426] Adjusting jest tests

* [PM-6426] Adjusting jest tests

* [PM-6426] Adjusting jest tests

* [PM-6426] Fixing issues identified in code review

* [PM-6426] Fixing issues identified in code review

* [PM-6426] Removing user-based alarms and fixing an issue found with setting steppedd alarm interavals

* [PM-6426] Removing user-based alarms and fixing an issue found with setting steppedd alarm interavals

* [PM-6426] Fixing issue with typing information on a test

* [PM-6426] Using the getUpperBoundDelayInMinutes method to handle setting stepped alarms and setTimeout fallbacks

* [PM-6426] Removing the potential for the TaskScheduler to be optional

* [PM-6426] Reworking implementation to leverage subscription based deregistration of alarms

* [PM-6426] Fixing jest tests

* [PM-6426] Implementing foreground and background task scheduler services to avoid duplication of task scheudlers and to have the background setup as a fallback to the poopup tasks

* [PM-6426] Implementing foreground and background task scheduler services to avoid duplication of task scheudlers and to have the background setup as a fallback to the poopup tasks

* [PM-6426] Merging main into branch

* [PM-6426] Fixing issues with the CLI Service Container implementation

* [PM-6426] Reworking swallowed promises to contain a catch statement allow us to debug potential issues with registrations of alarms

* [PM-6426] Adding jest tests to the ForegroundTaskSchedulerService and the BackgroundTaskSchedulerService

* [PM-6426] Adding jest tests to the ForegroundTaskSchedulerService and the BackgroundTaskSchedulerService

* [PM-6426] Adding jest tests to the ForegroundTaskSchedulerService and the BackgroundTaskSchedulerService

* [PM-6426] Adding jest tests to the ForegroundTaskSchedulerService and the BackgroundTaskSchedulerService

* [PM-6426] Adjusting implementation based on code review feedback

* [PM-6426] Reworking file structure

* [PM-6426] Reworking file structure

* [PM-6426] Adding comments to provide clarity on how the login strategy cache experiation state is used

* [PM-6426] Catching and logging erorrs that appear from methods that return a promise within VaultTimeoutService
This commit is contained in:
Cesar Gonzalez 2024-07-15 10:32:30 -05:00 committed by GitHub
parent 5fcf4bbd10
commit 974162b1a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1854 additions and 283 deletions

View File

@ -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
*/

View File

@ -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<PasswordGenerationServiceAbstraction>;
let autofillSettingsService: MockProxy<AutofillSettingsService>;
let browserTaskSchedulerService: MockProxy<BrowserTaskSchedulerService>;
let sut: GeneratePasswordToClipboardCommand;
beforeEach(() => {
passwordGenerationService = mock<PasswordGenerationServiceAbstraction>();
autofillSettingsService = mock<AutofillSettingsService>();
browserTaskSchedulerService = mock<BrowserTaskSchedulerService>({
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();
});
});
});

View File

@ -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,
);
}
}

View File

@ -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,
};

View File

@ -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
}
}

View File

@ -59,6 +59,7 @@
"clipboardRead",
"clipboardWrite",
"idle",
"alarms",
"webRequest",
"webRequestBlocking",
"webNavigation"

View File

@ -59,6 +59,7 @@
"clipboardRead",
"clipboardWrite",
"idle",
"alarms",
"scripting",
"offscreen",
"webRequest",

View File

@ -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<number>} null or Unix epoch timestamp when the alarm action is supposed to execute
* @example
* // getAlarmTime(clearClipboard)
*/
export async function getAlarmTime(commandName: AlarmKeys): Promise<number> {
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<void> {
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<void> {
await setAlarmTimeInternal(commandName, null);
}
async function setAlarmTimeInternal(commandName: AlarmKeys, time: number): Promise<void> {
if (BrowserApi.isManifestVersion(3)) {
await chrome.storage.session.set({ [commandName]: time });
} else {
alarmState[commandName] = time;
}
}

View File

@ -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:
}
});
};

View File

@ -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;
}
}

View File

@ -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<ActiveAlarm[]>;
abstract clearAllScheduledTasks(): Promise<void>;
abstract verifyAlarmsState(): Promise<void>;
abstract clearScheduledAlarm(alarmName: string): Promise<void>;
}

View File

@ -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<LogService>;
let stateProvider: MockProxy<StateProvider>;
let globalStateMock: MockProxy<GlobalState<any>>;
let portMock: chrome.runtime.Port;
let backgroundTaskSchedulerService: BackgroundTaskSchedulerService;
beforeEach(() => {
logService = mock<LogService>();
globalStateMock = mock<GlobalState<any>>({
state$: mock<Observable<any>>(),
update: jest.fn((callback) => callback([], {} as any)),
});
stateProvider = mock<StateProvider>({
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),
);
});
});
});

View File

@ -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<chrome.runtime.Port> = 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));
}
};
}

View File

@ -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<any>) => state$.value),
};
});
function setupGlobalBrowserMock(overrides: Partial<chrome.alarms.Alarm> = {}) {
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<ActiveAlarm[]>;
let logService: MockProxy<ConsoleLogService>;
let stateProvider: MockProxy<StateProvider>;
let globalStateMock: MockProxy<GlobalState<any>>;
let browserTaskSchedulerService: BrowserTaskSchedulerService;
let activeAlarms: ActiveAlarm[] = [];
const eventUploadsIntervalCreateInfo = { periodInMinutes: 5, delayInMinutes: 5 };
const scheduleNextSyncIntervalCreateInfo = { periodInMinutes: 5, delayInMinutes: 5 };
beforeEach(() => {
jest.useFakeTimers();
activeAlarms = [
mock<ActiveAlarm>({
alarmName: ScheduledTaskNames.eventUploadsInterval,
createInfo: eventUploadsIntervalCreateInfo,
}),
mock<ActiveAlarm>({
alarmName: ScheduledTaskNames.scheduleNextSyncInterval,
createInfo: scheduleNextSyncIntervalCreateInfo,
}),
mock<ActiveAlarm>({
alarmName: ScheduledTaskNames.fido2ClientAbortTimeout,
startTime: Date.now() - 60001,
createInfo: { delayInMinutes: 1, periodInMinutes: undefined },
}),
];
activeAlarmsMock$ = new BehaviorSubject(activeAlarms);
logService = mock<ConsoleLogService>();
globalStateMock = mock<GlobalState<any>>({
state$: mock<Observable<any>>(),
update: jest.fn((callback) => callback([], {} as any)),
});
stateProvider = mock<StateProvider>({
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.Alarm>();
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<chrome.alarms.Alarm>()));
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<chrome.alarms.Alarm>({
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();
});
});
});

View File

@ -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<ActiveAlarm[]>;
readonly activeAlarms$: Observable<ActiveAlarm[]>;
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<void> {
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<void> {
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<void> {
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<ActiveAlarm[]> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> => {
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<chrome.alarms.Alarm> {
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);
}
}

View File

@ -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<LogService>;
let stateProvider: MockProxy<StateProvider>;
let globalStateMock: MockProxy<GlobalState<any>>;
let portMock: chrome.runtime.Port;
let foregroundTaskSchedulerService: ForegroundTaskSchedulerService;
beforeEach(() => {
logService = mock<LogService>();
globalStateMock = mock<GlobalState<any>>({
state$: mock<Observable<any>>(),
update: jest.fn((callback) => callback([], {} as any)),
});
stateProvider = mock<StateProvider>({
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,
});
});
});

View File

@ -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);
}
}

View File

@ -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<ForegroundTaskSchedulerService>("taskSchedulerService"),
deps: [],
}),
];
@NgModule({

View File

@ -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

View File

@ -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;

View File

@ -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(

View File

@ -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({

View File

@ -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,

View File

@ -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<BillingAccountProfileStateService>;
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let taskSchedulerService: MockProxy<TaskSchedulerService>;
let stateProvider: FakeGlobalStateProvider;
let loginStrategyCacheExpirationState: FakeGlobalState<Date | null>;
@ -103,6 +105,7 @@ describe("LoginStrategyService", () => {
stateProvider = new FakeGlobalStateProvider();
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
kdfConfigService = mock<KdfConfigService>();
taskSchedulerService = mock<TaskSchedulerService>();
sut = new LoginStrategyService(
accountService,
@ -129,6 +132,7 @@ describe("LoginStrategyService", () => {
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,
taskSchedulerService,
);
loginStrategyCacheExpirationState = stateProvider.getFake(CACHE_EXPIRATION_KEY);

View File

@ -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<AuthenticationType | null>;
private loginStrategyCacheState: GlobalState<CacheData | null>;
private loginStrategyCacheExpirationState: GlobalState<Date | null>;
@ -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<void> {
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<void> {
await this.loginStrategyCacheExpirationState.update((_) => null);
this.sessionTimeout = null;
this.sessionTimeoutSubscription?.unsubscribe();
}
private async isSessionValid(): Promise<boolean> {
@ -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();

View File

@ -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<LogService>;
let taskSchedulerService: DefaultTaskSchedulerService;
beforeEach(() => {
jest.useFakeTimers();
logService = mock<LogService>();
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();
});
});

View File

@ -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<void> {
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.`);
}
}
}

View File

@ -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";

View File

@ -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];

View File

@ -0,0 +1,16 @@
import { Subscription } from "rxjs";
import { ScheduledTaskName } from "./scheduled-task-name.enum";
export abstract class TaskSchedulerService {
protected taskHandlers: Map<string, () => 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;
}

View File

@ -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<AuthService>;
let vaultSettingsService: MockProxy<VaultSettingsService>;
let domainSettingsService: MockProxy<DomainSettingsService>;
let taskSchedulerService: MockProxy<TaskSchedulerService>;
let client!: Fido2ClientService;
let tab!: chrome.tabs.Tab;
let isValidRpId!: jest.SpyInstance;
@ -45,6 +47,7 @@ describe("FidoAuthenticatorService", () => {
authService = mock<AuthService>();
vaultSettingsService = mock<VaultSettingsService>();
domainSettingsService = mock<DomainSettingsService>();
taskSchedulerService = mock<TaskSchedulerService>();
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);

View File

@ -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<boolean> {
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);
};
}
/**

View File

@ -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<any> = 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<void> {
const accounts = await firstValueFrom(this.accountService.accounts$);
@ -111,25 +119,22 @@ export class SystemService implements SystemServiceAbstraction {
}
async clearClipboard(clipboardValue: string, timeoutMs: number = null): Promise<void> {
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() {

View File

@ -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

View File

@ -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
);
}
}

View File

@ -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),
);
}
}

View File

@ -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<AuthService>;
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
let taskSchedulerService: MockProxy<TaskSchedulerService>;
let logService: MockProxy<LogService>;
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
let loggedOutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: string]>;
@ -60,6 +64,8 @@ describe("VaultTimeoutService", () => {
authService = mock();
vaultTimeoutSettingsService = mock();
stateEventRunnerService = mock();
taskSchedulerService = mock<TaskSchedulerService>();
logService = mock<LogService>();
lockedCallback = jest.fn();
loggedOutCallback = jest.fn();
@ -85,6 +91,8 @@ describe("VaultTimeoutService", () => {
authService,
vaultTimeoutSettingsService,
stateEventRunnerService,
taskSchedulerService,
logService,
lockedCallback,
loggedOutCallback,
);

View File

@ -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<void> = null,
private loggedOutCallback: (
logoutReason: LogoutReason,
userId?: string,
) => Promise<void> = 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<void> {