mirror of
https://github.com/bitwarden/browser.git
synced 2025-02-23 02:31:26 +01:00
[PM-5189] Merging main and fixing merge conflicts
This commit is contained in:
commit
71474320c5
6
.github/workflows/build-browser.yml
vendored
6
.github/workflows/build-browser.yml
vendored
@ -160,9 +160,9 @@ jobs:
|
||||
run: npm run dist
|
||||
working-directory: browser-source/apps/browser
|
||||
|
||||
# - name: Build Manifest v3
|
||||
# run: npm run dist:mv3
|
||||
# working-directory: browser-source/apps/browser
|
||||
- name: Build Manifest v3
|
||||
run: npm run dist:mv3
|
||||
working-directory: browser-source/apps/browser
|
||||
|
||||
- name: Gulp
|
||||
run: gulp ci
|
||||
|
@ -3006,10 +3006,27 @@
|
||||
"passkeyRemoved": {
|
||||
"message": "Passkey removed"
|
||||
},
|
||||
"unassignedItemsBanner": {
|
||||
"message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible."
|
||||
"unassignedItemsBannerNotice": {
|
||||
"message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console."
|
||||
},
|
||||
"unassignedItemsBannerSelfHost": {
|
||||
"message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible."
|
||||
"unassignedItemsBannerSelfHostNotice": {
|
||||
"message": "Notice: On May 16, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console."
|
||||
},
|
||||
"unassignedItemsBannerCTAPartOne": {
|
||||
"message": "Assign these items to a collection from the",
|
||||
"description": "This will be part of a larger sentence, which will read like so: Assign these items to a collection from the Admin Console to make them visible."
|
||||
},
|
||||
"unassignedItemsBannerCTAPartTwo": {
|
||||
"message": "to make them visible.",
|
||||
"description": "This will be part of a larger sentence, which will read like so: Assign these items to a collection from the Admin Console to make them visible."
|
||||
},
|
||||
"adminConsole": {
|
||||
"message": "Admin Console"
|
||||
},
|
||||
"errorAssigningTargetCollection": {
|
||||
"message": "Error assigning target collection."
|
||||
},
|
||||
"errorAssigningTargetFolder": {
|
||||
"message": "Error assigning target folder."
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,10 @@ import {
|
||||
eventCollectionServiceFactory,
|
||||
} from "../../../background/service-factories/event-collection-service.factory";
|
||||
import { billingAccountProfileStateServiceFactory } from "../../../platform/background/service-factories/billing-account-profile-state-service.factory";
|
||||
import {
|
||||
browserScriptInjectorServiceFactory,
|
||||
BrowserScriptInjectorServiceInitOptions,
|
||||
} from "../../../platform/background/service-factories/browser-script-injector-service.factory";
|
||||
import {
|
||||
CachedServices,
|
||||
factory,
|
||||
@ -45,7 +49,8 @@ export type AutoFillServiceInitOptions = AutoFillServiceOptions &
|
||||
EventCollectionServiceInitOptions &
|
||||
LogServiceInitOptions &
|
||||
UserVerificationServiceInitOptions &
|
||||
DomainSettingsServiceInitOptions;
|
||||
DomainSettingsServiceInitOptions &
|
||||
BrowserScriptInjectorServiceInitOptions;
|
||||
|
||||
export function autofillServiceFactory(
|
||||
cache: { autofillService?: AbstractAutoFillService } & CachedServices,
|
||||
@ -65,6 +70,7 @@ export function autofillServiceFactory(
|
||||
await domainSettingsServiceFactory(cache, opts),
|
||||
await userVerificationServiceFactory(cache, opts),
|
||||
await billingAccountProfileStateServiceFactory(cache, opts),
|
||||
await browserScriptInjectorServiceFactory(cache, opts),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -278,6 +278,17 @@ describe("AutofillInit", () => {
|
||||
});
|
||||
|
||||
describe("destroy", () => {
|
||||
it("clears the timeout used to collect page details on load", () => {
|
||||
jest.spyOn(window, "clearTimeout");
|
||||
|
||||
autofillInit.init();
|
||||
autofillInit.destroy();
|
||||
|
||||
expect(window.clearTimeout).toHaveBeenCalledWith(
|
||||
autofillInit["collectPageDetailsOnLoadTimeout"],
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the extension message listeners", () => {
|
||||
autofillInit.destroy();
|
||||
|
||||
|
@ -21,7 +21,7 @@ class AutofillInit implements AutofillInitInterface {
|
||||
private readonly domElementVisibilityService: DomElementVisibilityService;
|
||||
private readonly collectAutofillContentService: CollectAutofillContentService;
|
||||
private readonly insertAutofillContentService: InsertAutofillContentService;
|
||||
private sendCollectDetailsMessageTimeout: number | NodeJS.Timeout | undefined;
|
||||
private collectPageDetailsOnLoadTimeout: number | NodeJS.Timeout | undefined;
|
||||
private readonly extensionMessageHandlers: AutofillExtensionMessageHandlers = {
|
||||
collectPageDetails: ({ message }) => this.collectPageDetails(message),
|
||||
collectPageDetailsImmediately: ({ message }) => this.collectPageDetails(message, true),
|
||||
@ -84,14 +84,14 @@ class AutofillInit implements AutofillInitInterface {
|
||||
*/
|
||||
private collectPageDetailsOnLoad() {
|
||||
const sendCollectDetailsMessage = () => {
|
||||
this.clearSendCollectDetailsMessageTimeout();
|
||||
this.sendCollectDetailsMessageTimeout = setTimeout(
|
||||
() => this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }),
|
||||
this.clearCollectPageDetailsOnLoadTimeout();
|
||||
this.collectPageDetailsOnLoadTimeout = setTimeout(
|
||||
() => sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }),
|
||||
250,
|
||||
);
|
||||
};
|
||||
|
||||
if (document.readyState === "complete") {
|
||||
if (globalThis.document.readyState === "complete") {
|
||||
sendCollectDetailsMessage();
|
||||
}
|
||||
|
||||
@ -159,6 +159,15 @@ class AutofillInit implements AutofillInitInterface {
|
||||
this.autofillOverlayContentService?.blurMostRecentOverlayField(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the send collect details message timeout.
|
||||
*/
|
||||
private clearCollectPageDetailsOnLoadTimeout() {
|
||||
if (this.collectPageDetailsOnLoadTimeout) {
|
||||
clearTimeout(this.collectPageDetailsOnLoadTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the extension message listeners for the content script.
|
||||
*/
|
||||
@ -207,6 +216,7 @@ class AutofillInit implements AutofillInitInterface {
|
||||
* listeners, timeouts, and object instances to prevent memory leaks.
|
||||
*/
|
||||
destroy() {
|
||||
this.clearCollectPageDetailsOnLoadTimeout();
|
||||
chrome.runtime.onMessage.removeListener(this.handleExtensionMessage);
|
||||
this.collectAutofillContentService.destroy();
|
||||
this.autofillOverlayContentService?.destroy();
|
||||
|
@ -33,6 +33,7 @@ import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
|
||||
import { AutofillPort } from "../enums/autofill-port.enum";
|
||||
import AutofillField from "../models/autofill-field";
|
||||
import AutofillPageDetails from "../models/autofill-page-details";
|
||||
@ -68,6 +69,7 @@ describe("AutofillService", () => {
|
||||
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
||||
const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService);
|
||||
let domainSettingsService: DomainSettingsService;
|
||||
let scriptInjectorService: BrowserScriptInjectorService;
|
||||
const totpService = mock<TotpService>();
|
||||
const eventCollectionService = mock<EventCollectionService>();
|
||||
const logService = mock<LogService>();
|
||||
@ -79,6 +81,7 @@ describe("AutofillService", () => {
|
||||
inlineMenuVisibilitySettingMock$ = new BehaviorSubject(AutofillOverlayVisibility.OnFieldFocus);
|
||||
autofillSettingsService = mock<AutofillSettingsServiceAbstraction>();
|
||||
autofillSettingsService.inlineMenuVisibility$ = inlineMenuVisibilitySettingMock$;
|
||||
scriptInjectorService = new BrowserScriptInjectorService();
|
||||
autofillService = new AutofillService(
|
||||
cipherService,
|
||||
autofillSettingsService,
|
||||
@ -88,6 +91,7 @@ describe("AutofillService", () => {
|
||||
domainSettingsService,
|
||||
userVerificationService,
|
||||
billingAccountProfileStateService,
|
||||
scriptInjectorService,
|
||||
);
|
||||
|
||||
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
|
||||
@ -259,6 +263,7 @@ describe("AutofillService", () => {
|
||||
|
||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
|
||||
file: "content/content-message-handler.js",
|
||||
frameId: 0,
|
||||
...defaultExecuteScriptOptions,
|
||||
});
|
||||
});
|
||||
|
@ -22,6 +22,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
|
||||
import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window";
|
||||
import { AutofillPort } from "../enums/autofill-port.enum";
|
||||
import AutofillField from "../models/autofill-field";
|
||||
@ -57,6 +58,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
private domainSettingsService: DomainSettingsService,
|
||||
private userVerificationService: UserVerificationService,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private scriptInjectorService: ScriptInjectorService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -117,19 +119,22 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
if (triggeringOnPageLoad && autoFillOnPageLoadIsEnabled) {
|
||||
injectedScripts.push("autofiller.js");
|
||||
} else {
|
||||
await BrowserApi.executeScriptInTab(tab.id, {
|
||||
file: "content/content-message-handler.js",
|
||||
runAt: "document_start",
|
||||
await this.scriptInjectorService.inject({
|
||||
tabId: tab.id,
|
||||
injectDetails: { file: "content/content-message-handler.js", runAt: "document_start" },
|
||||
});
|
||||
}
|
||||
|
||||
injectedScripts.push("notificationBar.js", "contextMenuHandler.js");
|
||||
|
||||
for (const injectedScript of injectedScripts) {
|
||||
await BrowserApi.executeScriptInTab(tab.id, {
|
||||
file: `content/${injectedScript}`,
|
||||
frameId,
|
||||
runAt: "document_start",
|
||||
await this.scriptInjectorService.inject({
|
||||
tabId: tab.id,
|
||||
injectDetails: {
|
||||
file: `content/${injectedScript}`,
|
||||
runAt: "document_start",
|
||||
frame: frameId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -269,6 +269,7 @@ function createPortSpyMock(name: string) {
|
||||
disconnect: jest.fn(),
|
||||
sender: {
|
||||
tab: createChromeTabMock(),
|
||||
url: "https://jest-testing-website.com",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
74
apps/browser/src/autofill/spec/fido2-testing-utils.ts
Normal file
74
apps/browser/src/autofill/spec/fido2-testing-utils.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import {
|
||||
AssertCredentialResult,
|
||||
CreateCredentialResult,
|
||||
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
|
||||
|
||||
export function createCredentialCreationOptionsMock(
|
||||
customFields: Partial<CredentialCreationOptions> = {},
|
||||
): CredentialCreationOptions {
|
||||
return mock<CredentialCreationOptions>({
|
||||
publicKey: mock<PublicKeyCredentialCreationOptions>({
|
||||
authenticatorSelection: { authenticatorAttachment: "platform" },
|
||||
excludeCredentials: [{ id: new ArrayBuffer(32), type: "public-key" }],
|
||||
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
|
||||
user: { id: new ArrayBuffer(32), name: "test", displayName: "test" },
|
||||
}),
|
||||
...customFields,
|
||||
});
|
||||
}
|
||||
|
||||
export function createCreateCredentialResultMock(
|
||||
customFields: Partial<CreateCredentialResult> = {},
|
||||
): CreateCredentialResult {
|
||||
return mock<CreateCredentialResult>({
|
||||
credentialId: "mock",
|
||||
clientDataJSON: "mock",
|
||||
attestationObject: "mock",
|
||||
authData: "mock",
|
||||
publicKey: "mock",
|
||||
publicKeyAlgorithm: -7,
|
||||
transports: ["internal"],
|
||||
...customFields,
|
||||
});
|
||||
}
|
||||
|
||||
export function createCredentialRequestOptionsMock(
|
||||
customFields: Partial<CredentialRequestOptions> = {},
|
||||
): CredentialRequestOptions {
|
||||
return mock<CredentialRequestOptions>({
|
||||
mediation: "optional",
|
||||
publicKey: mock<PublicKeyCredentialRequestOptions>({
|
||||
allowCredentials: [{ id: new ArrayBuffer(32), type: "public-key" }],
|
||||
}),
|
||||
...customFields,
|
||||
});
|
||||
}
|
||||
|
||||
export function createAssertCredentialResultMock(
|
||||
customFields: Partial<AssertCredentialResult> = {},
|
||||
): AssertCredentialResult {
|
||||
return mock<AssertCredentialResult>({
|
||||
credentialId: "mock",
|
||||
clientDataJSON: "mock",
|
||||
authenticatorData: "mock",
|
||||
signature: "mock",
|
||||
userHandle: "mock",
|
||||
...customFields,
|
||||
});
|
||||
}
|
||||
|
||||
export function setupMockedWebAuthnSupport() {
|
||||
(globalThis as any).PublicKeyCredential = class PolyfillPublicKeyCredential {
|
||||
static isUserVerifyingPlatformAuthenticatorAvailable = () => Promise.resolve(true);
|
||||
};
|
||||
(globalThis as any).AuthenticatorAttestationResponse =
|
||||
class PolyfillAuthenticatorAttestationResponse {};
|
||||
(globalThis as any).AuthenticatorAssertionResponse =
|
||||
class PolyfillAuthenticatorAssertionResponse {};
|
||||
(globalThis as any).navigator.credentials = {
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
get: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
@ -214,6 +214,7 @@ import BrowserLocalStorageService from "../platform/services/browser-local-stora
|
||||
import BrowserMemoryStorageService from "../platform/services/browser-memory-storage.service";
|
||||
import BrowserMessagingPrivateModeBackgroundService from "../platform/services/browser-messaging-private-mode-background.service";
|
||||
import BrowserMessagingService from "../platform/services/browser-messaging.service";
|
||||
import { BrowserScriptInjectorService } from "../platform/services/browser-script-injector.service";
|
||||
import { DefaultBrowserStateService } from "../platform/services/default-browser-state.service";
|
||||
import I18nService from "../platform/services/i18n.service";
|
||||
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
|
||||
@ -223,9 +224,9 @@ import { BackgroundDerivedStateProvider } from "../platform/state/background-der
|
||||
import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service";
|
||||
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
|
||||
import FilelessImporterBackground from "../tools/background/fileless-importer.background";
|
||||
import { Fido2Background as Fido2BackgroundAbstraction } from "../vault/fido2/background/abstractions/fido2.background";
|
||||
import { Fido2Background } from "../vault/fido2/background/fido2.background";
|
||||
import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.service";
|
||||
import { Fido2Service as Fido2ServiceAbstraction } from "../vault/services/abstractions/fido2.service";
|
||||
import Fido2Service from "../vault/services/fido2.service";
|
||||
import { VaultFilterService } from "../vault/services/vault-filter.service";
|
||||
|
||||
import CommandsBackground from "./commands.background";
|
||||
@ -316,7 +317,7 @@ export default class MainBackground {
|
||||
activeUserStateProvider: ActiveUserStateProvider;
|
||||
derivedStateProvider: DerivedStateProvider;
|
||||
stateProvider: StateProvider;
|
||||
fido2Service: Fido2ServiceAbstraction;
|
||||
fido2Background: Fido2BackgroundAbstraction;
|
||||
individualVaultExportService: IndividualVaultExportServiceAbstraction;
|
||||
organizationVaultExportService: OrganizationVaultExportServiceAbstraction;
|
||||
vaultSettingsService: VaultSettingsServiceAbstraction;
|
||||
@ -324,6 +325,7 @@ export default class MainBackground {
|
||||
stateEventRunnerService: StateEventRunnerService;
|
||||
ssoLoginService: SsoLoginServiceAbstraction;
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService;
|
||||
scriptInjectorService: BrowserScriptInjectorService;
|
||||
|
||||
onUpdatedRan: boolean;
|
||||
onReplacedRan: boolean;
|
||||
@ -342,11 +344,11 @@ export default class MainBackground {
|
||||
private syncTimeout: any;
|
||||
private isSafari: boolean;
|
||||
private nativeMessagingBackground: NativeMessagingBackground;
|
||||
popupOnlyContext: boolean;
|
||||
|
||||
constructor(public isPrivateMode: boolean = false) {
|
||||
this.popupOnlyContext = isPrivateMode || BrowserApi.isManifestVersion(3);
|
||||
|
||||
constructor(
|
||||
public isPrivateMode: boolean = false,
|
||||
public popupOnlyContext: boolean = false,
|
||||
) {
|
||||
// Services
|
||||
const lockedCallback = async (userId?: string) => {
|
||||
if (this.notificationsService != null) {
|
||||
@ -791,6 +793,7 @@ export default class MainBackground {
|
||||
);
|
||||
this.totpService = new TotpService(this.cryptoFunctionService, this.logService);
|
||||
|
||||
this.scriptInjectorService = new BrowserScriptInjectorService();
|
||||
this.autofillService = new AutofillService(
|
||||
this.cipherService,
|
||||
this.autofillSettingsService,
|
||||
@ -800,6 +803,7 @@ export default class MainBackground {
|
||||
this.domainSettingsService,
|
||||
this.userVerificationService,
|
||||
this.billingAccountProfileStateService,
|
||||
this.scriptInjectorService,
|
||||
);
|
||||
this.auditService = new AuditService(this.cryptoFunctionService, this.apiService);
|
||||
|
||||
@ -849,7 +853,6 @@ export default class MainBackground {
|
||||
this.messagingService,
|
||||
);
|
||||
|
||||
this.fido2Service = new Fido2Service();
|
||||
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
|
||||
this.fido2AuthenticatorService = new Fido2AuthenticatorService(
|
||||
this.cipherService,
|
||||
@ -889,83 +892,90 @@ export default class MainBackground {
|
||||
this.isSafari = this.platformUtilsService.isSafari();
|
||||
|
||||
// Background
|
||||
this.runtimeBackground = new RuntimeBackground(
|
||||
this,
|
||||
this.autofillService,
|
||||
this.platformUtilsService as BrowserPlatformUtilsService,
|
||||
this.i18nService,
|
||||
this.notificationsService,
|
||||
this.stateService,
|
||||
this.autofillSettingsService,
|
||||
this.systemService,
|
||||
this.environmentService,
|
||||
this.messagingService,
|
||||
this.logService,
|
||||
this.configService,
|
||||
this.fido2Service,
|
||||
);
|
||||
this.nativeMessagingBackground = new NativeMessagingBackground(
|
||||
this.accountService,
|
||||
this.masterPasswordService,
|
||||
this.cryptoService,
|
||||
this.cryptoFunctionService,
|
||||
this.runtimeBackground,
|
||||
this.messagingService,
|
||||
this.appIdService,
|
||||
this.platformUtilsService,
|
||||
this.stateService,
|
||||
this.logService,
|
||||
this.authService,
|
||||
this.biometricStateService,
|
||||
);
|
||||
this.commandsBackground = new CommandsBackground(
|
||||
this,
|
||||
this.passwordGenerationService,
|
||||
this.platformUtilsService,
|
||||
this.vaultTimeoutService,
|
||||
this.authService,
|
||||
);
|
||||
this.notificationBackground = new NotificationBackground(
|
||||
this.autofillService,
|
||||
this.cipherService,
|
||||
this.authService,
|
||||
this.policyService,
|
||||
this.folderService,
|
||||
this.stateService,
|
||||
this.userNotificationSettingsService,
|
||||
this.domainSettingsService,
|
||||
this.environmentService,
|
||||
this.logService,
|
||||
themeStateService,
|
||||
this.configService,
|
||||
);
|
||||
this.overlayBackground = new OverlayBackground(
|
||||
this.logService,
|
||||
this.cipherService,
|
||||
this.autofillService,
|
||||
this.authService,
|
||||
this.environmentService,
|
||||
this.domainSettingsService,
|
||||
this.stateService,
|
||||
this.autofillSettingsService,
|
||||
this.i18nService,
|
||||
this.platformUtilsService,
|
||||
themeStateService,
|
||||
);
|
||||
this.filelessImporterBackground = new FilelessImporterBackground(
|
||||
this.configService,
|
||||
this.authService,
|
||||
this.policyService,
|
||||
this.notificationBackground,
|
||||
this.importService,
|
||||
this.syncService,
|
||||
);
|
||||
this.tabsBackground = new TabsBackground(
|
||||
this,
|
||||
this.notificationBackground,
|
||||
this.overlayBackground,
|
||||
);
|
||||
if (!this.popupOnlyContext) {
|
||||
this.fido2Background = new Fido2Background(
|
||||
this.logService,
|
||||
this.fido2ClientService,
|
||||
this.vaultSettingsService,
|
||||
this.scriptInjectorService,
|
||||
);
|
||||
this.runtimeBackground = new RuntimeBackground(
|
||||
this,
|
||||
this.autofillService,
|
||||
this.platformUtilsService as BrowserPlatformUtilsService,
|
||||
this.notificationsService,
|
||||
this.stateService,
|
||||
this.autofillSettingsService,
|
||||
this.systemService,
|
||||
this.environmentService,
|
||||
this.messagingService,
|
||||
this.logService,
|
||||
this.configService,
|
||||
this.fido2Background,
|
||||
);
|
||||
this.nativeMessagingBackground = new NativeMessagingBackground(
|
||||
this.accountService,
|
||||
this.masterPasswordService,
|
||||
this.cryptoService,
|
||||
this.cryptoFunctionService,
|
||||
this.runtimeBackground,
|
||||
this.messagingService,
|
||||
this.appIdService,
|
||||
this.platformUtilsService,
|
||||
this.stateService,
|
||||
this.logService,
|
||||
this.authService,
|
||||
this.biometricStateService,
|
||||
);
|
||||
this.commandsBackground = new CommandsBackground(
|
||||
this,
|
||||
this.passwordGenerationService,
|
||||
this.platformUtilsService,
|
||||
this.vaultTimeoutService,
|
||||
this.authService,
|
||||
);
|
||||
this.notificationBackground = new NotificationBackground(
|
||||
this.autofillService,
|
||||
this.cipherService,
|
||||
this.authService,
|
||||
this.policyService,
|
||||
this.folderService,
|
||||
this.stateService,
|
||||
this.userNotificationSettingsService,
|
||||
this.domainSettingsService,
|
||||
this.environmentService,
|
||||
this.logService,
|
||||
themeStateService,
|
||||
this.configService,
|
||||
);
|
||||
this.overlayBackground = new OverlayBackground(
|
||||
this.logService,
|
||||
this.cipherService,
|
||||
this.autofillService,
|
||||
this.authService,
|
||||
this.environmentService,
|
||||
this.domainSettingsService,
|
||||
this.stateService,
|
||||
this.autofillSettingsService,
|
||||
this.i18nService,
|
||||
this.platformUtilsService,
|
||||
themeStateService,
|
||||
);
|
||||
this.filelessImporterBackground = new FilelessImporterBackground(
|
||||
this.configService,
|
||||
this.authService,
|
||||
this.policyService,
|
||||
this.notificationBackground,
|
||||
this.importService,
|
||||
this.syncService,
|
||||
this.scriptInjectorService,
|
||||
);
|
||||
this.tabsBackground = new TabsBackground(
|
||||
this,
|
||||
this.notificationBackground,
|
||||
this.overlayBackground,
|
||||
);
|
||||
|
||||
const contextMenuClickedHandler = new ContextMenuClickedHandler(
|
||||
(options) => this.platformUtilsService.copyToClipboard(options.text),
|
||||
async (_tab) => {
|
||||
@ -1007,11 +1017,6 @@ export default class MainBackground {
|
||||
this.notificationsService,
|
||||
this.accountService,
|
||||
);
|
||||
this.webRequestBackground = new WebRequestBackground(
|
||||
this.platformUtilsService,
|
||||
this.cipherService,
|
||||
this.authService,
|
||||
);
|
||||
|
||||
this.usernameGenerationService = new UsernameGenerationService(
|
||||
this.cryptoService,
|
||||
@ -1033,34 +1038,41 @@ export default class MainBackground {
|
||||
this.authService,
|
||||
this.cipherService,
|
||||
);
|
||||
|
||||
if (BrowserApi.isManifestVersion(2)) {
|
||||
this.webRequestBackground = new WebRequestBackground(
|
||||
this.platformUtilsService,
|
||||
this.cipherService,
|
||||
this.authService,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async bootstrap() {
|
||||
this.containerService.attachToGlobal(self);
|
||||
|
||||
await this.stateService.init();
|
||||
await this.stateService.init({ runMigrations: !this.isPrivateMode });
|
||||
|
||||
await this.vaultTimeoutService.init(true);
|
||||
await (this.i18nService as I18nService).init();
|
||||
await (this.eventUploadService as EventUploadService).init(true);
|
||||
await this.runtimeBackground.init();
|
||||
await this.notificationBackground.init();
|
||||
this.filelessImporterBackground.init();
|
||||
await this.commandsBackground.init();
|
||||
|
||||
this.twoFactorService.init();
|
||||
|
||||
await this.overlayBackground.init();
|
||||
|
||||
await this.tabsBackground.init();
|
||||
if (!this.popupOnlyContext) {
|
||||
await this.vaultTimeoutService.init(true);
|
||||
this.fido2Background.init();
|
||||
await this.runtimeBackground.init();
|
||||
await this.notificationBackground.init();
|
||||
this.filelessImporterBackground.init();
|
||||
await this.commandsBackground.init();
|
||||
await this.overlayBackground.init();
|
||||
await this.tabsBackground.init();
|
||||
this.contextMenusBackground?.init();
|
||||
await this.idleBackground.init();
|
||||
if (BrowserApi.isManifestVersion(2)) {
|
||||
await this.webRequestBackground.init();
|
||||
}
|
||||
}
|
||||
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
|
||||
@ -1083,9 +1095,7 @@ export default class MainBackground {
|
||||
if (!this.isPrivateMode) {
|
||||
await this.refreshBadge();
|
||||
}
|
||||
// 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.fullSync(true);
|
||||
await this.fullSync(true);
|
||||
setTimeout(() => this.notificationsService.init(), 2500);
|
||||
resolve();
|
||||
}, 500);
|
||||
@ -1206,7 +1216,7 @@ export default class MainBackground {
|
||||
BrowserApi.sendMessage("updateBadge");
|
||||
}
|
||||
await this.refreshBadge();
|
||||
await this.mainContextMenuHandler.noAccess();
|
||||
await this.mainContextMenuHandler?.noAccess();
|
||||
// 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.notificationsService.updateConnection(false);
|
||||
|
@ -204,6 +204,8 @@ export class NativeMessagingBackground {
|
||||
this.privateKey = null;
|
||||
this.connected = false;
|
||||
|
||||
this.logService.error("NativeMessaging port disconnected because of error: " + error);
|
||||
|
||||
const reason = error != null ? "desktopIntegrationDisabled" : null;
|
||||
reject(new Error(reason));
|
||||
});
|
||||
|
@ -4,7 +4,6 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notificatio
|
||||
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
|
||||
@ -22,8 +21,7 @@ import { BrowserApi } from "../platform/browser/browser-api";
|
||||
import { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
|
||||
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
|
||||
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
|
||||
import { AbortManager } from "../vault/background/abort-manager";
|
||||
import { Fido2Service } from "../vault/services/abstractions/fido2.service";
|
||||
import { Fido2Background } from "../vault/fido2/background/abstractions/fido2.background";
|
||||
|
||||
import MainBackground from "./main.background";
|
||||
|
||||
@ -32,13 +30,11 @@ export default class RuntimeBackground {
|
||||
private pageDetailsToAutoFill: any[] = [];
|
||||
private onInstalledReason: string = null;
|
||||
private lockedVaultPendingNotifications: LockedVaultPendingNotificationsData[] = [];
|
||||
private abortManager = new AbortManager();
|
||||
|
||||
constructor(
|
||||
private main: MainBackground,
|
||||
private autofillService: AutofillService,
|
||||
private platformUtilsService: BrowserPlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
private notificationsService: NotificationsService,
|
||||
private stateService: BrowserStateService,
|
||||
private autofillSettingsService: AutofillSettingsServiceAbstraction,
|
||||
@ -47,7 +43,7 @@ export default class RuntimeBackground {
|
||||
private messagingService: MessagingService,
|
||||
private logService: LogService,
|
||||
private configService: ConfigService,
|
||||
private fido2Service: Fido2Service,
|
||||
private fido2Background: Fido2Background,
|
||||
) {
|
||||
// onInstalled listener must be wired up before anything else, so we do it in the ctor
|
||||
chrome.runtime.onInstalled.addListener((details: any) => {
|
||||
@ -66,12 +62,7 @@ export default class RuntimeBackground {
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: any,
|
||||
) => {
|
||||
const messagesWithResponse = [
|
||||
"checkFido2FeatureEnabled",
|
||||
"fido2RegisterCredentialRequest",
|
||||
"fido2GetCredentialRequest",
|
||||
"biometricUnlock",
|
||||
];
|
||||
const messagesWithResponse = ["biometricUnlock"];
|
||||
|
||||
if (messagesWithResponse.includes(msg.command)) {
|
||||
this.processMessage(msg, sender).then(
|
||||
@ -81,10 +72,7 @@ export default class RuntimeBackground {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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.processMessage(msg, sender);
|
||||
return false;
|
||||
this.processMessage(msg, sender).catch((e) => this.logService.error(e));
|
||||
};
|
||||
|
||||
BrowserApi.messageListener("runtime.background", backgroundMessageListener);
|
||||
@ -269,46 +257,6 @@ export default class RuntimeBackground {
|
||||
case "getClickedElementResponse":
|
||||
this.platformUtilsService.copyToClipboard(msg.identifier);
|
||||
break;
|
||||
case "triggerFido2ContentScriptInjection":
|
||||
await this.fido2Service.injectFido2ContentScripts(sender);
|
||||
break;
|
||||
case "fido2AbortRequest":
|
||||
this.abortManager.abort(msg.abortedRequestId);
|
||||
break;
|
||||
case "checkFido2FeatureEnabled":
|
||||
return await this.main.fido2ClientService.isFido2FeatureEnabled(msg.hostname, msg.origin);
|
||||
case "fido2RegisterCredentialRequest":
|
||||
return await this.abortManager.runWithAbortController(
|
||||
msg.requestId,
|
||||
async (abortController) => {
|
||||
try {
|
||||
return await this.main.fido2ClientService.createCredential(
|
||||
msg.data,
|
||||
sender.tab,
|
||||
abortController,
|
||||
);
|
||||
} finally {
|
||||
await BrowserApi.focusTab(sender.tab.id);
|
||||
await BrowserApi.focusWindow(sender.tab.windowId);
|
||||
}
|
||||
},
|
||||
);
|
||||
case "fido2GetCredentialRequest":
|
||||
return await this.abortManager.runWithAbortController(
|
||||
msg.requestId,
|
||||
async (abortController) => {
|
||||
try {
|
||||
return await this.main.fido2ClientService.assertCredential(
|
||||
msg.data,
|
||||
sender.tab,
|
||||
abortController,
|
||||
);
|
||||
} finally {
|
||||
await BrowserApi.focusTab(sender.tab.id);
|
||||
await BrowserApi.focusWindow(sender.tab.windowId);
|
||||
}
|
||||
},
|
||||
);
|
||||
case "switchAccount": {
|
||||
await this.main.switchAccount(msg.userId);
|
||||
break;
|
||||
@ -343,9 +291,8 @@ export default class RuntimeBackground {
|
||||
|
||||
private async checkOnInstalled() {
|
||||
setTimeout(async () => {
|
||||
// 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.autofillService.loadAutofillScriptsOnInstall();
|
||||
void this.fido2Background.injectFido2ContentScriptsInAllTabs();
|
||||
void this.autofillService.loadAutofillScriptsOnInstall();
|
||||
|
||||
if (this.onInstalledReason != null) {
|
||||
if (this.onInstalledReason === "install") {
|
||||
|
@ -22,13 +22,6 @@
|
||||
"exclude_matches": ["*://*/*.xml*", "file:///*.xml*"],
|
||||
"run_at": "document_start"
|
||||
},
|
||||
{
|
||||
"all_frames": true,
|
||||
"js": ["content/fido2/trigger-fido2-content-script-injection.js"],
|
||||
"matches": ["https://*/*"],
|
||||
"exclude_matches": ["https://*/*.xml*"],
|
||||
"run_at": "document_start"
|
||||
},
|
||||
{
|
||||
"all_frames": true,
|
||||
"css": ["content/autofill.css"],
|
||||
|
@ -23,13 +23,6 @@
|
||||
"exclude_matches": ["*://*/*.xml*", "file:///*.xml*"],
|
||||
"run_at": "document_start"
|
||||
},
|
||||
{
|
||||
"all_frames": true,
|
||||
"js": ["content/fido2/trigger-fido2-content-script-injection.js"],
|
||||
"matches": ["https://*/*"],
|
||||
"exclude_matches": ["https://*/*.xml*"],
|
||||
"run_at": "document_start"
|
||||
},
|
||||
{
|
||||
"all_frames": true,
|
||||
"css": ["content/autofill.css"],
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify";
|
||||
|
||||
@ -7,13 +5,6 @@ import { BrowserComponentState } from "./browserComponentState";
|
||||
|
||||
export class BrowserSendComponentState extends BrowserComponentState {
|
||||
sends: SendView[];
|
||||
typeCounts: Map<SendType, number>;
|
||||
|
||||
toJSON() {
|
||||
return Utils.merge(this, {
|
||||
typeCounts: Utils.mapToRecord(this.typeCounts),
|
||||
});
|
||||
}
|
||||
|
||||
static fromJSON(json: DeepJsonify<BrowserSendComponentState>) {
|
||||
if (json == null) {
|
||||
@ -22,7 +13,6 @@ export class BrowserSendComponentState extends BrowserComponentState {
|
||||
|
||||
return Object.assign(new BrowserSendComponentState(), json, {
|
||||
sends: json.sends?.map((s) => SendView.fromJSON(s)),
|
||||
typeCounts: Utils.recordToMap(json.typeCounts),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,19 @@
|
||||
import { BrowserScriptInjectorService } from "../../services/browser-script-injector.service";
|
||||
|
||||
import { CachedServices, FactoryOptions, factory } from "./factory-options";
|
||||
|
||||
type BrowserScriptInjectorServiceOptions = FactoryOptions;
|
||||
|
||||
export type BrowserScriptInjectorServiceInitOptions = BrowserScriptInjectorServiceOptions;
|
||||
|
||||
export function browserScriptInjectorServiceFactory(
|
||||
cache: { browserScriptInjectorService?: BrowserScriptInjectorService } & CachedServices,
|
||||
opts: BrowserScriptInjectorServiceInitOptions,
|
||||
): Promise<BrowserScriptInjectorService> {
|
||||
return factory(
|
||||
cache,
|
||||
"browserScriptInjectorService",
|
||||
opts,
|
||||
async () => new BrowserScriptInjectorService(),
|
||||
);
|
||||
}
|
@ -0,0 +1,435 @@
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) Federico Brigante <me@fregante.com> (https://fregante.com)
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* @see https://github.com/fregante/content-scripts-register-polyfill
|
||||
* @version 4.0.2
|
||||
*/
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
|
||||
import { BrowserApi } from "./browser-api";
|
||||
|
||||
let registerContentScripts: (
|
||||
contentScriptOptions: browser.contentScripts.RegisteredContentScriptOptions,
|
||||
callback?: (registeredContentScript: browser.contentScripts.RegisteredContentScript) => void,
|
||||
) => Promise<browser.contentScripts.RegisteredContentScript>;
|
||||
export async function registerContentScriptsPolyfill(
|
||||
contentScriptOptions: browser.contentScripts.RegisteredContentScriptOptions,
|
||||
callback?: (registeredContentScript: browser.contentScripts.RegisteredContentScript) => void,
|
||||
) {
|
||||
if (!registerContentScripts) {
|
||||
registerContentScripts = buildRegisterContentScriptsPolyfill();
|
||||
}
|
||||
|
||||
return registerContentScripts(contentScriptOptions, callback);
|
||||
}
|
||||
|
||||
function buildRegisterContentScriptsPolyfill() {
|
||||
const logService = new ConsoleLogService(false);
|
||||
const chromeProxy = globalThis.chrome && NestedProxy<typeof globalThis.chrome>(globalThis.chrome);
|
||||
const patternValidationRegex =
|
||||
/^(https?|wss?|file|ftp|\*):\/\/(\*|\*\.[^*/]+|[^*/]+)\/.*$|^file:\/\/\/.*$|^resource:\/\/(\*|\*\.[^*/]+|[^*/]+)\/.*$|^about:/;
|
||||
const isFirefox = globalThis.navigator?.userAgent.includes("Firefox/");
|
||||
const gotScripting = Boolean(globalThis.chrome?.scripting);
|
||||
const gotNavigation = typeof chrome === "object" && "webNavigation" in chrome;
|
||||
|
||||
function NestedProxy<T extends object>(target: T): T {
|
||||
return new Proxy(target, {
|
||||
get(target, prop) {
|
||||
if (!target[prop as keyof T]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof target[prop as keyof T] !== "function") {
|
||||
return NestedProxy(target[prop as keyof T]);
|
||||
}
|
||||
|
||||
return (...arguments_: any[]) =>
|
||||
new Promise((resolve, reject) => {
|
||||
target[prop as keyof T](...arguments_, (result: any) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function assertValidPattern(matchPattern: string) {
|
||||
if (!isValidPattern(matchPattern)) {
|
||||
throw new Error(
|
||||
`${matchPattern} is an invalid pattern, it must match ${String(patternValidationRegex)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isValidPattern(matchPattern: string) {
|
||||
return matchPattern === "<all_urls>" || patternValidationRegex.test(matchPattern);
|
||||
}
|
||||
|
||||
function getRawPatternRegex(matchPattern: string) {
|
||||
assertValidPattern(matchPattern);
|
||||
let [, protocol, host = "", pathname] = matchPattern.split(/(^[^:]+:[/][/])([^/]+)?/);
|
||||
protocol = protocol
|
||||
.replace("*", isFirefox ? "(https?|wss?)" : "https?")
|
||||
.replaceAll(/[/]/g, "[/]");
|
||||
|
||||
if (host === "*") {
|
||||
host = "[^/]+";
|
||||
} else if (host) {
|
||||
host = host
|
||||
.replace(/^[*][.]/, "([^/]+.)*")
|
||||
.replaceAll(/[.]/g, "[.]")
|
||||
.replace(/[*]$/, "[^.]+");
|
||||
}
|
||||
|
||||
pathname = pathname
|
||||
.replaceAll(/[/]/g, "[/]")
|
||||
.replaceAll(/[.]/g, "[.]")
|
||||
.replaceAll(/[*]/g, ".*");
|
||||
|
||||
return "^" + protocol + host + "(" + pathname + ")?$";
|
||||
}
|
||||
|
||||
function patternToRegex(...matchPatterns: string[]) {
|
||||
if (matchPatterns.length === 0) {
|
||||
return /$./;
|
||||
}
|
||||
|
||||
if (matchPatterns.includes("<all_urls>")) {
|
||||
// <all_urls> regex
|
||||
return /^(https?|file|ftp):[/]+/;
|
||||
}
|
||||
|
||||
if (matchPatterns.includes("*://*/*")) {
|
||||
// all stars regex
|
||||
return isFirefox ? /^(https?|wss?):[/][/][^/]+([/].*)?$/ : /^https?:[/][/][^/]+([/].*)?$/;
|
||||
}
|
||||
|
||||
return new RegExp(matchPatterns.map((x) => getRawPatternRegex(x)).join("|"));
|
||||
}
|
||||
|
||||
function castAllFramesTarget(target: number | { tabId: number; frameId: number }) {
|
||||
if (typeof target === "object") {
|
||||
return { ...target, allFrames: false };
|
||||
}
|
||||
|
||||
return {
|
||||
tabId: target,
|
||||
frameId: undefined,
|
||||
allFrames: true,
|
||||
};
|
||||
}
|
||||
|
||||
function castArray(possibleArray: any | any[]) {
|
||||
if (Array.isArray(possibleArray)) {
|
||||
return possibleArray;
|
||||
}
|
||||
|
||||
return [possibleArray];
|
||||
}
|
||||
|
||||
function arrayOrUndefined(value?: number) {
|
||||
return value === undefined ? undefined : [value];
|
||||
}
|
||||
|
||||
async function insertCSS(
|
||||
{
|
||||
tabId,
|
||||
frameId,
|
||||
files,
|
||||
allFrames,
|
||||
matchAboutBlank,
|
||||
runAt,
|
||||
}: {
|
||||
tabId: number;
|
||||
frameId?: number;
|
||||
files: browser.extensionTypes.ExtensionFileOrCode[];
|
||||
allFrames: boolean;
|
||||
matchAboutBlank: boolean;
|
||||
runAt: browser.extensionTypes.RunAt;
|
||||
},
|
||||
{ ignoreTargetErrors }: { ignoreTargetErrors?: boolean } = {},
|
||||
) {
|
||||
const everyInsertion = Promise.all(
|
||||
files.map(async (content) => {
|
||||
if (typeof content === "string") {
|
||||
content = { file: content };
|
||||
}
|
||||
|
||||
if (gotScripting) {
|
||||
return chrome.scripting.insertCSS({
|
||||
target: {
|
||||
tabId,
|
||||
frameIds: arrayOrUndefined(frameId),
|
||||
allFrames: frameId === undefined ? allFrames : undefined,
|
||||
},
|
||||
files: "file" in content ? [content.file] : undefined,
|
||||
css: "code" in content ? content.code : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return chromeProxy.tabs.insertCSS(tabId, {
|
||||
...content,
|
||||
matchAboutBlank,
|
||||
allFrames,
|
||||
frameId,
|
||||
runAt: runAt ?? "document_start",
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
if (ignoreTargetErrors) {
|
||||
await catchTargetInjectionErrors(everyInsertion);
|
||||
} else {
|
||||
await everyInsertion;
|
||||
}
|
||||
}
|
||||
function assertNoCode(files: browser.extensionTypes.ExtensionFileOrCode[]) {
|
||||
if (files.some((content) => "code" in content)) {
|
||||
throw new Error("chrome.scripting does not support injecting strings of `code`");
|
||||
}
|
||||
}
|
||||
|
||||
async function executeScript(
|
||||
{
|
||||
tabId,
|
||||
frameId,
|
||||
files,
|
||||
allFrames,
|
||||
matchAboutBlank,
|
||||
runAt,
|
||||
}: {
|
||||
tabId: number;
|
||||
frameId?: number;
|
||||
files: browser.extensionTypes.ExtensionFileOrCode[];
|
||||
allFrames: boolean;
|
||||
matchAboutBlank: boolean;
|
||||
runAt: browser.extensionTypes.RunAt;
|
||||
},
|
||||
{ ignoreTargetErrors }: { ignoreTargetErrors?: boolean } = {},
|
||||
) {
|
||||
const normalizedFiles = files.map((file) => (typeof file === "string" ? { file } : file));
|
||||
|
||||
if (gotScripting) {
|
||||
assertNoCode(normalizedFiles);
|
||||
const injection = chrome.scripting.executeScript({
|
||||
target: {
|
||||
tabId,
|
||||
frameIds: arrayOrUndefined(frameId),
|
||||
allFrames: frameId === undefined ? allFrames : undefined,
|
||||
},
|
||||
files: normalizedFiles.map(({ file }: { file: string }) => file),
|
||||
});
|
||||
|
||||
if (ignoreTargetErrors) {
|
||||
await catchTargetInjectionErrors(injection);
|
||||
} else {
|
||||
await injection;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const executions = [];
|
||||
for (const content of normalizedFiles) {
|
||||
if ("code" in content) {
|
||||
await executions.at(-1);
|
||||
}
|
||||
|
||||
executions.push(
|
||||
chromeProxy.tabs.executeScript(tabId, {
|
||||
...content,
|
||||
matchAboutBlank,
|
||||
allFrames,
|
||||
frameId,
|
||||
runAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (ignoreTargetErrors) {
|
||||
await catchTargetInjectionErrors(Promise.all(executions));
|
||||
} else {
|
||||
await Promise.all(executions);
|
||||
}
|
||||
}
|
||||
|
||||
async function injectContentScript(
|
||||
where: { tabId: number; frameId: number },
|
||||
scripts: {
|
||||
css: browser.extensionTypes.ExtensionFileOrCode[];
|
||||
js: browser.extensionTypes.ExtensionFileOrCode[];
|
||||
matchAboutBlank: boolean;
|
||||
runAt: browser.extensionTypes.RunAt;
|
||||
},
|
||||
options = {},
|
||||
) {
|
||||
const targets = castArray(where);
|
||||
await Promise.all(
|
||||
targets.map(async (target) =>
|
||||
injectContentScriptInSpecificTarget(castAllFramesTarget(target), scripts, options),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function injectContentScriptInSpecificTarget(
|
||||
{ frameId, tabId, allFrames }: { frameId?: number; tabId: number; allFrames: boolean },
|
||||
scripts: {
|
||||
css: browser.extensionTypes.ExtensionFileOrCode[];
|
||||
js: browser.extensionTypes.ExtensionFileOrCode[];
|
||||
matchAboutBlank: boolean;
|
||||
runAt: browser.extensionTypes.RunAt;
|
||||
},
|
||||
options = {},
|
||||
) {
|
||||
const injections = castArray(scripts).flatMap((script) => [
|
||||
insertCSS(
|
||||
{
|
||||
tabId,
|
||||
frameId,
|
||||
allFrames,
|
||||
files: script.css ?? [],
|
||||
matchAboutBlank: script.matchAboutBlank ?? script.match_about_blank,
|
||||
runAt: script.runAt ?? script.run_at,
|
||||
},
|
||||
options,
|
||||
),
|
||||
executeScript(
|
||||
{
|
||||
tabId,
|
||||
frameId,
|
||||
allFrames,
|
||||
files: script.js ?? [],
|
||||
matchAboutBlank: script.matchAboutBlank ?? script.match_about_blank,
|
||||
runAt: script.runAt ?? script.run_at,
|
||||
},
|
||||
options,
|
||||
),
|
||||
]);
|
||||
await Promise.all(injections);
|
||||
}
|
||||
|
||||
async function catchTargetInjectionErrors(promise: Promise<any>) {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
const targetErrors =
|
||||
/^No frame with id \d+ in tab \d+.$|^No tab with id: \d+.$|^The tab was closed.$|^The frame was removed.$/;
|
||||
if (!targetErrors.test(error?.message)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function isOriginPermitted(url: string) {
|
||||
return chromeProxy.permissions.contains({
|
||||
origins: [new URL(url).origin + "/*"],
|
||||
});
|
||||
}
|
||||
|
||||
return async (
|
||||
contentScriptOptions: browser.contentScripts.RegisteredContentScriptOptions,
|
||||
callback: CallableFunction,
|
||||
) => {
|
||||
const {
|
||||
js = [],
|
||||
css = [],
|
||||
matchAboutBlank,
|
||||
matches = [],
|
||||
excludeMatches,
|
||||
runAt,
|
||||
} = contentScriptOptions;
|
||||
let { allFrames } = contentScriptOptions;
|
||||
|
||||
if (gotNavigation) {
|
||||
allFrames = false;
|
||||
} else if (allFrames) {
|
||||
logService.warning(
|
||||
"`allFrames: true` requires the `webNavigation` permission to work correctly: https://github.com/fregante/content-scripts-register-polyfill#permissions",
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new Error(
|
||||
"Type error for parameter contentScriptOptions (Error processing matches: Array requires at least 1 items; you have 0) for contentScripts.register.",
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
matches.map(async (pattern: string) => {
|
||||
if (!(await chromeProxy.permissions.contains({ origins: [pattern] }))) {
|
||||
throw new Error(`Permission denied to register a content script for ${pattern}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const matchesRegex = patternToRegex(...matches);
|
||||
const excludeMatchesRegex = patternToRegex(
|
||||
...(excludeMatches !== null && excludeMatches !== void 0 ? excludeMatches : []),
|
||||
);
|
||||
const inject = async (url: string, tabId: number, frameId = 0) => {
|
||||
if (
|
||||
!matchesRegex.test(url) ||
|
||||
excludeMatchesRegex.test(url) ||
|
||||
!(await isOriginPermitted(url))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await injectContentScript(
|
||||
{ tabId, frameId },
|
||||
{ css, js, matchAboutBlank, runAt },
|
||||
{ ignoreTargetErrors: true },
|
||||
);
|
||||
};
|
||||
const tabListener = async (
|
||||
tabId: number,
|
||||
{ status }: chrome.tabs.TabChangeInfo,
|
||||
{ url }: chrome.tabs.Tab,
|
||||
) => {
|
||||
if (status === "loading" && url) {
|
||||
void inject(url, tabId);
|
||||
}
|
||||
};
|
||||
const navListener = async ({
|
||||
tabId,
|
||||
frameId,
|
||||
url,
|
||||
}: chrome.webNavigation.WebNavigationTransitionCallbackDetails) => {
|
||||
void inject(url, tabId, frameId);
|
||||
};
|
||||
|
||||
if (gotNavigation) {
|
||||
BrowserApi.addListener(chrome.webNavigation.onCommitted, navListener);
|
||||
} else {
|
||||
BrowserApi.addListener(chrome.tabs.onUpdated, tabListener);
|
||||
}
|
||||
|
||||
const registeredContentScript = {
|
||||
async unregister() {
|
||||
if (gotNavigation) {
|
||||
chrome.webNavigation.onCommitted.removeListener(navListener);
|
||||
} else {
|
||||
chrome.tabs.onUpdated.removeListener(tabListener);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof callback === "function") {
|
||||
callback(registeredContentScript);
|
||||
}
|
||||
|
||||
return registeredContentScript;
|
||||
};
|
||||
}
|
@ -550,4 +550,35 @@ describe("BrowserApi", () => {
|
||||
expect(callbackMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerContentScriptsMv2", () => {
|
||||
const details: browser.contentScripts.RegisteredContentScriptOptions = {
|
||||
matches: ["<all_urls>"],
|
||||
js: [{ file: "content/fido2/page-script.js" }],
|
||||
};
|
||||
|
||||
it("registers content scripts through the `browser.contentScripts` API when the API is available", async () => {
|
||||
globalThis.browser = mock<typeof browser>({
|
||||
contentScripts: { register: jest.fn() },
|
||||
});
|
||||
|
||||
await BrowserApi.registerContentScriptsMv2(details);
|
||||
|
||||
expect(browser.contentScripts.register).toHaveBeenCalledWith(details);
|
||||
});
|
||||
|
||||
it("registers content scripts through the `registerContentScriptsPolyfill` when the `browser.contentScripts.register` API is not available", async () => {
|
||||
globalThis.browser = mock<typeof browser>({
|
||||
contentScripts: { register: undefined },
|
||||
});
|
||||
jest.spyOn(BrowserApi, "addListener");
|
||||
|
||||
await BrowserApi.registerContentScriptsMv2(details);
|
||||
|
||||
expect(BrowserApi.addListener).toHaveBeenCalledWith(
|
||||
chrome.webNavigation.onCommitted,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -5,6 +5,8 @@ import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { TabMessage } from "../../types/tab-messages";
|
||||
import { BrowserPlatformUtilsService } from "../services/platform-utils/browser-platform-utils.service";
|
||||
|
||||
import { registerContentScriptsPolyfill } from "./browser-api.register-content-scripts-polyfill";
|
||||
|
||||
export class BrowserApi {
|
||||
static isWebExtensionsApi: boolean = typeof browser !== "undefined";
|
||||
static isSafariApi: boolean =
|
||||
@ -603,4 +605,41 @@ export class BrowserApi {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles registration of static content scripts within manifest v2.
|
||||
*
|
||||
* @param contentScriptOptions - Details of the registered content scripts
|
||||
*/
|
||||
static async registerContentScriptsMv2(
|
||||
contentScriptOptions: browser.contentScripts.RegisteredContentScriptOptions,
|
||||
): Promise<browser.contentScripts.RegisteredContentScript> {
|
||||
if (typeof browser !== "undefined" && !!browser.contentScripts?.register) {
|
||||
return await browser.contentScripts.register(contentScriptOptions);
|
||||
}
|
||||
|
||||
return await registerContentScriptsPolyfill(contentScriptOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles registration of static content scripts within manifest v3.
|
||||
*
|
||||
* @param scripts - Details of the registered content scripts
|
||||
*/
|
||||
static async registerContentScriptsMv3(
|
||||
scripts: chrome.scripting.RegisteredContentScript[],
|
||||
): Promise<void> {
|
||||
await chrome.scripting.registerContentScripts(scripts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles unregistering of static content scripts within manifest v3.
|
||||
*
|
||||
* @param filter - Optional filter to unregister content scripts. Passing an empty object will unregister all content scripts.
|
||||
*/
|
||||
static async unregisterContentScriptsMv3(
|
||||
filter?: chrome.scripting.ContentScriptFilter,
|
||||
): Promise<void> {
|
||||
await chrome.scripting.unregisterContentScripts(filter);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,45 @@
|
||||
export type CommonScriptInjectionDetails = {
|
||||
/**
|
||||
* Script injected into the document.
|
||||
* Overridden by `mv2Details` and `mv3Details`.
|
||||
*/
|
||||
file?: string;
|
||||
/**
|
||||
* Identifies the frame targeted for script injection. Defaults to the top level frame (0).
|
||||
* Can also be set to "all_frames" to inject into all frames in a tab.
|
||||
*/
|
||||
frame?: "all_frames" | number;
|
||||
/**
|
||||
* When the script executes. Defaults to "document_start".
|
||||
* @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/content_scripts
|
||||
*/
|
||||
runAt?: "document_start" | "document_end" | "document_idle";
|
||||
};
|
||||
|
||||
export type Mv2ScriptInjectionDetails = {
|
||||
file: string;
|
||||
};
|
||||
|
||||
export type Mv3ScriptInjectionDetails = {
|
||||
file: string;
|
||||
/**
|
||||
* The world in which the script should be executed. Defaults to "ISOLATED".
|
||||
* @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld
|
||||
*/
|
||||
world?: chrome.scripting.ExecutionWorld;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for injecting a script into a tab. The `file` property should present as a
|
||||
* path that is relative to the root directory of the extension build, ie "content/script.js".
|
||||
*/
|
||||
export type ScriptInjectionConfig = {
|
||||
tabId: number;
|
||||
injectDetails: CommonScriptInjectionDetails;
|
||||
mv2Details?: Mv2ScriptInjectionDetails;
|
||||
mv3Details?: Mv3ScriptInjectionDetails;
|
||||
};
|
||||
|
||||
export abstract class ScriptInjectorService {
|
||||
abstract inject(config: ScriptInjectionConfig): Promise<void>;
|
||||
}
|
@ -0,0 +1,173 @@
|
||||
import { BrowserApi } from "../browser/browser-api";
|
||||
|
||||
import {
|
||||
CommonScriptInjectionDetails,
|
||||
Mv3ScriptInjectionDetails,
|
||||
} from "./abstractions/script-injector.service";
|
||||
import { BrowserScriptInjectorService } from "./browser-script-injector.service";
|
||||
|
||||
describe("ScriptInjectorService", () => {
|
||||
const tabId = 1;
|
||||
const combinedManifestVersionFile = "content/autofill-init.js";
|
||||
const mv2SpecificFile = "content/autofill-init-mv2.js";
|
||||
const mv2Details = { file: mv2SpecificFile };
|
||||
const mv3SpecificFile = "content/autofill-init-mv3.js";
|
||||
const mv3Details: Mv3ScriptInjectionDetails = { file: mv3SpecificFile, world: "MAIN" };
|
||||
const sharedInjectDetails: CommonScriptInjectionDetails = {
|
||||
runAt: "document_start",
|
||||
};
|
||||
const manifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get");
|
||||
let scriptInjectorService: BrowserScriptInjectorService;
|
||||
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
|
||||
jest.spyOn(BrowserApi, "isManifestVersion");
|
||||
|
||||
beforeEach(() => {
|
||||
scriptInjectorService = new BrowserScriptInjectorService();
|
||||
});
|
||||
|
||||
describe("inject", () => {
|
||||
describe("injection of a single script that functions in both manifest v2 and v3", () => {
|
||||
it("injects the script in manifest v2 when given combined injection details", async () => {
|
||||
manifestVersionSpy.mockReturnValue(2);
|
||||
|
||||
await scriptInjectorService.inject({
|
||||
tabId,
|
||||
injectDetails: {
|
||||
file: combinedManifestVersionFile,
|
||||
frame: "all_frames",
|
||||
...sharedInjectDetails,
|
||||
},
|
||||
});
|
||||
|
||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabId, {
|
||||
...sharedInjectDetails,
|
||||
allFrames: true,
|
||||
file: combinedManifestVersionFile,
|
||||
});
|
||||
});
|
||||
|
||||
it("injects the script in manifest v3 when given combined injection details", async () => {
|
||||
manifestVersionSpy.mockReturnValue(3);
|
||||
|
||||
await scriptInjectorService.inject({
|
||||
tabId,
|
||||
injectDetails: {
|
||||
file: combinedManifestVersionFile,
|
||||
frame: 10,
|
||||
...sharedInjectDetails,
|
||||
},
|
||||
});
|
||||
|
||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(
|
||||
tabId,
|
||||
{ ...sharedInjectDetails, frameId: 10, file: combinedManifestVersionFile },
|
||||
{ world: "ISOLATED" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("injection of mv2 specific details", () => {
|
||||
describe("given the extension is running manifest v2", () => {
|
||||
it("injects the mv2 script injection details file", async () => {
|
||||
manifestVersionSpy.mockReturnValue(2);
|
||||
|
||||
await scriptInjectorService.inject({
|
||||
mv2Details,
|
||||
tabId,
|
||||
injectDetails: sharedInjectDetails,
|
||||
});
|
||||
|
||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabId, {
|
||||
...sharedInjectDetails,
|
||||
frameId: 0,
|
||||
file: mv2SpecificFile,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the extension is running manifest v3", () => {
|
||||
it("injects the common script injection details file", async () => {
|
||||
manifestVersionSpy.mockReturnValue(3);
|
||||
|
||||
await scriptInjectorService.inject({
|
||||
mv2Details,
|
||||
tabId,
|
||||
injectDetails: { ...sharedInjectDetails, file: combinedManifestVersionFile },
|
||||
});
|
||||
|
||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(
|
||||
tabId,
|
||||
{
|
||||
...sharedInjectDetails,
|
||||
frameId: 0,
|
||||
file: combinedManifestVersionFile,
|
||||
},
|
||||
{ world: "ISOLATED" },
|
||||
);
|
||||
});
|
||||
|
||||
it("throws an error if no common script injection details file is specified", async () => {
|
||||
manifestVersionSpy.mockReturnValue(3);
|
||||
|
||||
await expect(
|
||||
scriptInjectorService.inject({
|
||||
mv2Details,
|
||||
tabId,
|
||||
injectDetails: { ...sharedInjectDetails, file: null },
|
||||
}),
|
||||
).rejects.toThrow("No file specified for script injection");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("injection of mv3 specific details", () => {
|
||||
describe("given the extension is running manifest v3", () => {
|
||||
it("injects the mv3 script injection details file", async () => {
|
||||
manifestVersionSpy.mockReturnValue(3);
|
||||
|
||||
await scriptInjectorService.inject({
|
||||
mv3Details,
|
||||
tabId,
|
||||
injectDetails: sharedInjectDetails,
|
||||
});
|
||||
|
||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(
|
||||
tabId,
|
||||
{ ...sharedInjectDetails, frameId: 0, file: mv3SpecificFile },
|
||||
{ world: "MAIN" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the extension is running manifest v2", () => {
|
||||
it("injects the common script injection details file", async () => {
|
||||
manifestVersionSpy.mockReturnValue(2);
|
||||
|
||||
await scriptInjectorService.inject({
|
||||
mv3Details,
|
||||
tabId,
|
||||
injectDetails: { ...sharedInjectDetails, file: combinedManifestVersionFile },
|
||||
});
|
||||
|
||||
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabId, {
|
||||
...sharedInjectDetails,
|
||||
frameId: 0,
|
||||
file: combinedManifestVersionFile,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws an error if no common script injection details file is specified", async () => {
|
||||
manifestVersionSpy.mockReturnValue(2);
|
||||
|
||||
await expect(
|
||||
scriptInjectorService.inject({
|
||||
mv3Details,
|
||||
tabId,
|
||||
injectDetails: { ...sharedInjectDetails, file: "" },
|
||||
}),
|
||||
).rejects.toThrow("No file specified for script injection");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,78 @@
|
||||
import { BrowserApi } from "../browser/browser-api";
|
||||
|
||||
import {
|
||||
CommonScriptInjectionDetails,
|
||||
ScriptInjectionConfig,
|
||||
ScriptInjectorService,
|
||||
} from "./abstractions/script-injector.service";
|
||||
|
||||
export class BrowserScriptInjectorService extends ScriptInjectorService {
|
||||
/**
|
||||
* Facilitates the injection of a script into a tab context. Will adjust
|
||||
* behavior between manifest v2 and v3 based on the passed configuration.
|
||||
*
|
||||
* @param config - The configuration for the script injection.
|
||||
*/
|
||||
async inject(config: ScriptInjectionConfig): Promise<void> {
|
||||
const { tabId, injectDetails, mv3Details } = config;
|
||||
const file = this.getScriptFile(config);
|
||||
if (!file) {
|
||||
throw new Error("No file specified for script injection");
|
||||
}
|
||||
|
||||
const injectionDetails = this.buildInjectionDetails(injectDetails, file);
|
||||
|
||||
if (BrowserApi.isManifestVersion(3)) {
|
||||
await BrowserApi.executeScriptInTab(tabId, injectionDetails, {
|
||||
world: mv3Details?.world ?? "ISOLATED",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await BrowserApi.executeScriptInTab(tabId, injectionDetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the script file to inject based on the configuration.
|
||||
*
|
||||
* @param config - The configuration for the script injection.
|
||||
*/
|
||||
private getScriptFile(config: ScriptInjectionConfig): string {
|
||||
const { injectDetails, mv2Details, mv3Details } = config;
|
||||
|
||||
if (BrowserApi.isManifestVersion(3)) {
|
||||
return mv3Details?.file ?? injectDetails?.file;
|
||||
}
|
||||
|
||||
return mv2Details?.file ?? injectDetails?.file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the injection details for the script injection.
|
||||
*
|
||||
* @param injectDetails - The details for the script injection.
|
||||
* @param file - The file to inject.
|
||||
*/
|
||||
private buildInjectionDetails(
|
||||
injectDetails: CommonScriptInjectionDetails,
|
||||
file: string,
|
||||
): chrome.tabs.InjectDetails {
|
||||
const { frame, runAt } = injectDetails;
|
||||
const injectionDetails: chrome.tabs.InjectDetails = { file };
|
||||
|
||||
if (runAt) {
|
||||
injectionDetails.runAt = runAt;
|
||||
}
|
||||
|
||||
if (!frame) {
|
||||
return { ...injectionDetails, frameId: 0 };
|
||||
}
|
||||
|
||||
if (frame !== "all_frames") {
|
||||
return { ...injectionDetails, frameId: frame };
|
||||
}
|
||||
|
||||
return { ...injectionDetails, allFrames: true };
|
||||
}
|
||||
}
|
@ -94,10 +94,12 @@ import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
|
||||
import { BrowserFileDownloadService } from "../../platform/popup/services/browser-file-download.service";
|
||||
import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service";
|
||||
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
|
||||
import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service";
|
||||
import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service";
|
||||
import BrowserMessagingPrivateModePopupService from "../../platform/services/browser-messaging-private-mode-popup.service";
|
||||
import BrowserMessagingService from "../../platform/services/browser-messaging.service";
|
||||
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
|
||||
import { DefaultBrowserStateService } from "../../platform/services/default-browser-state.service";
|
||||
import I18nService from "../../platform/services/i18n.service";
|
||||
import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service";
|
||||
@ -120,7 +122,7 @@ const mainBackground: MainBackground = needsBackgroundInit
|
||||
: BrowserApi.getBackgroundPage().bitwardenMain;
|
||||
|
||||
function createLocalBgService() {
|
||||
const localBgService = new MainBackground(isPrivateMode);
|
||||
const localBgService = new MainBackground(isPrivateMode, true);
|
||||
// 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
|
||||
localBgService.bootstrap();
|
||||
@ -319,8 +321,14 @@ const safeProviders: SafeProvider[] = [
|
||||
DomainSettingsService,
|
||||
UserVerificationService,
|
||||
BillingAccountProfileStateService,
|
||||
ScriptInjectorService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ScriptInjectorService,
|
||||
useClass: BrowserScriptInjectorService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: KeyConnectorService,
|
||||
useFactory: getBgService<KeyConnectorService>("keyConnectorService"),
|
||||
|
@ -1,10 +1,5 @@
|
||||
import { FilelessImportTypeKeys } from "../../enums/fileless-import.enums";
|
||||
|
||||
type SuppressDownloadScriptInjectionConfig = {
|
||||
file: string;
|
||||
scriptingApiDetails?: { world: chrome.scripting.ExecutionWorld };
|
||||
};
|
||||
|
||||
type FilelessImportPortMessage = {
|
||||
command?: string;
|
||||
importType?: FilelessImportTypeKeys;
|
||||
@ -32,7 +27,6 @@ interface FilelessImporterBackground {
|
||||
}
|
||||
|
||||
export {
|
||||
SuppressDownloadScriptInjectionConfig,
|
||||
FilelessImportPortMessage,
|
||||
ImportNotificationMessageHandlers,
|
||||
LpImporterMessageHandlers,
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
triggerRuntimeOnConnectEvent,
|
||||
} from "../../autofill/spec/testing-utils";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
|
||||
import { FilelessImportPort, FilelessImportType } from "../enums/fileless-import.enums";
|
||||
|
||||
import FilelessImporterBackground from "./fileless-importer.background";
|
||||
@ -37,8 +38,10 @@ describe("FilelessImporterBackground ", () => {
|
||||
const notificationBackground = mock<NotificationBackground>();
|
||||
const importService = mock<ImportServiceAbstraction>();
|
||||
const syncService = mock<SyncService>();
|
||||
let scriptInjectorService: BrowserScriptInjectorService;
|
||||
|
||||
beforeEach(() => {
|
||||
scriptInjectorService = new BrowserScriptInjectorService();
|
||||
filelessImporterBackground = new FilelessImporterBackground(
|
||||
configService,
|
||||
authService,
|
||||
@ -46,6 +49,7 @@ describe("FilelessImporterBackground ", () => {
|
||||
notificationBackground,
|
||||
importService,
|
||||
syncService,
|
||||
scriptInjectorService,
|
||||
);
|
||||
filelessImporterBackground.init();
|
||||
});
|
||||
@ -138,7 +142,7 @@ describe("FilelessImporterBackground ", () => {
|
||||
|
||||
expect(executeScriptInTabSpy).toHaveBeenCalledWith(
|
||||
lpImporterPort.sender.tab.id,
|
||||
{ file: "content/lp-suppress-import-download.js", runAt: "document_start" },
|
||||
{ file: "content/lp-suppress-import-download.js", runAt: "document_start", frameId: 0 },
|
||||
{ world: "MAIN" },
|
||||
);
|
||||
});
|
||||
@ -149,14 +153,11 @@ describe("FilelessImporterBackground ", () => {
|
||||
triggerRuntimeOnConnectEvent(lpImporterPort);
|
||||
await flushPromises();
|
||||
|
||||
expect(executeScriptInTabSpy).toHaveBeenCalledWith(
|
||||
lpImporterPort.sender.tab.id,
|
||||
{
|
||||
file: "content/lp-suppress-import-download-script-append-mv2.js",
|
||||
runAt: "document_start",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(executeScriptInTabSpy).toHaveBeenCalledWith(lpImporterPort.sender.tab.id, {
|
||||
file: "content/lp-suppress-import-download-script-append-mv2.js",
|
||||
runAt: "document_start",
|
||||
frameId: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -11,6 +11,7 @@ import { ImportServiceAbstraction } from "@bitwarden/importer/core";
|
||||
|
||||
import NotificationBackground from "../../autofill/background/notification.background";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
|
||||
import { FilelessImporterInjectedScriptsConfig } from "../config/fileless-importer-injected-scripts";
|
||||
import {
|
||||
FilelessImportPort,
|
||||
@ -23,7 +24,6 @@ import {
|
||||
LpImporterMessageHandlers,
|
||||
FilelessImporterBackground as FilelessImporterBackgroundInterface,
|
||||
FilelessImportPortMessage,
|
||||
SuppressDownloadScriptInjectionConfig,
|
||||
} from "./abstractions/fileless-importer.background";
|
||||
|
||||
class FilelessImporterBackground implements FilelessImporterBackgroundInterface {
|
||||
@ -53,6 +53,7 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
|
||||
* @param notificationBackground - Used to inject the notification bar into the tab.
|
||||
* @param importService - Used to import the export data into the vault.
|
||||
* @param syncService - Used to trigger a full sync after the import is completed.
|
||||
* @param scriptInjectorService - Used to inject content scripts that initialize the import process
|
||||
*/
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@ -61,6 +62,7 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
|
||||
private notificationBackground: NotificationBackground,
|
||||
private importService: ImportServiceAbstraction,
|
||||
private syncService: SyncService,
|
||||
private scriptInjectorService: ScriptInjectorService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -110,23 +112,6 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
|
||||
await this.notificationBackground.requestFilelessImport(tab, importType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the script used to suppress the download of the LP importer export file.
|
||||
*
|
||||
* @param sender - The sender of the message.
|
||||
* @param injectionConfig - The configuration for the injection.
|
||||
*/
|
||||
private async injectScriptConfig(
|
||||
sender: chrome.runtime.MessageSender,
|
||||
injectionConfig: SuppressDownloadScriptInjectionConfig,
|
||||
) {
|
||||
await BrowserApi.executeScriptInTab(
|
||||
sender.tab.id,
|
||||
{ file: injectionConfig.file, runAt: "document_start" },
|
||||
injectionConfig.scriptingApiDetails,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the download of the CSV file from the LP importer. This is triggered
|
||||
* when the user opts to not save the export to Bitwarden within the notification bar.
|
||||
@ -219,12 +204,12 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface
|
||||
switch (port.name) {
|
||||
case FilelessImportPort.LpImporter:
|
||||
this.lpImporterPort = port;
|
||||
await this.injectScriptConfig(
|
||||
port.sender,
|
||||
BrowserApi.manifestVersion === 3
|
||||
? FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv3
|
||||
: FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv2,
|
||||
);
|
||||
await this.scriptInjectorService.inject({
|
||||
tabId: port.sender.tab.id,
|
||||
injectDetails: { runAt: "document_start" },
|
||||
mv2Details: FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv2,
|
||||
mv3Details: FilelessImporterInjectedScriptsConfig.LpSuppressImportDownload.mv3,
|
||||
});
|
||||
break;
|
||||
case FilelessImportPort.NotificationBar:
|
||||
this.importNotificationsPort = port;
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { SuppressDownloadScriptInjectionConfig } from "../background/abstractions/fileless-importer.background";
|
||||
import {
|
||||
Mv2ScriptInjectionDetails,
|
||||
Mv3ScriptInjectionDetails,
|
||||
} from "../../platform/services/abstractions/script-injector.service";
|
||||
|
||||
type FilelessImporterInjectedScriptsConfigurations = {
|
||||
LpSuppressImportDownload: {
|
||||
mv2: SuppressDownloadScriptInjectionConfig;
|
||||
mv3: SuppressDownloadScriptInjectionConfig;
|
||||
mv2: Mv2ScriptInjectionDetails;
|
||||
mv3: Mv3ScriptInjectionDetails;
|
||||
};
|
||||
};
|
||||
|
||||
@ -14,7 +17,7 @@ const FilelessImporterInjectedScriptsConfig: FilelessImporterInjectedScriptsConf
|
||||
},
|
||||
mv3: {
|
||||
file: "content/lp-suppress-import-download.js",
|
||||
scriptingApiDetails: { world: "MAIN" },
|
||||
world: "MAIN",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
@ -61,7 +61,7 @@
|
||||
<div class="icon"><i class="bwi bwi-fw bwi-lg bwi-file-text"></i></div>
|
||||
<span class="text">{{ "sendTypeText" | i18n }}</span>
|
||||
</div>
|
||||
<span class="row-sub-label">{{ typeCounts.get(sendType.Text) || 0 }}</span>
|
||||
<span class="row-sub-label">{{ getSendCount(sends, sendType.Text) }}</span>
|
||||
<span><i class="bwi bwi-angle-right bwi-lg row-sub-icon"></i></span>
|
||||
</button>
|
||||
<button
|
||||
@ -74,7 +74,7 @@
|
||||
<div class="icon"><i class="bwi bwi-fw bwi-lg bwi-file"></i></div>
|
||||
<span class="text">{{ "sendTypeFile" | i18n }}</span>
|
||||
</div>
|
||||
<span class="row-sub-label">{{ typeCounts.get(sendType.File) || 0 }}</span>
|
||||
<span class="row-sub-label">{{ getSendCount(sends, sendType.File) }}</span>
|
||||
<span><i class="bwi bwi-angle-right bwi-lg row-sub-icon"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -29,8 +29,6 @@ const ComponentId = "SendComponent";
|
||||
export class SendGroupingsComponent extends BaseSendComponent {
|
||||
// Header
|
||||
showLeftHeader = true;
|
||||
// Send Type Calculations
|
||||
typeCounts = new Map<SendType, number>();
|
||||
// State Handling
|
||||
state: BrowserSendComponentState;
|
||||
private loadedTimeout: number;
|
||||
@ -65,7 +63,6 @@ export class SendGroupingsComponent extends BaseSendComponent {
|
||||
dialogService,
|
||||
);
|
||||
super.onSuccessfulLoad = async () => {
|
||||
this.calculateTypeCounts();
|
||||
this.selectAll();
|
||||
};
|
||||
}
|
||||
@ -174,17 +171,8 @@ export class SendGroupingsComponent extends BaseSendComponent {
|
||||
return this.hasSearched || (!this.searchPending && this.isSearchable);
|
||||
}
|
||||
|
||||
private calculateTypeCounts() {
|
||||
// Create type counts
|
||||
const typeCounts = new Map<SendType, number>();
|
||||
this.sends.forEach((s) => {
|
||||
if (typeCounts.has(s.type)) {
|
||||
typeCounts.set(s.type, typeCounts.get(s.type) + 1);
|
||||
} else {
|
||||
typeCounts.set(s.type, 1);
|
||||
}
|
||||
});
|
||||
this.typeCounts = typeCounts;
|
||||
getSendCount(sends: SendView[], type: SendType): number {
|
||||
return sends.filter((s) => s.type === type).length;
|
||||
}
|
||||
|
||||
private async saveState() {
|
||||
@ -192,7 +180,6 @@ export class SendGroupingsComponent extends BaseSendComponent {
|
||||
scrollY: BrowserPopupUtils.getContentScrollY(window),
|
||||
searchText: this.searchText,
|
||||
sends: this.sends,
|
||||
typeCounts: this.typeCounts,
|
||||
});
|
||||
await this.stateService.setBrowserSendComponentState(this.state);
|
||||
}
|
||||
@ -206,9 +193,6 @@ export class SendGroupingsComponent extends BaseSendComponent {
|
||||
if (this.state.sends != null) {
|
||||
this.sends = this.state.sends;
|
||||
}
|
||||
if (this.state.typeCounts != null) {
|
||||
this.typeCounts = this.state.typeCounts;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider
|
||||
import { awaitAsync } from "@bitwarden/common/../spec/utils";
|
||||
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { BrowserComponentState } from "../../../models/browserComponentState";
|
||||
@ -33,7 +32,6 @@ describe("Browser Send State Service", () => {
|
||||
const state = new BrowserSendComponentState();
|
||||
state.scrollY = 0;
|
||||
state.searchText = "test";
|
||||
state.typeCounts = new Map<SendType, number>().set(SendType.File, 1);
|
||||
|
||||
await stateService.setBrowserSendComponentState(state);
|
||||
|
||||
|
@ -42,7 +42,7 @@ export class BrowserSendStateService {
|
||||
}
|
||||
|
||||
/** Set the active user's browser send component state
|
||||
* @param { BrowserSendComponentState } value sets the sends and type counts along with the scroll position and search text for
|
||||
* @param { BrowserSendComponentState } value sets the sends along with the scroll position and search text for
|
||||
* the send component on the browser
|
||||
*/
|
||||
async setBrowserSendComponentState(value: BrowserSendComponentState): Promise<void> {
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
|
||||
import { BrowserSendComponentState } from "../../../models/browserSendComponentState";
|
||||
|
||||
import { BROWSER_SEND_COMPONENT, BROWSER_SEND_TYPE_COMPONENT } from "./key-definitions";
|
||||
@ -12,7 +10,8 @@ describe("Key definitions", () => {
|
||||
const keyDef = BROWSER_SEND_COMPONENT;
|
||||
|
||||
const expectedState = {
|
||||
typeCounts: new Map<SendType, number>(),
|
||||
scrollY: 0,
|
||||
searchText: "test",
|
||||
};
|
||||
|
||||
const result = keyDef.deserializer(
|
||||
|
@ -0,0 +1,57 @@
|
||||
import {
|
||||
AssertCredentialParams,
|
||||
AssertCredentialResult,
|
||||
CreateCredentialParams,
|
||||
CreateCredentialResult,
|
||||
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
|
||||
|
||||
type SharedFido2ScriptInjectionDetails = {
|
||||
runAt: browser.contentScripts.RegisteredContentScriptOptions["runAt"];
|
||||
};
|
||||
|
||||
type SharedFido2ScriptRegistrationOptions = SharedFido2ScriptInjectionDetails & {
|
||||
matches: string[];
|
||||
excludeMatches: string[];
|
||||
allFrames: true;
|
||||
};
|
||||
|
||||
type Fido2ExtensionMessage = {
|
||||
[key: string]: any;
|
||||
command: string;
|
||||
hostname?: string;
|
||||
origin?: string;
|
||||
requestId?: string;
|
||||
abortedRequestId?: string;
|
||||
data?: AssertCredentialParams | CreateCredentialParams;
|
||||
};
|
||||
|
||||
type Fido2ExtensionMessageEventParams = {
|
||||
message: Fido2ExtensionMessage;
|
||||
sender: chrome.runtime.MessageSender;
|
||||
};
|
||||
|
||||
type Fido2BackgroundExtensionMessageHandlers = {
|
||||
[key: string]: CallableFunction;
|
||||
fido2AbortRequest: ({ message }: Fido2ExtensionMessageEventParams) => void;
|
||||
fido2RegisterCredentialRequest: ({
|
||||
message,
|
||||
sender,
|
||||
}: Fido2ExtensionMessageEventParams) => Promise<CreateCredentialResult>;
|
||||
fido2GetCredentialRequest: ({
|
||||
message,
|
||||
sender,
|
||||
}: Fido2ExtensionMessageEventParams) => Promise<AssertCredentialResult>;
|
||||
};
|
||||
|
||||
interface Fido2Background {
|
||||
init(): void;
|
||||
injectFido2ContentScriptsInAllTabs(): Promise<void>;
|
||||
}
|
||||
|
||||
export {
|
||||
SharedFido2ScriptInjectionDetails,
|
||||
SharedFido2ScriptRegistrationOptions,
|
||||
Fido2ExtensionMessage,
|
||||
Fido2BackgroundExtensionMessageHandlers,
|
||||
Fido2Background,
|
||||
};
|
414
apps/browser/src/vault/fido2/background/fido2.background.spec.ts
Normal file
414
apps/browser/src/vault/fido2/background/fido2.background.spec.ts
Normal file
@ -0,0 +1,414 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
AssertCredentialParams,
|
||||
CreateCredentialParams,
|
||||
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
|
||||
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||
import { Fido2ClientService } from "@bitwarden/common/vault/services/fido2/fido2-client.service";
|
||||
|
||||
import { createPortSpyMock } from "../../../autofill/spec/autofill-mocks";
|
||||
import {
|
||||
flushPromises,
|
||||
sendExtensionRuntimeMessage,
|
||||
triggerPortOnDisconnectEvent,
|
||||
triggerRuntimeOnConnectEvent,
|
||||
} from "../../../autofill/spec/testing-utils";
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import { BrowserScriptInjectorService } from "../../../platform/services/browser-script-injector.service";
|
||||
import { AbortManager } from "../../background/abort-manager";
|
||||
import { Fido2ContentScript, Fido2ContentScriptId } from "../enums/fido2-content-script.enum";
|
||||
import { Fido2PortName } from "../enums/fido2-port-name.enum";
|
||||
|
||||
import { Fido2ExtensionMessage } from "./abstractions/fido2.background";
|
||||
import { Fido2Background } from "./fido2.background";
|
||||
|
||||
const sharedExecuteScriptOptions = { runAt: "document_start" };
|
||||
const sharedScriptInjectionDetails = { frame: "all_frames", ...sharedExecuteScriptOptions };
|
||||
const contentScriptDetails = {
|
||||
file: Fido2ContentScript.ContentScript,
|
||||
...sharedScriptInjectionDetails,
|
||||
};
|
||||
const sharedRegistrationOptions = {
|
||||
matches: ["https://*/*"],
|
||||
excludeMatches: ["https://*/*.xml*"],
|
||||
allFrames: true,
|
||||
...sharedExecuteScriptOptions,
|
||||
};
|
||||
|
||||
describe("Fido2Background", () => {
|
||||
const tabsQuerySpy: jest.SpyInstance = jest.spyOn(BrowserApi, "tabsQuery");
|
||||
const isManifestVersionSpy: jest.SpyInstance = jest.spyOn(BrowserApi, "isManifestVersion");
|
||||
const focusTabSpy: jest.SpyInstance = jest.spyOn(BrowserApi, "focusTab").mockResolvedValue();
|
||||
const focusWindowSpy: jest.SpyInstance = jest
|
||||
.spyOn(BrowserApi, "focusWindow")
|
||||
.mockResolvedValue();
|
||||
let abortManagerMock!: MockProxy<AbortManager>;
|
||||
let abortController!: MockProxy<AbortController>;
|
||||
let registeredContentScripsMock!: MockProxy<browser.contentScripts.RegisteredContentScript>;
|
||||
let tabMock!: MockProxy<chrome.tabs.Tab>;
|
||||
let senderMock!: MockProxy<chrome.runtime.MessageSender>;
|
||||
let logService!: MockProxy<LogService>;
|
||||
let fido2ClientService!: MockProxy<Fido2ClientService>;
|
||||
let vaultSettingsService!: MockProxy<VaultSettingsService>;
|
||||
let scriptInjectorServiceMock!: MockProxy<BrowserScriptInjectorService>;
|
||||
let enablePasskeysMock$!: BehaviorSubject<boolean>;
|
||||
let fido2Background!: Fido2Background;
|
||||
|
||||
beforeEach(() => {
|
||||
tabMock = mock<chrome.tabs.Tab>({
|
||||
id: 123,
|
||||
url: "https://example.com",
|
||||
windowId: 456,
|
||||
});
|
||||
senderMock = mock<chrome.runtime.MessageSender>({ id: "1", tab: tabMock });
|
||||
logService = mock<LogService>();
|
||||
fido2ClientService = mock<Fido2ClientService>();
|
||||
vaultSettingsService = mock<VaultSettingsService>();
|
||||
abortManagerMock = mock<AbortManager>();
|
||||
abortController = mock<AbortController>();
|
||||
registeredContentScripsMock = mock<browser.contentScripts.RegisteredContentScript>();
|
||||
scriptInjectorServiceMock = mock<BrowserScriptInjectorService>();
|
||||
|
||||
enablePasskeysMock$ = new BehaviorSubject(true);
|
||||
vaultSettingsService.enablePasskeys$ = enablePasskeysMock$;
|
||||
fido2ClientService.isFido2FeatureEnabled.mockResolvedValue(true);
|
||||
fido2Background = new Fido2Background(
|
||||
logService,
|
||||
fido2ClientService,
|
||||
vaultSettingsService,
|
||||
scriptInjectorServiceMock,
|
||||
);
|
||||
fido2Background["abortManager"] = abortManagerMock;
|
||||
abortManagerMock.runWithAbortController.mockImplementation((_requestId, runner) =>
|
||||
runner(abortController),
|
||||
);
|
||||
isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 2);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("injectFido2ContentScriptsInAllTabs", () => {
|
||||
it("does not inject any FIDO2 content scripts when no tabs have a secure url protocol", async () => {
|
||||
const insecureTab = mock<chrome.tabs.Tab>({ id: 789, url: "http://example.com" });
|
||||
tabsQuerySpy.mockResolvedValueOnce([insecureTab]);
|
||||
|
||||
await fido2Background.injectFido2ContentScriptsInAllTabs();
|
||||
|
||||
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only injects the FIDO2 content script into tabs that contain a secure url protocol", async () => {
|
||||
const secondTabMock = mock<chrome.tabs.Tab>({ id: 456, url: "https://example.com" });
|
||||
const insecureTab = mock<chrome.tabs.Tab>({ id: 789, url: "http://example.com" });
|
||||
const noUrlTab = mock<chrome.tabs.Tab>({ id: 101, url: undefined });
|
||||
tabsQuerySpy.mockResolvedValueOnce([tabMock, secondTabMock, insecureTab, noUrlTab]);
|
||||
|
||||
await fido2Background.injectFido2ContentScriptsInAllTabs();
|
||||
|
||||
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({
|
||||
tabId: tabMock.id,
|
||||
injectDetails: contentScriptDetails,
|
||||
});
|
||||
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({
|
||||
tabId: secondTabMock.id,
|
||||
injectDetails: contentScriptDetails,
|
||||
});
|
||||
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalledWith({
|
||||
tabId: insecureTab.id,
|
||||
injectDetails: contentScriptDetails,
|
||||
});
|
||||
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalledWith({
|
||||
tabId: noUrlTab.id,
|
||||
injectDetails: contentScriptDetails,
|
||||
});
|
||||
});
|
||||
|
||||
it("injects the `page-script.js` content script into the provided tab", async () => {
|
||||
tabsQuerySpy.mockResolvedValueOnce([tabMock]);
|
||||
|
||||
await fido2Background.injectFido2ContentScriptsInAllTabs();
|
||||
|
||||
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({
|
||||
tabId: tabMock.id,
|
||||
injectDetails: sharedScriptInjectionDetails,
|
||||
mv2Details: { file: Fido2ContentScript.PageScriptAppend },
|
||||
mv3Details: { file: Fido2ContentScript.PageScript, world: "MAIN" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleEnablePasskeysUpdate", () => {
|
||||
let portMock!: MockProxy<chrome.runtime.Port>;
|
||||
|
||||
beforeEach(() => {
|
||||
fido2Background.init();
|
||||
jest.spyOn(BrowserApi, "registerContentScriptsMv2");
|
||||
jest.spyOn(BrowserApi, "registerContentScriptsMv3");
|
||||
jest.spyOn(BrowserApi, "unregisterContentScriptsMv3");
|
||||
portMock = createPortSpyMock(Fido2PortName.InjectedScript);
|
||||
triggerRuntimeOnConnectEvent(portMock);
|
||||
triggerRuntimeOnConnectEvent(createPortSpyMock("some-other-port"));
|
||||
|
||||
tabsQuerySpy.mockResolvedValue([tabMock]);
|
||||
});
|
||||
|
||||
it("does not destroy and re-inject the content scripts when triggering `handleEnablePasskeysUpdate` with an undefined currentEnablePasskeysSetting property", async () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(portMock.disconnect).not.toHaveBeenCalled();
|
||||
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("destroys the content scripts but skips re-injecting them when the enablePasskeys setting is set to `false`", async () => {
|
||||
enablePasskeysMock$.next(false);
|
||||
await flushPromises();
|
||||
|
||||
expect(portMock.disconnect).toHaveBeenCalled();
|
||||
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("destroys and re-injects the content scripts when the enablePasskeys setting is set to `true`", async () => {
|
||||
enablePasskeysMock$.next(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(portMock.disconnect).toHaveBeenCalled();
|
||||
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({
|
||||
tabId: tabMock.id,
|
||||
injectDetails: sharedScriptInjectionDetails,
|
||||
mv2Details: { file: Fido2ContentScript.PageScriptAppend },
|
||||
mv3Details: { file: Fido2ContentScript.PageScript, world: "MAIN" },
|
||||
});
|
||||
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({
|
||||
tabId: tabMock.id,
|
||||
injectDetails: contentScriptDetails,
|
||||
});
|
||||
});
|
||||
|
||||
describe("given manifest v2", () => {
|
||||
it("registers the page-script-append-mv2.js and content-script.js content scripts when the enablePasskeys setting is set to `true`", async () => {
|
||||
isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 2);
|
||||
|
||||
enablePasskeysMock$.next(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(BrowserApi.registerContentScriptsMv2).toHaveBeenCalledWith({
|
||||
js: [
|
||||
{ file: Fido2ContentScript.PageScriptAppend },
|
||||
{ file: Fido2ContentScript.ContentScript },
|
||||
],
|
||||
...sharedRegistrationOptions,
|
||||
});
|
||||
});
|
||||
|
||||
it("unregisters any existing registered content scripts when the enablePasskeys setting is set to `false`", async () => {
|
||||
isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 2);
|
||||
fido2Background["registeredContentScripts"] = registeredContentScripsMock;
|
||||
|
||||
enablePasskeysMock$.next(false);
|
||||
await flushPromises();
|
||||
|
||||
expect(registeredContentScripsMock.unregister).toHaveBeenCalled();
|
||||
expect(BrowserApi.registerContentScriptsMv2).not.toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given manifest v3", () => {
|
||||
it("registers the page-script.js and content-script.js content scripts when the enablePasskeys setting is set to `true`", async () => {
|
||||
isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 3);
|
||||
|
||||
enablePasskeysMock$.next(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(BrowserApi.registerContentScriptsMv3).toHaveBeenCalledWith([
|
||||
{
|
||||
id: Fido2ContentScriptId.PageScript,
|
||||
js: [Fido2ContentScript.PageScript],
|
||||
world: "MAIN",
|
||||
...sharedRegistrationOptions,
|
||||
},
|
||||
{
|
||||
id: Fido2ContentScriptId.ContentScript,
|
||||
js: [Fido2ContentScript.ContentScript],
|
||||
...sharedRegistrationOptions,
|
||||
},
|
||||
]);
|
||||
expect(BrowserApi.unregisterContentScriptsMv3).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unregisters the page-script.js and content-script.js content scripts when the enablePasskeys setting is set to `false`", async () => {
|
||||
isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 3);
|
||||
|
||||
enablePasskeysMock$.next(false);
|
||||
await flushPromises();
|
||||
|
||||
expect(BrowserApi.unregisterContentScriptsMv3).toHaveBeenCalledWith({
|
||||
ids: [Fido2ContentScriptId.PageScript, Fido2ContentScriptId.ContentScript],
|
||||
});
|
||||
expect(BrowserApi.registerContentScriptsMv3).not.toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extension message handlers", () => {
|
||||
beforeEach(() => {
|
||||
fido2Background.init();
|
||||
});
|
||||
|
||||
it("ignores messages that do not have a handler associated with a command within the message", () => {
|
||||
const message = mock<Fido2ExtensionMessage>({ command: "nonexistentCommand" });
|
||||
|
||||
sendExtensionRuntimeMessage(message);
|
||||
|
||||
expect(abortManagerMock.abort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a response for rejected promises returned by a handler", async () => {
|
||||
const message = mock<Fido2ExtensionMessage>({ command: "fido2RegisterCredentialRequest" });
|
||||
const sender = mock<chrome.runtime.MessageSender>();
|
||||
const sendResponse = jest.fn();
|
||||
fido2ClientService.createCredential.mockRejectedValue(new Error("error"));
|
||||
|
||||
sendExtensionRuntimeMessage(message, sender, sendResponse);
|
||||
await flushPromises();
|
||||
|
||||
expect(sendResponse).toHaveBeenCalledWith({ error: { message: "error" } });
|
||||
});
|
||||
|
||||
describe("fido2AbortRequest message", () => {
|
||||
it("aborts the request associated with the passed abortedRequestId", async () => {
|
||||
const message = mock<Fido2ExtensionMessage>({
|
||||
command: "fido2AbortRequest",
|
||||
abortedRequestId: "123",
|
||||
});
|
||||
|
||||
sendExtensionRuntimeMessage(message);
|
||||
await flushPromises();
|
||||
|
||||
expect(abortManagerMock.abort).toHaveBeenCalledWith(message.abortedRequestId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fido2RegisterCredentialRequest message", () => {
|
||||
it("creates a credential within the Fido2ClientService", async () => {
|
||||
const message = mock<Fido2ExtensionMessage>({
|
||||
command: "fido2RegisterCredentialRequest",
|
||||
requestId: "123",
|
||||
data: mock<CreateCredentialParams>(),
|
||||
});
|
||||
|
||||
sendExtensionRuntimeMessage(message, senderMock);
|
||||
await flushPromises();
|
||||
|
||||
expect(fido2ClientService.createCredential).toHaveBeenCalledWith(
|
||||
message.data,
|
||||
tabMock,
|
||||
abortController,
|
||||
);
|
||||
expect(focusTabSpy).toHaveBeenCalledWith(tabMock.id);
|
||||
expect(focusWindowSpy).toHaveBeenCalledWith(tabMock.windowId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fido2GetCredentialRequest", () => {
|
||||
it("asserts a credential within the Fido2ClientService", async () => {
|
||||
const message = mock<Fido2ExtensionMessage>({
|
||||
command: "fido2GetCredentialRequest",
|
||||
requestId: "123",
|
||||
data: mock<AssertCredentialParams>(),
|
||||
});
|
||||
|
||||
sendExtensionRuntimeMessage(message, senderMock);
|
||||
await flushPromises();
|
||||
|
||||
expect(fido2ClientService.assertCredential).toHaveBeenCalledWith(
|
||||
message.data,
|
||||
tabMock,
|
||||
abortController,
|
||||
);
|
||||
expect(focusTabSpy).toHaveBeenCalledWith(tabMock.id);
|
||||
expect(focusWindowSpy).toHaveBeenCalledWith(tabMock.windowId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handle ports onConnect", () => {
|
||||
let portMock!: MockProxy<chrome.runtime.Port>;
|
||||
|
||||
beforeEach(() => {
|
||||
fido2Background.init();
|
||||
portMock = createPortSpyMock(Fido2PortName.InjectedScript);
|
||||
fido2ClientService.isFido2FeatureEnabled.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("ignores port connections that do not have the correct port name", async () => {
|
||||
const port = createPortSpyMock("nonexistentPort");
|
||||
|
||||
triggerRuntimeOnConnectEvent(port);
|
||||
await flushPromises();
|
||||
|
||||
expect(port.onDisconnect.addListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores port connections that do not have a sender url", async () => {
|
||||
portMock.sender = undefined;
|
||||
|
||||
triggerRuntimeOnConnectEvent(portMock);
|
||||
await flushPromises();
|
||||
|
||||
expect(portMock.onDisconnect.addListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disconnects the port connection when the Fido2 feature is not enabled", async () => {
|
||||
fido2ClientService.isFido2FeatureEnabled.mockResolvedValue(false);
|
||||
|
||||
triggerRuntimeOnConnectEvent(portMock);
|
||||
await flushPromises();
|
||||
|
||||
expect(portMock.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disconnects the port connection when the url is malformed", async () => {
|
||||
portMock.sender.url = "malformed-url";
|
||||
|
||||
triggerRuntimeOnConnectEvent(portMock);
|
||||
await flushPromises();
|
||||
|
||||
expect(portMock.disconnect).toHaveBeenCalled();
|
||||
expect(logService.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds the port to the fido2ContentScriptPortsSet when the Fido2 feature is enabled", async () => {
|
||||
triggerRuntimeOnConnectEvent(portMock);
|
||||
await flushPromises();
|
||||
|
||||
expect(portMock.onDisconnect.addListener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleInjectScriptPortOnDisconnect", () => {
|
||||
let portMock!: MockProxy<chrome.runtime.Port>;
|
||||
|
||||
beforeEach(() => {
|
||||
fido2Background.init();
|
||||
portMock = createPortSpyMock(Fido2PortName.InjectedScript);
|
||||
triggerRuntimeOnConnectEvent(portMock);
|
||||
fido2Background["fido2ContentScriptPortsSet"].add(portMock);
|
||||
});
|
||||
|
||||
it("does not destroy or inject the content script when the port has already disconnected before the enablePasskeys setting is set to `false`", async () => {
|
||||
triggerPortOnDisconnectEvent(portMock);
|
||||
|
||||
enablePasskeysMock$.next(false);
|
||||
await flushPromises();
|
||||
|
||||
expect(portMock.disconnect).not.toHaveBeenCalled();
|
||||
expect(scriptInjectorServiceMock.inject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
356
apps/browser/src/vault/fido2/background/fido2.background.ts
Normal file
356
apps/browser/src/vault/fido2/background/fido2.background.ts
Normal file
@ -0,0 +1,356 @@
|
||||
import { firstValueFrom, startWith } from "rxjs";
|
||||
import { pairwise } from "rxjs/operators";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
AssertCredentialParams,
|
||||
AssertCredentialResult,
|
||||
CreateCredentialParams,
|
||||
CreateCredentialResult,
|
||||
Fido2ClientService,
|
||||
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
|
||||
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import { ScriptInjectorService } from "../../../platform/services/abstractions/script-injector.service";
|
||||
import { AbortManager } from "../../background/abort-manager";
|
||||
import { Fido2ContentScript, Fido2ContentScriptId } from "../enums/fido2-content-script.enum";
|
||||
import { Fido2PortName } from "../enums/fido2-port-name.enum";
|
||||
|
||||
import {
|
||||
Fido2Background as Fido2BackgroundInterface,
|
||||
Fido2BackgroundExtensionMessageHandlers,
|
||||
Fido2ExtensionMessage,
|
||||
SharedFido2ScriptInjectionDetails,
|
||||
SharedFido2ScriptRegistrationOptions,
|
||||
} from "./abstractions/fido2.background";
|
||||
|
||||
export class Fido2Background implements Fido2BackgroundInterface {
|
||||
private abortManager = new AbortManager();
|
||||
private fido2ContentScriptPortsSet = new Set<chrome.runtime.Port>();
|
||||
private registeredContentScripts: browser.contentScripts.RegisteredContentScript;
|
||||
private readonly sharedInjectionDetails: SharedFido2ScriptInjectionDetails = {
|
||||
runAt: "document_start",
|
||||
};
|
||||
private readonly sharedRegistrationOptions: SharedFido2ScriptRegistrationOptions = {
|
||||
matches: ["https://*/*"],
|
||||
excludeMatches: ["https://*/*.xml*"],
|
||||
allFrames: true,
|
||||
...this.sharedInjectionDetails,
|
||||
};
|
||||
private readonly extensionMessageHandlers: Fido2BackgroundExtensionMessageHandlers = {
|
||||
fido2AbortRequest: ({ message }) => this.abortRequest(message),
|
||||
fido2RegisterCredentialRequest: ({ message, sender }) =>
|
||||
this.registerCredentialRequest(message, sender),
|
||||
fido2GetCredentialRequest: ({ message, sender }) => this.getCredentialRequest(message, sender),
|
||||
};
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private fido2ClientService: Fido2ClientService,
|
||||
private vaultSettingsService: VaultSettingsService,
|
||||
private scriptInjectorService: ScriptInjectorService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initializes the FIDO2 background service. Sets up the extension message
|
||||
* and port listeners. Subscribes to the enablePasskeys$ observable to
|
||||
* handle passkey enable/disable events.
|
||||
*/
|
||||
init() {
|
||||
BrowserApi.messageListener("fido2.background", this.handleExtensionMessage);
|
||||
BrowserApi.addListener(chrome.runtime.onConnect, this.handleInjectedScriptPortConnection);
|
||||
this.vaultSettingsService.enablePasskeys$
|
||||
.pipe(startWith(undefined), pairwise())
|
||||
.subscribe(([previous, current]) => this.handleEnablePasskeysUpdate(previous, current));
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the FIDO2 content and page script into all existing browser tabs.
|
||||
*/
|
||||
async injectFido2ContentScriptsInAllTabs() {
|
||||
const tabs = await BrowserApi.tabsQuery({});
|
||||
for (let index = 0; index < tabs.length; index++) {
|
||||
const tab = tabs[index];
|
||||
if (!tab.url?.startsWith("https")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
void this.injectFido2ContentScripts(tab);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles reacting to the enablePasskeys setting being updated. If the setting
|
||||
* is enabled, the FIDO2 content scripts are injected into all tabs. If the setting
|
||||
* is disabled, the FIDO2 content scripts will be from all tabs. This logic will
|
||||
* not trigger until after the first setting update.
|
||||
*
|
||||
* @param previousEnablePasskeysSetting - The previous value of the enablePasskeys setting.
|
||||
* @param enablePasskeys - The new value of the enablePasskeys setting.
|
||||
*/
|
||||
private async handleEnablePasskeysUpdate(
|
||||
previousEnablePasskeysSetting: boolean,
|
||||
enablePasskeys: boolean,
|
||||
) {
|
||||
await this.updateContentScriptRegistration();
|
||||
|
||||
if (previousEnablePasskeysSetting === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.destroyLoadedFido2ContentScripts();
|
||||
if (enablePasskeys) {
|
||||
void this.injectFido2ContentScriptsInAllTabs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the registration status of static FIDO2 content
|
||||
* scripts based on the enablePasskeys setting.
|
||||
*/
|
||||
private async updateContentScriptRegistration() {
|
||||
if (BrowserApi.isManifestVersion(2)) {
|
||||
await this.updateMv2ContentScriptsRegistration();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.updateMv3ContentScriptsRegistration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the registration status of static FIDO2 content
|
||||
* scripts based on the enablePasskeys setting for manifest v2.
|
||||
*/
|
||||
private async updateMv2ContentScriptsRegistration() {
|
||||
if (!(await this.isPasskeySettingEnabled())) {
|
||||
await this.registeredContentScripts?.unregister();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.registeredContentScripts = await BrowserApi.registerContentScriptsMv2({
|
||||
js: [
|
||||
{ file: Fido2ContentScript.PageScriptAppend },
|
||||
{ file: Fido2ContentScript.ContentScript },
|
||||
],
|
||||
...this.sharedRegistrationOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the registration status of static FIDO2 content
|
||||
* scripts based on the enablePasskeys setting for manifest v3.
|
||||
*/
|
||||
private async updateMv3ContentScriptsRegistration() {
|
||||
if (await this.isPasskeySettingEnabled()) {
|
||||
void BrowserApi.registerContentScriptsMv3([
|
||||
{
|
||||
id: Fido2ContentScriptId.PageScript,
|
||||
js: [Fido2ContentScript.PageScript],
|
||||
world: "MAIN",
|
||||
...this.sharedRegistrationOptions,
|
||||
},
|
||||
{
|
||||
id: Fido2ContentScriptId.ContentScript,
|
||||
js: [Fido2ContentScript.ContentScript],
|
||||
...this.sharedRegistrationOptions,
|
||||
},
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void BrowserApi.unregisterContentScriptsMv3({
|
||||
ids: [Fido2ContentScriptId.PageScript, Fido2ContentScriptId.ContentScript],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the FIDO2 content and page script into the current tab.
|
||||
*
|
||||
* @param tab - The current tab to inject the scripts into.
|
||||
*/
|
||||
private async injectFido2ContentScripts(tab: chrome.tabs.Tab): Promise<void> {
|
||||
void this.scriptInjectorService.inject({
|
||||
tabId: tab.id,
|
||||
injectDetails: { frame: "all_frames", ...this.sharedInjectionDetails },
|
||||
mv2Details: { file: Fido2ContentScript.PageScriptAppend },
|
||||
mv3Details: { file: Fido2ContentScript.PageScript, world: "MAIN" },
|
||||
});
|
||||
|
||||
void this.scriptInjectorService.inject({
|
||||
tabId: tab.id,
|
||||
injectDetails: {
|
||||
file: Fido2ContentScript.ContentScript,
|
||||
frame: "all_frames",
|
||||
...this.sharedInjectionDetails,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates over the set of injected FIDO2 content script ports
|
||||
* and disconnects them, destroying the content scripts.
|
||||
*/
|
||||
private destroyLoadedFido2ContentScripts() {
|
||||
this.fido2ContentScriptPortsSet.forEach((port) => {
|
||||
port.disconnect();
|
||||
this.fido2ContentScriptPortsSet.delete(port);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts the FIDO2 request with the provided requestId.
|
||||
*
|
||||
* @param message - The FIDO2 extension message containing the requestId to abort.
|
||||
*/
|
||||
private abortRequest(message: Fido2ExtensionMessage) {
|
||||
this.abortManager.abort(message.abortedRequestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new FIDO2 credential with the provided request data.
|
||||
*
|
||||
* @param message - The FIDO2 extension message containing the request data.
|
||||
* @param sender - The sender of the message.
|
||||
*/
|
||||
private async registerCredentialRequest(
|
||||
message: Fido2ExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): Promise<CreateCredentialResult> {
|
||||
return await this.handleCredentialRequest<CreateCredentialResult>(
|
||||
message,
|
||||
sender.tab,
|
||||
this.fido2ClientService.createCredential.bind(this.fido2ClientService),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a FIDO2 credential with the provided request data.
|
||||
*
|
||||
* @param message - The FIDO2 extension message containing the request data.
|
||||
* @param sender - The sender of the message.
|
||||
*/
|
||||
private async getCredentialRequest(
|
||||
message: Fido2ExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): Promise<AssertCredentialResult> {
|
||||
return await this.handleCredentialRequest<AssertCredentialResult>(
|
||||
message,
|
||||
sender.tab,
|
||||
this.fido2ClientService.assertCredential.bind(this.fido2ClientService),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Fido2 credential requests by calling the provided callback with the
|
||||
* request data, tab, and abort controller. The callback is expected to return
|
||||
* a promise that resolves with the result of the credential request.
|
||||
*
|
||||
* @param requestId - The request ID associated with the request.
|
||||
* @param data - The request data to handle.
|
||||
* @param tab - The tab associated with the request.
|
||||
* @param callback - The callback to call with the request data, tab, and abort controller.
|
||||
*/
|
||||
private handleCredentialRequest = async <T>(
|
||||
{ requestId, data }: Fido2ExtensionMessage,
|
||||
tab: chrome.tabs.Tab,
|
||||
callback: (
|
||||
data: AssertCredentialParams | CreateCredentialParams,
|
||||
tab: chrome.tabs.Tab,
|
||||
abortController: AbortController,
|
||||
) => Promise<T>,
|
||||
) => {
|
||||
return await this.abortManager.runWithAbortController(requestId, async (abortController) => {
|
||||
try {
|
||||
return await callback(data, tab, abortController);
|
||||
} finally {
|
||||
await BrowserApi.focusTab(tab.id);
|
||||
await BrowserApi.focusWindow(tab.windowId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the enablePasskeys setting is enabled.
|
||||
*/
|
||||
private async isPasskeySettingEnabled() {
|
||||
return await firstValueFrom(this.vaultSettingsService.enablePasskeys$);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the FIDO2 extension message by calling the
|
||||
* appropriate handler based on the message command.
|
||||
*
|
||||
* @param message - The FIDO2 extension message to handle.
|
||||
* @param sender - The sender of the message.
|
||||
* @param sendResponse - The function to call with the response.
|
||||
*/
|
||||
private handleExtensionMessage = (
|
||||
message: Fido2ExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response?: any) => void,
|
||||
) => {
|
||||
const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command];
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageResponse = handler({ message, sender });
|
||||
if (!messageResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(messageResponse)
|
||||
.then(
|
||||
(response) => sendResponse(response),
|
||||
(error) => sendResponse({ error: { ...error, message: error.message } }),
|
||||
)
|
||||
.catch(this.logService.error);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the connection of a FIDO2 content script port by checking if the
|
||||
* FIDO2 feature is enabled for the sender's hostname and origin. If the feature
|
||||
* is not enabled, the port is disconnected.
|
||||
*
|
||||
* @param port - The port which is connecting
|
||||
*/
|
||||
private handleInjectedScriptPortConnection = async (port: chrome.runtime.Port) => {
|
||||
if (port.name !== Fido2PortName.InjectedScript || !port.sender?.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { hostname, origin } = new URL(port.sender.url);
|
||||
if (!(await this.fido2ClientService.isFido2FeatureEnabled(hostname, origin))) {
|
||||
port.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.fido2ContentScriptPortsSet.add(port);
|
||||
port.onDisconnect.addListener(this.handleInjectScriptPortOnDisconnect);
|
||||
} catch (error) {
|
||||
this.logService.error(error);
|
||||
port.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the disconnection of a FIDO2 content script port
|
||||
* by removing it from the set of connected ports.
|
||||
*
|
||||
* @param port - The port which is disconnecting
|
||||
*/
|
||||
private handleInjectScriptPortOnDisconnect = (port: chrome.runtime.Port) => {
|
||||
if (port.name !== Fido2PortName.InjectedScript) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fido2ContentScriptPortsSet.delete(port);
|
||||
};
|
||||
}
|
164
apps/browser/src/vault/fido2/content/content-script.spec.ts
Normal file
164
apps/browser/src/vault/fido2/content/content-script.spec.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { CreateCredentialResult } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
|
||||
|
||||
import { createPortSpyMock } from "../../../autofill/spec/autofill-mocks";
|
||||
import { triggerPortOnDisconnectEvent } from "../../../autofill/spec/testing-utils";
|
||||
import { Fido2PortName } from "../enums/fido2-port-name.enum";
|
||||
|
||||
import { InsecureCreateCredentialParams, MessageType } from "./messaging/message";
|
||||
import { MessageWithMetadata, Messenger } from "./messaging/messenger";
|
||||
|
||||
jest.mock("../../../autofill/utils", () => ({
|
||||
sendExtensionMessage: jest.fn((command, options) => {
|
||||
return chrome.runtime.sendMessage(Object.assign({ command }, options));
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Fido2 Content Script", () => {
|
||||
let messenger: Messenger;
|
||||
const messengerForDOMCommunicationSpy = jest
|
||||
.spyOn(Messenger, "forDOMCommunication")
|
||||
.mockImplementation((window) => {
|
||||
const windowOrigin = window.location.origin;
|
||||
|
||||
messenger = new Messenger({
|
||||
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
|
||||
addEventListener: (listener) => window.addEventListener("message", listener),
|
||||
removeEventListener: (listener) => window.removeEventListener("message", listener),
|
||||
});
|
||||
messenger.destroy = jest.fn();
|
||||
return messenger;
|
||||
});
|
||||
const portSpy: MockProxy<chrome.runtime.Port> = createPortSpyMock(Fido2PortName.InjectedScript);
|
||||
chrome.runtime.connect = jest.fn(() => portSpy);
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(document, "contentType", {
|
||||
value: "text/html",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("destroys the messenger when the port is disconnected", () => {
|
||||
require("./content-script");
|
||||
|
||||
triggerPortOnDisconnectEvent(portSpy);
|
||||
|
||||
expect(messenger.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles a FIDO2 credential creation request message from the window message listener, formats the message and sends the formatted message to the extension background", async () => {
|
||||
const message = mock<MessageWithMetadata>({
|
||||
type: MessageType.CredentialCreationRequest,
|
||||
data: mock<InsecureCreateCredentialParams>(),
|
||||
});
|
||||
const mockResult = { credentialId: "mock" } as CreateCredentialResult;
|
||||
jest.spyOn(chrome.runtime, "sendMessage").mockResolvedValue(mockResult);
|
||||
|
||||
require("./content-script");
|
||||
|
||||
const response = await messenger.handler!(message, new AbortController());
|
||||
|
||||
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
|
||||
command: "fido2RegisterCredentialRequest",
|
||||
data: expect.objectContaining({
|
||||
origin: globalThis.location.origin,
|
||||
sameOriginWithAncestors: true,
|
||||
}),
|
||||
requestId: expect.any(String),
|
||||
});
|
||||
expect(response).toEqual({
|
||||
type: MessageType.CredentialCreationResponse,
|
||||
result: mockResult,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles a FIDO2 credential get request message from the window message listener, formats the message and sends the formatted message to the extension background", async () => {
|
||||
const message = mock<MessageWithMetadata>({
|
||||
type: MessageType.CredentialGetRequest,
|
||||
data: mock<InsecureCreateCredentialParams>(),
|
||||
});
|
||||
|
||||
require("./content-script");
|
||||
|
||||
await messenger.handler!(message, new AbortController());
|
||||
|
||||
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
|
||||
command: "fido2GetCredentialRequest",
|
||||
data: expect.objectContaining({
|
||||
origin: globalThis.location.origin,
|
||||
sameOriginWithAncestors: true,
|
||||
}),
|
||||
requestId: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the abort handler when the FIDO2 request is complete", async () => {
|
||||
const message = mock<MessageWithMetadata>({
|
||||
type: MessageType.CredentialCreationRequest,
|
||||
data: mock<InsecureCreateCredentialParams>(),
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
const abortSpy = jest.spyOn(abortController.signal, "removeEventListener");
|
||||
|
||||
require("./content-script");
|
||||
|
||||
await messenger.handler!(message, abortController);
|
||||
|
||||
expect(abortSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends an extension message to abort the FIDO2 request when the abort controller is signaled", async () => {
|
||||
const message = mock<MessageWithMetadata>({
|
||||
type: MessageType.CredentialCreationRequest,
|
||||
data: mock<InsecureCreateCredentialParams>(),
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
const abortSpy = jest.spyOn(abortController.signal, "addEventListener");
|
||||
jest
|
||||
.spyOn(chrome.runtime, "sendMessage")
|
||||
.mockImplementationOnce(async (extensionId: string, message: unknown, options: any) => {
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
require("./content-script");
|
||||
|
||||
await messenger.handler!(message, abortController);
|
||||
|
||||
expect(abortSpy).toHaveBeenCalled();
|
||||
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
|
||||
command: "fido2AbortRequest",
|
||||
abortedRequestId: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects credential requests and returns an error result", async () => {
|
||||
const errorMessage = "Test error";
|
||||
const message = mock<MessageWithMetadata>({
|
||||
type: MessageType.CredentialCreationRequest,
|
||||
data: mock<InsecureCreateCredentialParams>(),
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
jest.spyOn(chrome.runtime, "sendMessage").mockResolvedValue({ error: errorMessage });
|
||||
|
||||
require("./content-script");
|
||||
const result = messenger.handler!(message, abortController);
|
||||
|
||||
await expect(result).rejects.toEqual(errorMessage);
|
||||
});
|
||||
|
||||
it("skips initializing the content script if the document content type is not 'text/html'", () => {
|
||||
Object.defineProperty(document, "contentType", {
|
||||
value: "application/json",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
require("./content-script");
|
||||
|
||||
expect(messengerForDOMCommunicationSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
@ -3,142 +3,134 @@ import {
|
||||
CreateCredentialParams,
|
||||
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
|
||||
|
||||
import { Message, MessageType } from "./messaging/message";
|
||||
import { Messenger } from "./messaging/messenger";
|
||||
import { sendExtensionMessage } from "../../../autofill/utils";
|
||||
import { Fido2PortName } from "../enums/fido2-port-name.enum";
|
||||
|
||||
function isFido2FeatureEnabled(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(
|
||||
{
|
||||
command: "checkFido2FeatureEnabled",
|
||||
hostname: window.location.hostname,
|
||||
origin: window.location.origin,
|
||||
},
|
||||
(response: { result?: boolean }) => resolve(response.result),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isSameOriginWithAncestors() {
|
||||
try {
|
||||
return window.self === window.top;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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);
|
||||
import {
|
||||
InsecureAssertCredentialParams,
|
||||
InsecureCreateCredentialParams,
|
||||
Message,
|
||||
MessageType,
|
||||
} from "./messaging/message";
|
||||
import { MessageWithMetadata, Messenger } from "./messaging/messenger";
|
||||
|
||||
(function (globalContext) {
|
||||
if (globalContext.document.contentType !== "text/html") {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the page-script already exists, send a reconnect message to the page-script
|
||||
// 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
|
||||
messenger.sendReconnectCommand();
|
||||
}
|
||||
// Initialization logic, set up the messenger and connect a port to the background script.
|
||||
const messenger = Messenger.forDOMCommunication(globalContext.window);
|
||||
messenger.handler = handleFido2Message;
|
||||
const port = chrome.runtime.connect({ name: Fido2PortName.InjectedScript });
|
||||
port.onDisconnect.addListener(handlePortOnDisconnect);
|
||||
|
||||
function initializeFido2ContentScript() {
|
||||
injectPageScript();
|
||||
|
||||
messenger.handler = async (message, abortController) => {
|
||||
/**
|
||||
* Handles FIDO2 credential requests and returns the result.
|
||||
*
|
||||
* @param message - The message to handle.
|
||||
* @param abortController - The abort controller used to handle exit conditions from the FIDO2 request.
|
||||
*/
|
||||
async function handleFido2Message(
|
||||
message: MessageWithMetadata,
|
||||
abortController: AbortController,
|
||||
) {
|
||||
const requestId = Date.now().toString();
|
||||
const abortHandler = () =>
|
||||
chrome.runtime.sendMessage({
|
||||
command: "fido2AbortRequest",
|
||||
abortedRequestId: requestId,
|
||||
});
|
||||
sendExtensionMessage("fido2AbortRequest", { abortedRequestId: requestId });
|
||||
abortController.signal.addEventListener("abort", abortHandler);
|
||||
|
||||
if (message.type === MessageType.CredentialCreationRequest) {
|
||||
return new Promise<Message | undefined>((resolve, reject) => {
|
||||
const data: CreateCredentialParams = {
|
||||
...message.data,
|
||||
origin: window.location.origin,
|
||||
sameOriginWithAncestors: isSameOriginWithAncestors(),
|
||||
};
|
||||
|
||||
chrome.runtime.sendMessage(
|
||||
{
|
||||
command: "fido2RegisterCredentialRequest",
|
||||
data,
|
||||
requestId: requestId,
|
||||
},
|
||||
(response) => {
|
||||
if (response && response.error !== undefined) {
|
||||
return reject(response.error);
|
||||
}
|
||||
|
||||
resolve({
|
||||
type: MessageType.CredentialCreationResponse,
|
||||
result: response.result,
|
||||
});
|
||||
},
|
||||
try {
|
||||
if (message.type === MessageType.CredentialCreationRequest) {
|
||||
return handleCredentialCreationRequestMessage(
|
||||
requestId,
|
||||
message.data as InsecureCreateCredentialParams,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === MessageType.CredentialGetRequest) {
|
||||
return new Promise<Message | undefined>((resolve, reject) => {
|
||||
const data: AssertCredentialParams = {
|
||||
...message.data,
|
||||
origin: window.location.origin,
|
||||
sameOriginWithAncestors: isSameOriginWithAncestors(),
|
||||
};
|
||||
|
||||
chrome.runtime.sendMessage(
|
||||
{
|
||||
command: "fido2GetCredentialRequest",
|
||||
data,
|
||||
requestId: requestId,
|
||||
},
|
||||
(response) => {
|
||||
if (response && response.error !== undefined) {
|
||||
return reject(response.error);
|
||||
}
|
||||
|
||||
resolve({
|
||||
type: MessageType.CredentialGetResponse,
|
||||
result: response.result,
|
||||
});
|
||||
},
|
||||
if (message.type === MessageType.CredentialGetRequest) {
|
||||
return handleCredentialGetRequestMessage(
|
||||
requestId,
|
||||
message.data as InsecureAssertCredentialParams,
|
||||
);
|
||||
}).finally(() =>
|
||||
abortController.signal.removeEventListener("abort", abortHandler),
|
||||
) as Promise<Message>;
|
||||
}
|
||||
} finally {
|
||||
abortController.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!(await isFido2FeatureEnabled())) {
|
||||
return;
|
||||
}
|
||||
|
||||
initializeFido2ContentScript();
|
||||
/**
|
||||
* Handles the credential creation request message and returns the result.
|
||||
*
|
||||
* @param requestId - The request ID of the message.
|
||||
* @param data - Data associated with the credential request.
|
||||
*/
|
||||
async function handleCredentialCreationRequestMessage(
|
||||
requestId: string,
|
||||
data: InsecureCreateCredentialParams,
|
||||
): Promise<Message | undefined> {
|
||||
return respondToCredentialRequest(
|
||||
"fido2RegisterCredentialRequest",
|
||||
MessageType.CredentialCreationResponse,
|
||||
requestId,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
const port = chrome.runtime.connect({ name: "fido2ContentScriptReady" });
|
||||
port.onDisconnect.addListener(() => {
|
||||
// Cleanup the messenger and remove the event listener
|
||||
// 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
|
||||
messenger.destroy();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Handles the credential get request message and returns the result.
|
||||
*
|
||||
* @param requestId - The request ID of the message.
|
||||
* @param data - Data associated with the credential request.
|
||||
*/
|
||||
async function handleCredentialGetRequestMessage(
|
||||
requestId: string,
|
||||
data: InsecureAssertCredentialParams,
|
||||
): Promise<Message | undefined> {
|
||||
return respondToCredentialRequest(
|
||||
"fido2GetCredentialRequest",
|
||||
MessageType.CredentialGetResponse,
|
||||
requestId,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// Only run the script if the document is an HTML document
|
||||
if (document.contentType === "text/html") {
|
||||
void run();
|
||||
}
|
||||
/**
|
||||
* Sends a message to the extension to handle the
|
||||
* credential request and returns the result.
|
||||
*
|
||||
* @param command - The command to send to the extension.
|
||||
* @param type - The type of message, either CredentialCreationResponse or CredentialGetResponse.
|
||||
* @param requestId - The request ID of the message.
|
||||
* @param messageData - Data associated with the credential request.
|
||||
*/
|
||||
async function respondToCredentialRequest(
|
||||
command: string,
|
||||
type: MessageType.CredentialCreationResponse | MessageType.CredentialGetResponse,
|
||||
requestId: string,
|
||||
messageData: InsecureCreateCredentialParams | InsecureAssertCredentialParams,
|
||||
): Promise<Message | undefined> {
|
||||
const data: CreateCredentialParams | AssertCredentialParams = {
|
||||
...messageData,
|
||||
origin: globalContext.location.origin,
|
||||
sameOriginWithAncestors: globalContext.self === globalContext.top,
|
||||
};
|
||||
|
||||
const result = await sendExtensionMessage(command, { data, requestId });
|
||||
|
||||
if (result && result.error !== undefined) {
|
||||
return Promise.reject(result.error);
|
||||
}
|
||||
|
||||
return Promise.resolve({ type, result });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the disconnect event of the port. Calls
|
||||
* to the messenger to destroy and tear down the
|
||||
* implemented page-script.js logic.
|
||||
*/
|
||||
function handlePortOnDisconnect() {
|
||||
void messenger.destroy();
|
||||
}
|
||||
})(globalThis);
|
||||
|
@ -68,7 +68,7 @@ describe("Messenger", () => {
|
||||
const abortController = new AbortController();
|
||||
// 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
|
||||
messengerA.request(createRequest(), abortController);
|
||||
messengerA.request(createRequest(), abortController.signal);
|
||||
abortController.abort();
|
||||
|
||||
const received = handlerB.receive();
|
||||
|
@ -47,7 +47,7 @@ export class Messenger {
|
||||
}
|
||||
|
||||
/**
|
||||
* The handler that will be called when a message is recieved. The handler should return
|
||||
* The handler that will be called when a message is received. The handler should return
|
||||
* a promise that resolves to the response message. If the handler throws an error, the
|
||||
* error will be sent back to the sender.
|
||||
*/
|
||||
@ -65,10 +65,10 @@ export class Messenger {
|
||||
* AbortController signals will be forwarded to the content script.
|
||||
*
|
||||
* @param request data to send to the content script
|
||||
* @param abortController the abort controller that might be used to abort the request
|
||||
* @param abortSignal the abort controller that might be used to abort the request
|
||||
* @returns the response from the content script
|
||||
*/
|
||||
async request(request: Message, abortController?: AbortController): Promise<Message> {
|
||||
async request(request: Message, abortSignal?: AbortSignal): Promise<Message> {
|
||||
const requestChannel = new MessageChannel();
|
||||
const { port1: localPort, port2: remotePort } = requestChannel;
|
||||
|
||||
@ -82,7 +82,7 @@ export class Messenger {
|
||||
metadata: { SENDER },
|
||||
type: MessageType.AbortRequest,
|
||||
});
|
||||
abortController?.signal.addEventListener("abort", abortListener);
|
||||
abortSignal?.addEventListener("abort", abortListener);
|
||||
|
||||
this.broadcastChannel.postMessage(
|
||||
{ ...request, SENDER, senderId: this.messengerId },
|
||||
@ -90,7 +90,7 @@ export class Messenger {
|
||||
);
|
||||
const response = await promise;
|
||||
|
||||
abortController?.signal.removeEventListener("abort", abortListener);
|
||||
abortSignal?.removeEventListener("abort", abortListener);
|
||||
|
||||
if (response.type === MessageType.ErrorResponse) {
|
||||
const error = new Error();
|
||||
@ -113,12 +113,7 @@ export class Messenger {
|
||||
|
||||
const message = event.data;
|
||||
const port = event.ports?.[0];
|
||||
if (
|
||||
message?.SENDER !== SENDER ||
|
||||
message.senderId == this.messengerId ||
|
||||
message == null ||
|
||||
port == null
|
||||
) {
|
||||
if (message?.SENDER !== SENDER || message.senderId == this.messengerId || port == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -167,10 +162,6 @@ export class Messenger {
|
||||
}
|
||||
}
|
||||
|
||||
async sendReconnectCommand() {
|
||||
await this.request({ type: MessageType.ReconnectRequest });
|
||||
}
|
||||
|
||||
private async sendDisconnectCommand() {
|
||||
await this.request({ type: MessageType.DisconnectRequest });
|
||||
}
|
||||
|
@ -0,0 +1,69 @@
|
||||
import { Fido2ContentScript } from "../enums/fido2-content-script.enum";
|
||||
|
||||
describe("FIDO2 page-script for manifest v2", () => {
|
||||
let createdScriptElement: HTMLScriptElement;
|
||||
jest.spyOn(window.document, "createElement");
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window.document, "contentType", { value: "text/html", writable: true });
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("skips appending the `page-script.js` file if the document contentType is not `text/html`", () => {
|
||||
Object.defineProperty(window.document, "contentType", { value: "text/plain", writable: true });
|
||||
|
||||
require("./page-script-append.mv2");
|
||||
|
||||
expect(window.document.createElement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appends the `page-script.js` file to the document head when the contentType is `text/html`", () => {
|
||||
jest.spyOn(window.document.head, "insertBefore").mockImplementation((node) => {
|
||||
createdScriptElement = node as HTMLScriptElement;
|
||||
return node;
|
||||
});
|
||||
|
||||
require("./page-script-append.mv2");
|
||||
|
||||
expect(window.document.createElement).toHaveBeenCalledWith("script");
|
||||
expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript);
|
||||
expect(window.document.head.insertBefore).toHaveBeenCalledWith(
|
||||
expect.any(HTMLScriptElement),
|
||||
window.document.head.firstChild,
|
||||
);
|
||||
expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`);
|
||||
});
|
||||
|
||||
it("appends the `page-script.js` file to the document element if the head is not available", () => {
|
||||
window.document.documentElement.removeChild(window.document.head);
|
||||
jest.spyOn(window.document.documentElement, "insertBefore").mockImplementation((node) => {
|
||||
createdScriptElement = node as HTMLScriptElement;
|
||||
return node;
|
||||
});
|
||||
|
||||
require("./page-script-append.mv2");
|
||||
|
||||
expect(window.document.createElement).toHaveBeenCalledWith("script");
|
||||
expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript);
|
||||
expect(window.document.documentElement.insertBefore).toHaveBeenCalledWith(
|
||||
expect.any(HTMLScriptElement),
|
||||
window.document.documentElement.firstChild,
|
||||
);
|
||||
expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`);
|
||||
});
|
||||
|
||||
it("removes the appended `page-script.js` file after the script has triggered a load event", () => {
|
||||
createdScriptElement = document.createElement("script");
|
||||
jest.spyOn(window.document, "createElement").mockImplementation((element) => {
|
||||
return createdScriptElement;
|
||||
});
|
||||
|
||||
require("./page-script-append.mv2");
|
||||
|
||||
jest.spyOn(createdScriptElement, "remove");
|
||||
createdScriptElement.dispatchEvent(new Event("load"));
|
||||
|
||||
expect(createdScriptElement.remove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* This script handles injection of the FIDO2 override page script into the document.
|
||||
* This is required for manifest v2, but will be removed when we migrate fully to manifest v3.
|
||||
*/
|
||||
import { Fido2ContentScript } from "../enums/fido2-content-script.enum";
|
||||
|
||||
(function (globalContext) {
|
||||
if (globalContext.document.contentType !== "text/html") {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = globalContext.document.createElement("script");
|
||||
script.src = chrome.runtime.getURL(Fido2ContentScript.PageScript);
|
||||
script.addEventListener("load", () => script.remove());
|
||||
|
||||
const scriptInsertionPoint =
|
||||
globalContext.document.head || globalContext.document.documentElement;
|
||||
scriptInsertionPoint.insertBefore(script, scriptInsertionPoint.firstChild);
|
||||
})(globalThis);
|
@ -5,212 +5,229 @@ import { WebauthnUtils } from "../webauthn-utils";
|
||||
import { MessageType } from "./messaging/message";
|
||||
import { Messenger } from "./messaging/messenger";
|
||||
|
||||
const BrowserPublicKeyCredential = window.PublicKeyCredential;
|
||||
|
||||
const browserNativeWebauthnSupport = window.PublicKeyCredential != undefined;
|
||||
let browserNativeWebauthnPlatformAuthenticatorSupport = false;
|
||||
if (!browserNativeWebauthnSupport) {
|
||||
// Polyfill webauthn support
|
||||
try {
|
||||
// credentials is read-only if supported, use type-casting to force assignment
|
||||
(navigator as any).credentials = {
|
||||
async create() {
|
||||
throw new Error("Webauthn not supported in this browser.");
|
||||
},
|
||||
async get() {
|
||||
throw new Error("Webauthn not supported in this browser.");
|
||||
},
|
||||
};
|
||||
window.PublicKeyCredential = class PolyfillPublicKeyCredential {
|
||||
static isUserVerifyingPlatformAuthenticatorAvailable() {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
} as any;
|
||||
window.AuthenticatorAttestationResponse =
|
||||
class PolyfillAuthenticatorAttestationResponse {} as any;
|
||||
} catch {
|
||||
/* empty */
|
||||
(function (globalContext) {
|
||||
if (globalContext.document.contentType !== "text/html") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const BrowserPublicKeyCredential = globalContext.PublicKeyCredential;
|
||||
const BrowserNavigatorCredentials = navigator.credentials;
|
||||
const BrowserAuthenticatorAttestationResponse = globalContext.AuthenticatorAttestationResponse;
|
||||
|
||||
if (browserNativeWebauthnSupport) {
|
||||
// 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
|
||||
BrowserPublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then((available) => {
|
||||
browserNativeWebauthnPlatformAuthenticatorSupport = available;
|
||||
|
||||
if (!available) {
|
||||
// Polyfill platform authenticator support
|
||||
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = () =>
|
||||
Promise.resolve(true);
|
||||
const browserNativeWebauthnSupport = globalContext.PublicKeyCredential != undefined;
|
||||
let browserNativeWebauthnPlatformAuthenticatorSupport = false;
|
||||
if (!browserNativeWebauthnSupport) {
|
||||
// Polyfill webauthn support
|
||||
try {
|
||||
// credentials are read-only if supported, use type-casting to force assignment
|
||||
(navigator as any).credentials = {
|
||||
async create() {
|
||||
throw new Error("Webauthn not supported in this browser.");
|
||||
},
|
||||
async get() {
|
||||
throw new Error("Webauthn not supported in this browser.");
|
||||
},
|
||||
};
|
||||
globalContext.PublicKeyCredential = class PolyfillPublicKeyCredential {
|
||||
static isUserVerifyingPlatformAuthenticatorAvailable() {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
} as any;
|
||||
globalContext.AuthenticatorAttestationResponse =
|
||||
class PolyfillAuthenticatorAttestationResponse {} as any;
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
void BrowserPublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(
|
||||
(available) => {
|
||||
browserNativeWebauthnPlatformAuthenticatorSupport = available;
|
||||
|
||||
const browserCredentials = {
|
||||
create: navigator.credentials.create.bind(
|
||||
navigator.credentials,
|
||||
) as typeof navigator.credentials.create,
|
||||
get: navigator.credentials.get.bind(navigator.credentials) as typeof navigator.credentials.get,
|
||||
};
|
||||
|
||||
const messenger = ((window as any).messenger = Messenger.forDOMCommunication(window));
|
||||
|
||||
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> {
|
||||
if (!isWebauthnCall(options)) {
|
||||
return await browserCredentials.create(options);
|
||||
}
|
||||
|
||||
const fallbackSupported =
|
||||
(options?.publicKey?.authenticatorSelection?.authenticatorAttachment === "platform" &&
|
||||
browserNativeWebauthnPlatformAuthenticatorSupport) ||
|
||||
(options?.publicKey?.authenticatorSelection?.authenticatorAttachment !== "platform" &&
|
||||
browserNativeWebauthnSupport);
|
||||
try {
|
||||
const response = await messenger.request(
|
||||
{
|
||||
type: MessageType.CredentialCreationRequest,
|
||||
data: WebauthnUtils.mapCredentialCreationOptions(options, fallbackSupported),
|
||||
if (!available) {
|
||||
// Polyfill platform authenticator support
|
||||
globalContext.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = () =>
|
||||
Promise.resolve(true);
|
||||
}
|
||||
},
|
||||
abortController,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.type !== MessageType.CredentialCreationResponse) {
|
||||
throw new Error("Something went wrong.");
|
||||
}
|
||||
const browserCredentials = {
|
||||
create: navigator.credentials.create.bind(
|
||||
navigator.credentials,
|
||||
) as typeof navigator.credentials.create,
|
||||
get: navigator.credentials.get.bind(navigator.credentials) as typeof navigator.credentials.get,
|
||||
};
|
||||
|
||||
return WebauthnUtils.mapCredentialRegistrationResult(response.result);
|
||||
} catch (error) {
|
||||
if (error && error.fallbackRequested && fallbackSupported) {
|
||||
await waitForFocus();
|
||||
const messenger = Messenger.forDOMCommunication(window);
|
||||
let waitForFocusTimeout: number | NodeJS.Timeout;
|
||||
let focusListenerHandler: () => void;
|
||||
|
||||
navigator.credentials.create = createWebAuthnCredential;
|
||||
navigator.credentials.get = getWebAuthnCredential;
|
||||
|
||||
/**
|
||||
* Creates a new webauthn credential.
|
||||
*
|
||||
* @param options Options for creating new credentials.
|
||||
* @returns Promise that resolves to the new credential object.
|
||||
*/
|
||||
async function createWebAuthnCredential(
|
||||
options?: CredentialCreationOptions,
|
||||
): Promise<Credential> {
|
||||
if (!isWebauthnCall(options)) {
|
||||
return await browserCredentials.create(options);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const authenticatorAttachmentIsPlatform =
|
||||
options?.publicKey?.authenticatorSelection?.authenticatorAttachment === "platform";
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
if (!isWebauthnCall(options)) {
|
||||
return await browserCredentials.get(options);
|
||||
const fallbackSupported =
|
||||
(authenticatorAttachmentIsPlatform && browserNativeWebauthnPlatformAuthenticatorSupport) ||
|
||||
(!authenticatorAttachmentIsPlatform && browserNativeWebauthnSupport);
|
||||
try {
|
||||
const response = await messenger.request(
|
||||
{
|
||||
type: MessageType.CredentialCreationRequest,
|
||||
data: WebauthnUtils.mapCredentialCreationOptions(options, fallbackSupported),
|
||||
},
|
||||
options?.signal,
|
||||
);
|
||||
|
||||
if (response.type !== MessageType.CredentialCreationResponse) {
|
||||
throw new Error("Something went wrong.");
|
||||
}
|
||||
|
||||
return WebauthnUtils.mapCredentialRegistrationResult(response.result);
|
||||
} catch (error) {
|
||||
if (error && error.fallbackRequested && fallbackSupported) {
|
||||
await waitForFocus();
|
||||
return await browserCredentials.create(options);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackSupported = browserNativeWebauthnSupport;
|
||||
|
||||
try {
|
||||
if (options?.mediation && options.mediation !== "optional") {
|
||||
throw new FallbackRequestedError();
|
||||
}
|
||||
|
||||
const response = await messenger.request(
|
||||
{
|
||||
type: MessageType.CredentialGetRequest,
|
||||
data: WebauthnUtils.mapCredentialRequestOptions(options, fallbackSupported),
|
||||
},
|
||||
abortController,
|
||||
);
|
||||
|
||||
if (response.type !== MessageType.CredentialGetResponse) {
|
||||
throw new Error("Something went wrong.");
|
||||
}
|
||||
|
||||
return WebauthnUtils.mapCredentialAssertResult(response.result);
|
||||
} catch (error) {
|
||||
if (error && error.fallbackRequested && fallbackSupported) {
|
||||
await waitForFocus();
|
||||
/**
|
||||
* Retrieves a webauthn credential.
|
||||
*
|
||||
* @param options Options for creating new credentials.
|
||||
* @returns Promise that resolves to the new credential object.
|
||||
*/
|
||||
async function getWebAuthnCredential(options?: CredentialRequestOptions): Promise<Credential> {
|
||||
if (!isWebauthnCall(options)) {
|
||||
return await browserCredentials.get(options);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const fallbackSupported = browserNativeWebauthnSupport;
|
||||
|
||||
function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) {
|
||||
return options && "publicKey" in options;
|
||||
}
|
||||
try {
|
||||
if (options?.mediation && options.mediation !== "optional") {
|
||||
throw new FallbackRequestedError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for window to be focused.
|
||||
* Safari doesn't allow scripts to trigger webauthn when window is not focused.
|
||||
*
|
||||
* @param fallbackWait How long to wait when the script is not able to add event listeners to `window.top`. Defaults to 500ms.
|
||||
* @param timeout Maximum time to wait for focus in milliseconds. Defaults to 5 minutes.
|
||||
* @returns Promise that resolves when window is focused, or rejects if timeout is reached.
|
||||
*/
|
||||
async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) {
|
||||
try {
|
||||
if (window.top.document.hasFocus()) {
|
||||
return;
|
||||
const response = await messenger.request(
|
||||
{
|
||||
type: MessageType.CredentialGetRequest,
|
||||
data: WebauthnUtils.mapCredentialRequestOptions(options, fallbackSupported),
|
||||
},
|
||||
options?.signal,
|
||||
);
|
||||
|
||||
if (response.type !== MessageType.CredentialGetResponse) {
|
||||
throw new Error("Something went wrong.");
|
||||
}
|
||||
|
||||
return WebauthnUtils.mapCredentialAssertResult(response.result);
|
||||
} catch (error) {
|
||||
if (error && error.fallbackRequested && fallbackSupported) {
|
||||
await waitForFocus();
|
||||
return await browserCredentials.get(options);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
} catch {
|
||||
// Cannot access window.top due to cross-origin frame, fallback to waiting
|
||||
return await new Promise((resolve) => window.setTimeout(resolve, fallbackWait));
|
||||
}
|
||||
|
||||
let focusListener;
|
||||
const focusPromise = new Promise<void>((resolve) => {
|
||||
focusListener = () => resolve();
|
||||
window.top.addEventListener("focus", focusListener);
|
||||
});
|
||||
|
||||
let timeoutId;
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
timeoutId = window.setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new DOMException("The operation either timed out or was not allowed.", "AbortError"),
|
||||
),
|
||||
timeout,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([focusPromise, timeoutPromise]);
|
||||
} finally {
|
||||
window.top.removeEventListener("focus", focusListener);
|
||||
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;
|
||||
function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) {
|
||||
return options && "publicKey" in options;
|
||||
}
|
||||
|
||||
// Handle reinitialization for reconnect request
|
||||
if (type === MessageType.ReconnectRequest && browserNativeWebauthnSupport) {
|
||||
navigator.credentials.create = createWebAuthnCredential;
|
||||
navigator.credentials.get = getWebAuthnCredential;
|
||||
/**
|
||||
* Wait for window to be focused.
|
||||
* Safari doesn't allow scripts to trigger webauthn when window is not focused.
|
||||
*
|
||||
* @param fallbackWait How long to wait when the script is not able to add event listeners to `window.top`. Defaults to 500ms.
|
||||
* @param timeout Maximum time to wait for focus in milliseconds. Defaults to 5 minutes.
|
||||
* @returns Promise that resolves when window is focused, or rejects if timeout is reached.
|
||||
*/
|
||||
async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) {
|
||||
try {
|
||||
if (globalContext.top.document.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Cannot access window.top due to cross-origin frame, fallback to waiting
|
||||
return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait));
|
||||
}
|
||||
|
||||
const focusPromise = new Promise<void>((resolve) => {
|
||||
focusListenerHandler = () => resolve();
|
||||
globalContext.top.addEventListener("focus", focusListenerHandler);
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
waitForFocusTimeout = globalContext.setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new DOMException("The operation either timed out or was not allowed.", "AbortError"),
|
||||
),
|
||||
timeout,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([focusPromise, timeoutPromise]);
|
||||
} finally {
|
||||
clearWaitForFocus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function clearWaitForFocus() {
|
||||
globalContext.top.removeEventListener("focus", focusListenerHandler);
|
||||
if (waitForFocusTimeout) {
|
||||
globalContext.clearTimeout(waitForFocusTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
try {
|
||||
if (browserNativeWebauthnSupport) {
|
||||
navigator.credentials.create = browserCredentials.create;
|
||||
navigator.credentials.get = browserCredentials.get;
|
||||
} else {
|
||||
(navigator as any).credentials = BrowserNavigatorCredentials;
|
||||
globalContext.PublicKeyCredential = BrowserPublicKeyCredential;
|
||||
globalContext.AuthenticatorAttestationResponse = BrowserAuthenticatorAttestationResponse;
|
||||
}
|
||||
|
||||
clearWaitForFocus();
|
||||
void messenger.destroy();
|
||||
} catch (e) {
|
||||
/** empty */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a listener to handle cleanup or reconnection when the extension's
|
||||
* context changes due to being reloaded or unloaded.
|
||||
*/
|
||||
messenger.handler = (message) => {
|
||||
const type = message.type;
|
||||
|
||||
// Handle cleanup for disconnect request
|
||||
if (type === MessageType.DisconnectRequest) {
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
})(globalThis);
|
||||
|
@ -0,0 +1,121 @@
|
||||
import {
|
||||
createAssertCredentialResultMock,
|
||||
createCreateCredentialResultMock,
|
||||
createCredentialCreationOptionsMock,
|
||||
createCredentialRequestOptionsMock,
|
||||
setupMockedWebAuthnSupport,
|
||||
} from "../../../autofill/spec/fido2-testing-utils";
|
||||
import { WebauthnUtils } from "../webauthn-utils";
|
||||
|
||||
import { MessageType } from "./messaging/message";
|
||||
import { Messenger } from "./messaging/messenger";
|
||||
|
||||
let messenger: Messenger;
|
||||
jest.mock("./messaging/messenger", () => {
|
||||
return {
|
||||
Messenger: class extends jest.requireActual("./messaging/messenger").Messenger {
|
||||
static forDOMCommunication: any = jest.fn((window) => {
|
||||
const windowOrigin = window.location.origin;
|
||||
|
||||
messenger = new Messenger({
|
||||
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
|
||||
addEventListener: (listener) => window.addEventListener("message", listener),
|
||||
removeEventListener: (listener) => window.removeEventListener("message", listener),
|
||||
});
|
||||
messenger.destroy = jest.fn();
|
||||
return messenger;
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
jest.mock("../webauthn-utils");
|
||||
|
||||
describe("Fido2 page script with native WebAuthn support", () => {
|
||||
const mockCredentialCreationOptions = createCredentialCreationOptionsMock();
|
||||
const mockCreateCredentialsResult = createCreateCredentialResultMock();
|
||||
const mockCredentialRequestOptions = createCredentialRequestOptionsMock();
|
||||
const mockCredentialAssertResult = createAssertCredentialResultMock();
|
||||
setupMockedWebAuthnSupport();
|
||||
|
||||
require("./page-script");
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe("creating WebAuthn credentials", () => {
|
||||
beforeEach(() => {
|
||||
messenger.request = jest.fn().mockResolvedValue({
|
||||
type: MessageType.CredentialCreationResponse,
|
||||
result: mockCreateCredentialsResult,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default browser credentials API if an error occurs", async () => {
|
||||
window.top.document.hasFocus = jest.fn().mockReturnValue(true);
|
||||
messenger.request = jest.fn().mockRejectedValue({ fallbackRequested: true });
|
||||
|
||||
try {
|
||||
await navigator.credentials.create(mockCredentialCreationOptions);
|
||||
expect("This will fail the test").toBe(true);
|
||||
} catch {
|
||||
expect(WebauthnUtils.mapCredentialRegistrationResult).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates and returns a WebAuthn credential when the navigator API is called to create credentials", async () => {
|
||||
await navigator.credentials.create(mockCredentialCreationOptions);
|
||||
|
||||
expect(WebauthnUtils.mapCredentialCreationOptions).toHaveBeenCalledWith(
|
||||
mockCredentialCreationOptions,
|
||||
true,
|
||||
);
|
||||
expect(WebauthnUtils.mapCredentialRegistrationResult).toHaveBeenCalledWith(
|
||||
mockCreateCredentialsResult,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get WebAuthn credentials", () => {
|
||||
beforeEach(() => {
|
||||
messenger.request = jest.fn().mockResolvedValue({
|
||||
type: MessageType.CredentialGetResponse,
|
||||
result: mockCredentialAssertResult,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default browser credentials API when an error occurs", async () => {
|
||||
window.top.document.hasFocus = jest.fn().mockReturnValue(true);
|
||||
messenger.request = jest.fn().mockRejectedValue({ fallbackRequested: true });
|
||||
|
||||
const returnValue = await navigator.credentials.get(mockCredentialRequestOptions);
|
||||
|
||||
expect(returnValue).toBeDefined();
|
||||
expect(WebauthnUtils.mapCredentialAssertResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gets and returns the WebAuthn credentials", async () => {
|
||||
await navigator.credentials.get(mockCredentialRequestOptions);
|
||||
|
||||
expect(WebauthnUtils.mapCredentialRequestOptions).toHaveBeenCalledWith(
|
||||
mockCredentialRequestOptions,
|
||||
true,
|
||||
);
|
||||
expect(WebauthnUtils.mapCredentialAssertResult).toHaveBeenCalledWith(
|
||||
mockCredentialAssertResult,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroy", () => {
|
||||
it("should destroy the message listener when receiving a disconnect request", async () => {
|
||||
jest.spyOn(globalThis.top, "removeEventListener");
|
||||
const SENDER = "bitwarden-webauthn";
|
||||
void messenger.handler({ type: MessageType.DisconnectRequest, SENDER, senderId: "1" });
|
||||
|
||||
expect(globalThis.top.removeEventListener).toHaveBeenCalledWith("focus", undefined);
|
||||
expect(messenger.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,96 @@
|
||||
import {
|
||||
createAssertCredentialResultMock,
|
||||
createCreateCredentialResultMock,
|
||||
createCredentialCreationOptionsMock,
|
||||
createCredentialRequestOptionsMock,
|
||||
} from "../../../autofill/spec/fido2-testing-utils";
|
||||
import { WebauthnUtils } from "../webauthn-utils";
|
||||
|
||||
import { MessageType } from "./messaging/message";
|
||||
import { Messenger } from "./messaging/messenger";
|
||||
|
||||
let messenger: Messenger;
|
||||
jest.mock("./messaging/messenger", () => {
|
||||
return {
|
||||
Messenger: class extends jest.requireActual("./messaging/messenger").Messenger {
|
||||
static forDOMCommunication: any = jest.fn((window) => {
|
||||
const windowOrigin = window.location.origin;
|
||||
|
||||
messenger = new Messenger({
|
||||
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
|
||||
addEventListener: (listener) => window.addEventListener("message", listener),
|
||||
removeEventListener: (listener) => window.removeEventListener("message", listener),
|
||||
});
|
||||
messenger.destroy = jest.fn();
|
||||
return messenger;
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
jest.mock("../webauthn-utils");
|
||||
|
||||
describe("Fido2 page script without native WebAuthn support", () => {
|
||||
const mockCredentialCreationOptions = createCredentialCreationOptionsMock();
|
||||
const mockCreateCredentialsResult = createCreateCredentialResultMock();
|
||||
const mockCredentialRequestOptions = createCredentialRequestOptionsMock();
|
||||
const mockCredentialAssertResult = createAssertCredentialResultMock();
|
||||
require("./page-script");
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe("creating WebAuthn credentials", () => {
|
||||
beforeEach(() => {
|
||||
messenger.request = jest.fn().mockResolvedValue({
|
||||
type: MessageType.CredentialCreationResponse,
|
||||
result: mockCreateCredentialsResult,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates and returns a WebAuthn credential", async () => {
|
||||
await navigator.credentials.create(mockCredentialCreationOptions);
|
||||
|
||||
expect(WebauthnUtils.mapCredentialCreationOptions).toHaveBeenCalledWith(
|
||||
mockCredentialCreationOptions,
|
||||
false,
|
||||
);
|
||||
expect(WebauthnUtils.mapCredentialRegistrationResult).toHaveBeenCalledWith(
|
||||
mockCreateCredentialsResult,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get WebAuthn credentials", () => {
|
||||
beforeEach(() => {
|
||||
messenger.request = jest.fn().mockResolvedValue({
|
||||
type: MessageType.CredentialGetResponse,
|
||||
result: mockCredentialAssertResult,
|
||||
});
|
||||
});
|
||||
|
||||
it("gets and returns the WebAuthn credentials", async () => {
|
||||
await navigator.credentials.get(mockCredentialRequestOptions);
|
||||
|
||||
expect(WebauthnUtils.mapCredentialRequestOptions).toHaveBeenCalledWith(
|
||||
mockCredentialRequestOptions,
|
||||
false,
|
||||
);
|
||||
expect(WebauthnUtils.mapCredentialAssertResult).toHaveBeenCalledWith(
|
||||
mockCredentialAssertResult,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroy", () => {
|
||||
it("should destroy the message listener when receiving a disconnect request", async () => {
|
||||
jest.spyOn(globalThis.top, "removeEventListener");
|
||||
const SENDER = "bitwarden-webauthn";
|
||||
void messenger.handler({ type: MessageType.DisconnectRequest, SENDER, senderId: "1" });
|
||||
|
||||
expect(globalThis.top.removeEventListener).toHaveBeenCalledWith("focus", undefined);
|
||||
expect(messenger.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
@ -1,16 +0,0 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -1,5 +0,0 @@
|
||||
(function () {
|
||||
// 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.runtime.sendMessage({ command: "triggerFido2ContentScriptInjection" });
|
||||
})();
|
@ -0,0 +1,10 @@
|
||||
export const Fido2ContentScript = {
|
||||
PageScript: "content/fido2/page-script.js",
|
||||
PageScriptAppend: "content/fido2/page-script-append-mv2.js",
|
||||
ContentScript: "content/fido2/content-script.js",
|
||||
} as const;
|
||||
|
||||
export const Fido2ContentScriptId = {
|
||||
PageScript: "fido2-page-script-registration",
|
||||
ContentScript: "fido2-content-script-registration",
|
||||
} as const;
|
@ -0,0 +1,3 @@
|
||||
export const Fido2PortName = {
|
||||
InjectedScript: "fido2-injected-content-script-port",
|
||||
} as const;
|
@ -40,12 +40,22 @@
|
||||
*ngIf="
|
||||
(unassignedItemsBannerEnabled$ | async) &&
|
||||
(unassignedItemsBannerService.showBanner$ | async) &&
|
||||
(unassignedItemsBannerService.bannerText$ | async)
|
||||
!(unassignedItemsBannerService.loading$ | async)
|
||||
"
|
||||
type="info"
|
||||
>
|
||||
<p>
|
||||
{{ unassignedItemsBannerService.bannerText$ | async | i18n }}
|
||||
{{ "unassignedItemsBannerCTAPartOne" | i18n }}
|
||||
<a
|
||||
[href]="unassignedItemsBannerService.adminConsoleUrl$ | async"
|
||||
bitLink
|
||||
linkType="contrast"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>{{ "adminConsole" | i18n }}</a
|
||||
>
|
||||
{{ "unassignedItemsBannerCTAPartTwo" | i18n }}
|
||||
<a
|
||||
href="https://bitwarden.com/help/unassigned-vault-items-moved-to-admin-console"
|
||||
bitLink
|
||||
|
@ -1,4 +0,0 @@
|
||||
export abstract class Fido2Service {
|
||||
init: () => Promise<void>;
|
||||
injectFido2ContentScripts: (sender: chrome.runtime.MessageSender) => Promise<void>;
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -1,35 +0,0 @@
|
||||
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")) {
|
||||
// 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.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",
|
||||
});
|
||||
}
|
||||
}
|
@ -68,6 +68,8 @@ const tabs = {
|
||||
|
||||
const scripting = {
|
||||
executeScript: jest.fn(),
|
||||
registerContentScripts: jest.fn(),
|
||||
unregisterContentScripts: jest.fn(),
|
||||
};
|
||||
|
||||
const windows = {
|
||||
@ -124,9 +126,19 @@ const offscreen = {
|
||||
},
|
||||
};
|
||||
|
||||
const permissions = {
|
||||
contains: jest.fn((permissions, callback) => {
|
||||
callback(true);
|
||||
}),
|
||||
};
|
||||
|
||||
const webNavigation = {
|
||||
getFrame: jest.fn(),
|
||||
getAllFrames: jest.fn(),
|
||||
onCommitted: {
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// set chrome
|
||||
@ -142,5 +154,6 @@ global.chrome = {
|
||||
privacy,
|
||||
extension,
|
||||
offscreen,
|
||||
permissions,
|
||||
webNavigation,
|
||||
} as any;
|
||||
|
@ -9,6 +9,7 @@
|
||||
"allowJs": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"lib": ["ES2021.String"],
|
||||
"paths": {
|
||||
"@bitwarden/admin-console": ["../../libs/admin-console/src"],
|
||||
"@bitwarden/angular/*": ["../../libs/angular/src/*"],
|
||||
|
@ -171,8 +171,6 @@ const mainConfig = {
|
||||
"content/notificationBar": "./src/autofill/content/notification-bar.ts",
|
||||
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
|
||||
"content/content-message-handler": "./src/autofill/content/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",
|
||||
@ -284,6 +282,8 @@ if (manifestVersion == 2) {
|
||||
mainConfig.entry.background = "./src/platform/background.ts";
|
||||
mainConfig.entry["content/lp-suppress-import-download-script-append-mv2"] =
|
||||
"./src/tools/content/lp-suppress-import-download-script-append.mv2.ts";
|
||||
mainConfig.entry["content/fido2/page-script-append-mv2"] =
|
||||
"./src/vault/fido2/content/page-script-append.mv2.ts";
|
||||
|
||||
configs.push(mainConfig);
|
||||
} else {
|
||||
|
@ -49,5 +49,14 @@
|
||||
},
|
||||
"unsupportedEncryptedImport": {
|
||||
"message": "Importing encrypted files is currently not supported."
|
||||
},
|
||||
"importUnassignedItemsError": {
|
||||
"message": "File contains unassigned items."
|
||||
},
|
||||
"errorAssigningTargetCollection": {
|
||||
"message": "Error assigning target collection."
|
||||
},
|
||||
"errorAssigningTargetFolder": {
|
||||
"message": "Error assigning target folder."
|
||||
}
|
||||
}
|
||||
|
@ -24,7 +24,7 @@
|
||||
"**/node_modules/argon2/package.json",
|
||||
"**/node_modules/argon2/lib/binding/napi-v3/argon2.node"
|
||||
],
|
||||
"electronVersion": "28.2.8",
|
||||
"electronVersion": "28.3.1",
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2024.4.1",
|
||||
"version": "2024.4.2",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
|
@ -14,6 +14,7 @@ import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
@ -27,6 +28,7 @@ import { DialogService } from "@bitwarden/components";
|
||||
import { SetPinComponent } from "../../auth/components/set-pin.component";
|
||||
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
|
||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||
import { NativeMessagingManifestService } from "../services/native-messaging-manifest.service";
|
||||
|
||||
@Component({
|
||||
selector: "app-settings",
|
||||
@ -126,6 +128,8 @@ export class SettingsComponent implements OnInit {
|
||||
private biometricStateService: BiometricStateService,
|
||||
private desktopAutofillSettingsService: DesktopAutofillSettingsService,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private nativeMessagingManifestService: NativeMessagingManifestService,
|
||||
) {
|
||||
const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
|
||||
|
||||
@ -628,11 +632,20 @@ export class SettingsComponent implements OnInit {
|
||||
}
|
||||
|
||||
await this.stateService.setEnableBrowserIntegration(this.form.value.enableBrowserIntegration);
|
||||
this.messagingService.send(
|
||||
this.form.value.enableBrowserIntegration
|
||||
? "enableBrowserIntegration"
|
||||
: "disableBrowserIntegration",
|
||||
|
||||
const errorResult = await this.nativeMessagingManifestService.generate(
|
||||
this.form.value.enableBrowserIntegration,
|
||||
);
|
||||
if (errorResult !== null) {
|
||||
this.logService.error("Error in browser integration: " + errorResult);
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: "browserIntegrationErrorTitle" },
|
||||
content: { key: "browserIntegrationErrorDesc" },
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: null,
|
||||
type: "danger",
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.form.value.enableBrowserIntegration) {
|
||||
this.form.controls.enableBrowserIntegrationFingerprint.setValue(false);
|
||||
@ -651,11 +664,19 @@ export class SettingsComponent implements OnInit {
|
||||
await this.stateService.setDuckDuckGoSharedKey(null);
|
||||
}
|
||||
|
||||
this.messagingService.send(
|
||||
this.form.value.enableDuckDuckGoBrowserIntegration
|
||||
? "enableDuckDuckGoBrowserIntegration"
|
||||
: "disableDuckDuckGoBrowserIntegration",
|
||||
const errorResult = await this.nativeMessagingManifestService.generateDuckDuckGo(
|
||||
this.form.value.enableDuckDuckGoBrowserIntegration,
|
||||
);
|
||||
if (errorResult !== null) {
|
||||
this.logService.error("Error in DDG browser integration: " + errorResult);
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: "browserIntegrationUnsupportedTitle" },
|
||||
content: errorResult.message,
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: null,
|
||||
type: "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async saveBrowserIntegrationFingerprint() {
|
||||
|
@ -0,0 +1,13 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
@Injectable()
|
||||
export class NativeMessagingManifestService {
|
||||
constructor() {}
|
||||
|
||||
async generate(create: boolean): Promise<Error | null> {
|
||||
return ipc.platform.nativeMessaging.manifests.generate(create);
|
||||
}
|
||||
async generateDuckDuckGo(create: boolean): Promise<Error | null> {
|
||||
return ipc.platform.nativeMessaging.manifests.generateDuckDuckGo(create);
|
||||
}
|
||||
}
|
@ -76,6 +76,7 @@ import { SearchBarService } from "../layout/search/search-bar.service";
|
||||
|
||||
import { DesktopFileDownloadService } from "./desktop-file-download.service";
|
||||
import { InitService } from "./init.service";
|
||||
import { NativeMessagingManifestService } from "./native-messaging-manifest.service";
|
||||
import { RendererCryptoFunctionService } from "./renderer-crypto-function.service";
|
||||
|
||||
const RELOAD_CALLBACK = new SafeInjectionToken<() => any>("RELOAD_CALLBACK");
|
||||
@ -249,6 +250,11 @@ const safeProviders: SafeProvider[] = [
|
||||
provide: DesktopAutofillSettingsService,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: NativeMessagingManifestService,
|
||||
useClass: NativeMessagingManifestService,
|
||||
deps: [],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@ -1632,6 +1632,12 @@
|
||||
"browserIntegrationUnsupportedTitle": {
|
||||
"message": "Browser integration not supported"
|
||||
},
|
||||
"browserIntegrationErrorTitle": {
|
||||
"message": "Error enabling browser integration"
|
||||
},
|
||||
"browserIntegrationErrorDesc": {
|
||||
"message": "An error has occurred while enabling browser integration."
|
||||
},
|
||||
"browserIntegrationMasOnlyDesc": {
|
||||
"message": "Unfortunately browser integration is only supported in the Mac App Store version for now."
|
||||
},
|
||||
@ -2705,5 +2711,11 @@
|
||||
},
|
||||
"passkeyRemoved": {
|
||||
"message": "Passkey removed"
|
||||
},
|
||||
"errorAssigningTargetCollection": {
|
||||
"message": "Error assigning target collection."
|
||||
},
|
||||
"errorAssigningTargetFolder": {
|
||||
"message": "Error assigning target folder."
|
||||
}
|
||||
}
|
||||
|
@ -291,12 +291,20 @@ export class Main {
|
||||
this.powerMonitorMain.init();
|
||||
await this.updaterMain.init();
|
||||
|
||||
if (
|
||||
(await this.stateService.getEnableBrowserIntegration()) ||
|
||||
(await firstValueFrom(
|
||||
this.desktopAutofillSettingsService.enableDuckDuckGoBrowserIntegration$,
|
||||
))
|
||||
) {
|
||||
const [browserIntegrationEnabled, ddgIntegrationEnabled] = await Promise.all([
|
||||
this.stateService.getEnableBrowserIntegration(),
|
||||
firstValueFrom(this.desktopAutofillSettingsService.enableDuckDuckGoBrowserIntegration$),
|
||||
]);
|
||||
|
||||
if (browserIntegrationEnabled || ddgIntegrationEnabled) {
|
||||
// Re-register the native messaging host integrations on startup, in case they are not present
|
||||
if (browserIntegrationEnabled) {
|
||||
this.nativeMessagingMain.generateManifests().catch(this.logService.error);
|
||||
}
|
||||
if (ddgIntegrationEnabled) {
|
||||
this.nativeMessagingMain.generateDdgManifests().catch(this.logService.error);
|
||||
}
|
||||
|
||||
this.nativeMessagingMain.listen();
|
||||
}
|
||||
|
||||
|
@ -75,22 +75,6 @@ export class MessagingMain {
|
||||
case "getWindowIsFocused":
|
||||
this.windowIsFocused();
|
||||
break;
|
||||
case "enableBrowserIntegration":
|
||||
this.main.nativeMessagingMain.generateManifests();
|
||||
this.main.nativeMessagingMain.listen();
|
||||
break;
|
||||
case "enableDuckDuckGoBrowserIntegration":
|
||||
this.main.nativeMessagingMain.generateDdgManifests();
|
||||
this.main.nativeMessagingMain.listen();
|
||||
break;
|
||||
case "disableBrowserIntegration":
|
||||
this.main.nativeMessagingMain.removeManifests();
|
||||
this.main.nativeMessagingMain.stop();
|
||||
break;
|
||||
case "disableDuckDuckGoBrowserIntegration":
|
||||
this.main.nativeMessagingMain.removeDdgManifests();
|
||||
this.main.nativeMessagingMain.stop();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -22,7 +22,55 @@ export class NativeMessagingMain {
|
||||
private windowMain: WindowMain,
|
||||
private userPath: string,
|
||||
private exePath: string,
|
||||
) {}
|
||||
) {
|
||||
ipcMain.handle(
|
||||
"nativeMessaging.manifests",
|
||||
async (_event: any, options: { create: boolean }) => {
|
||||
if (options.create) {
|
||||
this.listen();
|
||||
try {
|
||||
await this.generateManifests();
|
||||
} catch (e) {
|
||||
this.logService.error("Error generating manifests: " + e);
|
||||
return e;
|
||||
}
|
||||
} else {
|
||||
this.stop();
|
||||
try {
|
||||
await this.removeManifests();
|
||||
} catch (e) {
|
||||
this.logService.error("Error removing manifests: " + e);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"nativeMessaging.ddgManifests",
|
||||
async (_event: any, options: { create: boolean }) => {
|
||||
if (options.create) {
|
||||
this.listen();
|
||||
try {
|
||||
await this.generateDdgManifests();
|
||||
} catch (e) {
|
||||
this.logService.error("Error generating duckduckgo manifests: " + e);
|
||||
return e;
|
||||
}
|
||||
} else {
|
||||
this.stop();
|
||||
try {
|
||||
await this.removeDdgManifests();
|
||||
} catch (e) {
|
||||
this.logService.error("Error removing duckduckgo manifests: " + e);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
listen() {
|
||||
ipc.config.id = "bitwarden";
|
||||
@ -76,7 +124,7 @@ export class NativeMessagingMain {
|
||||
ipc.server.emit(socket, "message", message);
|
||||
}
|
||||
|
||||
generateManifests() {
|
||||
async generateManifests() {
|
||||
const baseJson = {
|
||||
name: "com.8bit.bitwarden",
|
||||
description: "Bitwarden desktop <-> browser bridge",
|
||||
@ -84,6 +132,10 @@ export class NativeMessagingMain {
|
||||
type: "stdio",
|
||||
};
|
||||
|
||||
if (!existsSync(baseJson.path)) {
|
||||
throw new Error(`Unable to find binary: ${baseJson.path}`);
|
||||
}
|
||||
|
||||
const firefoxJson = {
|
||||
...baseJson,
|
||||
...{ allowed_extensions: ["{446900e4-71c2-419f-a6a7-df9c091e268b}"] },
|
||||
@ -92,8 +144,11 @@ export class NativeMessagingMain {
|
||||
...baseJson,
|
||||
...{
|
||||
allowed_origins: [
|
||||
// Chrome extension
|
||||
"chrome-extension://nngceckbapebfimnlniiiahkandclblb/",
|
||||
// Edge extension
|
||||
"chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh/",
|
||||
// Opera extension
|
||||
"chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn/",
|
||||
],
|
||||
},
|
||||
@ -102,27 +157,17 @@ export class NativeMessagingMain {
|
||||
switch (process.platform) {
|
||||
case "win32": {
|
||||
const destination = path.join(this.userPath, "browsers");
|
||||
// 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.writeManifest(path.join(destination, "firefox.json"), firefoxJson);
|
||||
// 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.writeManifest(path.join(destination, "chrome.json"), chromeJson);
|
||||
await this.writeManifest(path.join(destination, "firefox.json"), firefoxJson);
|
||||
await this.writeManifest(path.join(destination, "chrome.json"), chromeJson);
|
||||
|
||||
// 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.createWindowsRegistry(
|
||||
"HKLM\\SOFTWARE\\Mozilla\\Firefox",
|
||||
"HKCU\\SOFTWARE\\Mozilla\\NativeMessagingHosts\\com.8bit.bitwarden",
|
||||
path.join(destination, "firefox.json"),
|
||||
);
|
||||
// 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.createWindowsRegistry(
|
||||
"HKCU\\SOFTWARE\\Google\\Chrome",
|
||||
"HKCU\\SOFTWARE\\Google\\Chrome\\NativeMessagingHosts\\com.8bit.bitwarden",
|
||||
path.join(destination, "chrome.json"),
|
||||
);
|
||||
const nmhs = this.getWindowsNMHS();
|
||||
for (const [key, value] of Object.entries(nmhs)) {
|
||||
let manifestPath = path.join(destination, "chrome.json");
|
||||
if (key === "Firefox") {
|
||||
manifestPath = path.join(destination, "firefox.json");
|
||||
}
|
||||
await this.createWindowsRegistry(value, manifestPath);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "darwin": {
|
||||
@ -136,38 +181,30 @@ export class NativeMessagingMain {
|
||||
manifest = firefoxJson;
|
||||
}
|
||||
|
||||
this.writeManifest(p, manifest).catch((e) =>
|
||||
this.logService.error(`Error writing manifest for ${key}. ${e}`),
|
||||
);
|
||||
await this.writeManifest(p, manifest);
|
||||
} else {
|
||||
this.logService.warning(`${key} not found skipping.`);
|
||||
this.logService.warning(`${key} not found, skipping.`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "linux":
|
||||
if (existsSync(`${this.homedir()}/.mozilla/`)) {
|
||||
// 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.writeManifest(
|
||||
await this.writeManifest(
|
||||
`${this.homedir()}/.mozilla/native-messaging-hosts/com.8bit.bitwarden.json`,
|
||||
firefoxJson,
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(`${this.homedir()}/.config/google-chrome/`)) {
|
||||
// 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.writeManifest(
|
||||
await this.writeManifest(
|
||||
`${this.homedir()}/.config/google-chrome/NativeMessagingHosts/com.8bit.bitwarden.json`,
|
||||
chromeJson,
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(`${this.homedir()}/.config/microsoft-edge/`)) {
|
||||
// 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.writeManifest(
|
||||
await this.writeManifest(
|
||||
`${this.homedir()}/.config/microsoft-edge/NativeMessagingHosts/com.8bit.bitwarden.json`,
|
||||
chromeJson,
|
||||
);
|
||||
@ -178,20 +215,23 @@ export class NativeMessagingMain {
|
||||
}
|
||||
}
|
||||
|
||||
generateDdgManifests() {
|
||||
async generateDdgManifests() {
|
||||
const manifest = {
|
||||
name: "com.8bit.bitwarden",
|
||||
description: "Bitwarden desktop <-> DuckDuckGo bridge",
|
||||
path: this.binaryPath(),
|
||||
type: "stdio",
|
||||
};
|
||||
|
||||
if (!existsSync(manifest.path)) {
|
||||
throw new Error(`Unable to find binary: ${manifest.path}`);
|
||||
}
|
||||
|
||||
switch (process.platform) {
|
||||
case "darwin": {
|
||||
/* eslint-disable-next-line no-useless-escape */
|
||||
const path = `${this.homedir()}/Library/Containers/com.duckduckgo.macos.browser/Data/Library/Application\ Support/NativeMessagingHosts/com.8bit.bitwarden.json`;
|
||||
this.writeManifest(path, manifest).catch((e) =>
|
||||
this.logService.error(`Error writing manifest for DuckDuckGo. ${e}`),
|
||||
);
|
||||
await this.writeManifest(path, manifest);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@ -199,86 +239,50 @@ export class NativeMessagingMain {
|
||||
}
|
||||
}
|
||||
|
||||
removeManifests() {
|
||||
async removeManifests() {
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
// 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
|
||||
fs.unlink(path.join(this.userPath, "browsers", "firefox.json"));
|
||||
// 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
|
||||
fs.unlink(path.join(this.userPath, "browsers", "chrome.json"));
|
||||
// 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.deleteWindowsRegistry(
|
||||
"HKCU\\SOFTWARE\\Mozilla\\NativeMessagingHosts\\com.8bit.bitwarden",
|
||||
);
|
||||
// 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.deleteWindowsRegistry(
|
||||
"HKCU\\SOFTWARE\\Google\\Chrome\\NativeMessagingHosts\\com.8bit.bitwarden",
|
||||
);
|
||||
case "win32": {
|
||||
await this.removeIfExists(path.join(this.userPath, "browsers", "firefox.json"));
|
||||
await this.removeIfExists(path.join(this.userPath, "browsers", "chrome.json"));
|
||||
|
||||
const nmhs = this.getWindowsNMHS();
|
||||
for (const [, value] of Object.entries(nmhs)) {
|
||||
await this.deleteWindowsRegistry(value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "darwin": {
|
||||
const nmhs = this.getDarwinNMHS();
|
||||
for (const [, value] of Object.entries(nmhs)) {
|
||||
const p = path.join(value, "NativeMessagingHosts", "com.8bit.bitwarden.json");
|
||||
if (existsSync(p)) {
|
||||
// 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
|
||||
fs.unlink(p);
|
||||
}
|
||||
await this.removeIfExists(
|
||||
path.join(value, "NativeMessagingHosts", "com.8bit.bitwarden.json"),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "linux":
|
||||
if (
|
||||
existsSync(`${this.homedir()}/.mozilla/native-messaging-hosts/com.8bit.bitwarden.json`)
|
||||
) {
|
||||
// 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
|
||||
fs.unlink(`${this.homedir()}/.mozilla/native-messaging-hosts/com.8bit.bitwarden.json`);
|
||||
}
|
||||
|
||||
if (
|
||||
existsSync(
|
||||
`${this.homedir()}/.config/google-chrome/NativeMessagingHosts/com.8bit.bitwarden.json`,
|
||||
)
|
||||
) {
|
||||
// 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
|
||||
fs.unlink(
|
||||
`${this.homedir()}/.config/google-chrome/NativeMessagingHosts/com.8bit.bitwarden.json`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
existsSync(
|
||||
`${this.homedir()}/.config/microsoft-edge/NativeMessagingHosts/com.8bit.bitwarden.json`,
|
||||
)
|
||||
) {
|
||||
// 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
|
||||
fs.unlink(
|
||||
`${this.homedir()}/.config/microsoft-edge/NativeMessagingHosts/com.8bit.bitwarden.json`,
|
||||
);
|
||||
}
|
||||
case "linux": {
|
||||
await this.removeIfExists(
|
||||
`${this.homedir()}/.mozilla/native-messaging-hosts/com.8bit.bitwarden.json`,
|
||||
);
|
||||
await this.removeIfExists(
|
||||
`${this.homedir()}/.config/google-chrome/NativeMessagingHosts/com.8bit.bitwarden.json`,
|
||||
);
|
||||
await this.removeIfExists(
|
||||
`${this.homedir()}/.config/microsoft-edge/NativeMessagingHosts/com.8bit.bitwarden.json`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
removeDdgManifests() {
|
||||
async removeDdgManifests() {
|
||||
switch (process.platform) {
|
||||
case "darwin": {
|
||||
/* eslint-disable-next-line no-useless-escape */
|
||||
const path = `${this.homedir()}/Library/Containers/com.duckduckgo.macos.browser/Data/Library/Application\ Support/NativeMessagingHosts/com.8bit.bitwarden.json`;
|
||||
if (existsSync(path)) {
|
||||
// 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
|
||||
fs.unlink(path);
|
||||
}
|
||||
await this.removeIfExists(path);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@ -286,6 +290,16 @@ export class NativeMessagingMain {
|
||||
}
|
||||
}
|
||||
|
||||
private getWindowsNMHS() {
|
||||
return {
|
||||
Firefox: "HKCU\\SOFTWARE\\Mozilla\\NativeMessagingHosts\\com.8bit.bitwarden",
|
||||
Chrome: "HKCU\\SOFTWARE\\Google\\Chrome\\NativeMessagingHosts\\com.8bit.bitwarden",
|
||||
Chromium: "HKCU\\SOFTWARE\\Chromium\\NativeMessagingHosts\\com.8bit.bitwarden",
|
||||
// Edge uses the same registry key as Chrome as a fallback, but it's has its own separate key as well.
|
||||
"Microsoft Edge": "HKCU\\SOFTWARE\\Microsoft\\Edge\\NativeMessagingHosts\\com.8bit.bitwarden",
|
||||
};
|
||||
}
|
||||
|
||||
private getDarwinNMHS() {
|
||||
/* eslint-disable no-useless-escape */
|
||||
return {
|
||||
@ -305,10 +319,13 @@ export class NativeMessagingMain {
|
||||
}
|
||||
|
||||
private async writeManifest(destination: string, manifest: object) {
|
||||
this.logService.debug(`Writing manifest: ${destination}`);
|
||||
|
||||
if (!existsSync(path.dirname(destination))) {
|
||||
await fs.mkdir(path.dirname(destination));
|
||||
}
|
||||
fs.writeFile(destination, JSON.stringify(manifest, null, 2)).catch(this.logService.error);
|
||||
|
||||
await fs.writeFile(destination, JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
|
||||
private binaryPath() {
|
||||
@ -327,39 +344,26 @@ export class NativeMessagingMain {
|
||||
return regedit;
|
||||
}
|
||||
|
||||
private async createWindowsRegistry(check: string, location: string, jsonFile: string) {
|
||||
private async createWindowsRegistry(location: string, jsonFile: string) {
|
||||
const regedit = this.getRegeditInstance();
|
||||
|
||||
const list = util.promisify(regedit.list);
|
||||
const createKey = util.promisify(regedit.createKey);
|
||||
const putValue = util.promisify(regedit.putValue);
|
||||
|
||||
this.logService.debug(`Adding registry: ${location}`);
|
||||
|
||||
// Check installed
|
||||
try {
|
||||
await list(check);
|
||||
} catch {
|
||||
this.logService.warning(`Not finding registry ${check} skipping.`);
|
||||
return;
|
||||
}
|
||||
await createKey(location);
|
||||
|
||||
try {
|
||||
await createKey(location);
|
||||
// Insert path to manifest
|
||||
const obj: any = {};
|
||||
obj[location] = {
|
||||
default: {
|
||||
value: jsonFile,
|
||||
type: "REG_DEFAULT",
|
||||
},
|
||||
};
|
||||
|
||||
// Insert path to manifest
|
||||
const obj: any = {};
|
||||
obj[location] = {
|
||||
default: {
|
||||
value: jsonFile,
|
||||
type: "REG_DEFAULT",
|
||||
},
|
||||
};
|
||||
|
||||
return putValue(obj);
|
||||
} catch (error) {
|
||||
this.logService.error(error);
|
||||
}
|
||||
return putValue(obj);
|
||||
}
|
||||
|
||||
private async deleteWindowsRegistry(key: string) {
|
||||
@ -385,4 +389,10 @@ export class NativeMessagingMain {
|
||||
return homedir();
|
||||
}
|
||||
}
|
||||
|
||||
private async removeIfExists(path: string) {
|
||||
if (existsSync(path)) {
|
||||
await fs.unlink(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2024.4.1",
|
||||
"version": "2024.4.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2024.4.1",
|
||||
"version": "2024.4.2",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@bitwarden/desktop-native": "file:../desktop_native",
|
||||
|
@ -2,7 +2,7 @@
|
||||
"name": "@bitwarden/desktop",
|
||||
"productName": "Bitwarden",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2024.4.1",
|
||||
"version": "2024.4.2",
|
||||
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
|
||||
"homepage": "https://bitwarden.com",
|
||||
"license": "GPL-3.0",
|
||||
|
@ -74,6 +74,13 @@ const nativeMessaging = {
|
||||
onMessage: (callback: (message: LegacyMessageWrapper | Message) => void) => {
|
||||
ipcRenderer.on("nativeMessaging", (_event, message) => callback(message));
|
||||
},
|
||||
|
||||
manifests: {
|
||||
generate: (create: boolean): Promise<Error | null> =>
|
||||
ipcRenderer.invoke("nativeMessaging.manifests", { create }),
|
||||
generateDuckDuckGo: (create: boolean): Promise<Error | null> =>
|
||||
ipcRenderer.invoke("nativeMessaging.ddgManifests", { create }),
|
||||
},
|
||||
};
|
||||
|
||||
const crypto = {
|
||||
|
@ -385,7 +385,12 @@
|
||||
</h3>
|
||||
<p class="tw-text-muted">{{ "secretsManagerAccessDescription" | i18n }}</p>
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="accessSecretsManager" />
|
||||
<input
|
||||
type="checkbox"
|
||||
[disabled]="isOnSecretsManagerStandalone"
|
||||
bitCheckbox
|
||||
formControlName="accessSecretsManager"
|
||||
/>
|
||||
<bit-label>
|
||||
{{ "userAccessSecretsManagerGA" | i18n }}
|
||||
</bit-label>
|
||||
|
@ -63,6 +63,7 @@ export interface MemberDialogParams {
|
||||
organizationUserId: string;
|
||||
allOrganizationUserEmails: string[];
|
||||
usesKeyConnector: boolean;
|
||||
isOnSecretsManagerStandalone: boolean;
|
||||
initialTab?: MemberDialogTab;
|
||||
numConfirmedMembers: number;
|
||||
}
|
||||
@ -88,6 +89,7 @@ export class MemberDialogComponent implements OnDestroy {
|
||||
organizationUserType = OrganizationUserType;
|
||||
PermissionMode = PermissionMode;
|
||||
showNoMasterPasswordWarning = false;
|
||||
isOnSecretsManagerStandalone: boolean;
|
||||
|
||||
protected organization$: Observable<Organization>;
|
||||
protected collectionAccessItems: AccessItemView[] = [];
|
||||
@ -160,6 +162,13 @@ export class MemberDialogComponent implements OnDestroy {
|
||||
this.editMode = this.params.organizationUserId != null;
|
||||
this.tabIndex = this.params.initialTab ?? MemberDialogTab.Role;
|
||||
this.title = this.i18nService.t(this.editMode ? "editMember" : "inviteMember");
|
||||
this.isOnSecretsManagerStandalone = this.params.isOnSecretsManagerStandalone;
|
||||
|
||||
if (this.isOnSecretsManagerStandalone) {
|
||||
this.formGroup.patchValue({
|
||||
accessSecretsManager: true,
|
||||
});
|
||||
}
|
||||
|
||||
const groups$ = this.organization$.pipe(
|
||||
switchMap((organization) =>
|
||||
|
@ -37,6 +37,7 @@ import {
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
|
||||
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { ProductType } from "@bitwarden/common/enums";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
@ -93,6 +94,7 @@ export class PeopleComponent extends BasePeopleComponent<OrganizationUserView> {
|
||||
organization: Organization;
|
||||
status: OrganizationUserStatusType = null;
|
||||
orgResetPasswordPolicyEnabled = false;
|
||||
orgIsOnSecretsManagerStandalone = false;
|
||||
|
||||
protected canUseSecretsManager$: Observable<boolean>;
|
||||
|
||||
@ -119,6 +121,7 @@ export class PeopleComponent extends BasePeopleComponent<OrganizationUserView> {
|
||||
private groupService: GroupService,
|
||||
private collectionService: CollectionService,
|
||||
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
|
||||
private organizationBillingService: OrganizationBillingService,
|
||||
) {
|
||||
super(
|
||||
apiService,
|
||||
@ -187,6 +190,11 @@ export class PeopleComponent extends BasePeopleComponent<OrganizationUserView> {
|
||||
.find((p) => p.organizationId === this.organization.id);
|
||||
this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled;
|
||||
|
||||
this.orgIsOnSecretsManagerStandalone =
|
||||
await this.organizationBillingService.isOnSecretsManagerStandalone(
|
||||
this.organization.id,
|
||||
);
|
||||
|
||||
await this.load();
|
||||
|
||||
this.searchText = qParams.search;
|
||||
@ -446,6 +454,7 @@ export class PeopleComponent extends BasePeopleComponent<OrganizationUserView> {
|
||||
organizationUserId: user != null ? user.id : null,
|
||||
allOrganizationUserEmails: this.allUsers?.map((user) => user.email) ?? [],
|
||||
usesKeyConnector: user?.usesKeyConnector,
|
||||
isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone,
|
||||
initialTab: initialTab,
|
||||
numConfirmedMembers: this.confirmedCount,
|
||||
},
|
||||
|
@ -1,88 +0,0 @@
|
||||
<div class="container page-content">
|
||||
<div class="row">
|
||||
<div class="col-3">
|
||||
<div class="card">
|
||||
<div class="card-header">{{ "settings" | i18n }}</div>
|
||||
<div class="list-group list-group-flush" *ngIf="organization$ | async as org">
|
||||
<a
|
||||
routerLink="account"
|
||||
class="list-group-item"
|
||||
routerLinkActive="active"
|
||||
*ngIf="org.isOwner"
|
||||
>
|
||||
{{ "organizationInfo" | i18n }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="policies"
|
||||
class="list-group-item"
|
||||
routerLinkActive="active"
|
||||
*ngIf="org.canManagePolicies"
|
||||
>
|
||||
{{ "policies" | i18n }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="two-factor"
|
||||
class="list-group-item"
|
||||
routerLinkActive="active"
|
||||
*ngIf="org.use2fa && org.isOwner"
|
||||
>
|
||||
{{ "twoStepLogin" | i18n }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="tools/import"
|
||||
class="list-group-item"
|
||||
routerLinkActive="active"
|
||||
*ngIf="org.canAccessImportExport"
|
||||
>
|
||||
{{ "importData" | i18n }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="tools/export"
|
||||
class="list-group-item"
|
||||
routerLinkActive="active"
|
||||
*ngIf="org.canAccessImportExport"
|
||||
>
|
||||
{{ "exportVault" | i18n }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="domain-verification"
|
||||
class="list-group-item"
|
||||
routerLinkActive="active"
|
||||
*ngIf="org?.canManageDomainVerification"
|
||||
>
|
||||
{{ "domainVerification" | i18n }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="sso"
|
||||
class="list-group-item"
|
||||
routerLinkActive="active"
|
||||
*ngIf="org.canManageSso"
|
||||
>
|
||||
{{ "singleSignOn" | i18n }}
|
||||
</a>
|
||||
<ng-container>
|
||||
<a
|
||||
routerLink="device-approvals"
|
||||
class="list-group-item"
|
||||
routerLinkActive="active"
|
||||
*ngIf="org.canManageDeviceApprovals"
|
||||
>
|
||||
{{ "deviceApprovals" | i18n }}
|
||||
</a>
|
||||
</ng-container>
|
||||
<a
|
||||
routerLink="scim"
|
||||
class="list-group-item"
|
||||
routerLinkActive="active"
|
||||
*ngIf="org.canManageScim"
|
||||
>
|
||||
{{ "scim" | i18n }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,27 +0,0 @@
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { Observable, switchMap } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
@Component({
|
||||
selector: "app-org-settings",
|
||||
templateUrl: "settings.component.html",
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
organization$: Observable<Organization>;
|
||||
FeatureFlag = FeatureFlag;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private organizationService: OrganizationService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.organization$ = this.route.params.pipe(
|
||||
switchMap((params) => this.organizationService.get$(params.organizationId)),
|
||||
);
|
||||
}
|
||||
}
|
@ -4,10 +4,19 @@
|
||||
*ngIf="
|
||||
(unassignedItemsBannerEnabled$ | async) &&
|
||||
(unassignedItemsBannerService.showBanner$ | async) &&
|
||||
(unassignedItemsBannerService.bannerText$ | async)
|
||||
!(unassignedItemsBannerService.loading$ | async)
|
||||
"
|
||||
>
|
||||
{{ unassignedItemsBannerService.bannerText$ | async | i18n }}
|
||||
{{ "unassignedItemsBannerCTAPartOne" | i18n }}
|
||||
<a
|
||||
[href]="unassignedItemsBannerService.adminConsoleUrl$ | async"
|
||||
bitLink
|
||||
linkType="contrast"
|
||||
rel="noreferrer"
|
||||
>{{ "adminConsole" | i18n }}</a
|
||||
>
|
||||
{{ "unassignedItemsBannerCTAPartTwo" | i18n }}
|
||||
<a
|
||||
href="https://bitwarden.com/help/unassigned-vault-items-moved-to-admin-console"
|
||||
bitLink
|
||||
|
@ -92,6 +92,7 @@ export class SendComponent extends BaseSendComponent {
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.dialogService.closeAll();
|
||||
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
||||
}
|
||||
|
||||
|
@ -48,7 +48,7 @@
|
||||
></app-group-badge>
|
||||
</td>
|
||||
<td bitCell [ngClass]="RowHeightClass" *ngIf="showPermissionsColumn">
|
||||
<p class="tw-mb-0 tw-text-muted">
|
||||
<p class="tw-mb-0 tw-text-muted" [title]="permissionTooltip">
|
||||
{{ permissionText }}
|
||||
</p>
|
||||
</td>
|
||||
|
@ -6,6 +6,7 @@ import { CollectionView } from "@bitwarden/common/vault/models/view/collection.v
|
||||
|
||||
import { GroupView } from "../../../admin-console/organizations/core";
|
||||
import { CollectionAdminView } from "../../core/views/collection-admin.view";
|
||||
import { Unassigned } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model";
|
||||
|
||||
import {
|
||||
convertToPermission,
|
||||
@ -52,8 +53,8 @@ export class VaultCollectionRowComponent {
|
||||
}
|
||||
|
||||
get permissionText() {
|
||||
if (!(this.collection as CollectionAdminView).assigned) {
|
||||
return "-";
|
||||
if (this.collection.id != Unassigned && !(this.collection as CollectionAdminView).assigned) {
|
||||
return this.i18nService.t("noAccess");
|
||||
} else {
|
||||
const permissionList = getPermissionList(this.organization?.flexibleCollections);
|
||||
return this.i18nService.t(
|
||||
@ -62,6 +63,13 @@ export class VaultCollectionRowComponent {
|
||||
}
|
||||
}
|
||||
|
||||
get permissionTooltip() {
|
||||
if (this.collection.id == Unassigned) {
|
||||
return this.i18nService.t("collectionAdminConsoleManaged");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
protected edit() {
|
||||
this.onEvent.next({ type: "editCollection", item: this.collection });
|
||||
}
|
||||
|
@ -7900,15 +7900,23 @@
|
||||
"machineAccountAccessUpdated": {
|
||||
"message": "Machine account access updated"
|
||||
},
|
||||
"unassignedItemsBanner": {
|
||||
"message": "Notice: Unassigned organization items are no longer visible in your All Vaults view across devices and are now only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible."
|
||||
},
|
||||
"unassignedItemsBannerSelfHost": {
|
||||
"message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in your All Vaults view across devices and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible."
|
||||
},
|
||||
"restrictedGroupAccessDesc": {
|
||||
"message": "You cannot add yourself to a group."
|
||||
},
|
||||
"unassignedItemsBannerNotice": {
|
||||
"message": "Notice: Unassigned organization items are no longer visible in your All Vaults view across devices and are now only accessible via the Admin Console."
|
||||
},
|
||||
"unassignedItemsBannerSelfHostNotice": {
|
||||
"message": "Notice: On May 16, 2024, unassigned organization items will no longer be visible in your All Vaults view across devices and will only be accessible via the Admin Console."
|
||||
},
|
||||
"unassignedItemsBannerCTAPartOne": {
|
||||
"message": "Assign these items to a collection from the",
|
||||
"description": "This will be part of a larger sentence, which will read like so: Assign these items to a collection from the Admin Console to make them visible."
|
||||
},
|
||||
"unassignedItemsBannerCTAPartTwo": {
|
||||
"message": "to make them visible.",
|
||||
"description": "This will be part of a larger sentence, which will read like so: Assign these items to a collection from the Admin Console to make them visible."
|
||||
},
|
||||
"deleteProvider": {
|
||||
"message": "Delete provider"
|
||||
},
|
||||
@ -7944,5 +7952,38 @@
|
||||
},
|
||||
"deleteProviderWarning": {
|
||||
"message": "Deleting your provider is permanent. It cannot be undone."
|
||||
},
|
||||
"errorAssigningTargetCollection": {
|
||||
"message": "Error assigning target collection."
|
||||
},
|
||||
"errorAssigningTargetFolder": {
|
||||
"message": "Error assigning target folder."
|
||||
},
|
||||
"createNewClientToManageAsProvider": {
|
||||
"message": "Create a new client organization to manage as a Provider. Additional seats will be reflected in the next billing cycle."
|
||||
},
|
||||
"selectAPlan": {
|
||||
"message": "Select a plan"
|
||||
},
|
||||
"thirtyFivePercentDiscount": {
|
||||
"message": "35% Discount"
|
||||
},
|
||||
"monthPerMember": {
|
||||
"message": "month per member"
|
||||
},
|
||||
"seats": {
|
||||
"message": "Seats"
|
||||
},
|
||||
"addOrganization": {
|
||||
"message": "Add organization"
|
||||
},
|
||||
"createdNewClient": {
|
||||
"message": "Successfully created new client"
|
||||
},
|
||||
"noAccess": {
|
||||
"message": "No access"
|
||||
},
|
||||
"collectionAdminConsoleManaged": {
|
||||
"message": "This collection is only accessible from the admin console"
|
||||
}
|
||||
}
|
||||
|
@ -69,7 +69,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: "manage-client-organizations",
|
||||
component: ManageClientOrganizationsComponent,
|
||||
data: { titleId: "manage-client-organizations" },
|
||||
data: { titleId: "clients" },
|
||||
},
|
||||
{
|
||||
path: "manage",
|
||||
|
@ -4,13 +4,16 @@ import { FormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SearchModule } from "@bitwarden/components";
|
||||
import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component";
|
||||
import { OrganizationPlansComponent, TaxInfoComponent } from "@bitwarden/web-vault/app/billing";
|
||||
import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/shared";
|
||||
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
|
||||
|
||||
import { DangerZoneComponent } from "../../../../../../apps/web/src/app/auth/settings/account/danger-zone.component";
|
||||
import { ManageClientOrganizationSubscriptionComponent } from "../../billing/providers/clients/manage-client-organization-subscription.component";
|
||||
import { ManageClientOrganizationsComponent } from "../../billing/providers/clients/manage-client-organizations.component";
|
||||
import {
|
||||
CreateClientOrganizationComponent,
|
||||
ManageClientOrganizationSubscriptionComponent,
|
||||
ManageClientOrganizationsComponent,
|
||||
} from "../../billing/providers/clients";
|
||||
|
||||
import { AddOrganizationComponent } from "./clients/add-organization.component";
|
||||
import { ClientsComponent } from "./clients/clients.component";
|
||||
@ -56,6 +59,7 @@ import { SetupComponent } from "./setup/setup.component";
|
||||
SetupComponent,
|
||||
SetupProviderComponent,
|
||||
UserAddEditComponent,
|
||||
CreateClientOrganizationComponent,
|
||||
ManageClientOrganizationsComponent,
|
||||
ManageClientOrganizationSubscriptionComponent,
|
||||
],
|
||||
|
@ -1,8 +1,15 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
|
||||
import { ProviderAddOrganizationRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-add-organization.request";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { CreateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/create-client-organization.request";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
|
||||
@Injectable()
|
||||
@ -11,6 +18,9 @@ export class WebProviderService {
|
||||
private cryptoService: CryptoService,
|
||||
private syncService: SyncService,
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
private encryptService: EncryptService,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async addOrganizationToProvider(providerId: string, organizationId: string) {
|
||||
@ -28,6 +38,46 @@ export class WebProviderService {
|
||||
return response;
|
||||
}
|
||||
|
||||
async createClientOrganization(
|
||||
providerId: string,
|
||||
name: string,
|
||||
ownerEmail: string,
|
||||
planType: PlanType,
|
||||
seats: number,
|
||||
): Promise<void> {
|
||||
const organizationKey = (await this.cryptoService.makeOrgKey<OrgKey>())[1];
|
||||
|
||||
const [publicKey, encryptedPrivateKey] = await this.cryptoService.makeKeyPair(organizationKey);
|
||||
|
||||
const encryptedCollectionName = await this.encryptService.encrypt(
|
||||
this.i18nService.t("defaultCollection"),
|
||||
organizationKey,
|
||||
);
|
||||
|
||||
const providerKey = await this.cryptoService.getProviderKey(providerId);
|
||||
|
||||
const encryptedProviderKey = await this.encryptService.encrypt(
|
||||
organizationKey.key,
|
||||
providerKey,
|
||||
);
|
||||
|
||||
const request = new CreateClientOrganizationRequest();
|
||||
request.name = name;
|
||||
request.ownerEmail = ownerEmail;
|
||||
request.planType = planType;
|
||||
request.seats = seats;
|
||||
|
||||
request.key = encryptedProviderKey.encryptedString;
|
||||
request.keyPair = new OrganizationKeysRequest(publicKey, encryptedPrivateKey.encryptedString);
|
||||
request.collectionName = encryptedCollectionName.encryptedString;
|
||||
|
||||
await this.billingApiService.createClientOrganization(providerId, request);
|
||||
|
||||
await this.apiService.refreshIdentityToken();
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
|
||||
async detachOrganization(providerId: string, organizationId: string): Promise<any> {
|
||||
await this.apiService.deleteProviderOrganization(providerId, organizationId);
|
||||
await this.syncService.fullSync(true);
|
||||
|
@ -1,23 +0,0 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
|
||||
@Component({
|
||||
selector: "provider-settings",
|
||||
templateUrl: "settings.component.html",
|
||||
})
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
export class SettingsComponent {
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private providerService: ProviderService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent.params.subscribe(async (params) => {
|
||||
await this.providerService.get(params.providerId);
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog dialogSize="large">
|
||||
<span bitDialogTitle class="tw-font-semibold">
|
||||
{{ "newClientOrganization" | i18n }}
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<p>{{ "createNewClientToManageAsProvider" | i18n }}</p>
|
||||
<div class="tw-mb-3">
|
||||
<span class="tw-text-lg tw-pr-1">{{ "selectAPlan" | i18n }}</span>
|
||||
<span bitBadge variant="success">{{ "thirtyFivePercentDiscount" | i18n }}</span>
|
||||
</div>
|
||||
<ng-container>
|
||||
<div class="tw-grid tw-grid-flow-col tw-grid-cols-2 tw-gap-4 tw-mb-4">
|
||||
<div
|
||||
*ngFor="let planCard of planCards"
|
||||
[ngClass]="getPlanCardContainerClasses(planCard.selected)"
|
||||
(click)="selectPlan(planCard.name)"
|
||||
>
|
||||
<div class="tw-relative">
|
||||
<div
|
||||
*ngIf="planCard.selected"
|
||||
class="tw-bg-primary-600 tw-text-center !tw-text-contrast tw-text-sm tw-font-bold tw-py-1 group-hover:tw-bg-primary-700"
|
||||
>
|
||||
{{ "selected" | i18n }}
|
||||
</div>
|
||||
<div class="tw-p-5" [ngClass]="{ 'tw-pt-12': !planCard.selected }">
|
||||
<h3 class="tw-text-2xl tw-font-bold tw-uppercase">{{ planCard.name }}</h3>
|
||||
<span class="tw-text-2xl tw-font-semibold">{{
|
||||
planCard.cost | currency: "$"
|
||||
}}</span>
|
||||
<span class="tw-text-sm tw-font-bold">/{{ "monthPerMember" | i18n }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="tw-grid tw-grid-flow-col tw-grid-cols-2 tw-gap-4">
|
||||
<bit-form-field>
|
||||
<bit-label>
|
||||
{{ "organizationName" | i18n }}
|
||||
</bit-label>
|
||||
<input type="text" bitInput formControlName="organizationName" />
|
||||
</bit-form-field>
|
||||
<bit-form-field>
|
||||
<bit-label>
|
||||
{{ "clientOwnerEmail" | i18n }}
|
||||
</bit-label>
|
||||
<input type="text" bitInput formControlName="clientOwnerEmail" />
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-grid tw-grid-flow-col tw-grid-cols-2 tw-gap-4">
|
||||
<bit-form-field>
|
||||
<bit-label>
|
||||
{{ "seats" | i18n }}
|
||||
</bit-label>
|
||||
<input type="text" bitInput formControlName="seats" />
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton bitFormButton buttonType="primary" type="submit">
|
||||
{{ "addOrganization" | i18n }}
|
||||
</button>
|
||||
<button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.Closed">
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
@ -0,0 +1,142 @@
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
|
||||
|
||||
type CreateClientOrganizationParams = {
|
||||
providerId: string;
|
||||
plans: PlanResponse[];
|
||||
};
|
||||
|
||||
export enum CreateClientOrganizationResultType {
|
||||
Closed = "closed",
|
||||
Submitted = "submitted",
|
||||
}
|
||||
|
||||
export const openCreateClientOrganizationDialog = (
|
||||
dialogService: DialogService,
|
||||
dialogConfig: DialogConfig<CreateClientOrganizationParams>,
|
||||
) =>
|
||||
dialogService.open<CreateClientOrganizationResultType, CreateClientOrganizationParams>(
|
||||
CreateClientOrganizationComponent,
|
||||
dialogConfig,
|
||||
);
|
||||
|
||||
type PlanCard = {
|
||||
name: string;
|
||||
cost: number;
|
||||
type: PlanType;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "app-create-client-organization",
|
||||
templateUrl: "./create-client-organization.component.html",
|
||||
})
|
||||
export class CreateClientOrganizationComponent implements OnInit {
|
||||
protected ResultType = CreateClientOrganizationResultType;
|
||||
protected formGroup = this.formBuilder.group({
|
||||
clientOwnerEmail: ["", [Validators.required, Validators.email]],
|
||||
organizationName: ["", Validators.required],
|
||||
seats: [null, [Validators.required, Validators.min(1)]],
|
||||
});
|
||||
protected planCards: PlanCard[];
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) private dialogParams: CreateClientOrganizationParams,
|
||||
private dialogRef: DialogRef<CreateClientOrganizationResultType>,
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private webProviderService: WebProviderService,
|
||||
) {}
|
||||
|
||||
protected getPlanCardContainerClasses(selected: boolean) {
|
||||
switch (selected) {
|
||||
case true: {
|
||||
return [
|
||||
"tw-group",
|
||||
"tw-cursor-pointer",
|
||||
"tw-block",
|
||||
"tw-rounded",
|
||||
"tw-border",
|
||||
"tw-border-solid",
|
||||
"tw-border-primary-600",
|
||||
"hover:tw-border-primary-700",
|
||||
"focus:tw-border-2",
|
||||
"focus:tw-border-primary-700",
|
||||
"focus:tw-rounded-lg",
|
||||
];
|
||||
}
|
||||
case false: {
|
||||
return [
|
||||
"tw-cursor-pointer",
|
||||
"tw-block",
|
||||
"tw-rounded",
|
||||
"tw-border",
|
||||
"tw-border-solid",
|
||||
"tw-border-secondary-300",
|
||||
"hover:tw-border-text-main",
|
||||
"focus:tw-border-2",
|
||||
"focus:tw-border-primary-700",
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const teamsPlan = this.dialogParams.plans.find((plan) => plan.type === PlanType.TeamsMonthly);
|
||||
const enterprisePlan = this.dialogParams.plans.find(
|
||||
(plan) => plan.type === PlanType.EnterpriseMonthly,
|
||||
);
|
||||
|
||||
this.planCards = [
|
||||
{
|
||||
name: this.i18nService.t("planNameTeams"),
|
||||
cost: teamsPlan.PasswordManager.seatPrice * 0.65, // 35% off for MSPs,
|
||||
type: teamsPlan.type,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: this.i18nService.t("planNameEnterprise"),
|
||||
cost: enterprisePlan.PasswordManager.seatPrice * 0.65, // 35% off for MSPs,
|
||||
type: enterprisePlan.type,
|
||||
selected: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
protected selectPlan(name: string) {
|
||||
this.planCards.find((planCard) => planCard.name === name).selected = true;
|
||||
this.planCards.find((planCard) => planCard.name !== name).selected = false;
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedPlanCard = this.planCards.find((planCard) => planCard.selected);
|
||||
|
||||
await this.webProviderService.createClientOrganization(
|
||||
this.dialogParams.providerId,
|
||||
this.formGroup.value.organizationName,
|
||||
this.formGroup.value.clientOwnerEmail,
|
||||
selectedPlanCard.type,
|
||||
this.formGroup.value.seats,
|
||||
);
|
||||
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("createdNewClient"));
|
||||
|
||||
this.dialogRef.close(this.ResultType.Submitted);
|
||||
};
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
export * from "./create-client-organization.component";
|
||||
export * from "./manage-client-organizations.component";
|
||||
export * from "./manage-client-organization-subscription.component";
|
@ -3,7 +3,7 @@ import { Component, Inject, OnInit } from "@angular/core";
|
||||
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||
import { ProviderSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/provider-subscription-update.request";
|
||||
import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/update-client-organization.request";
|
||||
import { Plans } from "@bitwarden/common/billing/models/response/provider-subscription-response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@ -45,7 +45,7 @@ export class ManageClientOrganizationSubscriptionComponent implements OnInit {
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const response = await this.billingApiService.getProviderClientSubscriptions(this.providerId);
|
||||
const response = await this.billingApiService.getProviderSubscription(this.providerId);
|
||||
this.AdditionalSeatPurchased = this.getPurchasedSeatsByPlan(this.planName, response.plans);
|
||||
const seatMinimum = this.getProviderSeatMinimumByPlan(this.planName, response.plans);
|
||||
const assignedByPlan = this.getAssignedByPlan(this.planName, response.plans);
|
||||
@ -69,10 +69,10 @@ export class ManageClientOrganizationSubscriptionComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new ProviderSubscriptionUpdateRequest();
|
||||
const request = new UpdateClientOrganizationRequest();
|
||||
request.assignedSeats = assignedSeats;
|
||||
|
||||
await this.billingApiService.putProviderClientSubscriptions(
|
||||
await this.billingApiService.updateClientOrganization(
|
||||
this.providerId,
|
||||
this.providerOrganizationId,
|
||||
request,
|
||||
|
@ -1,6 +1,12 @@
|
||||
<app-header>
|
||||
<bit-search [placeholder]="'search' | i18n" [(ngModel)]="searchText"></bit-search>
|
||||
<a bitButton routerLink="create" *ngIf="manageOrganizations" buttonType="primary">
|
||||
<a
|
||||
type="button"
|
||||
bitButton
|
||||
*ngIf="manageOrganizations"
|
||||
buttonType="primary"
|
||||
(click)="createClientOrganization()"
|
||||
>
|
||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||
{{ "addNewOrganization" | i18n }}
|
||||
</a>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { SelectionModel } from "@angular/cdk/collections";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { BehaviorSubject, Subject, firstValueFrom, from } from "rxjs";
|
||||
import { BehaviorSubject, firstValueFrom, from, lastValueFrom, Subject } from "rxjs";
|
||||
import { first, switchMap, takeUntil } from "rxjs/operators";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
@ -9,6 +9,8 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
@ -16,6 +18,10 @@ import { DialogService, TableDataSource } from "@bitwarden/components";
|
||||
|
||||
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
|
||||
|
||||
import {
|
||||
CreateClientOrganizationResultType,
|
||||
openCreateClientOrganizationDialog,
|
||||
} from "./create-client-organization.component";
|
||||
import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-organization-subscription.component";
|
||||
|
||||
@Component({
|
||||
@ -52,6 +58,7 @@ export class ManageClientOrganizationsComponent implements OnInit, OnDestroy {
|
||||
private pagedClientsCount = 0;
|
||||
selection = new SelectionModel<string>(true, []);
|
||||
protected dataSource = new TableDataSource<ProviderOrganizationOrganizationDetailsResponse>();
|
||||
protected plans: PlanResponse[];
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@ -63,6 +70,7 @@ export class ManageClientOrganizationsComponent implements OnInit, OnDestroy {
|
||||
private validationService: ValidationService,
|
||||
private webProviderService: WebProviderService,
|
||||
private dialogService: DialogService,
|
||||
private billingApiService: BillingApiService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@ -94,12 +102,16 @@ export class ManageClientOrganizationsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async load() {
|
||||
const response = await this.apiService.getProviderClients(this.providerId);
|
||||
this.clients = response.data != null && response.data.length > 0 ? response.data : [];
|
||||
const clientsResponse = await this.apiService.getProviderClients(this.providerId);
|
||||
this.clients =
|
||||
clientsResponse.data != null && clientsResponse.data.length > 0 ? clientsResponse.data : [];
|
||||
this.dataSource.data = this.clients;
|
||||
this.manageOrganizations =
|
||||
(await this.providerService.get(this.providerId)).type === ProviderUserType.ProviderAdmin;
|
||||
|
||||
const plansResponse = await this.billingApiService.getPlans();
|
||||
this.plans = plansResponse.data;
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
@ -177,4 +189,21 @@ export class ManageClientOrganizationsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.actionPromise = null;
|
||||
}
|
||||
|
||||
createClientOrganization = async () => {
|
||||
const reference = openCreateClientOrganizationDialog(this.dialogService, {
|
||||
data: {
|
||||
providerId: this.providerId,
|
||||
plans: this.plans,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(reference.closed);
|
||||
|
||||
if (result === CreateClientOrganizationResultType.Closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.load();
|
||||
};
|
||||
}
|
||||
|
@ -19,7 +19,7 @@
|
||||
<bit-nav-item
|
||||
icon="bwi-wrench"
|
||||
[text]="'machineAccounts' | i18n"
|
||||
route="service-accounts"
|
||||
route="machine-accounts"
|
||||
[relativeTo]="route.parent"
|
||||
></bit-nav-item>
|
||||
<bit-nav-item
|
||||
|
@ -6,7 +6,7 @@
|
||||
<bit-tab-link [route]="['secrets']">{{ "secrets" | i18n }}</bit-tab-link>
|
||||
<ng-container *ngIf="project.write">
|
||||
<bit-tab-link [route]="['people']">{{ "people" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link [route]="['service-accounts']">{{ "machineAccounts" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link [route]="['machine-accounts']">{{ "machineAccounts" | i18n }}</bit-tab-link>
|
||||
</ng-container>
|
||||
</bit-tab-nav-bar>
|
||||
<sm-new-menu></sm-new-menu>
|
||||
|
@ -30,7 +30,7 @@ const routes: Routes = [
|
||||
component: ProjectPeopleComponent,
|
||||
},
|
||||
{
|
||||
path: "service-accounts",
|
||||
path: "machine-accounts",
|
||||
component: ProjectServiceAccountsComponent,
|
||||
},
|
||||
],
|
||||
|
@ -21,8 +21,8 @@ export const serviceAccountAccessGuard: CanActivateFn = async (route: ActivatedR
|
||||
return createUrlTreeFromSnapshot(route, [
|
||||
"/sm",
|
||||
route.params.organizationId,
|
||||
"service-accounts",
|
||||
"machine-accounts",
|
||||
]);
|
||||
}
|
||||
return createUrlTreeFromSnapshot(route, ["/sm", route.params.organizationId, "service-accounts"]);
|
||||
return createUrlTreeFromSnapshot(route, ["/sm", route.params.organizationId, "machine-accounts"]);
|
||||
};
|
||||
|
@ -43,7 +43,7 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
|
||||
catchError(() => {
|
||||
// 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.router.navigate(["/sm", this.organizationId, "service-accounts"]);
|
||||
this.router.navigate(["/sm", this.organizationId, "machine-accounts"]);
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
@ -200,7 +200,7 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
|
||||
if (showAccessRemovalWarning) {
|
||||
// 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.router.navigate(["sm", this.organizationId, "service-accounts"]);
|
||||
this.router.navigate(["sm", this.organizationId, "machine-accounts"]);
|
||||
} else if (
|
||||
this.accessPolicySelectorService.isAccessRemoval(currentAccessPolicies, selectedPolicies)
|
||||
) {
|
||||
|
@ -45,7 +45,7 @@ export class ServiceAccountComponent implements OnInit, OnDestroy {
|
||||
catchError(() => {
|
||||
// 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.router.navigate(["/sm", this.organizationId, "service-accounts"]).then(() => {
|
||||
this.router.navigate(["/sm", this.organizationId, "machine-accounts"]).then(() => {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
|
@ -54,7 +54,7 @@ const routes: Routes = [
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "service-accounts",
|
||||
path: "machine-accounts",
|
||||
loadChildren: () => ServiceAccountsModule,
|
||||
data: {
|
||||
titleId: "machineAccounts",
|
||||
|
@ -1051,10 +1051,13 @@ const safeProviders: SafeProvider[] = [
|
||||
provide: OrganizationBillingServiceAbstraction,
|
||||
useClass: OrganizationBillingService,
|
||||
deps: [
|
||||
ApiServiceAbstraction,
|
||||
BillingApiServiceAbstraction,
|
||||
CryptoServiceAbstraction,
|
||||
EncryptService,
|
||||
I18nServiceAbstraction,
|
||||
OrganizationApiServiceAbstraction,
|
||||
SyncServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@ -12,9 +13,15 @@ describe("UnassignedItemsBanner", () => {
|
||||
let stateProvider: FakeStateProvider;
|
||||
let apiService: MockProxy<UnassignedItemsBannerApiService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let organizationService: MockProxy<OrganizationService>;
|
||||
|
||||
const sutFactory = () =>
|
||||
new UnassignedItemsBannerService(stateProvider, apiService, environmentService);
|
||||
new UnassignedItemsBannerService(
|
||||
stateProvider,
|
||||
apiService,
|
||||
environmentService,
|
||||
organizationService,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
const fakeAccountService = mockAccountServiceWith("userId" as UserId);
|
||||
@ -22,6 +29,8 @@ describe("UnassignedItemsBanner", () => {
|
||||
apiService = mock();
|
||||
environmentService = mock();
|
||||
environmentService.environment$ = of(null);
|
||||
organizationService = mock();
|
||||
organizationService.organizations$ = of([]);
|
||||
});
|
||||
|
||||
it("shows the banner if showBanner local state is true", async () => {
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { concatMap, map } from "rxjs";
|
||||
import { combineLatest, concatMap, map, startWith } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationService,
|
||||
canAccessOrgAdmin,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
@ -40,18 +44,41 @@ export class UnassignedItemsBannerService {
|
||||
}),
|
||||
);
|
||||
|
||||
private adminConsoleOrg$ = this.organizationService.organizations$.pipe(
|
||||
map((orgs) => orgs.find((o) => canAccessOrgAdmin(o))),
|
||||
);
|
||||
|
||||
adminConsoleUrl$ = combineLatest([
|
||||
this.adminConsoleOrg$,
|
||||
this.environmentService.environment$,
|
||||
]).pipe(
|
||||
map(([org, environment]) => {
|
||||
if (org == null || environment == null) {
|
||||
return "#";
|
||||
}
|
||||
|
||||
return environment.getWebVaultUrl() + "/#/organizations/" + org.id;
|
||||
}),
|
||||
);
|
||||
|
||||
bannerText$ = this.environmentService.environment$.pipe(
|
||||
map((e) =>
|
||||
e?.getRegion() == Region.SelfHosted
|
||||
? "unassignedItemsBannerSelfHost"
|
||||
: "unassignedItemsBanner",
|
||||
? "unassignedItemsBannerSelfHostNotice"
|
||||
: "unassignedItemsBannerNotice",
|
||||
),
|
||||
);
|
||||
|
||||
loading$ = combineLatest([this.adminConsoleUrl$, this.bannerText$]).pipe(
|
||||
startWith(true),
|
||||
map(() => false),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private apiService: UnassignedItemsBannerApiService,
|
||||
private environmentService: EnvironmentService,
|
||||
private organizationService: OrganizationService,
|
||||
) {}
|
||||
|
||||
async hideBanner() {
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
||||
import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response";
|
||||
import { ProviderSubscriptionUpdateRequest } from "../models/request/provider-subscription-update.request";
|
||||
import { OrganizationSubscriptionResponse } from "../../billing/models/response/organization-subscription.response";
|
||||
import { PlanResponse } from "../../billing/models/response/plan.response";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
|
||||
import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request";
|
||||
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
|
||||
|
||||
export abstract class BillingApiServiceAbstraction {
|
||||
@ -8,12 +12,21 @@ export abstract class BillingApiServiceAbstraction {
|
||||
organizationId: string,
|
||||
request: SubscriptionCancellationRequest,
|
||||
) => Promise<void>;
|
||||
|
||||
cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise<void>;
|
||||
createClientOrganization: (
|
||||
providerId: string,
|
||||
request: CreateClientOrganizationRequest,
|
||||
) => Promise<void>;
|
||||
getBillingStatus: (id: string) => Promise<OrganizationBillingStatusResponse>;
|
||||
getProviderClientSubscriptions: (providerId: string) => Promise<ProviderSubscriptionResponse>;
|
||||
putProviderClientSubscriptions: (
|
||||
getOrganizationSubscription: (
|
||||
organizationId: string,
|
||||
) => Promise<OrganizationSubscriptionResponse>;
|
||||
getPlans: () => Promise<ListResponse<PlanResponse>>;
|
||||
getProviderSubscription: (providerId: string) => Promise<ProviderSubscriptionResponse>;
|
||||
updateClientOrganization: (
|
||||
providerId: string,
|
||||
organizationId: string,
|
||||
request: ProviderSubscriptionUpdateRequest,
|
||||
request: UpdateClientOrganizationRequest,
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
@ -41,6 +41,8 @@ export type SubscriptionInformation = {
|
||||
};
|
||||
|
||||
export abstract class OrganizationBillingServiceAbstraction {
|
||||
isOnSecretsManagerStandalone: (organizationId: string) => Promise<boolean>;
|
||||
|
||||
purchaseSubscription: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
|
||||
|
||||
startFree: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user