[PM-4012] Vault Timing out on Chrome and Edge breaks passkeys until page is reloaded (#6845)
* changed content script injection strategy * added persistent connection and reinjection of the content script * cleanup resources on disconnect * cleanup resources on disconnect * concluded messanger event listeners cleanup and added unit tests * Switched to use browser api add listener instead of navtive apis * renamed cleanup to destroy and added reconnect and disconnect command functions * refactored to use foreach and check for only https urls * refactored the content script to only load the page script if it currently doesn't extist of the page, and if it does sends a reconnect command to the page-script to replace the native webauthn methods * updated unit test * removed memoized logic * moved the send disconect command to the messenger * updated unit test * test messenger handler * [PM-4012] fix: add `senderId` to messenger * destroy pending requets * cleaned up page script and terminated pending request * fixed cannot read properties of undefined * rearranged functions, renamed misspelled words, and created test * mocked EventTarget as there are issues on jest for listeners getting the events * Return fall back error instead * Update apps/browser/src/vault/fido2/content/content-script.ts Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com> * Update apps/browser/src/vault/fido2/content/messaging/messenger.ts Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com> * removed whitespace --------- Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com> Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>
This commit is contained in:
parent
3e174fec81
commit
f0cdcccf81
|
@ -156,7 +156,9 @@ import { BrowserSendService } from "../services/browser-send.service";
|
|||
import { BrowserSettingsService } from "../services/browser-settings.service";
|
||||
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
|
||||
import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.service";
|
||||
import { Fido2Service as Fido2ServiceAbstraction } from "../vault/services/abstractions/fido2.service";
|
||||
import { BrowserFolderService } from "../vault/services/browser-folder.service";
|
||||
import Fido2Service from "../vault/services/fido2.service";
|
||||
import { VaultFilterService } from "../vault/services/vault-filter.service";
|
||||
|
||||
import CommandsBackground from "./commands.background";
|
||||
|
@ -232,6 +234,7 @@ export default class MainBackground {
|
|||
authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
|
||||
accountService: AccountServiceAbstraction;
|
||||
globalStateProvider: GlobalStateProvider;
|
||||
fido2Service: Fido2ServiceAbstraction;
|
||||
|
||||
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
|
||||
backgroundWindow = window;
|
||||
|
@ -597,6 +600,7 @@ export default class MainBackground {
|
|||
this.messagingService,
|
||||
);
|
||||
|
||||
this.fido2Service = new Fido2Service();
|
||||
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
|
||||
this.fido2AuthenticatorService = new Fido2AuthenticatorService(
|
||||
this.cipherService,
|
||||
|
@ -645,6 +649,7 @@ export default class MainBackground {
|
|||
this.messagingService,
|
||||
this.logService,
|
||||
this.configService,
|
||||
this.fido2Service,
|
||||
);
|
||||
this.nativeMessagingBackground = new NativeMessagingBackground(
|
||||
this.cryptoService,
|
||||
|
@ -778,6 +783,8 @@ export default class MainBackground {
|
|||
await this.idleBackground.init();
|
||||
await this.webRequestBackground.init();
|
||||
|
||||
await this.fido2Service.init();
|
||||
|
||||
if (this.platformUtilsService.isFirefox() && !this.isPrivateMode) {
|
||||
// Set Private Mode windows to the default icon - they do not share state with the background page
|
||||
const privateWindows = await BrowserApi.getPrivateModeWindows();
|
||||
|
|
|
@ -20,6 +20,7 @@ import { BrowserStateService } from "../platform/services/abstractions/browser-s
|
|||
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
|
||||
import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service";
|
||||
import { AbortManager } from "../vault/background/abort-manager";
|
||||
import { Fido2Service } from "../vault/services/abstractions/fido2.service";
|
||||
|
||||
import MainBackground from "./main.background";
|
||||
|
||||
|
@ -42,6 +43,7 @@ export default class RuntimeBackground {
|
|||
private messagingService: MessagingService,
|
||||
private logService: LogService,
|
||||
private configService: ConfigServiceAbstraction,
|
||||
private fido2Service: Fido2Service,
|
||||
) {
|
||||
// onInstalled listener must be wired up before anything else, so we do it in the ctor
|
||||
chrome.runtime.onInstalled.addListener((details: any) => {
|
||||
|
@ -257,6 +259,9 @@ export default class RuntimeBackground {
|
|||
case "getClickedElementResponse":
|
||||
this.platformUtilsService.copyToClipboard(msg.identifier, { window: window });
|
||||
break;
|
||||
case "triggerFido2ContentScriptInjection":
|
||||
await this.fido2Service.injectFido2ContentScripts(sender);
|
||||
break;
|
||||
case "fido2AbortRequest":
|
||||
this.abortManager.abort(msg.abortedRequestId);
|
||||
break;
|
||||
|
|
|
@ -17,7 +17,10 @@
|
|||
"content_scripts": [
|
||||
{
|
||||
"all_frames": true,
|
||||
"js": ["content/trigger-autofill-script-injection.js", "content/fido2/content-script.js"],
|
||||
"js": [
|
||||
"content/trigger-autofill-script-injection.js",
|
||||
"content/fido2/trigger-fido2-content-script-injection.js"
|
||||
],
|
||||
"matches": ["http://*/*", "https://*/*", "file:///*"],
|
||||
"run_at": "document_start"
|
||||
},
|
||||
|
|
|
@ -61,12 +61,28 @@ async function isLocationBitwardenVault(activeUserSettings: Record<string, any>)
|
|||
return window.location.origin === activeUserSettings.serverConfig.environment.vault;
|
||||
}
|
||||
|
||||
function initializeFido2ContentScript() {
|
||||
const s = document.createElement("script");
|
||||
s.src = chrome.runtime.getURL("content/fido2/page-script.js");
|
||||
(document.head || document.documentElement).appendChild(s);
|
||||
const messenger = Messenger.forDOMCommunication(window);
|
||||
|
||||
const messenger = Messenger.forDOMCommunication(window);
|
||||
function injectPageScript() {
|
||||
// Locate an existing page-script on the page
|
||||
const existingPageScript = document.getElementById("bw-fido2-page-script");
|
||||
|
||||
// Inject the page-script if it doesn't exist
|
||||
if (!existingPageScript) {
|
||||
const s = document.createElement("script");
|
||||
s.src = chrome.runtime.getURL("content/fido2/page-script.js");
|
||||
s.id = "bw-fido2-page-script";
|
||||
(document.head || document.documentElement).appendChild(s);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If the page-script already exists, send a reconnect message to the page-script
|
||||
messenger.sendReconnectCommand();
|
||||
}
|
||||
|
||||
function initializeFido2ContentScript() {
|
||||
injectPageScript();
|
||||
|
||||
messenger.handler = async (message, abortController) => {
|
||||
const requestId = Date.now().toString();
|
||||
|
@ -78,7 +94,7 @@ function initializeFido2ContentScript() {
|
|||
abortController.signal.addEventListener("abort", abortHandler);
|
||||
|
||||
if (message.type === MessageType.CredentialCreationRequest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise<Message | undefined>((resolve, reject) => {
|
||||
const data: CreateCredentialParams = {
|
||||
...message.data,
|
||||
origin: window.location.origin,
|
||||
|
@ -92,7 +108,7 @@ function initializeFido2ContentScript() {
|
|||
requestId: requestId,
|
||||
},
|
||||
(response) => {
|
||||
if (response.error !== undefined) {
|
||||
if (response && response.error !== undefined) {
|
||||
return reject(response.error);
|
||||
}
|
||||
|
||||
|
@ -106,7 +122,7 @@ function initializeFido2ContentScript() {
|
|||
}
|
||||
|
||||
if (message.type === MessageType.CredentialGetRequest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise<Message | undefined>((resolve, reject) => {
|
||||
const data: AssertCredentialParams = {
|
||||
...message.data,
|
||||
origin: window.location.origin,
|
||||
|
@ -120,7 +136,7 @@ function initializeFido2ContentScript() {
|
|||
requestId: requestId,
|
||||
},
|
||||
(response) => {
|
||||
if (response.error !== undefined) {
|
||||
if (response && response.error !== undefined) {
|
||||
return reject(response.error);
|
||||
}
|
||||
|
||||
|
@ -155,6 +171,12 @@ async function run() {
|
|||
}
|
||||
|
||||
initializeFido2ContentScript();
|
||||
|
||||
const port = chrome.runtime.connect({ name: "fido2ContentScriptReady" });
|
||||
port.onDisconnect.addListener(() => {
|
||||
// Cleanup the messenger and remove the event listener
|
||||
messenger.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
run();
|
||||
|
|
|
@ -11,6 +11,8 @@ export enum MessageType {
|
|||
CredentialGetRequest,
|
||||
CredentialGetResponse,
|
||||
AbortRequest,
|
||||
DisconnectRequest,
|
||||
ReconnectRequest,
|
||||
AbortResponse,
|
||||
ErrorResponse,
|
||||
}
|
||||
|
@ -60,6 +62,14 @@ export type AbortRequest = {
|
|||
abortedRequestId: string;
|
||||
};
|
||||
|
||||
export type DisconnectRequest = {
|
||||
type: MessageType.DisconnectRequest;
|
||||
};
|
||||
|
||||
export type ReconnectRequest = {
|
||||
type: MessageType.ReconnectRequest;
|
||||
};
|
||||
|
||||
export type ErrorResponse = {
|
||||
type: MessageType.ErrorResponse;
|
||||
error: string;
|
||||
|
@ -76,5 +86,7 @@ export type Message =
|
|||
| CredentialGetRequest
|
||||
| CredentialGetResponse
|
||||
| AbortRequest
|
||||
| DisconnectRequest
|
||||
| ReconnectRequest
|
||||
| AbortResponse
|
||||
| ErrorResponse;
|
||||
|
|
|
@ -12,6 +12,12 @@ describe("Messenger", () => {
|
|||
beforeEach(() => {
|
||||
// jest does not support MessageChannel
|
||||
window.MessageChannel = MockMessageChannel as any;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
origin: "https://bitwarden.com",
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const channelPair = new TestChannelPair();
|
||||
messengerA = new Messenger(channelPair.channelA);
|
||||
|
@ -27,7 +33,7 @@ describe("Messenger", () => {
|
|||
const request = createRequest();
|
||||
messengerA.request(request);
|
||||
|
||||
const received = handlerB.recieve();
|
||||
const received = handlerB.receive();
|
||||
|
||||
expect(received.length).toBe(1);
|
||||
expect(received[0].message).toMatchObject(request);
|
||||
|
@ -37,7 +43,7 @@ describe("Messenger", () => {
|
|||
const request = createRequest();
|
||||
const response = createResponse();
|
||||
const requestPromise = messengerA.request(request);
|
||||
const received = handlerB.recieve();
|
||||
const received = handlerB.receive();
|
||||
received[0].respond(response);
|
||||
|
||||
const returned = await requestPromise;
|
||||
|
@ -49,7 +55,7 @@ describe("Messenger", () => {
|
|||
const request = createRequest();
|
||||
const error = new Error("Test error");
|
||||
const requestPromise = messengerA.request(request);
|
||||
const received = handlerB.recieve();
|
||||
const received = handlerB.receive();
|
||||
|
||||
received[0].reject(error);
|
||||
|
||||
|
@ -61,10 +67,60 @@ describe("Messenger", () => {
|
|||
messengerA.request(createRequest(), abortController);
|
||||
abortController.abort();
|
||||
|
||||
const received = handlerB.recieve();
|
||||
const received = handlerB.receive();
|
||||
|
||||
expect(received[0].abortController.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
describe("destroy", () => {
|
||||
beforeEach(() => {
|
||||
/**
|
||||
* In Jest's jsdom environment, there is an issue where event listeners are not
|
||||
* triggered upon dispatching an event. This is a workaround to mock the EventTarget
|
||||
*/
|
||||
window.EventTarget = MockEventTarget as any;
|
||||
});
|
||||
|
||||
it("should remove the message event listener", async () => {
|
||||
const channelPair = new TestChannelPair();
|
||||
const addEventListenerSpy = jest.spyOn(channelPair.channelA, "addEventListener");
|
||||
const removeEventListenerSpy = jest.spyOn(channelPair.channelA, "removeEventListener");
|
||||
messengerA = new Messenger(channelPair.channelA);
|
||||
jest
|
||||
.spyOn(messengerA as any, "sendDisconnectCommand")
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalled();
|
||||
|
||||
await messengerA.destroy();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should dispatch the destroy event on messenger destruction", async () => {
|
||||
const request = createRequest();
|
||||
messengerA.request(request);
|
||||
|
||||
const dispatchEventSpy = jest.spyOn((messengerA as any).onDestroy, "dispatchEvent");
|
||||
messengerA.destroy();
|
||||
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(Event));
|
||||
});
|
||||
|
||||
it("should trigger onDestroyListener when the destroy event is dispatched", async () => {
|
||||
const request = createRequest();
|
||||
messengerA.request(request);
|
||||
|
||||
const onDestroyListener = jest.fn();
|
||||
(messengerA as any).onDestroy.addEventListener("destroy", onDestroyListener);
|
||||
messengerA.destroy();
|
||||
|
||||
expect(onDestroyListener).toHaveBeenCalled();
|
||||
const eventArg = onDestroyListener.mock.calls[0][0];
|
||||
expect(eventArg).toBeInstanceOf(Event);
|
||||
expect(eventArg.type).toBe("destroy");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type TestMessage = MessageWithMetadata & { testId: string };
|
||||
|
@ -86,11 +142,13 @@ class TestChannelPair {
|
|||
|
||||
this.channelA = {
|
||||
addEventListener: (listener) => (broadcastChannel.port1.onmessage = listener),
|
||||
removeEventListener: () => (broadcastChannel.port1.onmessage = null),
|
||||
postMessage: (message, port) => broadcastChannel.port1.postMessage(message, port),
|
||||
};
|
||||
|
||||
this.channelB = {
|
||||
addEventListener: (listener) => (broadcastChannel.port2.onmessage = listener),
|
||||
removeEventListener: () => (broadcastChannel.port1.onmessage = null),
|
||||
postMessage: (message, port) => broadcastChannel.port2.postMessage(message, port),
|
||||
};
|
||||
}
|
||||
|
@ -102,7 +160,7 @@ class TestMessageHandler {
|
|||
abortController?: AbortController,
|
||||
) => Promise<Message | undefined>;
|
||||
|
||||
private recievedMessages: {
|
||||
private receivedMessages: {
|
||||
message: TestMessage;
|
||||
respond: (response: TestMessage) => void;
|
||||
reject: (error: Error) => void;
|
||||
|
@ -112,7 +170,7 @@ class TestMessageHandler {
|
|||
constructor() {
|
||||
this.handler = (message, abortController) =>
|
||||
new Promise((resolve, reject) => {
|
||||
this.recievedMessages.push({
|
||||
this.receivedMessages.push({
|
||||
message,
|
||||
abortController,
|
||||
respond: (response) => resolve(response),
|
||||
|
@ -121,9 +179,9 @@ class TestMessageHandler {
|
|||
});
|
||||
}
|
||||
|
||||
recieve() {
|
||||
const received = this.recievedMessages;
|
||||
this.recievedMessages = [];
|
||||
receive() {
|
||||
const received = this.receivedMessages;
|
||||
this.receivedMessages = [];
|
||||
return received;
|
||||
}
|
||||
}
|
||||
|
@ -144,7 +202,11 @@ class MockMessagePort<T> {
|
|||
|
||||
postMessage(message: T, port?: MessagePort) {
|
||||
this.remotePort.onmessage(
|
||||
new MessageEvent("message", { data: message, ports: port ? [port] : [] }),
|
||||
new MessageEvent("message", {
|
||||
data: message,
|
||||
ports: port ? [port] : [],
|
||||
origin: "https://bitwarden.com",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -152,3 +214,20 @@ class MockMessagePort<T> {
|
|||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
class MockEventTarget {
|
||||
listeners: Record<string, EventListener[]> = {};
|
||||
|
||||
addEventListener(type: string, callback: EventListener) {
|
||||
this.listeners[type] = this.listeners[type] || [];
|
||||
this.listeners[type].push(callback);
|
||||
}
|
||||
|
||||
dispatchEvent(event: Event) {
|
||||
(this.listeners[event.type] || []).forEach((callback) => callback(event));
|
||||
}
|
||||
|
||||
removeEventListener(type: string, callback: EventListener) {
|
||||
this.listeners[type] = (this.listeners[type] || []).filter((listener) => listener !== callback);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { FallbackRequestedError } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
|
||||
|
||||
import { Message, MessageType } from "./message";
|
||||
|
||||
const SENDER = "bitwarden-webauthn";
|
||||
|
@ -6,15 +8,16 @@ type PostMessageFunction = (message: MessageWithMetadata, remotePort: MessagePor
|
|||
|
||||
export type Channel = {
|
||||
addEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void;
|
||||
removeEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void;
|
||||
postMessage: PostMessageFunction;
|
||||
};
|
||||
|
||||
export type Metadata = { SENDER: typeof SENDER };
|
||||
export type Metadata = { SENDER: typeof SENDER; senderId: string };
|
||||
export type MessageWithMetadata = Message & Metadata;
|
||||
type Handler = (
|
||||
message: MessageWithMetadata,
|
||||
abortController?: AbortController,
|
||||
) => Promise<Message | undefined>;
|
||||
) => void | Promise<Message | undefined>;
|
||||
|
||||
/**
|
||||
* A class that handles communication between the page and content script. It converts
|
||||
|
@ -22,6 +25,9 @@ type Handler = (
|
|||
* handling aborts and exceptions across separate execution contexts.
|
||||
*/
|
||||
export class Messenger {
|
||||
private messageEventListener: (event: MessageEvent<MessageWithMetadata>) => void | null = null;
|
||||
private onDestroy = new EventTarget();
|
||||
|
||||
/**
|
||||
* Creates a messenger that uses the browser's `window.postMessage` API to initiate
|
||||
* requests in the content script. Every request will then create it's own
|
||||
|
@ -35,14 +41,8 @@ export class Messenger {
|
|||
|
||||
return new Messenger({
|
||||
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
|
||||
addEventListener: (listener) =>
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== windowOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
listener(event as MessageEvent<MessageWithMetadata>);
|
||||
}),
|
||||
addEventListener: (listener) => window.addEventListener("message", listener),
|
||||
removeEventListener: (listener) => window.removeEventListener("message", listener),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -53,38 +53,11 @@ export class Messenger {
|
|||
*/
|
||||
handler?: Handler;
|
||||
|
||||
private messengerId = this.generateUniqueId();
|
||||
|
||||
constructor(private broadcastChannel: Channel) {
|
||||
this.broadcastChannel.addEventListener(async (event) => {
|
||||
if (this.handler === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = event.data;
|
||||
const port = event.ports?.[0];
|
||||
if (message?.SENDER !== SENDER || message == null || port == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
port.onmessage = (event: MessageEvent<MessageWithMetadata>) => {
|
||||
if (event.data.type === MessageType.AbortRequest) {
|
||||
abortController.abort();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const handlerResponse = await this.handler(message, abortController);
|
||||
port.postMessage({ ...handlerResponse, SENDER });
|
||||
} catch (error) {
|
||||
port.postMessage({
|
||||
SENDER,
|
||||
type: MessageType.ErrorResponse,
|
||||
error: JSON.stringify(error, Object.getOwnPropertyNames(error)),
|
||||
});
|
||||
} finally {
|
||||
port.close();
|
||||
}
|
||||
});
|
||||
this.messageEventListener = this.createMessageEventListener();
|
||||
this.broadcastChannel.addEventListener(this.messageEventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -111,7 +84,10 @@ export class Messenger {
|
|||
});
|
||||
abortController?.signal.addEventListener("abort", abortListener);
|
||||
|
||||
this.broadcastChannel.postMessage({ ...request, SENDER }, remotePort);
|
||||
this.broadcastChannel.postMessage(
|
||||
{ ...request, SENDER, senderId: this.messengerId },
|
||||
remotePort,
|
||||
);
|
||||
const response = await promise;
|
||||
|
||||
abortController?.signal.removeEventListener("abort", abortListener);
|
||||
|
@ -127,4 +103,79 @@ export class Messenger {
|
|||
localPort.close();
|
||||
}
|
||||
}
|
||||
|
||||
private createMessageEventListener() {
|
||||
return async (event: MessageEvent<MessageWithMetadata>) => {
|
||||
const windowOrigin = window.location.origin;
|
||||
if (event.origin !== windowOrigin || !this.handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = event.data;
|
||||
const port = event.ports?.[0];
|
||||
if (
|
||||
message?.SENDER !== SENDER ||
|
||||
message.senderId == this.messengerId ||
|
||||
message == null ||
|
||||
port == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
port.onmessage = (event: MessageEvent<MessageWithMetadata>) => {
|
||||
if (event.data.type === MessageType.AbortRequest) {
|
||||
abortController.abort();
|
||||
}
|
||||
};
|
||||
|
||||
let onDestroyListener;
|
||||
const destroyPromise: Promise<never> = new Promise((_, reject) => {
|
||||
onDestroyListener = () => reject(new FallbackRequestedError());
|
||||
this.onDestroy.addEventListener("destroy", onDestroyListener);
|
||||
});
|
||||
|
||||
try {
|
||||
const handlerResponse = await Promise.race([
|
||||
this.handler(message, abortController),
|
||||
destroyPromise,
|
||||
]);
|
||||
port.postMessage({ ...handlerResponse, SENDER });
|
||||
} catch (error) {
|
||||
port.postMessage({
|
||||
SENDER,
|
||||
type: MessageType.ErrorResponse,
|
||||
error: JSON.stringify(error, Object.getOwnPropertyNames(error)),
|
||||
});
|
||||
} finally {
|
||||
this.onDestroy.removeEventListener("destroy", onDestroyListener);
|
||||
port.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the messenger by removing the message event listener
|
||||
*/
|
||||
async destroy() {
|
||||
this.onDestroy.dispatchEvent(new Event("destroy"));
|
||||
|
||||
if (this.messageEventListener) {
|
||||
await this.sendDisconnectCommand();
|
||||
this.broadcastChannel.removeEventListener(this.messageEventListener);
|
||||
this.messageEventListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
async sendReconnectCommand() {
|
||||
await this.request({ type: MessageType.ReconnectRequest });
|
||||
}
|
||||
|
||||
private async sendDisconnectCommand() {
|
||||
await this.request({ type: MessageType.DisconnectRequest });
|
||||
}
|
||||
|
||||
private generateUniqueId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,10 +53,21 @@ const browserCredentials = {
|
|||
};
|
||||
|
||||
const messenger = ((window as any).messenger = Messenger.forDOMCommunication(window));
|
||||
navigator.credentials.create = async (
|
||||
|
||||
navigator.credentials.create = createWebAuthnCredential;
|
||||
navigator.credentials.get = getWebAuthnCredential;
|
||||
|
||||
/**
|
||||
* Creates a new webauthn credential.
|
||||
*
|
||||
* @param options Options for creating new credentials.
|
||||
* @param abortController Abort controller to abort the request if needed.
|
||||
* @returns Promise that resolves to the new credential object.
|
||||
*/
|
||||
async function createWebAuthnCredential(
|
||||
options?: CredentialCreationOptions,
|
||||
abortController?: AbortController,
|
||||
): Promise<Credential> => {
|
||||
): Promise<Credential> {
|
||||
if (!isWebauthnCall(options)) {
|
||||
return await browserCredentials.create(options);
|
||||
}
|
||||
|
@ -88,12 +99,19 @@ navigator.credentials.create = async (
|
|||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
navigator.credentials.get = async (
|
||||
/**
|
||||
* Retrieves a webauthn credential.
|
||||
*
|
||||
* @param options Options for creating new credentials.
|
||||
* @param abortController Abort controller to abort the request if needed.
|
||||
* @returns Promise that resolves to the new credential object.
|
||||
*/
|
||||
async function getWebAuthnCredential(
|
||||
options?: CredentialRequestOptions,
|
||||
abortController?: AbortController,
|
||||
): Promise<Credential> => {
|
||||
): Promise<Credential> {
|
||||
if (!isWebauthnCall(options)) {
|
||||
return await browserCredentials.get(options);
|
||||
}
|
||||
|
@ -126,7 +144,7 @@ navigator.credentials.get = async (
|
|||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) {
|
||||
return options && "publicKey" in options;
|
||||
|
@ -174,3 +192,23 @@ async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) {
|
|||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a listener to handle cleanup or reconnection when the extension's
|
||||
* context changes due to being reloaded or unloaded.
|
||||
*/
|
||||
messenger.handler = (message, abortController) => {
|
||||
const type = message.type;
|
||||
|
||||
// Handle cleanup for disconnect request
|
||||
if (type === MessageType.DisconnectRequest && browserNativeWebauthnSupport) {
|
||||
navigator.credentials.create = browserCredentials.create;
|
||||
navigator.credentials.get = browserCredentials.get;
|
||||
}
|
||||
|
||||
// Handle reinitialization for reconnect request
|
||||
if (type === MessageType.ReconnectRequest && browserNativeWebauthnSupport) {
|
||||
navigator.credentials.create = createWebAuthnCredential;
|
||||
navigator.credentials.get = getWebAuthnCredential;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
describe("TriggerFido2ContentScriptInjection", () => {
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("init", () => {
|
||||
it("sends a message to the extension background", () => {
|
||||
require("../content/trigger-fido2-content-script-injection");
|
||||
|
||||
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
|
||||
command: "triggerFido2ContentScriptInjection",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
(function () {
|
||||
chrome.runtime.sendMessage({ command: "triggerFido2ContentScriptInjection" });
|
||||
})();
|
|
@ -0,0 +1,4 @@
|
|||
export abstract class Fido2Service {
|
||||
init: () => Promise<void>;
|
||||
injectFido2ContentScripts: (sender: chrome.runtime.MessageSender) => Promise<void>;
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
import Fido2Service from "./fido2.service";
|
||||
|
||||
describe("Fido2Service", () => {
|
||||
let fido2Service: Fido2Service;
|
||||
let tabMock: chrome.tabs.Tab;
|
||||
let sender: chrome.runtime.MessageSender;
|
||||
|
||||
beforeEach(() => {
|
||||
fido2Service = new Fido2Service();
|
||||
tabMock = { id: 123, url: "https://bitwarden.com" } as chrome.tabs.Tab;
|
||||
sender = { tab: tabMock };
|
||||
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("injectFido2ContentScripts", () => {
|
||||
const fido2ContentScript = "content/fido2/content-script.js";
|
||||
const defaultExecuteScriptOptions = { runAt: "document_start" };
|
||||
|
||||
it("accepts an extension message sender and injects the fido2 scripts into the tab of the sender", async () => {
|
||||
await fido2Service.injectFido2ContentScripts(sender);
|
||||
|
||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
|
||||
file: fido2ContentScript,
|
||||
...defaultExecuteScriptOptions,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,33 @@
|
|||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
import { Fido2Service as Fido2ServiceInterface } from "./abstractions/fido2.service";
|
||||
|
||||
export default class Fido2Service implements Fido2ServiceInterface {
|
||||
async init() {
|
||||
const tabs = await BrowserApi.tabsQuery({});
|
||||
tabs.forEach((tab) => {
|
||||
if (tab.url?.startsWith("https")) {
|
||||
this.injectFido2ContentScripts({ tab } as chrome.runtime.MessageSender);
|
||||
}
|
||||
});
|
||||
|
||||
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
|
||||
if (port.name === "fido2ContentScriptReady") {
|
||||
port.postMessage({ command: "fido2ContentScriptInit" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the FIDO2 content script into the current tab.
|
||||
* @param {chrome.runtime.MessageSender} sender
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async injectFido2ContentScripts(sender: chrome.runtime.MessageSender): Promise<void> {
|
||||
await BrowserApi.executeScriptInTab(sender.tab.id, {
|
||||
file: "content/fido2/content-script.js",
|
||||
frameId: sender.frameId,
|
||||
runAt: "document_start",
|
||||
});
|
||||
}
|
||||
}
|
|
@ -171,6 +171,8 @@ const mainConfig = {
|
|||
"content/notificationBar": "./src/autofill/content/notification-bar.ts",
|
||||
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
|
||||
"content/message_handler": "./src/autofill/content/message_handler.ts",
|
||||
"content/fido2/trigger-fido2-content-script-injection":
|
||||
"./src/vault/fido2/content/trigger-fido2-content-script-injection.ts",
|
||||
"content/fido2/content-script": "./src/vault/fido2/content/content-script.ts",
|
||||
"content/fido2/page-script": "./src/vault/fido2/content/page-script.ts",
|
||||
"notification/bar": "./src/autofill/notification/bar.ts",
|
||||
|
|
Loading…
Reference in New Issue