mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-04 18:37:45 +01:00
[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 { BrowserSettingsService } from "../services/browser-settings.service";
|
||||||
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
|
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
|
||||||
import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.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 { BrowserFolderService } from "../vault/services/browser-folder.service";
|
||||||
|
import Fido2Service from "../vault/services/fido2.service";
|
||||||
import { VaultFilterService } from "../vault/services/vault-filter.service";
|
import { VaultFilterService } from "../vault/services/vault-filter.service";
|
||||||
|
|
||||||
import CommandsBackground from "./commands.background";
|
import CommandsBackground from "./commands.background";
|
||||||
@ -232,6 +234,7 @@ export default class MainBackground {
|
|||||||
authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
|
authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
|
||||||
accountService: AccountServiceAbstraction;
|
accountService: AccountServiceAbstraction;
|
||||||
globalStateProvider: GlobalStateProvider;
|
globalStateProvider: GlobalStateProvider;
|
||||||
|
fido2Service: Fido2ServiceAbstraction;
|
||||||
|
|
||||||
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
|
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
|
||||||
backgroundWindow = window;
|
backgroundWindow = window;
|
||||||
@ -597,6 +600,7 @@ export default class MainBackground {
|
|||||||
this.messagingService,
|
this.messagingService,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.fido2Service = new Fido2Service();
|
||||||
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
|
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
|
||||||
this.fido2AuthenticatorService = new Fido2AuthenticatorService(
|
this.fido2AuthenticatorService = new Fido2AuthenticatorService(
|
||||||
this.cipherService,
|
this.cipherService,
|
||||||
@ -645,6 +649,7 @@ export default class MainBackground {
|
|||||||
this.messagingService,
|
this.messagingService,
|
||||||
this.logService,
|
this.logService,
|
||||||
this.configService,
|
this.configService,
|
||||||
|
this.fido2Service,
|
||||||
);
|
);
|
||||||
this.nativeMessagingBackground = new NativeMessagingBackground(
|
this.nativeMessagingBackground = new NativeMessagingBackground(
|
||||||
this.cryptoService,
|
this.cryptoService,
|
||||||
@ -778,6 +783,8 @@ export default class MainBackground {
|
|||||||
await this.idleBackground.init();
|
await this.idleBackground.init();
|
||||||
await this.webRequestBackground.init();
|
await this.webRequestBackground.init();
|
||||||
|
|
||||||
|
await this.fido2Service.init();
|
||||||
|
|
||||||
if (this.platformUtilsService.isFirefox() && !this.isPrivateMode) {
|
if (this.platformUtilsService.isFirefox() && !this.isPrivateMode) {
|
||||||
// Set Private Mode windows to the default icon - they do not share state with the background page
|
// Set Private Mode windows to the default icon - they do not share state with the background page
|
||||||
const privateWindows = await BrowserApi.getPrivateModeWindows();
|
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 { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
|
||||||
import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service";
|
import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service";
|
||||||
import { AbortManager } from "../vault/background/abort-manager";
|
import { AbortManager } from "../vault/background/abort-manager";
|
||||||
|
import { Fido2Service } from "../vault/services/abstractions/fido2.service";
|
||||||
|
|
||||||
import MainBackground from "./main.background";
|
import MainBackground from "./main.background";
|
||||||
|
|
||||||
@ -42,6 +43,7 @@ export default class RuntimeBackground {
|
|||||||
private messagingService: MessagingService,
|
private messagingService: MessagingService,
|
||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
private configService: ConfigServiceAbstraction,
|
private configService: ConfigServiceAbstraction,
|
||||||
|
private fido2Service: Fido2Service,
|
||||||
) {
|
) {
|
||||||
// onInstalled listener must be wired up before anything else, so we do it in the ctor
|
// onInstalled listener must be wired up before anything else, so we do it in the ctor
|
||||||
chrome.runtime.onInstalled.addListener((details: any) => {
|
chrome.runtime.onInstalled.addListener((details: any) => {
|
||||||
@ -257,6 +259,9 @@ export default class RuntimeBackground {
|
|||||||
case "getClickedElementResponse":
|
case "getClickedElementResponse":
|
||||||
this.platformUtilsService.copyToClipboard(msg.identifier, { window: window });
|
this.platformUtilsService.copyToClipboard(msg.identifier, { window: window });
|
||||||
break;
|
break;
|
||||||
|
case "triggerFido2ContentScriptInjection":
|
||||||
|
await this.fido2Service.injectFido2ContentScripts(sender);
|
||||||
|
break;
|
||||||
case "fido2AbortRequest":
|
case "fido2AbortRequest":
|
||||||
this.abortManager.abort(msg.abortedRequestId);
|
this.abortManager.abort(msg.abortedRequestId);
|
||||||
break;
|
break;
|
||||||
|
@ -17,7 +17,10 @@
|
|||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"all_frames": true,
|
"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:///*"],
|
"matches": ["http://*/*", "https://*/*", "file:///*"],
|
||||||
"run_at": "document_start"
|
"run_at": "document_start"
|
||||||
},
|
},
|
||||||
|
@ -61,12 +61,28 @@ async function isLocationBitwardenVault(activeUserSettings: Record<string, any>)
|
|||||||
return window.location.origin === activeUserSettings.serverConfig.environment.vault;
|
return window.location.origin === activeUserSettings.serverConfig.environment.vault;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeFido2ContentScript() {
|
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");
|
const s = document.createElement("script");
|
||||||
s.src = chrome.runtime.getURL("content/fido2/page-script.js");
|
s.src = chrome.runtime.getURL("content/fido2/page-script.js");
|
||||||
|
s.id = "bw-fido2-page-script";
|
||||||
(document.head || document.documentElement).appendChild(s);
|
(document.head || document.documentElement).appendChild(s);
|
||||||
|
|
||||||
const messenger = Messenger.forDOMCommunication(window);
|
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) => {
|
messenger.handler = async (message, abortController) => {
|
||||||
const requestId = Date.now().toString();
|
const requestId = Date.now().toString();
|
||||||
@ -78,7 +94,7 @@ function initializeFido2ContentScript() {
|
|||||||
abortController.signal.addEventListener("abort", abortHandler);
|
abortController.signal.addEventListener("abort", abortHandler);
|
||||||
|
|
||||||
if (message.type === MessageType.CredentialCreationRequest) {
|
if (message.type === MessageType.CredentialCreationRequest) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise<Message | undefined>((resolve, reject) => {
|
||||||
const data: CreateCredentialParams = {
|
const data: CreateCredentialParams = {
|
||||||
...message.data,
|
...message.data,
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
@ -92,7 +108,7 @@ function initializeFido2ContentScript() {
|
|||||||
requestId: requestId,
|
requestId: requestId,
|
||||||
},
|
},
|
||||||
(response) => {
|
(response) => {
|
||||||
if (response.error !== undefined) {
|
if (response && response.error !== undefined) {
|
||||||
return reject(response.error);
|
return reject(response.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +122,7 @@ function initializeFido2ContentScript() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === MessageType.CredentialGetRequest) {
|
if (message.type === MessageType.CredentialGetRequest) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise<Message | undefined>((resolve, reject) => {
|
||||||
const data: AssertCredentialParams = {
|
const data: AssertCredentialParams = {
|
||||||
...message.data,
|
...message.data,
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
@ -120,7 +136,7 @@ function initializeFido2ContentScript() {
|
|||||||
requestId: requestId,
|
requestId: requestId,
|
||||||
},
|
},
|
||||||
(response) => {
|
(response) => {
|
||||||
if (response.error !== undefined) {
|
if (response && response.error !== undefined) {
|
||||||
return reject(response.error);
|
return reject(response.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,6 +171,12 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initializeFido2ContentScript();
|
initializeFido2ContentScript();
|
||||||
|
|
||||||
|
const port = chrome.runtime.connect({ name: "fido2ContentScriptReady" });
|
||||||
|
port.onDisconnect.addListener(() => {
|
||||||
|
// Cleanup the messenger and remove the event listener
|
||||||
|
messenger.destroy();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
run();
|
run();
|
||||||
|
@ -11,6 +11,8 @@ export enum MessageType {
|
|||||||
CredentialGetRequest,
|
CredentialGetRequest,
|
||||||
CredentialGetResponse,
|
CredentialGetResponse,
|
||||||
AbortRequest,
|
AbortRequest,
|
||||||
|
DisconnectRequest,
|
||||||
|
ReconnectRequest,
|
||||||
AbortResponse,
|
AbortResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
}
|
}
|
||||||
@ -60,6 +62,14 @@ export type AbortRequest = {
|
|||||||
abortedRequestId: string;
|
abortedRequestId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DisconnectRequest = {
|
||||||
|
type: MessageType.DisconnectRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReconnectRequest = {
|
||||||
|
type: MessageType.ReconnectRequest;
|
||||||
|
};
|
||||||
|
|
||||||
export type ErrorResponse = {
|
export type ErrorResponse = {
|
||||||
type: MessageType.ErrorResponse;
|
type: MessageType.ErrorResponse;
|
||||||
error: string;
|
error: string;
|
||||||
@ -76,5 +86,7 @@ export type Message =
|
|||||||
| CredentialGetRequest
|
| CredentialGetRequest
|
||||||
| CredentialGetResponse
|
| CredentialGetResponse
|
||||||
| AbortRequest
|
| AbortRequest
|
||||||
|
| DisconnectRequest
|
||||||
|
| ReconnectRequest
|
||||||
| AbortResponse
|
| AbortResponse
|
||||||
| ErrorResponse;
|
| ErrorResponse;
|
||||||
|
@ -12,6 +12,12 @@ describe("Messenger", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// jest does not support MessageChannel
|
// jest does not support MessageChannel
|
||||||
window.MessageChannel = MockMessageChannel as any;
|
window.MessageChannel = MockMessageChannel as any;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: {
|
||||||
|
origin: "https://bitwarden.com",
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
const channelPair = new TestChannelPair();
|
const channelPair = new TestChannelPair();
|
||||||
messengerA = new Messenger(channelPair.channelA);
|
messengerA = new Messenger(channelPair.channelA);
|
||||||
@ -27,7 +33,7 @@ describe("Messenger", () => {
|
|||||||
const request = createRequest();
|
const request = createRequest();
|
||||||
messengerA.request(request);
|
messengerA.request(request);
|
||||||
|
|
||||||
const received = handlerB.recieve();
|
const received = handlerB.receive();
|
||||||
|
|
||||||
expect(received.length).toBe(1);
|
expect(received.length).toBe(1);
|
||||||
expect(received[0].message).toMatchObject(request);
|
expect(received[0].message).toMatchObject(request);
|
||||||
@ -37,7 +43,7 @@ describe("Messenger", () => {
|
|||||||
const request = createRequest();
|
const request = createRequest();
|
||||||
const response = createResponse();
|
const response = createResponse();
|
||||||
const requestPromise = messengerA.request(request);
|
const requestPromise = messengerA.request(request);
|
||||||
const received = handlerB.recieve();
|
const received = handlerB.receive();
|
||||||
received[0].respond(response);
|
received[0].respond(response);
|
||||||
|
|
||||||
const returned = await requestPromise;
|
const returned = await requestPromise;
|
||||||
@ -49,7 +55,7 @@ describe("Messenger", () => {
|
|||||||
const request = createRequest();
|
const request = createRequest();
|
||||||
const error = new Error("Test error");
|
const error = new Error("Test error");
|
||||||
const requestPromise = messengerA.request(request);
|
const requestPromise = messengerA.request(request);
|
||||||
const received = handlerB.recieve();
|
const received = handlerB.receive();
|
||||||
|
|
||||||
received[0].reject(error);
|
received[0].reject(error);
|
||||||
|
|
||||||
@ -61,10 +67,60 @@ describe("Messenger", () => {
|
|||||||
messengerA.request(createRequest(), abortController);
|
messengerA.request(createRequest(), abortController);
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
|
|
||||||
const received = handlerB.recieve();
|
const received = handlerB.receive();
|
||||||
|
|
||||||
expect(received[0].abortController.signal.aborted).toBe(true);
|
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 };
|
type TestMessage = MessageWithMetadata & { testId: string };
|
||||||
@ -86,11 +142,13 @@ class TestChannelPair {
|
|||||||
|
|
||||||
this.channelA = {
|
this.channelA = {
|
||||||
addEventListener: (listener) => (broadcastChannel.port1.onmessage = listener),
|
addEventListener: (listener) => (broadcastChannel.port1.onmessage = listener),
|
||||||
|
removeEventListener: () => (broadcastChannel.port1.onmessage = null),
|
||||||
postMessage: (message, port) => broadcastChannel.port1.postMessage(message, port),
|
postMessage: (message, port) => broadcastChannel.port1.postMessage(message, port),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.channelB = {
|
this.channelB = {
|
||||||
addEventListener: (listener) => (broadcastChannel.port2.onmessage = listener),
|
addEventListener: (listener) => (broadcastChannel.port2.onmessage = listener),
|
||||||
|
removeEventListener: () => (broadcastChannel.port1.onmessage = null),
|
||||||
postMessage: (message, port) => broadcastChannel.port2.postMessage(message, port),
|
postMessage: (message, port) => broadcastChannel.port2.postMessage(message, port),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -102,7 +160,7 @@ class TestMessageHandler {
|
|||||||
abortController?: AbortController,
|
abortController?: AbortController,
|
||||||
) => Promise<Message | undefined>;
|
) => Promise<Message | undefined>;
|
||||||
|
|
||||||
private recievedMessages: {
|
private receivedMessages: {
|
||||||
message: TestMessage;
|
message: TestMessage;
|
||||||
respond: (response: TestMessage) => void;
|
respond: (response: TestMessage) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
@ -112,7 +170,7 @@ class TestMessageHandler {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.handler = (message, abortController) =>
|
this.handler = (message, abortController) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
this.recievedMessages.push({
|
this.receivedMessages.push({
|
||||||
message,
|
message,
|
||||||
abortController,
|
abortController,
|
||||||
respond: (response) => resolve(response),
|
respond: (response) => resolve(response),
|
||||||
@ -121,9 +179,9 @@ class TestMessageHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
recieve() {
|
receive() {
|
||||||
const received = this.recievedMessages;
|
const received = this.receivedMessages;
|
||||||
this.recievedMessages = [];
|
this.receivedMessages = [];
|
||||||
return received;
|
return received;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -144,7 +202,11 @@ class MockMessagePort<T> {
|
|||||||
|
|
||||||
postMessage(message: T, port?: MessagePort) {
|
postMessage(message: T, port?: MessagePort) {
|
||||||
this.remotePort.onmessage(
|
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
|
// 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";
|
import { Message, MessageType } from "./message";
|
||||||
|
|
||||||
const SENDER = "bitwarden-webauthn";
|
const SENDER = "bitwarden-webauthn";
|
||||||
@ -6,15 +8,16 @@ type PostMessageFunction = (message: MessageWithMetadata, remotePort: MessagePor
|
|||||||
|
|
||||||
export type Channel = {
|
export type Channel = {
|
||||||
addEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void;
|
addEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void;
|
||||||
|
removeEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void;
|
||||||
postMessage: PostMessageFunction;
|
postMessage: PostMessageFunction;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Metadata = { SENDER: typeof SENDER };
|
export type Metadata = { SENDER: typeof SENDER; senderId: string };
|
||||||
export type MessageWithMetadata = Message & Metadata;
|
export type MessageWithMetadata = Message & Metadata;
|
||||||
type Handler = (
|
type Handler = (
|
||||||
message: MessageWithMetadata,
|
message: MessageWithMetadata,
|
||||||
abortController?: AbortController,
|
abortController?: AbortController,
|
||||||
) => Promise<Message | undefined>;
|
) => void | Promise<Message | undefined>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A class that handles communication between the page and content script. It converts
|
* 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.
|
* handling aborts and exceptions across separate execution contexts.
|
||||||
*/
|
*/
|
||||||
export class Messenger {
|
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
|
* 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
|
* requests in the content script. Every request will then create it's own
|
||||||
@ -35,14 +41,8 @@ export class Messenger {
|
|||||||
|
|
||||||
return new Messenger({
|
return new Messenger({
|
||||||
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
|
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
|
||||||
addEventListener: (listener) =>
|
addEventListener: (listener) => window.addEventListener("message", listener),
|
||||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
removeEventListener: (listener) => window.removeEventListener("message", listener),
|
||||||
if (event.origin !== windowOrigin) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
listener(event as MessageEvent<MessageWithMetadata>);
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,38 +53,11 @@ export class Messenger {
|
|||||||
*/
|
*/
|
||||||
handler?: Handler;
|
handler?: Handler;
|
||||||
|
|
||||||
|
private messengerId = this.generateUniqueId();
|
||||||
|
|
||||||
constructor(private broadcastChannel: Channel) {
|
constructor(private broadcastChannel: Channel) {
|
||||||
this.broadcastChannel.addEventListener(async (event) => {
|
this.messageEventListener = this.createMessageEventListener();
|
||||||
if (this.handler === undefined) {
|
this.broadcastChannel.addEventListener(this.messageEventListener);
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -111,7 +84,10 @@ export class Messenger {
|
|||||||
});
|
});
|
||||||
abortController?.signal.addEventListener("abort", abortListener);
|
abortController?.signal.addEventListener("abort", abortListener);
|
||||||
|
|
||||||
this.broadcastChannel.postMessage({ ...request, SENDER }, remotePort);
|
this.broadcastChannel.postMessage(
|
||||||
|
{ ...request, SENDER, senderId: this.messengerId },
|
||||||
|
remotePort,
|
||||||
|
);
|
||||||
const response = await promise;
|
const response = await promise;
|
||||||
|
|
||||||
abortController?.signal.removeEventListener("abort", abortListener);
|
abortController?.signal.removeEventListener("abort", abortListener);
|
||||||
@ -127,4 +103,79 @@ export class Messenger {
|
|||||||
localPort.close();
|
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));
|
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,
|
options?: CredentialCreationOptions,
|
||||||
abortController?: AbortController,
|
abortController?: AbortController,
|
||||||
): Promise<Credential> => {
|
): Promise<Credential> {
|
||||||
if (!isWebauthnCall(options)) {
|
if (!isWebauthnCall(options)) {
|
||||||
return await browserCredentials.create(options);
|
return await browserCredentials.create(options);
|
||||||
}
|
}
|
||||||
@ -88,12 +99,19 @@ navigator.credentials.create = async (
|
|||||||
|
|
||||||
throw error;
|
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,
|
options?: CredentialRequestOptions,
|
||||||
abortController?: AbortController,
|
abortController?: AbortController,
|
||||||
): Promise<Credential> => {
|
): Promise<Credential> {
|
||||||
if (!isWebauthnCall(options)) {
|
if (!isWebauthnCall(options)) {
|
||||||
return await browserCredentials.get(options);
|
return await browserCredentials.get(options);
|
||||||
}
|
}
|
||||||
@ -126,7 +144,7 @@ navigator.credentials.get = async (
|
|||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) {
|
function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) {
|
||||||
return options && "publicKey" in options;
|
return options && "publicKey" in options;
|
||||||
@ -174,3 +192,23 @@ async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) {
|
|||||||
window.clearTimeout(timeoutId);
|
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>;
|
||||||
|
}
|
35
apps/browser/src/vault/services/fido2.service.spec.ts
Normal file
35
apps/browser/src/vault/services/fido2.service.spec.ts
Normal file
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
33
apps/browser/src/vault/services/fido2.service.ts
Normal file
33
apps/browser/src/vault/services/fido2.service.ts
Normal file
@ -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/notificationBar": "./src/autofill/content/notification-bar.ts",
|
||||||
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
|
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
|
||||||
"content/message_handler": "./src/autofill/content/message_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/content-script": "./src/vault/fido2/content/content-script.ts",
|
||||||
"content/fido2/page-script": "./src/vault/fido2/content/page-script.ts",
|
"content/fido2/page-script": "./src/vault/fido2/content/page-script.ts",
|
||||||
"notification/bar": "./src/autofill/notification/bar.ts",
|
"notification/bar": "./src/autofill/notification/bar.ts",
|
||||||
|
Loading…
Reference in New Issue
Block a user