mirror of
https://github.com/bitwarden/browser
synced 2026-02-09 05:00:10 +00:00
Merge branch 'main' into PM-19741
This commit is contained in:
28
.github/workflows/build-web.yml
vendored
28
.github/workflows/build-web.yml
vendored
@@ -133,12 +133,34 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Get Latest Server Version
|
||||
id: latest-server-version
|
||||
uses: bitwarden/gh-actions/get-release-version@main
|
||||
with:
|
||||
repository: bitwarden/server
|
||||
trim: false
|
||||
|
||||
- name: Set Server Ref
|
||||
id: set-server-ref
|
||||
run: |
|
||||
SERVER_REF="${{ steps.latest-server-version.outputs.version }}"
|
||||
echo "Latest server release version: $SERVER_REF"
|
||||
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||||
SERVER_REF="$GITHUB_REF"
|
||||
elif [[ "$GITHUB_REF" == "refs/heads/rc" ]]; then
|
||||
SERVER_REF="$GITHUB_REF"
|
||||
elif [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||||
SERVER_REF="refs/heads/main"
|
||||
fi
|
||||
echo "Server ref: $SERVER_REF"
|
||||
echo "server_ref=$SERVER_REF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check out Server repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
path: server
|
||||
repository: bitwarden/server
|
||||
ref: ${{ github.event.pull_request.head.sha && 'main' || github.ref }}
|
||||
ref: ${{ steps.set-server-ref.outputs.server_ref }}
|
||||
|
||||
- name: Check Branch to Publish
|
||||
env:
|
||||
@@ -160,7 +182,7 @@ jobs:
|
||||
VERSION=$( jq -r ".version" package.json)
|
||||
jq --arg version "$VERSION+${GITHUB_SHA:0:7}" '.version = $version' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
|
||||
########## Set up Docker ##########
|
||||
- name: Set up Docker
|
||||
uses: docker/setup-docker-action@b60f85385d03ac8acfca6d9996982511d8620a19 # v4.3.0
|
||||
@@ -304,7 +326,7 @@ jobs:
|
||||
- name: Log out of Docker
|
||||
run: docker logout $_AZ_REGISTRY
|
||||
|
||||
|
||||
|
||||
crowdin-push:
|
||||
name: Crowdin Push
|
||||
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
// Must mock modules before importing
|
||||
@@ -26,22 +27,26 @@ describe("ExtensionTwoFactorAuthEmailComponentService", () => {
|
||||
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let window: MockProxy<Window>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
dialogService = mock<DialogService>();
|
||||
window = mock<Window>();
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
extensionTwoFactorAuthEmailComponentService = new ExtensionTwoFactorAuthEmailComponentService(
|
||||
dialogService,
|
||||
window,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("openPopoutIfApprovedForEmail2fa", () => {
|
||||
it("should open a popout if the user confirms the warning to popout the extension when in the popup", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true);
|
||||
@@ -61,6 +66,7 @@ describe("ExtensionTwoFactorAuthEmailComponentService", () => {
|
||||
|
||||
it("should not open a popout if the user cancels the warning to popout the extension when in the popup", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
dialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true);
|
||||
@@ -80,6 +86,7 @@ describe("ExtensionTwoFactorAuthEmailComponentService", () => {
|
||||
|
||||
it("should not open a popout if not in the popup", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(false);
|
||||
|
||||
// Act
|
||||
@@ -89,5 +96,15 @@ describe("ExtensionTwoFactorAuthEmailComponentService", () => {
|
||||
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(openTwoFactorAuthEmailPopout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not prompt or open a popout if the feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true);
|
||||
|
||||
await extensionTwoFactorAuthEmailComponentService.openPopoutIfApprovedForEmail2fa();
|
||||
|
||||
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(openTwoFactorAuthEmailPopout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
DefaultTwoFactorAuthEmailComponentService,
|
||||
TwoFactorAuthEmailComponentService,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { openTwoFactorAuthEmailPopout } from "../../auth/popup/utils/auth-popout-window";
|
||||
@@ -15,11 +17,21 @@ export class ExtensionTwoFactorAuthEmailComponentService
|
||||
constructor(
|
||||
private dialogService: DialogService,
|
||||
private window: Window,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async openPopoutIfApprovedForEmail2fa(): Promise<void> {
|
||||
const isTwoFactorFormPersistenceEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
|
||||
if (isTwoFactorFormPersistenceEnabled) {
|
||||
// If the feature flag is enabled, we don't need to prompt the user to open the popout
|
||||
return;
|
||||
}
|
||||
|
||||
if (BrowserPopupUtils.inPopup(this.window)) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "warning" },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import { CollectionView } from "../../content/components/common-types";
|
||||
import { NotificationQueueMessageTypes } from "../../enums/notification-queue-message-type.enum";
|
||||
import AutofillPageDetails from "../../models/autofill-page-details";
|
||||
|
||||
@@ -83,6 +84,7 @@ type NotificationBackgroundExtensionMessage = {
|
||||
tab?: chrome.tabs.Tab;
|
||||
sender?: string;
|
||||
notificationType?: string;
|
||||
organizationId?: string;
|
||||
fadeOutNotification?: boolean;
|
||||
};
|
||||
|
||||
@@ -94,6 +96,10 @@ type NotificationBackgroundExtensionMessageHandlers = {
|
||||
[key: string]: CallableFunction;
|
||||
unlockCompleted: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgGetFolderData: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<FolderView[]>;
|
||||
bgGetCollectionData: ({
|
||||
message,
|
||||
sender,
|
||||
}: BackgroundOnMessageHandlerParams) => Promise<CollectionView[]>;
|
||||
bgCloseNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgOpenAtRisksPasswords: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgAdjustNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
@@ -101,11 +107,14 @@ type NotificationBackgroundExtensionMessageHandlers = {
|
||||
bgChangedPassword: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgRemoveTabFromNotificationQueue: ({ sender }: BackgroundSenderParam) => void;
|
||||
bgSaveCipher: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
|
||||
bgOpenAddEditVaultItemPopout: ({
|
||||
message,
|
||||
sender,
|
||||
}: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgOpenViewVaultItemPopout: ({
|
||||
message,
|
||||
sender,
|
||||
}: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgOpenVault: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgNeverSave: ({ sender }: BackgroundSenderParam) => Promise<void>;
|
||||
bgUnlockPopoutOpened: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
|
||||
bgReopenUnlockPopout: ({ sender }: BackgroundSenderParam) => Promise<void>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
|
||||
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -53,6 +54,7 @@ describe("NotificationBackground", () => {
|
||||
let notificationBackground: NotificationBackground;
|
||||
const autofillService = mock<AutofillService>();
|
||||
const cipherService = mock<CipherService>();
|
||||
const collectionService = mock<CollectionService>();
|
||||
let activeAccountStatusMock$: BehaviorSubject<AuthenticationStatus>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
const policyService = mock<DefaultPolicyService>();
|
||||
@@ -83,6 +85,7 @@ describe("NotificationBackground", () => {
|
||||
authService,
|
||||
autofillService,
|
||||
cipherService,
|
||||
collectionService,
|
||||
configService,
|
||||
domainSettingsService,
|
||||
environmentService,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, switchMap, map, of } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
OrganizationCategories,
|
||||
NotificationCipherData,
|
||||
} from "../content/components/cipher/types";
|
||||
import { CollectionView } from "../content/components/common-types";
|
||||
import { NotificationQueueMessageType } from "../enums/notification-queue-message-type.enum";
|
||||
import { AutofillService } from "../services/abstractions/autofill.service";
|
||||
|
||||
@@ -92,9 +94,11 @@ export default class NotificationBackground {
|
||||
bgGetEnableAddedLoginPrompt: () => this.getEnableAddedLoginPrompt(),
|
||||
bgGetExcludedDomains: () => this.getExcludedDomains(),
|
||||
bgGetFolderData: () => this.getFolderData(),
|
||||
bgGetCollectionData: ({ message }) => this.getCollectionData(message),
|
||||
bgGetOrgData: () => this.getOrgData(),
|
||||
bgNeverSave: ({ sender }) => this.saveNever(sender.tab),
|
||||
bgOpenVault: ({ message, sender }) => this.openVault(message, sender.tab),
|
||||
bgOpenAddEditVaultItemPopout: ({ message, sender }) =>
|
||||
this.openAddEditVaultItem(message, sender.tab),
|
||||
bgOpenViewVaultItemPopout: ({ message, sender }) => this.viewItem(message, sender.tab),
|
||||
bgRemoveTabFromNotificationQueue: ({ sender }) =>
|
||||
this.removeTabFromNotificationQueue(sender.tab),
|
||||
@@ -114,6 +118,7 @@ export default class NotificationBackground {
|
||||
private authService: AuthService,
|
||||
private autofillService: AutofillService,
|
||||
private cipherService: CipherService,
|
||||
private collectionService: CollectionService,
|
||||
private configService: ConfigService,
|
||||
private domainSettingsService: DomainSettingsService,
|
||||
private environmentService: EnvironmentService,
|
||||
@@ -789,17 +794,36 @@ export default class NotificationBackground {
|
||||
userId,
|
||||
);
|
||||
|
||||
await this.openAddEditVaultItemPopout(senderTab, { cipherId: cipherView.id });
|
||||
await this.openAddEditVaultItemPopout(senderTab, { cipherId: cipherView?.id });
|
||||
}
|
||||
|
||||
private async openVault(
|
||||
private async openAddEditVaultItem(
|
||||
message: NotificationBackgroundExtensionMessage,
|
||||
senderTab: chrome.tabs.Tab,
|
||||
) {
|
||||
if (!message.cipherId) {
|
||||
await this.openAddEditVaultItemPopout(senderTab);
|
||||
const { cipherId, organizationId } = message;
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getOptionalUserId));
|
||||
if (cipherId) {
|
||||
await this.openAddEditVaultItemPopout(senderTab, { cipherId });
|
||||
return;
|
||||
}
|
||||
await this.openAddEditVaultItemPopout(senderTab, { cipherId: message.cipherId });
|
||||
|
||||
const queueItem = this.notificationQueue.find((item) => item.tab.id === senderTab.id);
|
||||
|
||||
if (queueItem?.type === NotificationQueueMessageType.AddLogin) {
|
||||
const cipherView = this.convertAddLoginQueueMessageToCipherView(queueItem);
|
||||
cipherView.organizationId = organizationId;
|
||||
|
||||
if (userId) {
|
||||
await this.cipherService.setAddEditCipherInfo({ cipher: cipherView }, userId);
|
||||
}
|
||||
|
||||
await this.openAddEditVaultItemPopout(senderTab);
|
||||
this.removeTabFromNotificationQueue(senderTab);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.openAddEditVaultItemPopout(senderTab);
|
||||
}
|
||||
|
||||
private async viewItem(
|
||||
@@ -898,6 +922,25 @@ export default class NotificationBackground {
|
||||
return await firstValueFrom(this.folderService.folderViews$(activeUserId));
|
||||
}
|
||||
|
||||
private async getCollectionData(
|
||||
message: NotificationBackgroundExtensionMessage,
|
||||
): Promise<CollectionView[]> {
|
||||
const collections = (await this.collectionService.getAllDecrypted()).reduce<CollectionView[]>(
|
||||
(acc, collection) => {
|
||||
if (collection.organizationId === message?.orgId) {
|
||||
acc.push({
|
||||
id: collection.id,
|
||||
name: collection.name,
|
||||
organizationId: collection.organizationId,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
return collections;
|
||||
}
|
||||
|
||||
private async getWebVaultUrl(): Promise<string> {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
return env.getWebVaultUrl();
|
||||
@@ -924,6 +967,7 @@ export default class NotificationBackground {
|
||||
const organizations = await firstValueFrom(
|
||||
this.organizationService.organizations$(activeUserId),
|
||||
);
|
||||
|
||||
return organizations.map((org) => {
|
||||
const { id, name, productTierType } = org;
|
||||
return {
|
||||
@@ -1054,6 +1098,7 @@ export default class NotificationBackground {
|
||||
cipherView.folderId = folderId;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = loginView;
|
||||
cipherView.organizationId = null;
|
||||
|
||||
return cipherView;
|
||||
}
|
||||
|
||||
@@ -26,3 +26,9 @@ export type OrgView = {
|
||||
name: string;
|
||||
productTierType?: ProductTierType;
|
||||
};
|
||||
|
||||
export type CollectionView = {
|
||||
id: string;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
@@ -3,9 +3,12 @@ import { html } from "lit";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { Theme } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { Option, OrgView, FolderView } from "../common-types";
|
||||
import { Business, Family, Folder, User } from "../icons";
|
||||
import { Option, OrgView, FolderView, CollectionView } from "../common-types";
|
||||
import { Business, Family, Folder, User, CollectionShared } from "../icons";
|
||||
import { ButtonRow } from "../rows/button-row";
|
||||
import { selectedCollection as selectedCollectionSignal } from "../signals/selected-collection";
|
||||
import { selectedFolder as selectedFolderSignal } from "../signals/selected-folder";
|
||||
import { selectedVault as selectedVaultSignal } from "../signals/selected-vault";
|
||||
|
||||
function getVaultIconByProductTier(productTierType?: ProductTierType): Option["icon"] {
|
||||
switch (productTierType) {
|
||||
@@ -29,11 +32,13 @@ export type NotificationButtonRowProps = {
|
||||
text: string;
|
||||
handlePrimaryButtonClick: (args: any) => void;
|
||||
};
|
||||
collections?: CollectionView[];
|
||||
theme: Theme;
|
||||
};
|
||||
|
||||
export function NotificationButtonRow({
|
||||
folders,
|
||||
collections,
|
||||
i18n,
|
||||
organizations,
|
||||
primaryButton,
|
||||
@@ -77,6 +82,21 @@ export function NotificationButtonRow({
|
||||
)
|
||||
: [];
|
||||
|
||||
const collectionOptions: Option[] = collections?.length
|
||||
? collections.reduce<Option[]>(
|
||||
(options, { id, name }: any) => [
|
||||
...options,
|
||||
{
|
||||
icon: CollectionShared,
|
||||
text: name,
|
||||
value: id === null ? "0" : id,
|
||||
default: id === null,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
: [];
|
||||
|
||||
return html`
|
||||
${ButtonRow({
|
||||
theme,
|
||||
@@ -88,15 +108,27 @@ export function NotificationButtonRow({
|
||||
id: "organization",
|
||||
label: i18n.vault,
|
||||
options: organizationOptions,
|
||||
selectedSignal: selectedVaultSignal,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(folderOptions.length > 1
|
||||
...(folderOptions.length > 1 && !collectionOptions.length
|
||||
? [
|
||||
{
|
||||
id: "folder",
|
||||
label: i18n.folder,
|
||||
options: folderOptions,
|
||||
selectedSignal: selectedFolderSignal,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(collectionOptions.length > 1
|
||||
? [
|
||||
{
|
||||
id: "collection",
|
||||
label: "Collection", // @TODO localize
|
||||
options: collectionOptions,
|
||||
selectedSignal: selectedCollectionSignal,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
NotificationType,
|
||||
} from "../../../notification/abstractions/notification-bar";
|
||||
import { NotificationCipherData } from "../cipher/types";
|
||||
import { FolderView, OrgView } from "../common-types";
|
||||
import { CollectionView, FolderView, OrgView } from "../common-types";
|
||||
import { themes, spacing } from "../constants/styles";
|
||||
|
||||
import { NotificationBody, componentClassPrefix as notificationBodyClassPrefix } from "./body";
|
||||
@@ -25,6 +25,7 @@ export type NotificationContainerProps = NotificationBarIframeInitData & {
|
||||
handleEditOrUpdateAction: (e: Event) => void;
|
||||
} & {
|
||||
ciphers?: NotificationCipherData[];
|
||||
collections?: CollectionView[];
|
||||
folders?: FolderView[];
|
||||
i18n: { [key: string]: string };
|
||||
organizations?: OrgView[];
|
||||
@@ -36,6 +37,7 @@ export function NotificationContainer({
|
||||
handleEditOrUpdateAction,
|
||||
handleSaveAction,
|
||||
ciphers,
|
||||
collections,
|
||||
folders,
|
||||
i18n,
|
||||
organizations,
|
||||
@@ -64,6 +66,7 @@ export function NotificationContainer({
|
||||
: null}
|
||||
${NotificationFooter({
|
||||
handleSaveAction,
|
||||
collections,
|
||||
folders,
|
||||
i18n,
|
||||
notificationType: type,
|
||||
|
||||
@@ -7,12 +7,13 @@ import {
|
||||
NotificationType,
|
||||
NotificationTypes,
|
||||
} from "../../../notification/abstractions/notification-bar";
|
||||
import { OrgView, FolderView } from "../common-types";
|
||||
import { OrgView, FolderView, CollectionView } from "../common-types";
|
||||
import { spacing, themes } from "../constants/styles";
|
||||
|
||||
import { NotificationButtonRow } from "./button-row";
|
||||
|
||||
export type NotificationFooterProps = {
|
||||
collections?: CollectionView[];
|
||||
folders?: FolderView[];
|
||||
i18n: { [key: string]: string };
|
||||
notificationType?: NotificationType;
|
||||
@@ -22,6 +23,7 @@ export type NotificationFooterProps = {
|
||||
};
|
||||
|
||||
export function NotificationFooter({
|
||||
collections,
|
||||
folders,
|
||||
i18n,
|
||||
notificationType,
|
||||
@@ -36,6 +38,7 @@ export function NotificationFooter({
|
||||
<div class=${notificationFooterStyles({ theme })}>
|
||||
${!isChangeNotification
|
||||
? NotificationButtonRow({
|
||||
collections,
|
||||
folders,
|
||||
organizations,
|
||||
i18n,
|
||||
|
||||
@@ -32,6 +32,9 @@ export class OptionSelection extends LitElement {
|
||||
@property({ type: (selectedOption: Option["value"]) => selectedOption })
|
||||
handleSelectionUpdate?: (args: any) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
selectedSignal?: { set: (value: any) => void };
|
||||
|
||||
@state()
|
||||
private showMenu = false;
|
||||
|
||||
@@ -77,7 +80,7 @@ export class OptionSelection extends LitElement {
|
||||
private handleOptionSelection = (selectedOption: Option) => {
|
||||
this.showMenu = false;
|
||||
this.selection = selectedOption;
|
||||
|
||||
this.selectedSignal?.set(selectedOption.value);
|
||||
// Any side-effects that should occur from the selection
|
||||
this.handleSelectionUpdate?.(selectedOption.value);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export type ButtonRowProps = {
|
||||
label?: string;
|
||||
options: Option[];
|
||||
handleSelectionUpdate?: (args: any) => void;
|
||||
selectedSignal?: { set: (value: any) => void };
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -32,7 +33,7 @@ export function ButtonRow({ theme, primaryButton, selectButtons }: ButtonRowProp
|
||||
})}
|
||||
<div class=${optionSelectionsStyles}>
|
||||
${selectButtons?.map(
|
||||
({ id, label, options, handleSelectionUpdate }) =>
|
||||
({ id, label, options, handleSelectionUpdate, selectedSignal }) =>
|
||||
html`
|
||||
<option-selection
|
||||
key=${id}
|
||||
@@ -40,6 +41,7 @@ export function ButtonRow({ theme, primaryButton, selectButtons }: ButtonRowProp
|
||||
.label=${label}
|
||||
.options=${options}
|
||||
.handleSelectionUpdate=${handleSelectionUpdate}
|
||||
.selectedSignal=${selectedSignal}
|
||||
></option-selection>
|
||||
` || nothing,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { signal } from "@lit-labs/signals";
|
||||
|
||||
export const selectedCollection = signal<string>("0");
|
||||
@@ -0,0 +1,3 @@
|
||||
import { signal } from "@lit-labs/signals";
|
||||
|
||||
export const selectedFolder = signal<string>("0");
|
||||
@@ -0,0 +1,3 @@
|
||||
import { signal } from "@lit-labs/signals";
|
||||
|
||||
export const selectedVault = signal<string>("0");
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Theme } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { NotificationCipherData } from "../../../autofill/content/components/cipher/types";
|
||||
import { FolderView, OrgView } from "../../../autofill/content/components/common-types";
|
||||
import {
|
||||
FolderView,
|
||||
OrgView,
|
||||
CollectionView,
|
||||
} from "../../../autofill/content/components/common-types";
|
||||
|
||||
const NotificationTypes = {
|
||||
Add: "add",
|
||||
@@ -20,6 +24,7 @@ type NotificationTaskInfo = {
|
||||
type NotificationBarIframeInitData = {
|
||||
ciphers?: NotificationCipherData[];
|
||||
folders?: FolderView[];
|
||||
collections?: CollectionView[];
|
||||
importType?: string;
|
||||
isVaultLocked?: boolean;
|
||||
launchTimestamp?: number;
|
||||
|
||||
@@ -6,9 +6,11 @@ import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view
|
||||
|
||||
import { AdjustNotificationBarMessageData } from "../background/abstractions/notification.background";
|
||||
import { NotificationCipherData } from "../content/components/cipher/types";
|
||||
import { OrgView } from "../content/components/common-types";
|
||||
import { CollectionView, OrgView } from "../content/components/common-types";
|
||||
import { NotificationConfirmationContainer } from "../content/components/notification/confirmation/container";
|
||||
import { NotificationContainer } from "../content/components/notification/container";
|
||||
import { selectedFolder as selectedFolderSignal } from "../content/components/signals/selected-folder";
|
||||
import { selectedVault as selectedVaultSignal } from "../content/components/signals/selected-vault";
|
||||
import { buildSvgDomElement } from "../utils";
|
||||
import { circleCheckIcon } from "../utils/svg-icons";
|
||||
|
||||
@@ -41,6 +43,7 @@ function load() {
|
||||
applyNotificationBarStyle();
|
||||
});
|
||||
}
|
||||
|
||||
function applyNotificationBarStyle() {
|
||||
if (!useComponentBar) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -140,7 +143,7 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
|
||||
document.body.innerHTML = "";
|
||||
// Current implementations utilize a require for scss files which creates the need to remove the node.
|
||||
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((node) => node.remove());
|
||||
|
||||
const orgId = selectedVaultSignal.get();
|
||||
await Promise.all([
|
||||
new Promise<OrgView[]>((resolve) =>
|
||||
sendPlatformMessage({ command: "bgGetOrgData" }, resolve),
|
||||
@@ -151,13 +154,18 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
|
||||
new Promise<NotificationCipherData[]>((resolve) =>
|
||||
sendPlatformMessage({ command: "bgGetDecryptedCiphers" }, resolve),
|
||||
),
|
||||
]).then(([organizations, folders, ciphers]) => {
|
||||
new Promise<CollectionView[]>((resolve) =>
|
||||
sendPlatformMessage({ command: "bgGetCollectionData", orgId }, resolve),
|
||||
),
|
||||
]).then(([organizations, folders, ciphers, collections]) => {
|
||||
notificationBarIframeInitData = {
|
||||
...notificationBarIframeInitData,
|
||||
organizations,
|
||||
folders,
|
||||
ciphers,
|
||||
organizations,
|
||||
collections,
|
||||
};
|
||||
|
||||
// @TODO use context to avoid prop drilling
|
||||
return render(
|
||||
NotificationContainer({
|
||||
@@ -254,9 +262,18 @@ function handleCloseNotification(e: Event) {
|
||||
}
|
||||
|
||||
function handleSaveAction(e: Event) {
|
||||
const selectedVault = selectedVaultSignal.get();
|
||||
if (selectedVault.length > 1) {
|
||||
openAddEditVaultItemPopout(e, { organizationId: selectedVault });
|
||||
handleCloseNotification(e);
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
sendSaveCipherMessage(removeIndividualVault());
|
||||
const selectedFolder = selectedFolderSignal.get();
|
||||
|
||||
sendSaveCipherMessage(removeIndividualVault(), selectedFolder);
|
||||
if (removeIndividualVault()) {
|
||||
return;
|
||||
}
|
||||
@@ -351,6 +368,17 @@ function handleSaveCipherAttemptCompletedMessage(message: NotificationBarWindowM
|
||||
);
|
||||
}
|
||||
|
||||
function openAddEditVaultItemPopout(
|
||||
e: Event,
|
||||
options: { cipherId?: string; organizationId?: string },
|
||||
) {
|
||||
e.preventDefault();
|
||||
sendPlatformMessage({
|
||||
command: "bgOpenAddEditVaultItemPopout",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function openViewVaultItemPopout(cipherId: string) {
|
||||
sendPlatformMessage({
|
||||
command: "bgOpenViewVaultItemPopout",
|
||||
|
||||
@@ -1206,6 +1206,7 @@ export default class MainBackground {
|
||||
this.authService,
|
||||
this.autofillService,
|
||||
this.cipherService,
|
||||
this.collectionService,
|
||||
this.configService,
|
||||
this.domainSettingsService,
|
||||
this.environmentService,
|
||||
|
||||
@@ -557,7 +557,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: TwoFactorAuthEmailComponentService,
|
||||
useClass: ExtensionTwoFactorAuthEmailComponentService,
|
||||
deps: [DialogService, WINDOW],
|
||||
deps: [DialogService, WINDOW, ConfigService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TwoFactorAuthWebAuthnComponentService,
|
||||
|
||||
@@ -57,7 +57,7 @@ export class ConfirmCommand {
|
||||
}
|
||||
const publicKeyResponse = await this.apiService.getUserPublicKey(orgUser.userId);
|
||||
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
|
||||
const key = await this.encryptService.rsaEncrypt(orgKey.key, publicKey);
|
||||
const key = await this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey);
|
||||
const req = new OrganizationUserConfirmRequest();
|
||||
req.key = key.encryptedString;
|
||||
await this.organizationUserApiService.postOrganizationUserConfirm(
|
||||
|
||||
12
apps/desktop/desktop_native/Cargo.lock
generated
12
apps/desktop/desktop_native/Cargo.lock
generated
@@ -2110,18 +2110,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.8"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e2ec53ad785f4d35dac0adea7f7dc6f1bb277ad84a680c7afefeae05d1f5916"
|
||||
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.8"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56a66c0c55993aa927429d0f8a0abfd74f084e4d9c192cffed01e418d83eefb"
|
||||
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3022,9 +3022,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.17.0"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
|
||||
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
|
||||
@@ -35,7 +35,7 @@ napi-build = "=2.1.4"
|
||||
napi-derive = "=2.16.13"
|
||||
oo7 = "=0.3.3"
|
||||
oslog = "=0.2.0"
|
||||
pin-project = "=1.1.8"
|
||||
pin-project = "=1.1.10"
|
||||
pkcs8 = "=0.10.2"
|
||||
rand = "=0.8.5"
|
||||
rsa = "=0.9.8"
|
||||
@@ -54,7 +54,7 @@ thiserror = "=1.0.69"
|
||||
tokio = "=1.43.1"
|
||||
tokio-stream = "=0.1.15"
|
||||
tokio-util = "=0.7.13"
|
||||
typenum = "=1.17.0"
|
||||
typenum = "=1.18.0"
|
||||
uniffi = "=0.28.3"
|
||||
widestring = "=1.1.0"
|
||||
windows = "=0.61.1"
|
||||
|
||||
@@ -3,6 +3,21 @@ const child_process = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const process = require("process");
|
||||
|
||||
// Map of the Node arch equivalents for the rust target triplets, used to move the file to the correct location
|
||||
const rustTargetsMap = {
|
||||
"i686-pc-windows-msvc": { nodeArch: 'ia32', platform: 'win32' },
|
||||
"x86_64-pc-windows-msvc": { nodeArch: 'x64', platform: 'win32' },
|
||||
"aarch64-pc-windows-msvc": { nodeArch: 'arm64', platform: 'win32' },
|
||||
"x86_64-apple-darwin": { nodeArch: 'x64', platform: 'darwin' },
|
||||
"aarch64-apple-darwin": { nodeArch: 'arm64', platform: 'darwin' },
|
||||
'x86_64-unknown-linux-musl': { nodeArch: 'x64', platform: 'linux' },
|
||||
'aarch64-unknown-linux-musl': { nodeArch: 'arm64', platform: 'linux' },
|
||||
}
|
||||
|
||||
// Ensure the dist directory exists
|
||||
fs.mkdirSync(path.join(__dirname, "dist"), { recursive: true });
|
||||
|
||||
const args = process.argv.slice(2); // Get arguments passed to the script
|
||||
const mode = args.includes("--release") ? "release" : "debug";
|
||||
const targetArg = args.find(arg => arg.startsWith("--target="));
|
||||
@@ -13,13 +28,21 @@ let crossPlatform = process.argv.length > 2 && process.argv[2] === "cross-platfo
|
||||
function buildNapiModule(target, release = true) {
|
||||
const targetArg = target ? `--target ${target}` : "";
|
||||
const releaseArg = release ? "--release" : "";
|
||||
return child_process.execSync(`npm run build -- ${releaseArg} ${targetArg}`, { stdio: 'inherit', cwd: path.join(__dirname, "napi") });
|
||||
child_process.execSync(`npm run build -- ${releaseArg} ${targetArg}`, { stdio: 'inherit', cwd: path.join(__dirname, "napi") });
|
||||
}
|
||||
|
||||
function buildProxyBin(target, release = true) {
|
||||
const targetArg = target ? `--target ${target}` : "";
|
||||
const releaseArg = release ? "--release" : "";
|
||||
return child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")});
|
||||
child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")});
|
||||
|
||||
if (target) {
|
||||
// Copy the resulting binary to the dist folder
|
||||
const targetFolder = release ? "release" : "debug";
|
||||
const ext = process.platform === "win32" ? ".exe" : "";
|
||||
const nodeArch = rustTargetsMap[target].nodeArch;
|
||||
fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `desktop_proxy${ext}`), path.join(__dirname, "dist", `desktop_proxy.${process.platform}-${nodeArch}${ext}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (!crossPlatform && !target) {
|
||||
@@ -36,45 +59,17 @@ if (target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Note that targets contains pairs of [rust target, node arch]
|
||||
// We do this to move the output binaries to a location that can
|
||||
// be easily accessed from electron-builder using ${os} and ${arch}
|
||||
let targets = [];
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
targets = [
|
||||
["i686-pc-windows-msvc", 'ia32'],
|
||||
["x86_64-pc-windows-msvc", 'x64'],
|
||||
["aarch64-pc-windows-msvc", 'arm64']
|
||||
];
|
||||
break;
|
||||
// Filter the targets based on the current platform, and build for each of them
|
||||
let platformTargets = Object.entries(rustTargetsMap).filter(([_, { platform: p }]) => p === process.platform);
|
||||
console.log("Cross building native modules for the targets: ", platformTargets.map(([target, _]) => target).join(", "));
|
||||
|
||||
case "darwin":
|
||||
targets = [
|
||||
["x86_64-apple-darwin", 'x64'],
|
||||
["aarch64-apple-darwin", 'arm64']
|
||||
];
|
||||
break;
|
||||
|
||||
default:
|
||||
targets = [
|
||||
['x86_64-unknown-linux-musl', 'x64'],
|
||||
['aarch64-unknown-linux-musl', 'arm64']
|
||||
];
|
||||
|
||||
process.env["PKG_CONFIG_ALLOW_CROSS"] = "1";
|
||||
process.env["PKG_CONFIG_ALL_STATIC"] = "1";
|
||||
break;
|
||||
// When building for Linux, we need to set some environment variables to allow cross-compilation
|
||||
if (process.platform === "linux") {
|
||||
process.env["PKG_CONFIG_ALLOW_CROSS"] = "1";
|
||||
process.env["PKG_CONFIG_ALL_STATIC"] = "1";
|
||||
}
|
||||
|
||||
console.log("Cross building native modules for the targets: ", targets.map(([target, _]) => target).join(", "));
|
||||
|
||||
fs.mkdirSync(path.join(__dirname, "dist"), { recursive: true });
|
||||
|
||||
targets.forEach(([target, nodeArch]) => {
|
||||
platformTargets.forEach(([target, _]) => {
|
||||
buildNapiModule(target);
|
||||
buildProxyBin(target);
|
||||
|
||||
const ext = process.platform === "win32" ? ".exe" : "";
|
||||
fs.copyFileSync(path.join(__dirname, "target", target, "release", `desktop_proxy${ext}`), path.join(__dirname, "dist", `desktop_proxy.${process.platform}-${nodeArch}${ext}`));
|
||||
});
|
||||
|
||||
@@ -9,58 +9,14 @@ use windows::Win32::System::Com::*;
|
||||
use windows::Win32::System::LibraryLoader::*;
|
||||
use windows_core::*;
|
||||
|
||||
mod pluginauthenticator;
|
||||
mod webauthn;
|
||||
|
||||
const AUTHENTICATOR_NAME: &str = "Bitwarden Desktop Authenticator";
|
||||
//const AAGUID: &str = "d548826e-79b4-db40-a3d8-11116f7e8349";
|
||||
const CLSID: &str = "0f7dc5d9-69ce-4652-8572-6877fd695062";
|
||||
const RPID: &str = "bitwarden.com";
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST {
|
||||
pub transactionId: GUID,
|
||||
pub cbRequestSignature: Dword,
|
||||
pub pbRequestSignature: *mut byte,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST {
|
||||
pub hWnd: HWND,
|
||||
pub transactionId: GUID,
|
||||
pub cbRequestSignature: Dword,
|
||||
pub pbRequestSignature: *mut byte,
|
||||
pub cbEncodedRequest: Dword,
|
||||
pub pbEncodedRequest: *mut byte,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE {
|
||||
pub cbOpSignPubKey: Dword,
|
||||
pub pbOpSignPubKey: PByte,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE {
|
||||
pub cbEncodedResponse: Dword,
|
||||
pub pbEncodedResponse: *mut byte,
|
||||
}
|
||||
|
||||
type Dword = u32;
|
||||
type Byte = u8;
|
||||
type byte = u8;
|
||||
pub type PByte = *mut Byte;
|
||||
|
||||
type EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST =
|
||||
*const EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
|
||||
pub type EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST =
|
||||
*const EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST;
|
||||
pub type EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE =
|
||||
*mut EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE;
|
||||
pub type EXPERIMENTAL_PWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE =
|
||||
*mut EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE;
|
||||
|
||||
/// Handles initialization and registration for the Bitwarden desktop app as a
|
||||
/// plugin authenticator with Windows.
|
||||
/// For now, also adds the authenticator
|
||||
@@ -109,7 +65,8 @@ fn initialize_com_library() -> std::result::Result<(), String> {
|
||||
|
||||
/// Registers the Bitwarden Plugin Authenticator COM library with Windows.
|
||||
fn register_com_library() -> std::result::Result<(), String> {
|
||||
static FACTORY: windows_core::StaticComObject<Factory> = Factory().into_static();
|
||||
static FACTORY: windows_core::StaticComObject<pluginauthenticator::Factory> =
|
||||
pluginauthenticator::Factory().into_static();
|
||||
let clsid: *const GUID = &GUID::from_u128(0xa98925d161f640de9327dc418fcb2ff4);
|
||||
|
||||
match unsafe {
|
||||
@@ -146,25 +103,25 @@ fn add_authenticator() -> std::result::Result<(), String> {
|
||||
let cbor_authenticator_info = "A60182684649444F5F325F30684649444F5F325F310282637072666B686D61632D7365637265740350D548826E79B4DB40A3D811116F7E834904A362726BF5627570F5627576F5098168696E7465726E616C0A81A263616C672664747970656A7075626C69632D6B6579";
|
||||
let mut authenticator_info_bytes = hex::decode(cbor_authenticator_info).unwrap();
|
||||
|
||||
let add_authenticator_options = EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS {
|
||||
pwszAuthenticatorName: authenticator_name_ptr,
|
||||
pwszPluginClsId: clsid_ptr,
|
||||
pwszPluginRpId: relying_party_id_ptr,
|
||||
pwszLightThemeLogo: ptr::null(), // unused by Windows
|
||||
pwszDarkThemeLogo: ptr::null(), // unused by Windows
|
||||
cbAuthenticatorInfo: authenticator_info_bytes.len() as u32,
|
||||
pbAuthenticatorInfo: authenticator_info_bytes.as_mut_ptr(),
|
||||
let add_authenticator_options = webauthn::ExperimentalWebAuthnPluginAddAuthenticatorOptions {
|
||||
authenticator_name: authenticator_name_ptr,
|
||||
com_clsid: clsid_ptr,
|
||||
rpid: relying_party_id_ptr,
|
||||
light_theme_logo: ptr::null(), // unused by Windows
|
||||
dark_theme_logo: ptr::null(), // unused by Windows
|
||||
cbor_authenticator_info_byte_count: authenticator_info_bytes.len() as u32,
|
||||
cbor_authenticator_info: authenticator_info_bytes.as_mut_ptr(),
|
||||
};
|
||||
|
||||
let plugin_signing_public_key_byte_count: Dword = 0;
|
||||
let plugin_signing_public_key_byte_count: u32 = 0;
|
||||
let mut plugin_signing_public_key: c_uchar = 0;
|
||||
let plugin_signing_public_key_ptr: PByte = &mut plugin_signing_public_key;
|
||||
let plugin_signing_public_key_ptr = &mut plugin_signing_public_key;
|
||||
|
||||
let mut add_response = EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE {
|
||||
cbOpSignPubKey: plugin_signing_public_key_byte_count,
|
||||
pbOpSignPubKey: plugin_signing_public_key_ptr,
|
||||
let mut add_response = webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse {
|
||||
plugin_operation_signing_key_byte_count: plugin_signing_public_key_byte_count,
|
||||
plugin_operation_signing_key: plugin_signing_public_key_ptr,
|
||||
};
|
||||
let mut add_response_ptr: *mut EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE =
|
||||
let mut add_response_ptr: *mut webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse =
|
||||
&mut add_response;
|
||||
|
||||
let result = unsafe {
|
||||
@@ -193,23 +150,10 @@ fn add_authenticator() -> std::result::Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS {
|
||||
pub pwszAuthenticatorName: *const u16,
|
||||
pub pwszPluginClsId: *const u16,
|
||||
pub pwszPluginRpId: *const u16,
|
||||
pub pwszLightThemeLogo: *const u16,
|
||||
pub pwszDarkThemeLogo: *const u16,
|
||||
pub cbAuthenticatorInfo: u32,
|
||||
pub pbAuthenticatorInfo: *const u8,
|
||||
}
|
||||
|
||||
type EXPERIMENTAL_WebAuthNPluginAddAuthenticatorFnDeclaration = unsafe extern "cdecl" fn(
|
||||
pPluginAddAuthenticatorOptions: *const EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS,
|
||||
ppPluginAddAuthenticatorResponse: *mut EXPERIMENTAL_PWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE,
|
||||
)
|
||||
-> HRESULT;
|
||||
pPluginAddAuthenticatorOptions: *const webauthn::ExperimentalWebAuthnPluginAddAuthenticatorOptions,
|
||||
ppPluginAddAuthenticatorResponse: *mut *mut webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse,
|
||||
) -> HRESULT;
|
||||
|
||||
unsafe fn delay_load<T>(library: PCSTR, function: PCSTR) -> Option<T> {
|
||||
let library = LoadLibraryExA(library, None, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
|
||||
@@ -228,70 +172,3 @@ unsafe fn delay_load<T>(library: PCSTR, function: PCSTR) -> Option<T> {
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[interface("e6466e9a-b2f3-47c5-b88d-89bc14a8d998")]
|
||||
unsafe trait EXPERIMENTAL_IPluginAuthenticator: IUnknown {
|
||||
fn EXPERIMENTAL_PluginMakeCredential(
|
||||
&self,
|
||||
request: EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST,
|
||||
response: *mut EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE,
|
||||
) -> HRESULT;
|
||||
fn EXPERIMENTAL_PluginGetAssertion(
|
||||
&self,
|
||||
request: EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST,
|
||||
response: *mut EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE,
|
||||
) -> HRESULT;
|
||||
fn EXPERIMENTAL_PluginCancelOperation(
|
||||
&self,
|
||||
request: EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST,
|
||||
) -> HRESULT;
|
||||
}
|
||||
|
||||
#[implement(EXPERIMENTAL_IPluginAuthenticator)]
|
||||
struct PACOMObject;
|
||||
|
||||
impl EXPERIMENTAL_IPluginAuthenticator_Impl for PACOMObject_Impl {
|
||||
unsafe fn EXPERIMENTAL_PluginMakeCredential(
|
||||
&self,
|
||||
_request: EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST,
|
||||
_response: *mut EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE,
|
||||
) -> HRESULT {
|
||||
HRESULT(0)
|
||||
}
|
||||
|
||||
unsafe fn EXPERIMENTAL_PluginGetAssertion(
|
||||
&self,
|
||||
_request: EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST,
|
||||
_response: *mut EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE,
|
||||
) -> HRESULT {
|
||||
HRESULT(0)
|
||||
}
|
||||
|
||||
unsafe fn EXPERIMENTAL_PluginCancelOperation(
|
||||
&self,
|
||||
_request: EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST,
|
||||
) -> HRESULT {
|
||||
HRESULT(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[implement(IClassFactory)]
|
||||
struct Factory();
|
||||
|
||||
impl IClassFactory_Impl for Factory_Impl {
|
||||
fn CreateInstance(
|
||||
&self,
|
||||
outer: Ref<IUnknown>,
|
||||
iid: *const GUID,
|
||||
object: *mut *mut core::ffi::c_void,
|
||||
) -> Result<()> {
|
||||
assert!(outer.is_null());
|
||||
let unknown: IInspectable = PACOMObject.into();
|
||||
unsafe { unknown.query(iid, object).ok() }
|
||||
}
|
||||
|
||||
fn LockServer(&self, lock: BOOL) -> Result<()> {
|
||||
assert!(lock.as_bool());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
This file exposes the functions and types defined here: https://github.com/microsoft/webauthn/blob/master/experimental/pluginauthenticator.h
|
||||
*/
|
||||
|
||||
use windows::Win32::System::Com::*;
|
||||
use windows_core::*;
|
||||
|
||||
/// Used when creating and asserting credentials.
|
||||
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST
|
||||
/// Header File Usage: EXPERIMENTAL_PluginMakeCredential()
|
||||
/// EXPERIMENTAL_PluginGetAssertion()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct ExperimentalWebAuthnPluginOperationRequest {
|
||||
pub window_handle: windows::Win32::Foundation::HWND,
|
||||
pub transaction_id: windows_core::GUID,
|
||||
pub request_signature_byte_count: u32,
|
||||
pub request_signature_pointer: *mut u8,
|
||||
pub encoded_request_byte_count: u32,
|
||||
pub encoded_request_pointer: *mut u8,
|
||||
}
|
||||
|
||||
/// Used as a response when creating and asserting credentials.
|
||||
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE
|
||||
/// Header File Usage: EXPERIMENTAL_PluginMakeCredential()
|
||||
/// EXPERIMENTAL_PluginGetAssertion()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct ExperimentalWebAuthnPluginOperationResponse {
|
||||
pub encoded_response_byte_count: u32,
|
||||
pub encoded_response_pointer: *mut u8,
|
||||
}
|
||||
|
||||
/// Used to cancel an operation.
|
||||
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST
|
||||
/// Header File Usage: EXPERIMENTAL_PluginCancelOperation()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct ExperimentalWebAuthnPluginCancelOperationRequest {
|
||||
pub transaction_id: windows_core::GUID,
|
||||
pub request_signature_byte_count: u32,
|
||||
pub request_signature_pointer: *mut u8,
|
||||
}
|
||||
|
||||
#[interface("e6466e9a-b2f3-47c5-b88d-89bc14a8d998")]
|
||||
pub unsafe trait EXPERIMENTAL_IPluginAuthenticator: IUnknown {
|
||||
fn EXPERIMENTAL_PluginMakeCredential(
|
||||
&self,
|
||||
request: *const ExperimentalWebAuthnPluginOperationRequest,
|
||||
response: *mut ExperimentalWebAuthnPluginOperationResponse,
|
||||
) -> HRESULT;
|
||||
fn EXPERIMENTAL_PluginGetAssertion(
|
||||
&self,
|
||||
request: *const ExperimentalWebAuthnPluginOperationRequest,
|
||||
response: *mut ExperimentalWebAuthnPluginOperationResponse,
|
||||
) -> HRESULT;
|
||||
fn EXPERIMENTAL_PluginCancelOperation(
|
||||
&self,
|
||||
request: *const ExperimentalWebAuthnPluginCancelOperationRequest,
|
||||
) -> HRESULT;
|
||||
}
|
||||
|
||||
#[implement(EXPERIMENTAL_IPluginAuthenticator)]
|
||||
pub struct PluginAuthenticatorComObject;
|
||||
|
||||
#[implement(IClassFactory)]
|
||||
pub struct Factory();
|
||||
|
||||
impl EXPERIMENTAL_IPluginAuthenticator_Impl for PluginAuthenticatorComObject_Impl {
|
||||
unsafe fn EXPERIMENTAL_PluginMakeCredential(
|
||||
&self,
|
||||
_request: *const ExperimentalWebAuthnPluginOperationRequest,
|
||||
_response: *mut ExperimentalWebAuthnPluginOperationResponse,
|
||||
) -> HRESULT {
|
||||
HRESULT(0)
|
||||
}
|
||||
|
||||
unsafe fn EXPERIMENTAL_PluginGetAssertion(
|
||||
&self,
|
||||
_request: *const ExperimentalWebAuthnPluginOperationRequest,
|
||||
_response: *mut ExperimentalWebAuthnPluginOperationResponse,
|
||||
) -> HRESULT {
|
||||
HRESULT(0)
|
||||
}
|
||||
|
||||
unsafe fn EXPERIMENTAL_PluginCancelOperation(
|
||||
&self,
|
||||
_request: *const ExperimentalWebAuthnPluginCancelOperationRequest,
|
||||
) -> HRESULT {
|
||||
HRESULT(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl IClassFactory_Impl for Factory_Impl {
|
||||
fn CreateInstance(
|
||||
&self,
|
||||
outer: Ref<IUnknown>,
|
||||
iid: *const GUID,
|
||||
object: *mut *mut core::ffi::c_void,
|
||||
) -> Result<()> {
|
||||
assert!(outer.is_null());
|
||||
let unknown: IInspectable = PluginAuthenticatorComObject.into();
|
||||
unsafe { unknown.query(iid, object).ok() }
|
||||
}
|
||||
|
||||
fn LockServer(&self, lock: BOOL) -> Result<()> {
|
||||
assert!(lock.as_bool());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
This file exposes the functions and types defined here: https://github.com/microsoft/webauthn/blob/master/experimental/webauthn.h
|
||||
*/
|
||||
|
||||
/// Used when adding a Windows plugin authenticator.
|
||||
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS
|
||||
/// Header File Usage: EXPERIMENTAL_WebAuthNPluginAddAuthenticator()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct ExperimentalWebAuthnPluginAddAuthenticatorOptions {
|
||||
pub authenticator_name: *const u16,
|
||||
pub com_clsid: *const u16,
|
||||
pub rpid: *const u16,
|
||||
pub light_theme_logo: *const u16,
|
||||
pub dark_theme_logo: *const u16,
|
||||
pub cbor_authenticator_info_byte_count: u32,
|
||||
pub cbor_authenticator_info: *const u8,
|
||||
}
|
||||
|
||||
/// Used as a response type when adding a Windows plugin authenticator.
|
||||
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE
|
||||
/// Header File Usage: EXPERIMENTAL_WebAuthNPluginAddAuthenticator()
|
||||
/// EXPERIMENTAL_WebAuthNPluginFreeAddAuthenticatorResponse()
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct ExperimentalWebAuthnPluginAddAuthenticatorResponse {
|
||||
pub plugin_operation_signing_key_byte_count: u32,
|
||||
pub plugin_operation_signing_key: *mut u8,
|
||||
}
|
||||
@@ -15,18 +15,16 @@
|
||||
</head>
|
||||
|
||||
<body class="layout_frontend">
|
||||
<div class="mt-5 d-flex justify-content-center">
|
||||
<div>
|
||||
<img src="../images/logo-dark@2x.png" class="mb-4 logo" alt="Bitwarden" />
|
||||
<div id="content">
|
||||
<p class="text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x text-muted"
|
||||
title="Loading"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</p>
|
||||
</div>
|
||||
<div class="tw-p-8 tw-flex tw-flex-col tw-items-center">
|
||||
<img class="new-logo-themed tw-mb-4" alt="Bitwarden" />
|
||||
<div id="content">
|
||||
<p class="tw-text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
title="Loading"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
@import "../scss/styles.scss";
|
||||
119
apps/web/src/connectors/sso.spec.ts
Normal file
119
apps/web/src/connectors/sso.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { initiateWebAppSso, initiateBrowserSso } from "./sso";
|
||||
|
||||
describe("sso", () => {
|
||||
let originalLocation: Location;
|
||||
let originalPostMessage: any;
|
||||
let postMessageSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original window methods
|
||||
originalLocation = window.location;
|
||||
originalPostMessage = window.postMessage;
|
||||
|
||||
// Mock location
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
href: "",
|
||||
origin: "https://test.bitwarden.com",
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock postMessage
|
||||
postMessageSpy = jest.spyOn(window, "postMessage");
|
||||
|
||||
// Set up document
|
||||
document.cookie = "ssoHandOffMessage=SSO login successful;SameSite=strict";
|
||||
const contentElement = document.createElement("div");
|
||||
contentElement.id = "content";
|
||||
document.body.appendChild(contentElement);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original window methods
|
||||
Object.defineProperty(window, "location", { value: originalLocation });
|
||||
window.postMessage = originalPostMessage;
|
||||
|
||||
// Clean up document
|
||||
const contentElement = document.getElementById("content");
|
||||
if (contentElement) {
|
||||
document.body.removeChild(contentElement);
|
||||
}
|
||||
document.cookie = "ssoHandOffMessage=;SameSite=strict;max-age=0";
|
||||
|
||||
// Clear mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("initiateWebAppSso", () => {
|
||||
it("redirects to the SSO component with code and state", () => {
|
||||
const code = "testcode";
|
||||
const state = "teststate";
|
||||
|
||||
initiateWebAppSso(code, state);
|
||||
|
||||
expect(window.location.href).toBe(
|
||||
"https://test.bitwarden.com/#/sso?code=testcode&state=teststate",
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects to the return URI when included in state", () => {
|
||||
const code = "testcode";
|
||||
const state = "teststate_returnUri='/organizations'";
|
||||
|
||||
initiateWebAppSso(code, state);
|
||||
|
||||
expect(window.location.href).toBe("https://test.bitwarden.com/#/organizations");
|
||||
});
|
||||
|
||||
it("handles empty code parameter", () => {
|
||||
initiateWebAppSso("", "teststate");
|
||||
expect(window.location.href).toBe("https://test.bitwarden.com/#/sso?code=&state=teststate");
|
||||
});
|
||||
|
||||
it("handles empty state parameter", () => {
|
||||
initiateWebAppSso("testcode", "");
|
||||
expect(window.location.href).toBe("https://test.bitwarden.com/#/sso?code=testcode&state=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initiateBrowserSso", () => {
|
||||
it("posts message with code and state", () => {
|
||||
const code = "testcode";
|
||||
const state = "teststate";
|
||||
const lastpass = false;
|
||||
|
||||
initiateBrowserSso(code, state, lastpass);
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
{ command: "authResult", code, state, lastpass },
|
||||
window.location.origin,
|
||||
);
|
||||
});
|
||||
|
||||
it("updates content with message from cookie", () => {
|
||||
const code = "testcode";
|
||||
const state = "teststate";
|
||||
const lastpass = false;
|
||||
|
||||
initiateBrowserSso(code, state, lastpass);
|
||||
|
||||
const contentElement = document.getElementById("content");
|
||||
const paragraphElement = contentElement?.querySelector("p");
|
||||
expect(paragraphElement?.innerText).toBe("SSO login successful");
|
||||
});
|
||||
|
||||
it("handles lastpass flag correctly", () => {
|
||||
const code = "testcode";
|
||||
const state = "teststate";
|
||||
const lastpass = true;
|
||||
|
||||
initiateBrowserSso(code, state, lastpass);
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
{ command: "authResult", code, state, lastpass },
|
||||
window.location.origin,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,6 @@
|
||||
// @ts-strict-ignore
|
||||
import { getQsParam } from "./common";
|
||||
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require("./sso.scss");
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
const code = getQsParam("code");
|
||||
const state = getQsParam("state");
|
||||
@@ -20,7 +16,7 @@ window.addEventListener("load", () => {
|
||||
}
|
||||
});
|
||||
|
||||
function initiateWebAppSso(code: string, state: string) {
|
||||
export function initiateWebAppSso(code: string, state: string) {
|
||||
// If we've initiated SSO from somewhere other than the SSO component on the web app, the SSO component will add
|
||||
// a _returnUri to the state variable. Here we're extracting that URI and sending the user there instead of to the SSO component.
|
||||
const returnUri = extractFromRegex(state, "(?<=_returnUri=')(.*)(?=')");
|
||||
@@ -31,7 +27,7 @@ function initiateWebAppSso(code: string, state: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function initiateBrowserSso(code: string, state: string, lastpass: boolean) {
|
||||
export function initiateBrowserSso(code: string, state: string, lastpass: boolean) {
|
||||
window.postMessage({ command: "authResult", code, state, lastpass }, window.location.origin);
|
||||
const handOffMessage = ("; " + document.cookie)
|
||||
.split("; ssoHandOffMessage=")
|
||||
|
||||
@@ -122,7 +122,7 @@ const plugins = [
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/connectors/sso.html",
|
||||
filename: "sso-connector.html",
|
||||
chunks: ["connectors/sso"],
|
||||
chunks: ["connectors/sso", "styles"],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/connectors/redirect.html",
|
||||
|
||||
@@ -84,10 +84,8 @@ export class SetupBusinessUnitComponent extends BaseAcceptComponent {
|
||||
|
||||
const organizationKey = await firstValueFrom(organizationKey$);
|
||||
|
||||
const { encryptedString: encryptedOrganizationKey } = await this.encryptService.encrypt(
|
||||
organizationKey.key,
|
||||
providerKey,
|
||||
);
|
||||
const { encryptedString: encryptedOrganizationKey } =
|
||||
await this.encryptService.wrapSymmetricKey(organizationKey, providerKey);
|
||||
|
||||
if (!encryptedProviderKey || !encryptedOrganizationKey) {
|
||||
return await fail();
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<ng-container>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
|
||||
<input bitInput type="text" appAutofocus appInputVerbatim [formControl]="tokenFormControl" />
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
appAutofocus
|
||||
appInputVerbatim
|
||||
[formControl]="tokenFormControl"
|
||||
(keyup)="onTokenChange($event)"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { Component, Input, Output, EventEmitter } from "@angular/core";
|
||||
import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -32,4 +32,10 @@ import {
|
||||
})
|
||||
export class TwoFactorAuthAuthenticatorComponent {
|
||||
@Input({ required: true }) tokenFormControl: FormControl | undefined = undefined;
|
||||
@Output() tokenChange = new EventEmitter<{ token: string }>();
|
||||
|
||||
onTokenChange(event: Event) {
|
||||
const tokenValue = (event.target as HTMLInputElement).value || "";
|
||||
this.tokenChange.emit({ token: tokenValue });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import {
|
||||
TwoFactorAuthEmailComponentCache,
|
||||
TwoFactorAuthEmailComponentCacheService,
|
||||
} from "./two-factor-auth-email-component-cache.service";
|
||||
|
||||
describe("TwoFactorAuthEmailCache", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("returns null when input is null", () => {
|
||||
const result = TwoFactorAuthEmailComponentCache.fromJSON(null as any);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a TwoFactorAuthEmailCache instance from valid JSON", () => {
|
||||
const jsonData = { emailSent: true };
|
||||
const result = TwoFactorAuthEmailComponentCache.fromJSON(jsonData);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBeInstanceOf(TwoFactorAuthEmailComponentCache);
|
||||
expect(result?.emailSent).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwoFactorAuthEmailComponentCacheService", () => {
|
||||
let service: TwoFactorAuthEmailComponentCacheService;
|
||||
let mockViewCacheService: MockProxy<ViewCacheService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let cacheData: BehaviorSubject<TwoFactorAuthEmailComponentCache | null>;
|
||||
let mockSignal: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewCacheService = mock<ViewCacheService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
cacheData = new BehaviorSubject<TwoFactorAuthEmailComponentCache | null>(null);
|
||||
mockSignal = jest.fn(() => cacheData.getValue());
|
||||
mockSignal.set = jest.fn((value: TwoFactorAuthEmailComponentCache | null) =>
|
||||
cacheData.next(value),
|
||||
);
|
||||
mockViewCacheService.signal.mockReturnValue(mockSignal);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TwoFactorAuthEmailComponentCacheService,
|
||||
{ provide: ViewCacheService, useValue: mockViewCacheService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(TwoFactorAuthEmailComponentCacheService);
|
||||
});
|
||||
|
||||
it("creates the service", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("init", () => {
|
||||
it("sets featureEnabled to true when flag is enabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
await service.init();
|
||||
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
|
||||
service.cacheData({ emailSent: true });
|
||||
expect(mockSignal.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets featureEnabled to false when flag is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
await service.init();
|
||||
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
|
||||
service.cacheData({ emailSent: true });
|
||||
expect(mockSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cacheData", () => {
|
||||
beforeEach(async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("caches email sent state when feature is enabled", () => {
|
||||
service.cacheData({ emailSent: true });
|
||||
|
||||
expect(mockSignal.set).toHaveBeenCalledWith({
|
||||
emailSent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not cache data when feature is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
await service.init();
|
||||
|
||||
service.cacheData({ emailSent: true });
|
||||
|
||||
expect(mockSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCachedData", () => {
|
||||
beforeEach(async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("clears cached data when feature is enabled", () => {
|
||||
service.clearCachedData();
|
||||
|
||||
expect(mockSignal.set).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("does not clear cached data when feature is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
await service.init();
|
||||
|
||||
service.clearCachedData();
|
||||
|
||||
expect(mockSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedData", () => {
|
||||
beforeEach(async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("returns cached data when feature is enabled", () => {
|
||||
const testData = new TwoFactorAuthEmailComponentCache();
|
||||
testData.emailSent = true;
|
||||
cacheData.next(testData);
|
||||
|
||||
const result = service.getCachedData();
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
expect(mockSignal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when feature is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
await service.init();
|
||||
|
||||
const result = service.getCachedData();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockSignal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import {
|
||||
TwoFactorAuthEmailComponentCache,
|
||||
TwoFactorAuthEmailComponentCacheService,
|
||||
} from "./two-factor-auth-email-component-cache.service";
|
||||
|
||||
describe("TwoFactorAuthEmailComponentCache", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("returns null when input is null", () => {
|
||||
const result = TwoFactorAuthEmailComponentCache.fromJSON(null as any);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a TwoFactorAuthEmailCache instance from valid JSON", () => {
|
||||
const jsonData = { emailSent: true };
|
||||
const result = TwoFactorAuthEmailComponentCache.fromJSON(jsonData);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBeInstanceOf(TwoFactorAuthEmailComponentCache);
|
||||
expect(result?.emailSent).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwoFactorAuthEmailComponentCacheService", () => {
|
||||
let service: TwoFactorAuthEmailComponentCacheService;
|
||||
let mockViewCacheService: MockProxy<ViewCacheService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let cacheData: BehaviorSubject<TwoFactorAuthEmailComponentCache | null>;
|
||||
let mockSignal: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewCacheService = mock<ViewCacheService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
cacheData = new BehaviorSubject<TwoFactorAuthEmailComponentCache | null>(null);
|
||||
mockSignal = jest.fn(() => cacheData.getValue());
|
||||
mockSignal.set = jest.fn((value: TwoFactorAuthEmailComponentCache | null) =>
|
||||
cacheData.next(value),
|
||||
);
|
||||
mockViewCacheService.signal.mockReturnValue(mockSignal);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TwoFactorAuthEmailComponentCacheService,
|
||||
{ provide: ViewCacheService, useValue: mockViewCacheService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(TwoFactorAuthEmailComponentCacheService);
|
||||
});
|
||||
|
||||
it("creates the service", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("init", () => {
|
||||
it("sets featureEnabled to true when flag is enabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
await service.init();
|
||||
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
|
||||
service.cacheData({ emailSent: true });
|
||||
expect(mockSignal.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets featureEnabled to false when flag is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
await service.init();
|
||||
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
|
||||
service.cacheData({ emailSent: true });
|
||||
expect(mockSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cacheData", () => {
|
||||
beforeEach(async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("caches email sent state when feature is enabled", () => {
|
||||
service.cacheData({ emailSent: true });
|
||||
|
||||
expect(mockSignal.set).toHaveBeenCalledWith({
|
||||
emailSent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not cache data when feature is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
await service.init();
|
||||
|
||||
service.cacheData({ emailSent: true });
|
||||
|
||||
expect(mockSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCachedData", () => {
|
||||
beforeEach(async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("clears cached data when feature is enabled", () => {
|
||||
service.clearCachedData();
|
||||
|
||||
expect(mockSignal.set).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("does not clear cached data when feature is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
await service.init();
|
||||
|
||||
service.clearCachedData();
|
||||
|
||||
expect(mockSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedData", () => {
|
||||
beforeEach(async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("returns cached data when feature is enabled", () => {
|
||||
const testData = new TwoFactorAuthEmailComponentCache();
|
||||
testData.emailSent = true;
|
||||
cacheData.next(testData);
|
||||
|
||||
const result = service.getCachedData();
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
expect(mockSignal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when feature is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
await service.init();
|
||||
|
||||
const result = service.getCachedData();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockSignal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { inject, Injectable, WritableSignal } from "@angular/core";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
/**
|
||||
* The key for the email two factor auth component cache.
|
||||
*/
|
||||
export const TWO_FACTOR_AUTH_EMAIL_COMPONENT_CACHE_KEY = "two-factor-auth-email-component-cache";
|
||||
|
||||
/**
|
||||
* Cache model for the email two factor auth component.
|
||||
*/
|
||||
export class TwoFactorAuthEmailComponentCache {
|
||||
emailSent: boolean = false;
|
||||
|
||||
static fromJSON(
|
||||
obj: Partial<Jsonify<TwoFactorAuthEmailComponentCache>>,
|
||||
): TwoFactorAuthEmailComponentCache | null {
|
||||
// Return null if the cache is empty
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new TwoFactorAuthEmailComponentCache(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache service for the two factor auth email component.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorAuthEmailComponentCacheService {
|
||||
private viewCacheService: ViewCacheService = inject(ViewCacheService);
|
||||
private configService: ConfigService = inject(ConfigService);
|
||||
|
||||
/** True when the feature flag is enabled */
|
||||
private featureEnabled: boolean = false;
|
||||
|
||||
/**
|
||||
* Signal for the cached email state.
|
||||
*/
|
||||
private emailCache: WritableSignal<TwoFactorAuthEmailComponentCache | null> =
|
||||
this.viewCacheService.signal<TwoFactorAuthEmailComponentCache | null>({
|
||||
key: TWO_FACTOR_AUTH_EMAIL_COMPONENT_CACHE_KEY,
|
||||
initialValue: null,
|
||||
deserializer: TwoFactorAuthEmailComponentCache.fromJSON,
|
||||
});
|
||||
|
||||
/**
|
||||
* Must be called once before interacting with the cached data.
|
||||
*/
|
||||
async init() {
|
||||
this.featureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache the email sent state.
|
||||
*/
|
||||
cacheData(data: { emailSent: boolean }): void {
|
||||
if (!this.featureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emailCache.set({
|
||||
emailSent: data.emailSent,
|
||||
} as TwoFactorAuthEmailComponentCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached email data.
|
||||
*/
|
||||
clearCachedData(): void {
|
||||
if (!this.featureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emailCache.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether the email has been sent.
|
||||
*/
|
||||
getCachedData(): TwoFactorAuthEmailComponentCache | null {
|
||||
if (!this.featureEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.emailCache();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
<bit-form-field class="!tw-mb-0">
|
||||
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
|
||||
<input bitInput type="text" appAutofocus appInputVerbatim [formControl]="tokenFormControl" />
|
||||
<input
|
||||
bitInput
|
||||
type="text"
|
||||
appAutofocus
|
||||
appInputVerbatim
|
||||
[formControl]="tokenFormControl"
|
||||
(keyup)="onTokenChange($event)"
|
||||
/>
|
||||
</bit-form-field>
|
||||
|
||||
<div class="tw-mb-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { Component, Input, OnInit, Output, EventEmitter } from "@angular/core";
|
||||
import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { TwoFactorAuthEmailComponentCacheService } from "./two-factor-auth-email-component-cache.service";
|
||||
import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-component.service";
|
||||
|
||||
@Component({
|
||||
@@ -40,14 +41,20 @@ import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-comp
|
||||
AsyncActionsModule,
|
||||
FormsModule,
|
||||
],
|
||||
providers: [],
|
||||
providers: [
|
||||
{
|
||||
provide: TwoFactorAuthEmailComponentCacheService,
|
||||
useClass: TwoFactorAuthEmailComponentCacheService,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class TwoFactorAuthEmailComponent implements OnInit {
|
||||
@Input({ required: true }) tokenFormControl: FormControl | undefined = undefined;
|
||||
@Output() tokenChange = new EventEmitter<{ token: string }>();
|
||||
|
||||
twoFactorEmail: string | undefined = undefined;
|
||||
emailPromise: Promise<any> | undefined = undefined;
|
||||
tokenValue: string = "";
|
||||
emailPromise: Promise<any> | undefined;
|
||||
emailSent = false;
|
||||
|
||||
constructor(
|
||||
protected i18nService: I18nService,
|
||||
@@ -59,14 +66,22 @@ export class TwoFactorAuthEmailComponent implements OnInit {
|
||||
protected appIdService: AppIdService,
|
||||
private toastService: ToastService,
|
||||
private twoFactorAuthEmailComponentService: TwoFactorAuthEmailComponentService,
|
||||
private cacheService: TwoFactorAuthEmailComponentCacheService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.twoFactorAuthEmailComponentService.openPopoutIfApprovedForEmail2fa?.();
|
||||
await this.cacheService.init();
|
||||
|
||||
// Check if email was already sent
|
||||
const cachedData = this.cacheService.getCachedData();
|
||||
if (cachedData?.emailSent) {
|
||||
this.emailSent = true;
|
||||
}
|
||||
|
||||
const providers = await this.twoFactorService.getProviders();
|
||||
|
||||
if (!providers) {
|
||||
if (!providers || providers.size === 0) {
|
||||
throw new Error("User has no 2FA Providers");
|
||||
}
|
||||
|
||||
@@ -78,11 +93,20 @@ export class TwoFactorAuthEmailComponent implements OnInit {
|
||||
|
||||
this.twoFactorEmail = email2faProviderData.Email;
|
||||
|
||||
if (providers.size > 1) {
|
||||
if (!this.emailSent) {
|
||||
await this.sendEmail(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the token value to the parent component
|
||||
* @param event - The event object from the input field
|
||||
*/
|
||||
onTokenChange(event: Event) {
|
||||
const tokenValue = (event.target as HTMLInputElement).value || "";
|
||||
this.tokenChange.emit({ token: tokenValue });
|
||||
}
|
||||
|
||||
async sendEmail(doToast: boolean) {
|
||||
if (this.emailPromise !== undefined) {
|
||||
return;
|
||||
@@ -113,6 +137,10 @@ export class TwoFactorAuthEmailComponent implements OnInit {
|
||||
request.authRequestId = (await this.loginStrategyService.getAuthRequestId()) ?? "";
|
||||
this.emailPromise = this.apiService.postTwoFactorEmail(request);
|
||||
await this.emailPromise;
|
||||
|
||||
this.emailSent = true;
|
||||
this.cacheService.cacheData({ emailSent: this.emailSent });
|
||||
|
||||
if (doToast) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import {
|
||||
TwoFactorAuthComponentCache,
|
||||
TwoFactorAuthComponentCacheService,
|
||||
TwoFactorAuthComponentData,
|
||||
} from "./two-factor-auth-component-cache.service";
|
||||
|
||||
describe("TwoFactorAuthCache", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("returns null when input is null", () => {
|
||||
const result = TwoFactorAuthComponentCache.fromJSON(null as any);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a TwoFactorAuthCache instance from valid JSON", () => {
|
||||
const jsonData = {
|
||||
token: "123456",
|
||||
remember: true,
|
||||
selectedProviderType: TwoFactorProviderType.Email,
|
||||
};
|
||||
const result = TwoFactorAuthComponentCache.fromJSON(jsonData as any);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBeInstanceOf(TwoFactorAuthComponentCache);
|
||||
expect(result?.token).toBe("123456");
|
||||
expect(result?.remember).toBe(true);
|
||||
expect(result?.selectedProviderType).toBe(TwoFactorProviderType.Email);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwoFactorAuthComponentCacheService", () => {
|
||||
let service: TwoFactorAuthComponentCacheService;
|
||||
let mockViewCacheService: MockProxy<ViewCacheService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let cacheData: BehaviorSubject<TwoFactorAuthComponentCache | null>;
|
||||
let mockSignal: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewCacheService = mock<ViewCacheService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
cacheData = new BehaviorSubject<TwoFactorAuthComponentCache | null>(null);
|
||||
mockSignal = jest.fn(() => cacheData.getValue());
|
||||
mockSignal.set = jest.fn((value: TwoFactorAuthComponentCache | null) => cacheData.next(value));
|
||||
mockViewCacheService.signal.mockReturnValue(mockSignal);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TwoFactorAuthComponentCacheService,
|
||||
{ provide: ViewCacheService, useValue: mockViewCacheService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(TwoFactorAuthComponentCacheService);
|
||||
});
|
||||
|
||||
it("creates the service", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("init", () => {
|
||||
it("sets featureEnabled to true when flag is enabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
await service.init();
|
||||
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
|
||||
service.cacheData({ token: "123456" });
|
||||
expect(mockSignal.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets featureEnabled to false when flag is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
await service.init();
|
||||
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
|
||||
service.cacheData({ token: "123456" });
|
||||
expect(mockSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cacheData", () => {
|
||||
beforeEach(async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("caches complete data when feature is enabled", () => {
|
||||
const testData: TwoFactorAuthComponentData = {
|
||||
token: "123456",
|
||||
remember: true,
|
||||
selectedProviderType: TwoFactorProviderType.Email,
|
||||
};
|
||||
|
||||
service.cacheData(testData);
|
||||
|
||||
expect(mockSignal.set).toHaveBeenCalledWith({
|
||||
token: "123456",
|
||||
remember: true,
|
||||
selectedProviderType: TwoFactorProviderType.Email,
|
||||
});
|
||||
});
|
||||
|
||||
it("caches partial data when feature is enabled", () => {
|
||||
service.cacheData({ token: "123456" });
|
||||
|
||||
expect(mockSignal.set).toHaveBeenCalledWith({
|
||||
token: "123456",
|
||||
remember: undefined,
|
||||
selectedProviderType: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not cache data when feature is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
await service.init();
|
||||
|
||||
service.cacheData({ token: "123456" });
|
||||
|
||||
expect(mockSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCachedData", () => {
|
||||
beforeEach(async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("clears cached data when feature is enabled", () => {
|
||||
service.clearCachedData();
|
||||
|
||||
expect(mockSignal.set).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("does not clear cached data when feature is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
await service.init();
|
||||
|
||||
service.clearCachedData();
|
||||
|
||||
expect(mockSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedData", () => {
|
||||
beforeEach(async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it("returns cached data when feature is enabled", () => {
|
||||
const testData = new TwoFactorAuthComponentCache();
|
||||
testData.token = "123456";
|
||||
testData.remember = true;
|
||||
testData.selectedProviderType = TwoFactorProviderType.Email;
|
||||
cacheData.next(testData);
|
||||
|
||||
const result = service.getCachedData();
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
expect(mockSignal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when feature is disabled", async () => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
await service.init();
|
||||
|
||||
const result = service.getCachedData();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockSignal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { inject, Injectable, WritableSignal } from "@angular/core";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
const TWO_FACTOR_AUTH_COMPONENT_CACHE_KEY = "two-factor-auth-component-cache";
|
||||
|
||||
/**
|
||||
* Cache model for the two factor authentication data.
|
||||
*/
|
||||
export class TwoFactorAuthComponentCache {
|
||||
token: string | undefined = undefined;
|
||||
remember: boolean | undefined = undefined;
|
||||
selectedProviderType: TwoFactorProviderType | undefined = undefined;
|
||||
|
||||
static fromJSON(
|
||||
obj: Partial<Jsonify<TwoFactorAuthComponentCache>>,
|
||||
): TwoFactorAuthComponentCache | null {
|
||||
// Return null if the cache is empty
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new TwoFactorAuthComponentCache(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TwoFactorAuthComponentData {
|
||||
token?: string;
|
||||
remember?: boolean;
|
||||
selectedProviderType?: TwoFactorProviderType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache service used for the two factor auth component.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorAuthComponentCacheService {
|
||||
private viewCacheService: ViewCacheService = inject(ViewCacheService);
|
||||
private configService: ConfigService = inject(ConfigService);
|
||||
|
||||
/** True when the `PM9115_TwoFactorExtensionDataPersistence` flag is enabled */
|
||||
private featureEnabled: boolean = false;
|
||||
|
||||
/**
|
||||
* Signal for the cached TwoFactorAuthData.
|
||||
*/
|
||||
private twoFactorAuthComponentCache: WritableSignal<TwoFactorAuthComponentCache | null> =
|
||||
this.viewCacheService.signal<TwoFactorAuthComponentCache | null>({
|
||||
key: TWO_FACTOR_AUTH_COMPONENT_CACHE_KEY,
|
||||
initialValue: null,
|
||||
deserializer: TwoFactorAuthComponentCache.fromJSON,
|
||||
});
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Must be called once before interacting with the cached data.
|
||||
*/
|
||||
async init() {
|
||||
this.featureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cache with the new TwoFactorAuthData.
|
||||
*/
|
||||
cacheData(data: TwoFactorAuthComponentData): void {
|
||||
if (!this.featureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.twoFactorAuthComponentCache.set({
|
||||
token: data.token,
|
||||
remember: data.remember,
|
||||
selectedProviderType: data.selectedProviderType,
|
||||
} as TwoFactorAuthComponentCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached TwoFactorAuthData.
|
||||
*/
|
||||
clearCachedData(): void {
|
||||
if (!this.featureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.twoFactorAuthComponentCache.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached TwoFactorAuthData (when available).
|
||||
*/
|
||||
getCachedData(): TwoFactorAuthComponentCache | null {
|
||||
if (!this.featureEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.twoFactorAuthComponentCache();
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,13 @@
|
||||
>
|
||||
<app-two-factor-auth-email
|
||||
[tokenFormControl]="tokenFormControl"
|
||||
(tokenChange)="saveFormDataWithPartialData($event)"
|
||||
*ngIf="selectedProviderType === providerType.Email"
|
||||
/>
|
||||
|
||||
<app-two-factor-auth-authenticator
|
||||
[tokenFormControl]="tokenFormControl"
|
||||
(tokenChange)="saveFormDataWithPartialData($event)"
|
||||
*ngIf="selectedProviderType === providerType.Authenticator"
|
||||
/>
|
||||
<app-two-factor-auth-yubikey
|
||||
@@ -36,7 +38,7 @@
|
||||
/>
|
||||
<bit-form-control *ngIf="!hideRememberMe()">
|
||||
<bit-label>{{ "dontAskAgainOnThisDeviceFor30Days" | i18n }}</bit-label>
|
||||
<input type="checkbox" bitCheckbox formControlName="remember" />
|
||||
<input type="checkbox" bitCheckbox formControlName="remember" (change)="onRememberChange()" />
|
||||
</bit-form-control>
|
||||
|
||||
<app-two-factor-auth-webauthn
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute, convertToParamMap, Router } from "@angular/router";
|
||||
import { ActivatedRoute, Router, convertToParamMap } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
@@ -38,6 +36,7 @@ import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
|
||||
|
||||
import { TwoFactorAuthComponentCacheService } from "./two-factor-auth-component-cache.service";
|
||||
import { TwoFactorAuthComponentService } from "./two-factor-auth-component.service";
|
||||
import { TwoFactorAuthComponent } from "./two-factor-auth.component";
|
||||
|
||||
@@ -72,6 +71,7 @@ describe("TwoFactorAuthComponent", () => {
|
||||
let anonLayoutWrapperDataService: MockProxy<AnonLayoutWrapperDataService>;
|
||||
let mockEnvService: MockProxy<EnvironmentService>;
|
||||
let mockLoginSuccessHandlerService: MockProxy<LoginSuccessHandlerService>;
|
||||
let mockTwoFactorAuthCompCacheService: MockProxy<TwoFactorAuthComponentCacheService>;
|
||||
|
||||
let mockUserDecryptionOpts: {
|
||||
noMasterPassword: UserDecryptionOptions;
|
||||
@@ -112,6 +112,10 @@ describe("TwoFactorAuthComponent", () => {
|
||||
|
||||
anonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
|
||||
|
||||
mockTwoFactorAuthCompCacheService = mock<TwoFactorAuthComponentCacheService>();
|
||||
mockTwoFactorAuthCompCacheService.getCachedData.mockReturnValue(null);
|
||||
mockTwoFactorAuthCompCacheService.init.mockResolvedValue();
|
||||
|
||||
mockUserDecryptionOpts = {
|
||||
noMasterPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
@@ -155,7 +159,9 @@ describe("TwoFactorAuthComponent", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(undefined);
|
||||
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(
|
||||
mockUserDecryptionOpts.withMasterPassword,
|
||||
);
|
||||
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -194,6 +200,10 @@ describe("TwoFactorAuthComponent", () => {
|
||||
{ provide: EnvironmentService, useValue: mockEnvService },
|
||||
{ provide: AnonLayoutWrapperDataService, useValue: anonLayoutWrapperDataService },
|
||||
{ provide: LoginSuccessHandlerService, useValue: mockLoginSuccessHandlerService },
|
||||
{
|
||||
provide: TwoFactorAuthComponentCacheService,
|
||||
useValue: mockTwoFactorAuthCompCacheService,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -60,6 +60,10 @@ import { TwoFactorAuthDuoComponent } from "./child-components/two-factor-auth-du
|
||||
import { TwoFactorAuthEmailComponent } from "./child-components/two-factor-auth-email/two-factor-auth-email.component";
|
||||
import { TwoFactorAuthWebAuthnComponent } from "./child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component";
|
||||
import { TwoFactorAuthYubikeyComponent } from "./child-components/two-factor-auth-yubikey.component";
|
||||
import {
|
||||
TwoFactorAuthComponentCacheService,
|
||||
TwoFactorAuthComponentData,
|
||||
} from "./two-factor-auth-component-cache.service";
|
||||
import {
|
||||
DuoLaunchAction,
|
||||
LegacyKeyMigrationAction,
|
||||
@@ -90,7 +94,11 @@ import {
|
||||
TwoFactorAuthYubikeyComponent,
|
||||
TwoFactorAuthWebAuthnComponent,
|
||||
],
|
||||
providers: [],
|
||||
providers: [
|
||||
{
|
||||
provide: TwoFactorAuthComponentCacheService,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
@ViewChild("continueButton", { read: ElementRef, static: false }) continueButton:
|
||||
@@ -160,6 +168,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private environmentService: EnvironmentService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private twoFactorAuthComponentCacheService: TwoFactorAuthComponentCacheService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -168,7 +177,33 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.listenForAuthnSessionTimeout();
|
||||
|
||||
await this.setSelected2faProviderType();
|
||||
// Initialize the cache
|
||||
await this.twoFactorAuthComponentCacheService.init();
|
||||
|
||||
// Load cached form data if available
|
||||
let loadedCachedProviderType = false;
|
||||
const cachedData = this.twoFactorAuthComponentCacheService.getCachedData();
|
||||
if (cachedData) {
|
||||
if (cachedData.token) {
|
||||
this.form.patchValue({ token: cachedData.token });
|
||||
}
|
||||
if (cachedData.remember !== undefined) {
|
||||
this.form.patchValue({ remember: cachedData.remember });
|
||||
}
|
||||
if (cachedData.selectedProviderType !== undefined) {
|
||||
this.selectedProviderType = cachedData.selectedProviderType;
|
||||
loadedCachedProviderType = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a cached provider type, set it to the default and cache it
|
||||
if (!loadedCachedProviderType) {
|
||||
this.selectedProviderType = await this.initializeSelected2faProviderType();
|
||||
this.twoFactorAuthComponentCacheService.cacheData({
|
||||
selectedProviderType: this.selectedProviderType,
|
||||
});
|
||||
}
|
||||
|
||||
await this.set2faProvidersAndData();
|
||||
await this.setAnonLayoutDataByTwoFactorProviderType();
|
||||
|
||||
@@ -181,7 +216,29 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private async setSelected2faProviderType() {
|
||||
/**
|
||||
* Save specific form data fields to the cache
|
||||
*/
|
||||
async saveFormDataWithPartialData(data: Partial<TwoFactorAuthComponentData>) {
|
||||
// Get current cached data
|
||||
const currentData = this.twoFactorAuthComponentCacheService.getCachedData();
|
||||
|
||||
this.twoFactorAuthComponentCacheService.cacheData({
|
||||
token: data?.token ?? currentData?.token ?? "",
|
||||
remember: data?.remember ?? currentData?.remember ?? false,
|
||||
selectedProviderType: data?.selectedProviderType ?? currentData?.selectedProviderType,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the remember value to the cache when the checkbox is checked or unchecked
|
||||
*/
|
||||
async onRememberChange() {
|
||||
const rememberValue = !!this.rememberFormControl.value;
|
||||
await this.saveFormDataWithPartialData({ remember: rememberValue });
|
||||
}
|
||||
|
||||
private async initializeSelected2faProviderType(): Promise<TwoFactorProviderType> {
|
||||
const webAuthnSupported = this.platformUtilsService.supportsWebAuthn(this.win);
|
||||
|
||||
if (
|
||||
@@ -190,18 +247,19 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
) {
|
||||
const webAuthn2faResponse = this.activatedRoute.snapshot.paramMap.get("webAuthnResponse");
|
||||
if (webAuthn2faResponse) {
|
||||
this.selectedProviderType = TwoFactorProviderType.WebAuthn;
|
||||
return;
|
||||
return TwoFactorProviderType.WebAuthn;
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedProviderType = await this.twoFactorService.getDefaultProvider(webAuthnSupported);
|
||||
return await this.twoFactorService.getDefaultProvider(webAuthnSupported);
|
||||
}
|
||||
|
||||
private async set2faProvidersAndData() {
|
||||
this.twoFactorProviders = await this.twoFactorService.getProviders();
|
||||
const providerData = this.twoFactorProviders?.get(this.selectedProviderType);
|
||||
this.selectedProviderData = providerData;
|
||||
if (this.selectedProviderType !== undefined) {
|
||||
const providerData = this.twoFactorProviders?.get(this.selectedProviderType);
|
||||
this.selectedProviderData = providerData;
|
||||
}
|
||||
}
|
||||
|
||||
private listenForAuthnSessionTimeout() {
|
||||
@@ -267,6 +325,13 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
// In all flows but WebAuthn, the remember value is taken from the form.
|
||||
const rememberValue = remember ?? this.rememberFormControl.value ?? false;
|
||||
|
||||
// Cache form data before submitting
|
||||
this.twoFactorAuthComponentCacheService.cacheData({
|
||||
token: tokenValue,
|
||||
remember: rememberValue,
|
||||
selectedProviderType: this.selectedProviderType,
|
||||
});
|
||||
|
||||
try {
|
||||
this.formPromise = this.loginStrategyService.logInTwoFactor(
|
||||
new TokenTwoFactorRequest(this.selectedProviderType, tokenValue, rememberValue),
|
||||
@@ -274,6 +339,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
const authResult: AuthResult = await this.formPromise;
|
||||
this.logService.info("Successfully submitted two factor token");
|
||||
|
||||
await this.handleAuthResult(authResult);
|
||||
} catch {
|
||||
this.logService.error("Error submitting two factor token");
|
||||
@@ -299,6 +365,13 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
this.selectedProviderType = response.type;
|
||||
await this.setAnonLayoutDataByTwoFactorProviderType();
|
||||
|
||||
// Update the cached provider type when a new one is chosen
|
||||
this.twoFactorAuthComponentCacheService.cacheData({
|
||||
token: "",
|
||||
remember: false,
|
||||
selectedProviderType: response.type,
|
||||
});
|
||||
|
||||
this.form.reset();
|
||||
this.form.updateValueAndValidity();
|
||||
}
|
||||
@@ -376,6 +449,9 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private async handleAuthResult(authResult: AuthResult) {
|
||||
// Clear form cache
|
||||
this.twoFactorAuthComponentCacheService.clearCachedData();
|
||||
|
||||
if (await this.handleMigrateEncryptionKey(authResult)) {
|
||||
return; // stop login process
|
||||
}
|
||||
|
||||
@@ -123,7 +123,9 @@ describe("AuthRequestService", () => {
|
||||
});
|
||||
|
||||
it("should use the user key if the master key and hash do not exist", async () => {
|
||||
keyService.getUserKey.mockResolvedValueOnce({ key: new Uint8Array(64) } as UserKey);
|
||||
keyService.getUserKey.mockResolvedValueOnce(
|
||||
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||
);
|
||||
|
||||
await sut.approveOrDenyAuthRequest(
|
||||
true,
|
||||
@@ -131,7 +133,7 @@ describe("AuthRequestService", () => {
|
||||
);
|
||||
|
||||
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
{ key: new Uint8Array(64) },
|
||||
new SymmetricCryptoKey(new Uint8Array(64)),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -434,7 +434,7 @@ describe("PinService", () => {
|
||||
.fn()
|
||||
.mockResolvedValue(pinKeyEncryptedUserKeyPersistant);
|
||||
sut.makePinKey = jest.fn().mockResolvedValue(mockPinKey);
|
||||
encryptService.decryptToBytes.mockResolvedValue(mockUserKey.key);
|
||||
encryptService.decryptToBytes.mockResolvedValue(mockUserKey.toEncoded());
|
||||
}
|
||||
|
||||
function mockPinEncryptedKeyDataByPinLockType(pinLockType: PinLockType) {
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("WebAuthnLoginPrfKeyService", () => {
|
||||
|
||||
const result = await service.createSymmetricKeyFromPrf(randomBytes(32));
|
||||
|
||||
expect(result.key.length).toBe(64);
|
||||
expect(result.toEncoded().length).toBe(64);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ export enum FeatureFlag {
|
||||
|
||||
/* Auth */
|
||||
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
|
||||
PM9115_TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence",
|
||||
|
||||
/* Autofill */
|
||||
BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain",
|
||||
@@ -54,7 +55,6 @@ export enum FeatureFlag {
|
||||
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
|
||||
NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss",
|
||||
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
|
||||
VaultBulkManagementAction = "vault-bulk-management-action",
|
||||
SecurityTasks = "security-tasks",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms",
|
||||
@@ -108,7 +108,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
|
||||
[FeatureFlag.NewDeviceVerificationTemporaryDismiss]: FALSE,
|
||||
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.SecurityTasks]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
[FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE,
|
||||
@@ -116,6 +115,7 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
|
||||
[FeatureFlag.PM9115_TwoFactorExtensionDataPersistence]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
|
||||
@@ -47,7 +47,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
}
|
||||
|
||||
if (this.blockType0) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("No wrappingKey provided for wrapping.");
|
||||
}
|
||||
|
||||
return await this.encryptUint8Array(keyToBeWrapped.key, wrappingKey);
|
||||
return await this.encryptUint8Array(keyToBeWrapped.toEncoded(), wrappingKey);
|
||||
}
|
||||
|
||||
private async encryptUint8Array(
|
||||
@@ -117,7 +117,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
}
|
||||
|
||||
if (this.blockType0) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
}
|
||||
|
||||
if (this.blockType0) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,9 @@ describe("EncryptService", () => {
|
||||
it("fails if type 0 key is provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: mock32Key.key,
|
||||
encryptionKey: makeStaticByteArray(32),
|
||||
});
|
||||
|
||||
await expect(encryptService.wrapSymmetricKey(mock32Key, mock32Key)).rejects.toThrow(
|
||||
@@ -99,10 +98,9 @@ describe("EncryptService", () => {
|
||||
it("throws if type 0 key is provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: mock32Key.key,
|
||||
encryptionKey: makeStaticByteArray(32),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -140,10 +138,9 @@ describe("EncryptService", () => {
|
||||
it("throws if type 0 key is provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: mock32Key.key,
|
||||
encryptionKey: makeStaticByteArray(32),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -187,10 +184,9 @@ describe("EncryptService", () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: mock32Key.key,
|
||||
encryptionKey: makeStaticByteArray(32),
|
||||
});
|
||||
|
||||
await expect(encryptService.encrypt(null!, key)).rejects.toThrow(
|
||||
@@ -273,10 +269,9 @@ describe("EncryptService", () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: mock32Key.key,
|
||||
encryptionKey: makeStaticByteArray(32),
|
||||
});
|
||||
|
||||
await expect(encryptService.encryptToBytes(plainValue, key)).rejects.toThrow(
|
||||
@@ -574,7 +569,7 @@ describe("EncryptService", () => {
|
||||
const actual = await encryptService.encapsulateKeyUnsigned(testKey, publicKey);
|
||||
|
||||
expect(cryptoFunctionService.rsaEncrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(testKey.key),
|
||||
expect.toEqualBuffer(testKey.toEncoded()),
|
||||
expect.toEqualBuffer(publicKey),
|
||||
"sha1",
|
||||
);
|
||||
@@ -625,7 +620,7 @@ describe("EncryptService", () => {
|
||||
"sha1",
|
||||
);
|
||||
|
||||
expect(actual.key).toEqualBuffer(data);
|
||||
expect(actual.toEncoded()).toEqualBuffer(data);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,8 +221,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
}
|
||||
|
||||
const newEncryptedPublicKey = await this.encryptService.encrypt(publicKey, newUserKey);
|
||||
const newEncryptedUserKey = await this.encryptService.rsaEncrypt(
|
||||
newUserKey.key,
|
||||
const newEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
newUserKey,
|
||||
publicKey,
|
||||
);
|
||||
|
||||
|
||||
@@ -450,7 +450,7 @@ describe("deviceTrustService", () => {
|
||||
|
||||
// RsaEncrypt must be called w/ a user key array buffer of 64 bytes
|
||||
const userKey = cryptoSvcRsaEncryptSpy.mock.calls[0][0];
|
||||
expect(userKey.key.byteLength).toBe(64);
|
||||
expect(userKey.inner().type).toBe(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
|
||||
expect(encryptServiceWrapDecapsulationKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(encryptServiceWrapEncapsulationKeySpy).toHaveBeenCalledTimes(1);
|
||||
@@ -706,7 +706,9 @@ describe("deviceTrustService", () => {
|
||||
);
|
||||
encryptService.decryptToBytes.mockResolvedValue(null);
|
||||
encryptService.encrypt.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
encryptService.rsaEncrypt.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(
|
||||
new EncString("test_encrypted_data"),
|
||||
);
|
||||
|
||||
const protectedDeviceResponse = new ProtectedDeviceResponse({
|
||||
id: "id",
|
||||
@@ -861,8 +863,8 @@ describe("deviceTrustService", () => {
|
||||
|
||||
// Mock the decryption of the public key with the old user key
|
||||
encryptService.decryptToBytes.mockImplementationOnce((_encValue, privateKeyValue) => {
|
||||
expect(privateKeyValue.key.byteLength).toBe(64);
|
||||
expect(new Uint8Array(privateKeyValue.key)[0]).toBe(FakeOldUserKeyMarker);
|
||||
expect(privateKeyValue.inner().type).toBe(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
expect(new Uint8Array(privateKeyValue.toEncoded())[0]).toBe(FakeOldUserKeyMarker);
|
||||
const data = new Uint8Array(250);
|
||||
data.fill(FakeDecryptedPublicKeyMarker, 0, 1);
|
||||
return Promise.resolve(data);
|
||||
@@ -870,8 +872,8 @@ describe("deviceTrustService", () => {
|
||||
|
||||
// Mock the encryption of the new user key with the decrypted public key
|
||||
encryptService.encapsulateKeyUnsigned.mockImplementationOnce((data, publicKey) => {
|
||||
expect(data.key.byteLength).toBe(64); // New key should also be 64 bytes
|
||||
expect(new Uint8Array(data.key)[0]).toBe(FakeNewUserKeyMarker); // New key should have the first byte be '1';
|
||||
expect(data.inner().type).toBe(EncryptionType.AesCbc256_HmacSha256_B64); // New key should also be 64 bytes
|
||||
expect(new Uint8Array(data.toEncoded())[0]).toBe(FakeNewUserKeyMarker); // New key should have the first byte be '1';
|
||||
|
||||
expect(new Uint8Array(publicKey)[0]).toBe(FakeDecryptedPublicKeyMarker);
|
||||
return Promise.resolve(new EncString("4.ZW5jcnlwdGVkdXNlcg=="));
|
||||
@@ -882,7 +884,7 @@ describe("deviceTrustService", () => {
|
||||
expect(plainValue).toBeInstanceOf(Uint8Array);
|
||||
expect(new Uint8Array(plainValue as Uint8Array)[0]).toBe(FakeDecryptedPublicKeyMarker);
|
||||
|
||||
expect(new Uint8Array(key.key)[0]).toBe(FakeNewUserKeyMarker);
|
||||
expect(new Uint8Array(key.toEncoded())[0]).toBe(FakeNewUserKeyMarker);
|
||||
return Promise.resolve(
|
||||
new EncString("2.ZW5jcnlwdGVkcHVibGlj|ZW5jcnlwdGVkcHVibGlj|ZW5jcnlwdGVkcHVibGlj"),
|
||||
);
|
||||
|
||||
@@ -19,7 +19,6 @@ describe("SymmetricCryptoKey", () => {
|
||||
const cryptoKey = new SymmetricCryptoKey(key);
|
||||
|
||||
expect(cryptoKey).toEqual({
|
||||
key: key,
|
||||
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
innerKey: {
|
||||
type: EncryptionType.AesCbc256_B64,
|
||||
@@ -33,7 +32,6 @@ describe("SymmetricCryptoKey", () => {
|
||||
const cryptoKey = new SymmetricCryptoKey(key);
|
||||
|
||||
expect(cryptoKey).toEqual({
|
||||
key: key,
|
||||
keyB64:
|
||||
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+Pw==",
|
||||
innerKey: {
|
||||
|
||||
@@ -24,7 +24,6 @@ export type Aes256CbcKey = {
|
||||
export class SymmetricCryptoKey {
|
||||
private innerKey: Aes256CbcHmacKey | Aes256CbcKey;
|
||||
|
||||
key: Uint8Array;
|
||||
keyB64: string;
|
||||
|
||||
/**
|
||||
@@ -40,7 +39,6 @@ export class SymmetricCryptoKey {
|
||||
type: EncryptionType.AesCbc256_B64,
|
||||
encryptionKey: key,
|
||||
};
|
||||
this.key = key;
|
||||
this.keyB64 = this.toBase64();
|
||||
} else if (key.byteLength === 64) {
|
||||
this.innerKey = {
|
||||
@@ -48,7 +46,6 @@ export class SymmetricCryptoKey {
|
||||
encryptionKey: key.slice(0, 32),
|
||||
authenticationKey: key.slice(32),
|
||||
};
|
||||
this.key = key;
|
||||
this.keyB64 = this.toBase64();
|
||||
} else {
|
||||
throw new Error(`Unsupported encType/key length ${key.byteLength}`);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PBKDF2KdfConfig, Argon2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { EncryptionType } from "../enums";
|
||||
|
||||
import { KeyGenerationService } from "./key-generation.service";
|
||||
|
||||
@@ -52,7 +53,7 @@ describe("KeyGenerationService", () => {
|
||||
|
||||
expect(salt).toEqual(inputSalt);
|
||||
expect(material).toEqual(inputMaterial);
|
||||
expect(derivedKey.key.length).toEqual(64);
|
||||
expect(derivedKey.inner().type).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -67,7 +68,7 @@ describe("KeyGenerationService", () => {
|
||||
|
||||
const key = await sut.deriveKeyFromMaterial(material, salt, purpose);
|
||||
|
||||
expect(key.key.length).toEqual(64);
|
||||
expect(key.inner().type).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,7 +82,7 @@ describe("KeyGenerationService", () => {
|
||||
|
||||
const key = await sut.deriveKeyFromPassword(password, salt, kdfConfig);
|
||||
|
||||
expect(key.key.length).toEqual(32);
|
||||
expect(key.inner().type).toEqual(EncryptionType.AesCbc256_B64);
|
||||
});
|
||||
|
||||
it("should derive a 32 byte key from a password using argon2id", async () => {
|
||||
@@ -94,7 +95,7 @@ describe("KeyGenerationService", () => {
|
||||
|
||||
const key = await sut.deriveKeyFromPassword(password, salt, kdfConfig);
|
||||
|
||||
expect(key.key.length).toEqual(32);
|
||||
expect(key.inner().type).toEqual(EncryptionType.AesCbc256_B64);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { MasterKey, PinKey } from "@bitwarden/common/types/key";
|
||||
import { KdfConfig, PBKDF2KdfConfig, Argon2KdfConfig, KdfType } from "@bitwarden/key-management";
|
||||
|
||||
import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service";
|
||||
@@ -78,10 +79,21 @@ export class KeyGenerationService implements KeyGenerationServiceAbstraction {
|
||||
return new SymmetricCryptoKey(key);
|
||||
}
|
||||
|
||||
async stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey> {
|
||||
async stretchKey(key: MasterKey | PinKey): Promise<SymmetricCryptoKey> {
|
||||
const newKey = new Uint8Array(64);
|
||||
const encKey = await this.cryptoFunctionService.hkdfExpand(key.key, "enc", 32, "sha256");
|
||||
const macKey = await this.cryptoFunctionService.hkdfExpand(key.key, "mac", 32, "sha256");
|
||||
// Master key and pin key are always 32 bytes
|
||||
const encKey = await this.cryptoFunctionService.hkdfExpand(
|
||||
key.inner().encryptionKey,
|
||||
"enc",
|
||||
32,
|
||||
"sha256",
|
||||
);
|
||||
const macKey = await this.cryptoFunctionService.hkdfExpand(
|
||||
key.inner().encryptionKey,
|
||||
"mac",
|
||||
32,
|
||||
"sha256",
|
||||
);
|
||||
|
||||
newKey.set(new Uint8Array(encKey));
|
||||
newKey.set(new Uint8Array(macKey), 32);
|
||||
|
||||
@@ -497,7 +497,7 @@ describe("keyService", () => {
|
||||
const output = new Uint8Array(64);
|
||||
output.set(encryptedPrivateKey.dataBytes);
|
||||
output.set(
|
||||
key.key.subarray(0, 64 - encryptedPrivateKey.dataBytes.length),
|
||||
key.toEncoded().subarray(0, 64 - encryptedPrivateKey.dataBytes.length),
|
||||
encryptedPrivateKey.dataBytes.length,
|
||||
);
|
||||
return output;
|
||||
@@ -827,7 +827,7 @@ describe("keyService", () => {
|
||||
masterPasswordService.masterKeyHashSubject.next(storedMasterKeyHash);
|
||||
|
||||
cryptoFunctionService.pbkdf2
|
||||
.calledWith(masterKey.key, masterPassword as string, "sha256", 2)
|
||||
.calledWith(masterKey.inner().encryptionKey, masterPassword as string, "sha256", 2)
|
||||
.mockResolvedValue(Utils.fromB64ToArray(mockReturnedHash));
|
||||
|
||||
const actualDidMatch = await keyService.compareKeyHash(
|
||||
|
||||
@@ -26,7 +26,7 @@ import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/ke
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { KeySuffixOptions, HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { KeySuffixOptions, HashPurpose, EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { convertValues } from "@bitwarden/common/platform/misc/convert-values";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist";
|
||||
@@ -346,7 +346,12 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
}
|
||||
|
||||
const iterations = hashPurpose === HashPurpose.LocalAuthorization ? 2 : 1;
|
||||
const hash = await this.cryptoFunctionService.pbkdf2(key.key, password, "sha256", iterations);
|
||||
const hash = await this.cryptoFunctionService.pbkdf2(
|
||||
key.inner().encryptionKey,
|
||||
password,
|
||||
"sha256",
|
||||
iterations,
|
||||
);
|
||||
return Utils.fromBufferToB64(hash);
|
||||
}
|
||||
|
||||
@@ -823,13 +828,13 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
newSymKey: SymmetricCryptoKey,
|
||||
): Promise<[T, EncString]> {
|
||||
let protectedSymKey: EncString;
|
||||
if (encryptionKey.key.byteLength === 32) {
|
||||
if (encryptionKey.inner().type === EncryptionType.AesCbc256_B64) {
|
||||
const stretchedEncryptionKey = await this.keyGenerationService.stretchKey(encryptionKey);
|
||||
protectedSymKey = await this.encryptService.wrapSymmetricKey(
|
||||
newSymKey,
|
||||
stretchedEncryptionKey,
|
||||
);
|
||||
} else if (encryptionKey.key.byteLength === 64) {
|
||||
} else if (encryptionKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
protectedSymKey = await this.encryptService.wrapSymmetricKey(newSymKey, encryptionKey);
|
||||
} else {
|
||||
throw new Error("Invalid key size.");
|
||||
|
||||
735
package-lock.json
generated
735
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -44,7 +44,7 @@
|
||||
"@babel/preset-env": "7.24.8",
|
||||
"@compodoc/compodoc": "1.1.26",
|
||||
"@electron/notarize": "2.5.0",
|
||||
"@electron/rebuild": "3.7.1",
|
||||
"@electron/rebuild": "3.7.2",
|
||||
"@lit-labs/signals": "0.1.2",
|
||||
"@ngtools/webpack": "18.2.12",
|
||||
"@storybook/addon-a11y": "8.5.2",
|
||||
@@ -100,12 +100,12 @@
|
||||
"electron-store": "8.2.0",
|
||||
"electron-updater": "6.3.9",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-prettier": "10.0.1",
|
||||
"eslint-import-resolver-typescript": "3.7.0",
|
||||
"eslint-config-prettier": "10.1.2",
|
||||
"eslint-import-resolver-typescript": "3.10.1",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-rxjs": "5.0.3",
|
||||
"eslint-plugin-rxjs-angular": "2.0.1",
|
||||
"eslint-plugin-storybook": "0.11.2",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"eslint-plugin-tailwindcss": "3.18.0",
|
||||
"html-loader": "5.1.0",
|
||||
"html-webpack-injector": "1.1.4",
|
||||
@@ -116,7 +116,7 @@
|
||||
"jest-mock-extended": "3.0.7",
|
||||
"jest-preset-angular": "14.1.1",
|
||||
"json5": "2.2.3",
|
||||
"lint-staged": "15.4.1",
|
||||
"lint-staged": "15.5.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"nx": "20.8.0",
|
||||
"postcss": "8.5.1",
|
||||
@@ -136,7 +136,7 @@
|
||||
"tsconfig-paths-webpack-plugin": "4.2.0",
|
||||
"type-fest": "2.19.0",
|
||||
"typescript": "5.4.2",
|
||||
"typescript-eslint": "8.20.0",
|
||||
"typescript-eslint": "8.30.1",
|
||||
"typescript-strict-plugin": "2.4.4",
|
||||
"url": "0.11.4",
|
||||
"util": "0.12.5",
|
||||
|
||||
Reference in New Issue
Block a user