1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

[PM-22178] Add WebBrowserInteractionService (#15261)

* add `WebBrowserInteractionService` and check for the extension observable

* update checkForExtension to use observables rather than window timeouts

* add open extension to WebBrowserInteractionService

* add at-risk-passwords to `PopupPageUrls`

* refactor `PopupPageUrls` to `ExtensionPageUrls`

* add test for passing a page

* refactor `Default` to `Index`

* clean up complete/next issue using `race`

* refactor page to url

* continue listening for messages from the extension after subscribed

* mark risk passwords a deprecated

* remove takeUntilDestroyed

* add back `takeUntilDestroyed` for internal `messages`

* removed null filter - unneeded

* add tap to send message for extension installation

* add check for accepted urls to prevent any bad actors from opening the extension
This commit is contained in:
Nick Krantz
2025-06-27 12:55:20 -05:00
committed by GitHub
parent 4e3d83147e
commit cb36b96855
9 changed files with 250 additions and 2 deletions

View File

@@ -0,0 +1,111 @@
import { fakeAsync, TestBed, tick } from "@angular/core/testing";
import { ExtensionPageUrls } from "@bitwarden/common/vault/enums";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
import { WebBrowserInteractionService } from "./web-browser-interaction.service";
describe("WebBrowserInteractionService", () => {
let service: WebBrowserInteractionService;
const postMessage = jest.fn();
window.postMessage = postMessage;
const dispatchEvent = (command: string) => {
window.dispatchEvent(new MessageEvent("message", { data: { command } }));
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [WebBrowserInteractionService],
});
postMessage.mockClear();
service = TestBed.inject(WebBrowserInteractionService);
});
describe("extensionInstalled$", () => {
it("posts a message to check for the extension", () => {
service.extensionInstalled$.subscribe();
expect(postMessage).toHaveBeenCalledWith({
command: VaultMessages.checkBwInstalled,
});
});
it("returns false after the timeout", fakeAsync(() => {
service.extensionInstalled$.subscribe((installed) => {
expect(installed).toBe(false);
});
tick(1500);
}));
it("returns true when the extension is installed", (done) => {
service.extensionInstalled$.subscribe((installed) => {
expect(installed).toBe(true);
done();
});
dispatchEvent(VaultMessages.HasBwInstalled);
});
it("continues to listen for extension state changes after the first response", fakeAsync(() => {
const results: boolean[] = [];
service.extensionInstalled$.subscribe((installed) => {
results.push(installed);
});
// initial timeout, should emit false
tick(1500);
expect(results[0]).toBe(false);
// then emit `HasBwInstalled`
dispatchEvent(VaultMessages.HasBwInstalled);
tick();
expect(results[1]).toBe(true);
}));
});
describe("openExtension", () => {
it("posts a message to open the extension", fakeAsync(() => {
service.openExtension().catch(() => {});
expect(postMessage).toHaveBeenCalledWith({
command: VaultMessages.OpenBrowserExtensionToUrl,
});
tick(1500);
}));
it("posts a message with the passed page", fakeAsync(() => {
service.openExtension(ExtensionPageUrls.Index).catch(() => {});
expect(postMessage).toHaveBeenCalledWith({
command: VaultMessages.OpenBrowserExtensionToUrl,
url: ExtensionPageUrls.Index,
});
tick(1500);
}));
it("resolves when the extension opens", async () => {
const openExtensionPromise = service.openExtension().catch(() => {
fail();
});
dispatchEvent(VaultMessages.PopupOpened);
await openExtensionPromise;
});
it("rejects if the extension does not open within the timeout", fakeAsync(() => {
service.openExtension().catch((error) => {
expect(error).toBe("Failed to open the extension");
});
tick(1500);
}));
});
});

View File

@@ -0,0 +1,76 @@
import { DestroyRef, inject, Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { concatWith, filter, fromEvent, map, Observable, race, take, tap, timer } from "rxjs";
import { ExtensionPageUrls } from "@bitwarden/common/vault/enums";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
/**
* The amount of time in milliseconds to wait for a response from the browser extension.
* NOTE: This value isn't computed by any means, it is just a reasonable timeout for the extension to respond.
*/
const MESSAGE_RESPONSE_TIMEOUT_MS = 1500;
@Injectable({
providedIn: "root",
})
export class WebBrowserInteractionService {
destroyRef = inject(DestroyRef);
private messages$ = fromEvent<MessageEvent>(window, "message").pipe(
takeUntilDestroyed(this.destroyRef),
);
/** Emits the installation status of the extension. */
extensionInstalled$ = this.checkForExtension().pipe(
concatWith(
this.messages$.pipe(
filter((event) => event.data.command === VaultMessages.HasBwInstalled),
map(() => true),
),
),
);
/** Attempts to open the extension, rejects if the extension is not installed or it fails to open. */
openExtension = (url?: ExtensionPageUrls) => {
return new Promise<void>((resolve, reject) => {
race(
this.messages$.pipe(
filter((event) => event.data.command === VaultMessages.PopupOpened),
map(() => true),
),
timer(MESSAGE_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
)
.pipe(take(1))
.subscribe((didOpen) => {
if (!didOpen) {
return reject("Failed to open the extension");
}
resolve();
});
window.postMessage({ command: VaultMessages.OpenBrowserExtensionToUrl, url });
});
};
/** Sends a message via the window object to check if the extension is installed */
private checkForExtension(): Observable<boolean> {
const checkForExtension$ = race(
this.messages$.pipe(
filter((event) => event.data.command === VaultMessages.HasBwInstalled),
map(() => true),
),
timer(MESSAGE_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
).pipe(
tap({
subscribe: () => {
window.postMessage({ command: VaultMessages.checkBwInstalled });
},
}),
take(1),
);
return checkForExtension$;
}
}