diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 78003b00ff2..9eace676f4f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,6 +6,7 @@ ## Secrets Manager team files ## bitwarden_license/bit-web/src/app/secrets-manager @bitwarden/team-secrets-manager-dev +apps/web/src/app/secrets-manager/ @bitwarden/team-secrets-manager-dev ## Auth team files ## apps/browser/src/auth @bitwarden/team-auth-dev diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 4bd656a282c..d908b267f4b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -13,6 +13,9 @@ "loginOrCreateNewAccount": { "message": "Log in or create a new account to access your secure vault." }, + "inviteAccepted": { + "message": "Invitation accepted" + }, "createAccount": { "message": "Create account" }, @@ -68,6 +71,12 @@ "masterPassHint": { "message": "Master password hint (optional)" }, + "joinOrganization": { + "message": "Join organization" + }, + "finishJoiningThisOrganizationBySettingAMasterPassword": { + "message": "Finish joining this organization by setting a master password." + }, "tab": { "message": "Tab" }, @@ -3037,6 +3046,10 @@ "message": "Unlock your account to view matching logins", "description": "Text to display in overlay when the account is locked." }, + "unlockYourAccountToViewAutofillSuggestions": { + "message": "Unlock your account to view autofill suggestions", + "description": "Text to display in overlay when the account is locked." + }, "unlockAccount": { "message": "Unlock account", "description": "Button text to display in overlay when the account is locked." @@ -3061,6 +3074,22 @@ "message": "Add new vault item", "description": "Screen reader text (aria-label) for new item button in overlay" }, + "newLogin": { + "message": "New login", + "description": "Button text to display within inline menu when there are no matching items on a login field" + }, + "addNewLoginItem": { + "message": "Add new vault login item", + "description": "Screen reader text (aria-label) for new login button within inline menu" + }, + "newCard": { + "message": "New card", + "description": "Button text to display within inline menu when there are no matching items on a credit card field" + }, + "addNewCardItem": { + "message": "Add new vault card item", + "description": "Screen reader text (aria-label) for new card button within inline menu" + }, "bitwardenOverlayMenuAvailable": { "message": "Bitwarden auto-fill menu available. Press the down arrow key to select.", "description": "Screen reader text for announcing when the overlay opens on the page" @@ -3734,6 +3763,10 @@ } } }, + "cardNumberEndsWith": { + "message": "card number ends with", + "description": "Used within the inline menu to provide an aria description when users are attempting to fill a card cipher." + }, "loginCredentials": { "message": "Login credentials" }, diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index 3c67872e238..763261cae26 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -34,6 +34,7 @@ export type WebsiteIconData = { export type FocusedFieldData = { focusedFieldStyles: Partial; focusedFieldRects: Partial; + filledByCipherType?: CipherType; tabId?: number; frameId?: number; }; @@ -50,13 +51,26 @@ export type InlineMenuPosition = { list?: InlineMenuElementPosition; }; +export type NewLoginCipherData = { + uri?: string; + hostname: string; + username: string; + password: string; +}; + +export type NewCardCipherData = { + cardholderName: string; + number: string; + expirationMonth: string; + expirationYear: string; + expirationDate?: string; + cvv: string; +}; + export type OverlayAddNewItemMessage = { - login?: { - uri?: string; - hostname: string; - username: string; - password: string; - }; + addNewCipherType?: CipherType; + login?: NewLoginCipherData; + card?: NewCardCipherData; }; export type CloseInlineMenuMessage = { @@ -91,6 +105,7 @@ export type OverlayPortMessage = { command: string; direction?: string; inlineMenuCipherId?: string; + addNewCipherType?: CipherType; }; export type InlineMenuCipherData = { @@ -178,7 +193,7 @@ export type InlineMenuListPortMessageHandlers = { autofillInlineMenuBlurred: () => void; unlockVault: ({ port }: PortConnectionParam) => void; fillAutofillInlineMenuCipher: ({ message, port }: PortOnMessageHandlerParams) => void; - addNewVaultItem: ({ port }: PortConnectionParam) => void; + addNewVaultItem: ({ message, port }: PortOnMessageHandlerParams) => void; viewSelectedCipher: ({ message, port }: PortOnMessageHandlerParams) => void; redirectAutofillInlineMenuFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; updateAutofillInlineMenuListHeight: ({ message, port }: PortOnMessageHandlerParams) => void; @@ -187,5 +202,5 @@ export type InlineMenuListPortMessageHandlers = { export interface OverlayBackground { init(): Promise; removePageDetails(tabId: number): void; - updateOverlayCiphers(): Promise; + updateOverlayCiphers(updateAllCipherTypes?: boolean): Promise; } diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 41d9d8ec32e..de668cd8178 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -322,6 +322,7 @@ describe("OverlayBackground", () => { it("removes the page details and port key for a specific tab from the pageDetailsForTab object", async () => { await initOverlayElementPorts(); const tabId = 1; + portKeyForTabSpy[tabId] = "portKey"; sendMockExtensionMessage( { command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() }, mock({ tab: createChromeTabMock({ id: tabId }), frameId: 1 }), @@ -705,6 +706,13 @@ describe("OverlayBackground", () => { type: CipherType.Card, card: { subTitle: "subtitle-2" }, }); + const cipher3 = mock({ + id: "id-3", + localData: { lastUsedDate: 222 }, + name: "name-3", + type: CipherType.Login, + login: { username: "username-3", uri: url }, + }); beforeEach(() => { activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -751,16 +759,53 @@ describe("OverlayBackground", () => { ); }); - it("queries all ciphers for the given url, sort them by last used, and format them for usage in the overlay", async () => { + it("queries all cipher types, sorts them by last used, and formats them for usage in the overlay", async () => { getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); await overlayBackground.updateOverlayCiphers(); + expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url, [CipherType.Card]); + expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled(); + expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual( + new Map([ + ["inline-menu-cipher-0", cipher2], + ["inline-menu-cipher-1", cipher1], + ]), + ); + }); + + it("queries only login ciphers when not updating all cipher types", async () => { + overlayBackground["cardAndIdentityCiphers"] = new Set([]); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher3, cipher1]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + + await overlayBackground.updateOverlayCiphers(false); + expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url); expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled(); + expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual( + new Map([ + ["inline-menu-cipher-0", cipher1], + ["inline-menu-cipher-1", cipher3], + ]), + ); + }); + + it("queries all cipher types when the card and identity ciphers set is not built when only updating login ciphers", async () => { + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + + await overlayBackground.updateOverlayCiphers(false); + + expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url, [CipherType.Card]); + expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled(); expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual( new Map([ ["inline-menu-cipher-0", cipher2], @@ -771,6 +816,7 @@ describe("OverlayBackground", () => { it("posts an `updateOverlayListCiphers` message to the overlay list port, and send a `updateAutofillInlineMenuListCiphers` message to the tab indicating that the list of ciphers is populated", async () => { overlayBackground["inlineMenuListPort"] = mock(); + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id }); cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); @@ -780,21 +826,6 @@ describe("OverlayBackground", () => { expect(overlayBackground["inlineMenuListPort"].postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", ciphers: [ - { - card: cipher2.card.subTitle, - favorite: cipher2.favorite, - icon: { - fallbackImage: "", - icon: "bwi-credit-card", - image: undefined, - imageEnabled: true, - }, - id: "inline-menu-cipher-0", - login: null, - name: "name-2", - reprompt: cipher2.reprompt, - type: 3, - }, { card: null, favorite: cipher1.favorite, @@ -810,7 +841,7 @@ describe("OverlayBackground", () => { }, name: "name-1", reprompt: cipher1.reprompt, - type: 1, + type: CipherType.Login, }, ], }); @@ -884,6 +915,7 @@ describe("OverlayBackground", () => { sendMockExtensionMessage( { command: "autofillOverlayAddNewVaultItem", + addNewCipherType: CipherType.Login, login: { uri: "https://tacos.com", hostname: "", @@ -899,6 +931,29 @@ describe("OverlayBackground", () => { expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher"); expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled(); }); + + it("creates a new card cipher", async () => { + sendMockExtensionMessage( + { + command: "autofillOverlayAddNewVaultItem", + addNewCipherType: CipherType.Card, + card: { + cardholderName: "cardholderName", + number: "4242424242424242", + expirationMonth: "12", + expirationYear: "2025", + expirationDate: "12/25", + cvv: "123", + }, + }, + sender, + ); + await flushPromises(); + + expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher"); + expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled(); + }); }); describe("checkIsInlineMenuCiphersPopulated message handler", () => { @@ -929,8 +984,9 @@ describe("OverlayBackground", () => { it("returns true if the overlay login ciphers are populated", async () => { overlayBackground["inlineMenuCiphers"] = new Map([ - ["inline-menu-cipher-0", mock()], + ["inline-menu-cipher-0", mock({ type: CipherType.Login })], ]); + await overlayBackground["getInlineMenuCipherData"](); sendMockExtensionMessage( { command: "checkIsInlineMenuCiphersPopulated" }, @@ -2029,12 +2085,16 @@ describe("OverlayBackground", () => { sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); await flushPromises(); - sendPortMessage(listMessageConnectorSpy, { command: "addNewVaultItem", portKey }); + sendPortMessage(listMessageConnectorSpy, { + command: "addNewVaultItem", + portKey, + addNewCipherType: CipherType.Login, + }); await flushPromises(); expect(tabsSendMessageSpy).toHaveBeenCalledWith( sender.tab, - { command: "addNewVaultItemFromOverlay" }, + { command: "addNewVaultItemFromOverlay", addNewCipherType: CipherType.Login }, { frameId: sender.frameId }, ); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 74ec5071099..76b0f4b76ec 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -19,6 +19,7 @@ import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-stat import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon"; +import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; @@ -54,6 +55,8 @@ import { CloseInlineMenuMessage, InlineMenuPosition, ToggleInlineMenuHiddenMessage, + NewLoginCipherData, + NewCardCipherData, } from "./abstractions/overlay.background"; export class OverlayBackground implements OverlayBackgroundInterface { @@ -69,6 +72,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { private inlineMenuCiphers: Map = new Map(); private inlineMenuPageTranslations: Record; private inlineMenuPosition: InlineMenuPosition = {}; + private cardAndIdentityCiphers: Set | null = null; + private currentInlineMenuCiphersCount: number = 0; private delayedCloseTimeout: number | NodeJS.Timeout; private startInlineMenuFadeInSubject = new Subject(); private cancelInlineMenuFadeInSubject = new Subject(); @@ -132,7 +137,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { autofillInlineMenuBlurred: () => this.checkInlineMenuButtonFocused(), unlockVault: ({ port }) => this.unlockVault(port), fillAutofillInlineMenuCipher: ({ message, port }) => this.fillInlineMenuCipher(message, port), - addNewVaultItem: ({ port }) => this.getNewVaultItemDetails(port), + addNewVaultItem: ({ message, port }) => this.getNewVaultItemDetails(message, port), viewSelectedCipher: ({ message, port }) => this.viewSelectedCipher(message, port), redirectAutofillInlineMenuFocusOut: ({ message, port }) => this.redirectInlineMenuFocusOut(message, port), @@ -220,7 +225,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { * Queries all ciphers for the given url, and sorts them by last used. Will not update the * list of ciphers if the extension is not unlocked. */ - async updateOverlayCiphers() { + async updateOverlayCiphers(updateAllCipherTypes = true) { const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); if (authStatus !== AuthenticationStatus.Unlocked) { if (this.focusedFieldData) { @@ -235,9 +240,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { } this.inlineMenuCiphers = new Map(); - const ciphersViews = ( - await this.cipherService.getAllDecryptedForUrl(currentTab?.url || "") - ).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)); + const ciphersViews = await this.getCipherViews(currentTab, updateAllCipherTypes); for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) { this.inlineMenuCiphers.set(`inline-menu-cipher-${cipherIndex}`, ciphersViews[cipherIndex]); } @@ -249,6 +252,51 @@ export class OverlayBackground implements OverlayBackgroundInterface { }); } + /** + * Gets the decrypted ciphers within a user's vault based on the current tab's URL. + * + * @param currentTab - The current tab + * @param updateAllCipherTypes - Identifies credit card and identity cipher types should also be updated + */ + private async getCipherViews( + currentTab: chrome.tabs.Tab, + updateAllCipherTypes: boolean, + ): Promise { + if (updateAllCipherTypes || !this.cardAndIdentityCiphers) { + return this.getAllCipherTypeViews(currentTab); + } + + const cipherViews = ( + await this.cipherService.getAllDecryptedForUrl(currentTab?.url || "") + ).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)); + + return cipherViews.concat(...this.cardAndIdentityCiphers); + } + + /** + * Queries all cipher types from the user's vault returns them sorted by last used. + * + * @param currentTab - The current tab + */ + private async getAllCipherTypeViews(currentTab: chrome.tabs.Tab): Promise { + if (!this.cardAndIdentityCiphers) { + this.cardAndIdentityCiphers = new Set([]); + } + + this.cardAndIdentityCiphers.clear(); + const cipherViews = ( + await this.cipherService.getAllDecryptedForUrl(currentTab.url, [CipherType.Card]) + ).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)); + for (let cipherIndex = 0; cipherIndex < cipherViews.length; cipherIndex++) { + const cipherView = cipherViews[cipherIndex]; + if (cipherView.type === CipherType.Card && !this.cardAndIdentityCiphers.has(cipherView)) { + this.cardAndIdentityCiphers.add(cipherView); + } + } + + return cipherViews; + } + /** * Strips out unnecessary data from the ciphers and returns an array of * objects that contain the cipher data needed for the inline menu list. @@ -260,6 +308,9 @@ export class OverlayBackground implements OverlayBackgroundInterface { for (let cipherIndex = 0; cipherIndex < inlineMenuCiphersArray.length; cipherIndex++) { const [inlineMenuCipherId, cipher] = inlineMenuCiphersArray[cipherIndex]; + if (this.focusedFieldData?.filledByCipherType !== cipher.type) { + continue; + } inlineMenuCipherData.push({ id: inlineMenuCipherId, @@ -273,6 +324,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { }); } + this.currentInlineMenuCiphersCount = inlineMenuCipherData.length; return inlineMenuCipherData; } @@ -1062,7 +1114,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { buttonPageTitle: this.i18nService.translate("bitwardenOverlayButton"), toggleBitwardenVaultOverlay: this.i18nService.translate("toggleBitwardenVaultOverlay"), listPageTitle: this.i18nService.translate("bitwardenVault"), - unlockYourAccount: this.i18nService.translate("unlockYourAccountToViewMatchingLogins"), + unlockYourAccount: this.i18nService.translate("unlockYourAccountToViewAutofillSuggestions"), unlockAccount: this.i18nService.translate("unlockAccount"), fillCredentialsFor: this.i18nService.translate("fillCredentialsFor"), username: this.i18nService.translate("username")?.toLowerCase(), @@ -1070,6 +1122,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { noItemsToShow: this.i18nService.translate("noItemsToShow"), newItem: this.i18nService.translate("newItem"), addNewVaultItem: this.i18nService.translate("addNewVaultItem"), + newLogin: this.i18nService.translate("newLogin"), + addNewLoginItem: this.i18nService.translate("addNewLoginItem"), + newCard: this.i18nService.translate("newCard"), + addNewCardItem: this.i18nService.translate("addNewCardItem"), + cardNumberEndsWith: this.i18nService.translate("cardNumberEndsWith"), }; } @@ -1100,16 +1157,20 @@ export class OverlayBackground implements OverlayBackgroundInterface { * Triggers adding a new vault item from the overlay. Gathers data * input by the user before calling to open the add/edit window. * + * @param addNewCipherType - The type of cipher to add * @param sender - The sender of the port message */ - private getNewVaultItemDetails({ sender }: chrome.runtime.Port) { - if (!this.senderTabHasFocusedField(sender)) { + private getNewVaultItemDetails( + { addNewCipherType }: OverlayPortMessage, + { sender }: chrome.runtime.Port, + ) { + if (!addNewCipherType || !this.senderTabHasFocusedField(sender)) { return; } void BrowserApi.tabSendMessage( sender.tab, - { command: "addNewVaultItemFromOverlay" }, + { command: "addNewVaultItemFromOverlay", addNewCipherType }, { frameId: this.focusedFieldData.frameId || 0, }, @@ -1120,18 +1181,60 @@ export class OverlayBackground implements OverlayBackgroundInterface { * Handles adding a new vault item from the overlay. Gathers data login * data captured in the extension message. * + * @param addNewCipherType - The type of cipher to add * @param login - The login data captured from the extension message + * @param card - The card data captured from the extension message * @param sender - The sender of the extension message */ private async addNewVaultItem( - { login }: OverlayAddNewItemMessage, + { addNewCipherType, login, card }: OverlayAddNewItemMessage, sender: chrome.runtime.MessageSender, ) { - if (!login) { + if (!addNewCipherType) { return; } - this.closeInlineMenu(sender); + const cipherView: CipherView = this.buildNewVaultItemCipherView({ + addNewCipherType, + login, + card, + }); + + if (cipherView) { + this.closeInlineMenu(sender); + await this.cipherService.setAddEditCipherInfo({ + cipher: cipherView, + collectionIds: cipherView.collectionIds, + }); + + await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id }); + await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher"); + } + } + + /** + * Builds and returns a new cipher view with the provided vault item data. + * + * @param addNewCipherType - The type of cipher to add + * @param login - The login data captured from the extension message + * @param card - The card data captured from the extension message + */ + private buildNewVaultItemCipherView({ addNewCipherType, login, card }: OverlayAddNewItemMessage) { + if (login && addNewCipherType === CipherType.Login) { + return this.buildLoginCipherView(login); + } + + if (card && addNewCipherType === CipherType.Card) { + return this.buildCardCipherView(card); + } + } + + /** + * Builds a new login cipher view with the provided login data. + * + * @param login - The login data captured from the extension message + */ + private buildLoginCipherView(login: NewLoginCipherData) { const uriView = new LoginUriView(); uriView.uri = login.uri; @@ -1146,13 +1249,30 @@ export class OverlayBackground implements OverlayBackgroundInterface { cipherView.type = CipherType.Login; cipherView.login = loginView; - await this.cipherService.setAddEditCipherInfo({ - cipher: cipherView, - collectionIds: cipherView.collectionIds, - }); + return cipherView; + } - await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id }); - await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher"); + /** + * Builds a new card cipher view with the provided card data. + * + * @param card - The card data captured from the extension message + */ + private buildCardCipherView(card: NewCardCipherData) { + const cardView = new CardView(); + cardView.cardholderName = card.cardholderName || ""; + cardView.number = card.number || ""; + cardView.expMonth = card.expirationMonth || ""; + cardView.expYear = card.expirationYear || ""; + cardView.code = card.cvv || ""; + cardView.brand = card.number ? CardView.getCardBrandByPatterns(card.number) : ""; + + const cipherView = new CipherView(); + cipherView.name = ""; + cipherView.folderId = null; + cipherView.type = CipherType.Card; + cipherView.card = cardView; + + return cipherView; } /** @@ -1209,7 +1329,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param sender - The sender of the message */ private checkIsInlineMenuCiphersPopulated(sender: chrome.runtime.MessageSender) { - return this.senderTabHasFocusedField(sender) && this.inlineMenuCiphers.size > 0; + return this.senderTabHasFocusedField(sender) && this.currentInlineMenuCiphersCount > 0; } /** @@ -1477,6 +1597,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { portName: isInlineMenuListPort ? AutofillOverlayPort.ListMessageConnector : AutofillOverlayPort.ButtonMessageConnector, + filledByCipherType: this.focusedFieldData?.filledByCipherType, }); void this.updateInlineMenuPosition( { diff --git a/apps/browser/src/autofill/background/tabs.background.ts b/apps/browser/src/autofill/background/tabs.background.ts index f68ae6c6edc..0513220c277 100644 --- a/apps/browser/src/autofill/background/tabs.background.ts +++ b/apps/browser/src/autofill/background/tabs.background.ts @@ -104,7 +104,7 @@ export default class TabsBackground { return; } - await this.overlayBackground.updateOverlayCiphers(); + await this.overlayBackground.updateOverlayCiphers(false); if (this.main.onUpdatedRan) { return; @@ -134,7 +134,7 @@ export default class TabsBackground { await Promise.all([ this.main.refreshBadge(), this.main.refreshMenu(), - this.overlayBackground.updateOverlayCiphers(), + this.overlayBackground.updateOverlayCiphers(false), ]); }; } diff --git a/apps/browser/src/autofill/content/abstractions/autofill-init.ts b/apps/browser/src/autofill/content/abstractions/autofill-init.ts index 8b00b4ecc9e..ba815a0f29a 100644 --- a/apps/browser/src/autofill/content/abstractions/autofill-init.ts +++ b/apps/browser/src/autofill/content/abstractions/autofill-init.ts @@ -1,4 +1,5 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { AutofillOverlayElementType } from "../../enums/autofill-overlay.enum"; import AutofillScript from "../../models/autofill-script"; @@ -18,6 +19,7 @@ export type AutofillExtensionMessage = { isFocusingFieldElement?: boolean; authStatus?: AuthenticationStatus; isOpeningFullInlineMenu?: boolean; + addNewCipherType?: CipherType; data?: { direction?: "previous" | "next" | "current"; forceCloseInlineMenu?: boolean; diff --git a/apps/browser/src/autofill/models/autofill-field.ts b/apps/browser/src/autofill/models/autofill-field.ts index 0636190d955..26f01bdeac8 100644 --- a/apps/browser/src/autofill/models/autofill-field.ts +++ b/apps/browser/src/autofill/models/autofill-field.ts @@ -1,3 +1,5 @@ +import { CipherType } from "@bitwarden/common/vault/enums"; + /** * Represents a single field that is collected from the page source and is potentially autofilled. */ @@ -106,4 +108,6 @@ export default class AutofillField { rel?: string | null; checked?: boolean; + + filledByCipherType?: CipherType; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts index 1687029074b..5a00ffbaaa8 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts @@ -1,4 +1,5 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { InlineMenuCipherData } from "../../../background/abstractions/overlay.background"; @@ -14,6 +15,7 @@ export type InitAutofillInlineMenuListMessage = AutofillInlineMenuListMessage & theme: string; translations: Record; ciphers?: InlineMenuCipherData[]; + filledByCipherType?: CipherType; portKey: string; }; diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap index 1b44636b089..3b0e84514f2 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with an empty list of ciphers creates the views for the no results inline menu 1`] = ` +exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with an empty list of ciphers creates the views for the no results inline menu that does not have a fill by cipher type 1`] = `
@@ -13,7 +13,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with class="inline-menu-list-button-container" >
`; -exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers for an authenticated user creates the view for a list of ciphers 1`] = ` +exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with an empty list of ciphers creates the views for the no results inline menu that should be filled by a card cipher 1`] = ` +
+
+ noItemsToShow +
+
+ +
+
+`; + +exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with an empty list of ciphers creates the views for the no results inline menu that should be filled by a login cipher 1`] = ` +
+
+ noItemsToShow +
+
+ +
+
+`; + +exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers for an authenticated user creates the view for a list of login ciphers 1`] = `
@@ -87,7 +186,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f website login 1 + + + +
+ + + +`; + +exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers for an authenticated user creates the views for a list of card ciphers 1`] = ` +
+
    +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + -

    {{ pageTitle }}

    +

    + {{ pageTitle }} +

    +
    diff --git a/apps/browser/src/platform/popup/layout/popup-header.component.ts b/apps/browser/src/platform/popup/layout/popup-header.component.ts index 1b491ea881c..1e41f7ccbe0 100644 --- a/apps/browser/src/platform/popup/layout/popup-header.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-header.component.ts @@ -1,6 +1,6 @@ import { BooleanInput, coerceBooleanProperty } from "@angular/cdk/coercion"; import { CommonModule, Location } from "@angular/common"; -import { Component, Input } from "@angular/core"; +import { Component, Input, Signal, inject } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -10,6 +10,8 @@ import { TypographyModule, } from "@bitwarden/components"; +import { PopupPageComponent } from "./popup-page.component"; + @Component({ selector: "popup-header", templateUrl: "popup-header.component.html", @@ -17,6 +19,12 @@ import { imports: [TypographyModule, CommonModule, IconButtonModule, JslibModule, AsyncActionsModule], }) export class PopupHeaderComponent { + protected pageContentScrolled: Signal = inject(PopupPageComponent).isScrolled; + + /** Background color */ + @Input() + background: "default" | "alt" = "default"; + /** Display the back button, which uses Location.back() to go back one page in history */ @Input() get showBackButton() { diff --git a/apps/browser/src/platform/popup/layout/popup-layout.mdx b/apps/browser/src/platform/popup/layout/popup-layout.mdx index 49f76501aea..aa11b4099a9 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.mdx +++ b/apps/browser/src/platform/popup/layout/popup-layout.mdx @@ -74,6 +74,9 @@ Basic usage example: - `showBackButton`: optional, defaults to `false` - Toggles the back button to appear. The back button uses `Location.back()` to navigate back one page in history. +- `background`: optional + - `"default"` uses a white background + - `"alt"` uses a transparent background **Slots** @@ -92,6 +95,12 @@ Usage example: ``` +### Transparent header + + + + + Common interactive elements to insert into the `end` slot are: - `app-current-account`: shows current account and switcher diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts index 408988dca3b..3d067b53056 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -303,6 +303,7 @@ export default { MockSettingsPageComponent, MockVaultPagePoppedComponent, NoItemsModule, + VaultComponent, ], providers: [ { @@ -311,6 +312,7 @@ export default { return new I18nMockService({ back: "Back", loading: "Loading", + search: "Search", }); }, }, @@ -421,3 +423,20 @@ export const Loading: Story = { `, }), }; + +export const TransparentHeader: Story = { + render: (args) => ({ + props: args, + template: /* HTML */ ` + + + 🤠 Custom Content + + + + + `, + }), +}; diff --git a/apps/browser/src/platform/popup/layout/popup-page.component.html b/apps/browser/src/platform/popup/layout/popup-page.component.html index d53ef24803d..e62f9e70f91 100644 --- a/apps/browser/src/platform/popup/layout/popup-page.component.html +++ b/apps/browser/src/platform/popup/layout/popup-page.component.html @@ -1,20 +1,21 @@
    -
    +
    - - - - -
    -
    -
    -
    diff --git a/apps/browser/src/platform/popup/layout/popup-page.component.ts b/apps/browser/src/platform/popup/layout/popup-page.component.ts index 0ce13ac4a49..50db3e370fc 100644 --- a/apps/browser/src/platform/popup/layout/popup-page.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-page.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { Component, Input, inject } from "@angular/core"; +import { Component, Input, inject, signal } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -17,6 +17,13 @@ export class PopupPageComponent { @Input() loading = false; + protected scrolled = signal(false); + isScrolled = this.scrolled.asReadonly(); + /** Accessible loading label for the spinner. Defaults to "loading" */ @Input() loadingText?: string = this.i18nService.t("loading"); + + handleScroll(event: Event) { + this.scrolled.set((event.currentTarget as HTMLElement).scrollTop !== 0); + } } diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index bb9d0383785..57195564c78 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -16,6 +16,7 @@ import { RegistrationStartComponent, RegistrationStartSecondaryComponent, RegistrationStartSecondaryComponentData, + SetPasswordJitComponent, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -409,6 +410,15 @@ const routes: Routes = [ }, ], }, + { + path: "set-password-jit", + canActivate: [canAccessFeature(FeatureFlag.EmailVerification)], + component: SetPasswordJitComponent, + data: { + pageTitle: "joinOrganization", + pageSubtitle: "finishJoiningThisOrganizationBySettingAMasterPassword", + } satisfies AnonLayoutWrapperData, + }, ], }, { diff --git a/apps/browser/src/tools/popup/generator/generator.component.html b/apps/browser/src/tools/popup/generator/generator.component.html index 4c39c22b488..d92d32a5623 100644 --- a/apps/browser/src/tools/popup/generator/generator.component.html +++ b/apps/browser/src/tools/popup/generator/generator.component.html @@ -240,7 +240,7 @@ />
    - + , + path: Cow<'static, str>, +} + +const SCREEN_LOCK_MONITORS: [ScreenLock; 2] = [ + ScreenLock { + interface: Cow::Borrowed("org.gnome.ScreenSaver"), + path: Cow::Borrowed("/org/gnome/ScreenSaver"), + }, + ScreenLock { + interface: Cow::Borrowed("org.freedesktop.ScreenSaver"), + path: Cow::Borrowed("/org/freedesktop/ScreenSaver"), + }, +]; + +pub async fn on_lock(tx: tokio::sync::mpsc::Sender<()>) -> Result<(), Box> { + let connection = Connection::session().await?; + + let proxy = zbus::fdo::DBusProxy::new(&connection).await?; + for monitor in SCREEN_LOCK_MONITORS.iter() { + let match_rule = MatchRule::builder() + .msg_type(zbus::MessageType::Signal) + .interface(monitor.interface.clone())? + .member("ActiveChanged")? + .build(); + proxy.add_match_rule(match_rule).await?; + } + + tokio::spawn(async move { + while let Ok(Some(_)) = zbus::MessageStream::from(&connection).try_next().await { + tx.send(()).await.unwrap(); + } + }); + + Ok(()) +} + +pub async fn is_lock_monitor_available() -> bool { + let connection = Connection::session().await.unwrap(); + for monitor in SCREEN_LOCK_MONITORS { + let res = connection.call_method(Some(monitor.interface.clone()), monitor.path.clone(), Some(monitor.interface.clone()), "GetActive", &()).await; + if res.is_ok() { + return true; + } + } + false +} diff --git a/apps/desktop/desktop_native/core/src/powermonitor/mod.rs b/apps/desktop/desktop_native/core/src/powermonitor/mod.rs new file mode 100644 index 00000000000..2a996395508 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/powermonitor/mod.rs @@ -0,0 +1,5 @@ +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "windows", path = "unimplemented.rs")] +#[cfg_attr(target_os = "macos", path = "unimplemented.rs")] +mod powermonitor; +pub use powermonitor::*; diff --git a/apps/desktop/desktop_native/core/src/powermonitor/unimplemented.rs b/apps/desktop/desktop_native/core/src/powermonitor/unimplemented.rs new file mode 100644 index 00000000000..0078c0bf921 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/powermonitor/unimplemented.rs @@ -0,0 +1,7 @@ +pub async fn on_lock(_: tokio::sync::mpsc::Sender<()>) -> Result<(), Box> { + unimplemented!(); +} + +pub async fn is_lock_monitor_available() -> bool { + return false; +} diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index abfc998de04..fdb48543e8d 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -41,3 +41,7 @@ export namespace clipboards { export function read(): Promise export function write(text: string, password: boolean): Promise } +export namespace powermonitors { + export function onLock(callback: (err: Error | null, ) => any): Promise + export function isLockMonitorAvailable(): Promise +} diff --git a/apps/desktop/desktop_native/napi/index.js b/apps/desktop/desktop_native/napi/index.js index 75617ee2f1a..92e58a21705 100644 --- a/apps/desktop/desktop_native/napi/index.js +++ b/apps/desktop/desktop_native/napi/index.js @@ -206,8 +206,9 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { passwords, biometrics, clipboards } = nativeBinding +const { passwords, biometrics, clipboards, powermonitors } = nativeBinding module.exports.passwords = passwords module.exports.biometrics = biometrics module.exports.clipboards = clipboards +module.exports.powermonitors = powermonitors diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index e2e7eb7244f..fdb3efcc095 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -1,6 +1,5 @@ #[macro_use] extern crate napi_derive; - #[napi] pub mod passwords { /// Fetch the stored password from the keychain. @@ -142,3 +141,26 @@ pub mod clipboards { .map_err(|e| napi::Error::from_reason(e.to_string())) } } + +#[napi] +pub mod powermonitors { + use napi::{threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode}, tokio}; + + #[napi] + pub async fn on_lock(callback: ThreadsafeFunction<(), CalleeHandled>) -> napi::Result<()> { + let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32); + desktop_core::powermonitor::on_lock(tx).await.map_err(|e| napi::Error::from_reason(e.to_string()))?; + tokio::spawn(async move { + while let Some(message) = rx.recv().await { + callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking); + } + }); + Ok(()) + } + + #[napi] + pub async fn is_lock_monitor_available() -> napi::Result { + Ok(desktop_core::powermonitor::is_lock_monitor_available().await) + } + +} diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index 0ca295f9b92..5f137f8ae30 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -25,7 +25,7 @@ "**/node_modules/argon2/package.json", "**/node_modules/argon2/build/Release/argon2.node" ], - "electronVersion": "31.2.1", + "electronVersion": "31.3.0", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 2ef5df2c7cb..4f006f23642 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -46,7 +46,7 @@ export class SettingsComponent implements OnInit { protected readonly VaultTimeoutAction = VaultTimeoutAction; showMinToTray = false; - vaultTimeoutOptions: VaultTimeoutOption[]; + vaultTimeoutOptions: VaultTimeoutOption[] = []; localeOptions: any[]; themeOptions: any[]; clearClipboardOptions: any[]; @@ -161,29 +161,6 @@ export class SettingsComponent implements OnInit { // DuckDuckGo browser is only for macos initially this.showDuckDuckGoIntegrationOption = isMac; - this.vaultTimeoutOptions = [ - { name: this.i18nService.t("oneMinute"), value: 1 }, - { name: this.i18nService.t("fiveMinutes"), value: 5 }, - { name: this.i18nService.t("fifteenMinutes"), value: 15 }, - { name: this.i18nService.t("thirtyMinutes"), value: 30 }, - { name: this.i18nService.t("oneHour"), value: 60 }, - { name: this.i18nService.t("fourHours"), value: 240 }, - { name: this.i18nService.t("onIdle"), value: VaultTimeoutStringType.OnIdle }, - { name: this.i18nService.t("onSleep"), value: VaultTimeoutStringType.OnSleep }, - ]; - - if (this.platformUtilsService.getDevice() !== DeviceType.LinuxDesktop) { - this.vaultTimeoutOptions.push({ - name: this.i18nService.t("onLocked"), - value: VaultTimeoutStringType.OnLocked, - }); - } - - this.vaultTimeoutOptions = this.vaultTimeoutOptions.concat([ - { name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart }, - { name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }, - ]); - const localeOptions: any[] = []; this.i18nService.supportedTranslationLocales.forEach((locale) => { let name = locale; @@ -215,6 +192,8 @@ export class SettingsComponent implements OnInit { } async ngOnInit() { + this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions(); + this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword(); this.isWindows = (await this.platformUtilsService.getDevice()) === DeviceType.WindowsDesktop; @@ -718,6 +697,33 @@ export class SettingsComponent implements OnInit { ); } + private async generateVaultTimeoutOptions(): Promise { + let vaultTimeoutOptions: VaultTimeoutOption[] = [ + { name: this.i18nService.t("oneMinute"), value: 1 }, + { name: this.i18nService.t("fiveMinutes"), value: 5 }, + { name: this.i18nService.t("fifteenMinutes"), value: 15 }, + { name: this.i18nService.t("thirtyMinutes"), value: 30 }, + { name: this.i18nService.t("oneHour"), value: 60 }, + { name: this.i18nService.t("fourHours"), value: 240 }, + { name: this.i18nService.t("onIdle"), value: VaultTimeoutStringType.OnIdle }, + { name: this.i18nService.t("onSleep"), value: VaultTimeoutStringType.OnSleep }, + ]; + + if (await ipc.platform.powermonitor.isLockMonitorAvailable()) { + vaultTimeoutOptions.push({ + name: this.i18nService.t("onLocked"), + value: VaultTimeoutStringType.OnLocked, + }); + } + + vaultTimeoutOptions = vaultTimeoutOptions.concat([ + { name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart }, + { name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }, + ]); + + return vaultTimeoutOptions; + } + ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 0e3f10345a1..a93299e45a4 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -16,6 +16,7 @@ import { RegistrationStartComponent, RegistrationStartSecondaryComponent, RegistrationStartSecondaryComponentData, + SetPasswordJitComponent, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -149,6 +150,15 @@ const routes: Routes = [ }, ], }, + { + path: "set-password-jit", + canActivate: [canAccessFeature(FeatureFlag.EmailVerification)], + component: SetPasswordJitComponent, + data: { + pageTitle: "joinOrganization", + pageSubtitle: "finishJoiningThisOrganizationBySettingAMasterPassword", + } satisfies AnonLayoutWrapperData, + }, ], }, ]; diff --git a/apps/desktop/src/app/services/desktop-set-password-jit.service.ts b/apps/desktop/src/app/services/desktop-set-password-jit.service.ts new file mode 100644 index 00000000000..f6ea3d0ce84 --- /dev/null +++ b/apps/desktop/src/app/services/desktop-set-password-jit.service.ts @@ -0,0 +1,21 @@ +import { inject } from "@angular/core"; + +import { + DefaultSetPasswordJitService, + SetPasswordCredentials, + SetPasswordJitService, +} from "@bitwarden/auth/angular"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +export class DesktopSetPasswordJitService + extends DefaultSetPasswordJitService + implements SetPasswordJitService +{ + messagingService = inject(MessagingService); + + override async setPassword(credentials: SetPasswordCredentials) { + await super.setPassword(credentials); + + this.messagingService.send("redrawMenu"); + } +} diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index c0b4bf4eb1c..85bfbc09f63 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -18,16 +18,30 @@ import { CLIENT_TYPE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; +import { SetPasswordJitService } from "@bitwarden/auth/angular"; +import { + InternalUserDecryptionOptionsServiceAbstraction, + PinServiceAbstraction, +} from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; -import { KdfConfigService as KdfConfigServiceAbstraction } from "@bitwarden/common/auth/abstractions/kdf-config.service"; +import { + KdfConfigService, + KdfConfigService as KdfConfigServiceAbstraction, +} from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { ClientType } from "@bitwarden/common/enums"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; -import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { + CryptoService, + CryptoService as CryptoServiceAbstraction, +} from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -56,7 +70,6 @@ import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vau import { DialogService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; -import { PinServiceAbstraction } from "../../../../../libs/auth/src/common/abstractions"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; import { ElectronCryptoService } from "../../platform/services/electron-crypto.service"; @@ -77,6 +90,7 @@ import { NativeMessagingService } from "../../services/native-messaging.service" import { SearchBarService } from "../layout/search/search-bar.service"; import { DesktopFileDownloadService } from "./desktop-file-download.service"; +import { DesktopSetPasswordJitService } from "./desktop-set-password-jit.service"; import { InitService } from "./init.service"; import { NativeMessagingManifestService } from "./native-messaging-manifest.service"; import { RendererCryptoFunctionService } from "./renderer-crypto-function.service"; @@ -254,6 +268,20 @@ const safeProviders: SafeProvider[] = [ provide: CLIENT_TYPE, useValue: ClientType.Desktop, }), + safeProvider({ + provide: SetPasswordJitService, + useClass: DesktopSetPasswordJitService, + deps: [ + ApiService, + CryptoService, + I18nServiceAbstraction, + KdfConfigService, + InternalMasterPasswordServiceAbstraction, + OrganizationApiServiceAbstraction, + OrganizationUserService, + InternalUserDecryptionOptionsServiceAbstraction, + ], + }), ]; @NgModule({ diff --git a/apps/desktop/src/app/tools/generator.component.html b/apps/desktop/src/app/tools/generator.component.html index 9af67522deb..0a8043fe594 100644 --- a/apps/desktop/src/app/tools/generator.component.html +++ b/apps/desktop/src/app/tools/generator.component.html @@ -265,7 +265,7 @@ />
    - + { this.messagingService.send("systemLocked"); }); + } else { + powermonitors + .onLock(() => { + this.messagingService.send("systemLocked"); + }) + .catch((error) => { + this.logService.error("Error setting up lock monitor", { error }); + }); } + ipcMain.handle("powermonitor.isLockMonitorAvailable", async (_event: any, _message: any) => { + if (process.platform !== "linux") { + return true; + } else { + return await powermonitors.isLockMonitorAvailable(); + } + }); // System idle global.setInterval(() => { diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index d81d6476526..b3007753eed 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -59,6 +59,11 @@ const clipboard = { write: (message: ClipboardWriteMessage) => ipcRenderer.invoke("clipboard.write", message), }; +const powermonitor = { + isLockMonitorAvailable: (): Promise => + ipcRenderer.invoke("powermonitor.isLockMonitorAvailable"), +}; + const nativeMessaging = { sendReply: (message: EncryptedMessageResponse | UnencryptedMessageResponse) => { ipcRenderer.send("nativeMessagingReply", message); @@ -148,6 +153,7 @@ export default { passwords, biometric, clipboard, + powermonitor, nativeMessaging, crypto, }; diff --git a/apps/web/src/app/admin-console/organizations/policies/master-password.component.html b/apps/web/src/app/admin-console/organizations/policies/master-password.component.html index 85670f7d193..63a59208cc0 100644 --- a/apps/web/src/app/admin-console/organizations/policies/master-password.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/master-password.component.html @@ -50,6 +50,6 @@ - !@#$%^&* + !@#$%^&*
    diff --git a/apps/web/src/app/admin-console/organizations/policies/password-generator.component.html b/apps/web/src/app/admin-console/organizations/policies/password-generator.component.html index 80df5fa2e6a..63d6671afec 100644 --- a/apps/web/src/app/admin-console/organizations/policies/password-generator.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/password-generator.component.html @@ -44,7 +44,7 @@ - !@#$%^&* + !@#$%^&*

    {{ "passphrase" | i18n }}

    diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index 6a28efcbaad..c85f0f3204c 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -1,2 +1,3 @@ export * from "./webauthn-login"; +export * from "./set-password-jit"; export * from "./registration"; diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts index 999e603ef6a..007165a1bca 100644 --- a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts @@ -145,6 +145,7 @@ describe("DefaultRegistrationFinishService", () => { passwordInputResult = { masterKey: masterKey, masterKeyHash: "masterKeyHash", + localMasterKeyHash: "localMasterKeyHash", kdfConfig: DEFAULT_KDF_CONFIG, hint: "hint", }; diff --git a/apps/web/src/app/auth/core/services/set-password-jit/index.ts b/apps/web/src/app/auth/core/services/set-password-jit/index.ts new file mode 100644 index 00000000000..fc119fd964f --- /dev/null +++ b/apps/web/src/app/auth/core/services/set-password-jit/index.ts @@ -0,0 +1 @@ +export * from "./web-set-password-jit.service"; diff --git a/apps/web/src/app/auth/core/services/set-password-jit/web-set-password-jit.service.ts b/apps/web/src/app/auth/core/services/set-password-jit/web-set-password-jit.service.ts new file mode 100644 index 00000000000..62175f1256d --- /dev/null +++ b/apps/web/src/app/auth/core/services/set-password-jit/web-set-password-jit.service.ts @@ -0,0 +1,27 @@ +import { inject } from "@angular/core"; + +import { + DefaultSetPasswordJitService, + SetPasswordCredentials, + SetPasswordJitService, +} from "@bitwarden/auth/angular"; + +import { RouterService } from "../../../../core/router.service"; +import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; + +export class WebSetPasswordJitService + extends DefaultSetPasswordJitService + implements SetPasswordJitService +{ + routerService = inject(RouterService); + acceptOrganizationInviteService = inject(AcceptOrganizationInviteService); + + override async setPassword(credentials: SetPasswordCredentials) { + await super.setPassword(credentials); + + // SSO JIT accepts org invites when setting their MP, meaning + // we can clear the deep linked url for accepting it. + await this.routerService.getAndClearLoginRedirectUrl(); + await this.acceptOrganizationInviteService.clearOrganizationInvitation(); + } +} diff --git a/apps/web/src/app/auth/settings/two-factor-authenticator.component.ts b/apps/web/src/app/auth/settings/two-factor-authenticator.component.ts index 7672ef70281..b4bef9f74e3 100644 --- a/apps/web/src/app/auth/settings/two-factor-authenticator.component.ts +++ b/apps/web/src/app/auth/settings/two-factor-authenticator.component.ts @@ -7,9 +7,12 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; +import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request"; import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request"; import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -43,6 +46,7 @@ export class TwoFactorAuthenticatorComponent @Output() onChangeStatus = new EventEmitter(); type = TwoFactorProviderType.Authenticator; key: string; + private userVerificationToken: string; override componentName = "app-two-factor-authenticator"; qrScriptError = false; @@ -63,6 +67,7 @@ export class TwoFactorAuthenticatorComponent logService: LogService, private accountService: AccountService, dialogService: DialogService, + private configService: ConfigService, ) { super( apiService, @@ -112,16 +117,46 @@ export class TwoFactorAuthenticatorComponent const request = await this.buildRequestModel(UpdateTwoFactorAuthenticatorRequest); request.token = this.formGroup.value.token; request.key = this.key; + request.userVerificationToken = this.userVerificationToken; const response = await this.apiService.putTwoFactorAuthenticator(request); await this.processResponse(response); this.onUpdated.emit(true); } + protected override async disableMethod() { + const twoFactorAuthenticatorTokenFeatureFlag = await this.configService.getFeatureFlag( + FeatureFlag.AuthenticatorTwoFactorToken, + ); + if (twoFactorAuthenticatorTokenFeatureFlag === false) { + return super.disableMethod(); + } + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "disable" }, + content: { key: "twoStepDisableDesc" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + const request = await this.buildRequestModel(DisableTwoFactorAuthenticatorRequest); + request.type = this.type; + request.key = this.key; + request.userVerificationToken = this.userVerificationToken; + await this.apiService.deleteTwoFactorAuthenticator(request); + this.enabled = false; + this.platformUtilsService.showToast("success", null, this.i18nService.t("twoStepDisabled")); + this.onUpdated.emit(false); + } + private async processResponse(response: TwoFactorAuthenticatorResponse) { this.formGroup.get("token").setValue(null); this.enabled = response.enabled; this.key = response.key; + this.userVerificationToken = response.userVerificationToken; await this.waitForQRiousToLoadOrError().catch((error) => { this.logService.error(error); diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index 5f9e6463f1b..08eec09ff97 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -65,7 +65,7 @@ - {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ + {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ {{ i.amount | currency: "$" }} {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }} diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index dff6cc5c611..35cb0c2ac79 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -75,7 +75,7 @@ {{ i.productName | i18n }} - - {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ + {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ {{ i.amount | currency: "$" }} diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 93a5a9c00a7..45cfa8a3552 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -17,11 +17,20 @@ import { } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service"; -import { RegistrationFinishService as RegistrationFinishServiceAbstraction } from "@bitwarden/auth/angular"; +import { + SetPasswordJitService, + RegistrationFinishService as RegistrationFinishServiceAbstraction, +} from "@bitwarden/auth/angular"; +import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { ClientType } from "@bitwarden/common/enums"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -48,7 +57,7 @@ import { import { VaultTimeout, VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type"; import { PolicyListService } from "../admin-console/core/policy-list.service"; -import { WebRegistrationFinishService } from "../auth"; +import { WebSetPasswordJitService, WebRegistrationFinishService } from "../auth"; import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service"; import { HtmlStorageService } from "../core/html-storage.service"; import { I18nService } from "../core/i18n.service"; @@ -184,6 +193,20 @@ const safeProviders: SafeProvider[] = [ PolicyService, ], }), + safeProvider({ + provide: SetPasswordJitService, + useClass: WebSetPasswordJitService, + deps: [ + ApiService, + CryptoServiceAbstraction, + I18nServiceAbstraction, + KdfConfigService, + InternalMasterPasswordServiceAbstraction, + OrganizationApiServiceAbstraction, + OrganizationUserService, + InternalUserDecryptionOptionsServiceAbstraction, + ], + }), ]; @NgModule({ diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html index c91e14fa529..08195d95bf9 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html @@ -15,21 +15,45 @@ class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0" > {{ "moreFromBitwarden" | i18n }} - - - diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts index d7400e478c1..a07f56db2d7 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts @@ -80,7 +80,10 @@ describe("NavigationProductSwitcherComponent", () => { isActive: false, name: "Other Product", icon: "bwi-lock", - marketingRoute: "https://www.example.com/", + marketingRoute: { + route: "https://www.example.com/", + external: true, + }, }, ], }); @@ -100,7 +103,10 @@ describe("NavigationProductSwitcherComponent", () => { isActive: false, name: "Other Product", icon: "bwi-lock", - marketingRoute: "https://www.example.com/", + marketingRoute: { + route: "https://www.example.com/", + external: true, + }, otherProductOverrides: { name: "Alternate name" }, }, ], @@ -117,7 +123,10 @@ describe("NavigationProductSwitcherComponent", () => { isActive: false, name: "Other Product", icon: "bwi-lock", - marketingRoute: "https://www.example.com/", + marketingRoute: { + route: "https://www.example.com/", + external: true, + }, otherProductOverrides: { name: "Alternate name", supportingText: "Supporting Text" }, }, ], @@ -134,9 +143,27 @@ describe("NavigationProductSwitcherComponent", () => { mockProducts$.next({ bento: [], other: [ - { name: "AA Product", icon: "bwi-lock", marketingRoute: "https://www.example.com/" }, - { name: "Test Product", icon: "bwi-lock", marketingRoute: "https://www.example.com/" }, - { name: "Organizations", icon: "bwi-lock", marketingRoute: "https://www.example.com/" }, + { + name: "AA Product", + icon: "bwi-lock", + marketingRoute: { + route: "https://www.example.com/", + external: true, + }, + }, + { + name: "Test Product", + icon: "bwi-lock", + marketingRoute: { + route: "https://www.example.com/", + external: true, + }, + }, + { + name: "Organizations", + icon: "bwi-lock", + marketingRoute: { route: "https://www.example.com/", external: true }, + }, ], }); @@ -157,7 +184,10 @@ describe("NavigationProductSwitcherComponent", () => { { name: "Organizations", icon: "bwi-lock", - marketingRoute: "https://www.example.com/", + marketingRoute: { + route: "https://www.example.com/", + external: true, + }, isActive: true, }, ], diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html b/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html index 62d8b6a075e..55f72401946 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html +++ b/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html @@ -34,17 +34,30 @@ class="tw-mt-4 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-t-text-muted tw-p-2 tw-pb-0" > {{ "moreFromBitwarden" | i18n }} - - - {{ product.name }} - - + + + + + {{ product.name }} + + + + + + {{ product.name }} + + +
    diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts index 434391cd50c..28474d792a5 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts @@ -30,7 +30,13 @@ export type ProductSwitcherItem = { /** * Route for items in the `otherProducts$` section */ - marketingRoute?: string | any[]; + marketingRoute?: { + route: string | any[]; + external: boolean; + }; + /** + * Route definition for external/internal routes for items in the `otherProducts$` section + */ /** * Used to apply css styles to show when a button is selected @@ -136,7 +142,10 @@ export class ProductSwitcherService { name: "Password Manager", icon: "bwi-lock", appRoute: "/vault", - marketingRoute: "https://bitwarden.com/products/personal/", + marketingRoute: { + route: "https://bitwarden.com/products/personal/", + external: true, + }, isActive: !this.router.url.includes("/sm/") && !this.router.url.includes("/organizations/") && @@ -146,7 +155,10 @@ export class ProductSwitcherService { name: "Secrets Manager", icon: "bwi-cli", appRoute: ["/sm", smOrg?.id], - marketingRoute: "https://bitwarden.com/products/secrets-manager/", + marketingRoute: { + route: "/sm-landing", + external: false, + }, isActive: this.router.url.includes("/sm/"), otherProductOverrides: { supportingText: this.i18n.transform("secureYourInfrastructure"), @@ -156,7 +168,10 @@ export class ProductSwitcherService { name: "Admin Console", icon: "bwi-business", appRoute: ["/organizations", acOrg?.id], - marketingRoute: "https://bitwarden.com/products/business/", + marketingRoute: { + route: "https://bitwarden.com/products/business/", + external: true, + }, isActive: this.router.url.includes("/organizations/"), }, provider: { @@ -168,7 +183,10 @@ export class ProductSwitcherService { orgs: { name: "Organizations", icon: "bwi-business", - marketingRoute: "https://bitwarden.com/products/business/", + marketingRoute: { + route: "https://bitwarden.com/products/business/", + external: true, + }, otherProductOverrides: { name: "Share your passwords", supportingText: this.i18n.transform("protectYourFamilyOrBusiness"), diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index a4172ad03ea..034f65366aa 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -17,6 +17,7 @@ import { RegistrationStartComponent, RegistrationStartSecondaryComponent, RegistrationStartSecondaryComponentData, + SetPasswordJitComponent, LockIcon, RegistrationLinkExpiredComponent, } from "@bitwarden/auth/angular"; @@ -59,6 +60,8 @@ import { EnvironmentSelectorComponent } from "./components/environment-selector/ import { DataProperties } from "./core"; import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; import { UserLayoutComponent } from "./layouts/user-layout.component"; +import { RequestSMAccessComponent } from "./secrets-manager/secrets-manager-landing/request-sm-access.component"; +import { SMLandingComponent } from "./secrets-manager/secrets-manager-landing/sm-landing.component"; import { DomainRulesComponent } from "./settings/domain-rules.component"; import { PreferencesComponent } from "./settings/preferences.component"; import { GeneratorComponent } from "./tools/generator.component"; @@ -206,6 +209,15 @@ const routes: Routes = [ }, ], }, + { + path: "set-password-jit", + canActivate: [canAccessFeature(FeatureFlag.EmailVerification)], + component: SetPasswordJitComponent, + data: { + pageTitle: "joinOrganization", + pageSubtitle: "finishJoiningThisOrganizationBySettingAMasterPassword", + } satisfies AnonLayoutWrapperData, + }, { path: "signup-link-expired", canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()], @@ -405,6 +417,16 @@ const routes: Routes = [ component: SendComponent, data: { titleId: "send" } satisfies DataProperties, }, + { + path: "sm-landing", + component: SMLandingComponent, + data: { titleId: "moreProductsFromBitwarden" }, + }, + { + path: "request-sm-access", + component: RequestSMAccessComponent, + data: { titleId: "requestAccessToSecretsManager" }, + }, { path: "create-organization", component: CreateOrganizationComponent, diff --git a/apps/web/src/app/secrets-manager/models/requests/request-sm-access.request.ts b/apps/web/src/app/secrets-manager/models/requests/request-sm-access.request.ts new file mode 100644 index 00000000000..fb580b93ee4 --- /dev/null +++ b/apps/web/src/app/secrets-manager/models/requests/request-sm-access.request.ts @@ -0,0 +1,6 @@ +import { Guid } from "@bitwarden/common/src/types/guid"; + +export class RequestSMAccessRequest { + OrganizationId: Guid; + EmailContent: string; +} diff --git a/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.html b/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.html new file mode 100644 index 00000000000..901ce0d178a --- /dev/null +++ b/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.html @@ -0,0 +1,39 @@ + + + +
    +
    +
    +

    {{ "youNeedApprovalFromYourAdminToTrySecretsManager" | i18n }}

    + + {{ "addANote" | i18n }} + + + + {{ "organization" | i18n }} + + + + +
    + + +
    +
    +
    +
    +
    diff --git a/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts b/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts new file mode 100644 index 00000000000..890cbe8ca12 --- /dev/null +++ b/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts @@ -0,0 +1,75 @@ +import { Component, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { Router } from "@angular/router"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Guid } from "@bitwarden/common/types/guid"; +import { NoItemsModule, SearchModule, ToastService } from "@bitwarden/components"; + +import { HeaderModule } from "../../layouts/header/header.module"; +import { OssModule } from "../../oss.module"; +import { SharedModule } from "../../shared/shared.module"; +import { RequestSMAccessRequest } from "../models/requests/request-sm-access.request"; + +import { SmLandingApiService } from "./sm-landing-api.service"; + +@Component({ + selector: "app-request-sm-access", + standalone: true, + templateUrl: "request-sm-access.component.html", + imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule, OssModule], +}) +export class RequestSMAccessComponent implements OnInit { + requestAccessForm = new FormGroup({ + requestAccessEmailContents: new FormControl( + this.i18nService.t("requestAccessSMDefaultEmailContent"), + [Validators.required], + ), + selectedOrganization: new FormControl(null, [Validators.required]), + }); + organizations: Organization[] = []; + + constructor( + private router: Router, + private i18nService: I18nService, + private organizationService: OrganizationService, + private smLandingApiService: SmLandingApiService, + private toastService: ToastService, + ) {} + + async ngOnInit() { + this.organizations = (await this.organizationService.getAll()) + .filter((e) => e.enabled) + .sort((a, b) => a.name.localeCompare(b.name)); + + if (this.organizations === null || this.organizations.length < 1) { + await this.navigateToCreateOrganizationPage(); + } + } + + submit = async () => { + this.requestAccessForm.markAllAsTouched(); + if (this.requestAccessForm.invalid) { + return; + } + + const formValue = this.requestAccessForm.value; + const request = new RequestSMAccessRequest(); + request.OrganizationId = formValue.selectedOrganization.id as Guid; + request.EmailContent = formValue.requestAccessEmailContents; + + await this.smLandingApiService.requestSMAccessFromAdmins(request); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("smAccessRequestEmailSent"), + }); + await this.router.navigate(["/"]); + }; + + async navigateToCreateOrganizationPage() { + await this.router.navigate(["/create-organization"]); + } +} diff --git a/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing-api.service.ts b/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing-api.service.ts new file mode 100644 index 00000000000..db2b2ee69a1 --- /dev/null +++ b/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing-api.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; + +import { RequestSMAccessRequest } from "../models/requests/request-sm-access.request"; + +@Injectable({ + providedIn: "root", +}) +export class SmLandingApiService { + constructor(private apiService: ApiService) {} + + async requestSMAccessFromAdmins(request: RequestSMAccessRequest): Promise { + await this.apiService.send("POST", "/request-access/request-sm-access", request, true, false); + } +} diff --git a/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.html b/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.html new file mode 100644 index 00000000000..659baa7fde1 --- /dev/null +++ b/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.html @@ -0,0 +1,53 @@ + + +
    +
    + +
    +
    +

    {{ "bitwardenSecretsManager" | i18n }}

    + +

    + {{ "developmentDevOpsAndITTeamsChooseBWSecret" | i18n }} +

    +
      +
    • + {{ "centralizeSecretsManagement" | i18n }} + {{ "centralizeSecretsManagementDescription" | i18n }} +
    • +
    • + {{ "preventSecretLeaks" | i18n }} {{ "preventSecretLeaksDescription" | i18n }} +
    • +
    • + {{ "enhanceDeveloperProductivity" | i18n }} + {{ "enhanceDeveloperProductivityDescription" | i18n }} +
    • +
    • + {{ "strengthenBusinessSecurity" | i18n }} + {{ "strengthenBusinessSecurityDescription" | i18n }} +
    • +
    +
    + +

    + {{ "giveMembersAccess" | i18n }} +

    +
      +
    • + {{ "openYourOrganizations" | i18n }} {{ "members" | i18n }} + {{ "viewAndSelectTheMembers" | i18n }} +
    • +
    • + {{ "usingTheMenuSelect" | i18n }} {{ "activateSecretsManager" | i18n }} + {{ "toGrantAccessToSelectedMembers" | i18n }} +
    • +
    +
    + + + {{ "learnMore" | i18n }} + +
    +
    diff --git a/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts b/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts new file mode 100644 index 00000000000..392f8403bde --- /dev/null +++ b/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts @@ -0,0 +1,76 @@ +import { Component } from "@angular/core"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { NoItemsModule, SearchModule } from "@bitwarden/components"; + +import { HeaderModule } from "../../layouts/header/header.module"; +import { SharedModule } from "../../shared/shared.module"; + +@Component({ + selector: "app-sm-landing", + standalone: true, + imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule], + templateUrl: "sm-landing.component.html", +}) +export class SMLandingComponent { + tryItNowUrl: string; + learnMoreUrl: string = "https://bitwarden.com/help/secrets-manager-overview/"; + imageSrc: string = "../images/sm.webp"; + showSecretsManagerInformation: boolean = true; + showGiveMembersAccessInstructions: boolean = false; + + constructor(private organizationService: OrganizationService) {} + + async ngOnInit() { + const enabledOrganizations = (await this.organizationService.getAll()).filter((e) => e.enabled); + + if (enabledOrganizations.length > 0) { + this.handleEnabledOrganizations(enabledOrganizations); + } else { + // Person is not part of any orgs they need to be in an organization in order to use SM + this.tryItNowUrl = "/create-organization"; + } + } + + private handleEnabledOrganizations(enabledOrganizations: Organization[]) { + // People get to this page because SM (Secrets Manager) isn't enabled for them (or the Organization they are a part of) + // 1 - SM is enabled for the Organization but not that user + //1a - person is Admin+ (Admin or higher) and just needs instructions on how to enable it for themselves + //1b - person is beneath admin status and needs to request SM access from Administrators/Owners + // 2 - SM is not enabled for the organization yet + //2a - person is Owner/Provider - Direct them to the subscription/billing page + //2b - person is Admin - Direct them to request access page where an email is sent to owner/admins + //2c - person is user - Direct them to request access page where an email is sent to owner/admins + + // We use useSecretsManager because we want to get the first org the person is a part of where SM is enabled but they don't have access enabled yet + const adminPlusNeedsInstructionsToEnableSM = enabledOrganizations.find( + (o) => o.isAdmin && o.useSecretsManager, + ); + const ownerNeedsToEnableSM = enabledOrganizations.find( + (o) => o.isOwner && !o.useSecretsManager, + ); + + // 1a If Organization has SM Enabled, but this logged in person does not have it enabled, but they are admin+ then give them instructions to enable. + if (adminPlusNeedsInstructionsToEnableSM != undefined) { + this.showHowToEnableSMForMembers(adminPlusNeedsInstructionsToEnableSM.id); + } + // 2a Owners can enable SM in the subscription area of Admin Console. + else if (ownerNeedsToEnableSM != undefined) { + this.tryItNowUrl = `/organizations/${ownerNeedsToEnableSM.id}/billing/subscription`; + } + // 1b and 2b 2c, they must be lower than an Owner, and they need access, or want their org to have access to SM. + else { + this.tryItNowUrl = "/request-sm-access"; + } + } + + private showHowToEnableSMForMembers(orgId: string) { + this.showGiveMembersAccessInstructions = true; + this.showSecretsManagerInformation = false; + this.learnMoreUrl = + "https://bitwarden.com/help/secrets-manager-quick-start/#give-members-access"; + this.imageSrc = "../images/sm-give-access.png"; + this.tryItNowUrl = `/organizations/${orgId}/members`; + } +} diff --git a/apps/web/src/app/tools/generator.component.html b/apps/web/src/app/tools/generator.component.html index f52d1f020d3..d73fe377011 100644 --- a/apps/web/src/app/tools/generator.component.html +++ b/apps/web/src/app/tools/generator.component.html @@ -210,7 +210,7 @@ [disabled]="enforcedPasswordPolicyOptions?.useSpecial" attr.aria-label="{{ 'specialCharacters' | i18n }}" /> - +
    {{ "important" | i18n }} {{ "masterPassImportant" | i18n }} - {{ minPasswordMsg }}. + {{ minPasswordLengthMsg }}. diff --git a/libs/auth/src/angular/input-password/input-password.component.ts b/libs/auth/src/angular/input-password/input-password.component.ts index 7b5651492e1..0fa5d0ac41c 100644 --- a/libs/auth/src/angular/input-password/input-password.component.ts +++ b/libs/auth/src/angular/input-password/input-password.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -12,6 +12,7 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod import { DEFAULT_KDF_CONFIG } from "@bitwarden/common/auth/models/domain/kdf-config"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { AsyncActionsModule, @@ -48,17 +49,16 @@ import { PasswordInputResult } from "./password-input-result"; JslibModule, ], }) -export class InputPasswordComponent implements OnInit { +export class InputPasswordComponent { @Output() onPasswordFormSubmit = new EventEmitter(); @Input({ required: true }) email: string; - @Input() protected buttonText: string; + @Input() buttonText: string; @Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null; @Input() loading: boolean = false; private minHintLength = 0; protected maxHintLength = 50; - protected minPasswordLength = Utils.minimumPasswordLength; protected minPasswordMsg = ""; protected passwordStrengthScore: PasswordStrengthScore; @@ -103,17 +103,14 @@ export class InputPasswordComponent implements OnInit { private toastService: ToastService, ) {} - async ngOnInit() { + get minPasswordLengthMsg() { if ( this.masterPasswordPolicyOptions != null && this.masterPasswordPolicyOptions.minLength > 0 ) { - this.minPasswordMsg = this.i18nService.t( - "characterMinimum", - this.masterPasswordPolicyOptions.minLength, - ); + return this.i18nService.t("characterMinimum", this.masterPasswordPolicyOptions.minLength); } else { - this.minPasswordMsg = this.i18nService.t("characterMinimum", this.minPasswordLength); + return this.i18nService.t("characterMinimum", this.minPasswordLength); } } @@ -181,9 +178,16 @@ export class InputPasswordComponent implements OnInit { const masterKeyHash = await this.cryptoService.hashMasterKey(password, masterKey); + const localMasterKeyHash = await this.cryptoService.hashMasterKey( + password, + masterKey, + HashPurpose.LocalAuthorization, + ); + this.onPasswordFormSubmit.emit({ masterKey, masterKeyHash, + localMasterKeyHash, kdfConfig, hint: this.formGroup.controls.hint.value, }); diff --git a/libs/auth/src/angular/input-password/password-input-result.ts b/libs/auth/src/angular/input-password/password-input-result.ts index 0a74b88a2e8..ce9f0b7386b 100644 --- a/libs/auth/src/angular/input-password/password-input-result.ts +++ b/libs/auth/src/angular/input-password/password-input-result.ts @@ -4,6 +4,7 @@ import { MasterKey } from "@bitwarden/common/types/key"; export interface PasswordInputResult { masterKey: MasterKey; masterKeyHash: string; + localMasterKeyHash: string; kdfConfig: PBKDF2KdfConfig; hint: string; } diff --git a/libs/auth/src/angular/password-callout/password-callout.component.html b/libs/auth/src/angular/password-callout/password-callout.component.html index f8a73ab6234..589d2a20456 100644 --- a/libs/auth/src/angular/password-callout/password-callout.component.html +++ b/libs/auth/src/angular/password-callout/password-callout.component.html @@ -1,7 +1,7 @@ {{ message | i18n }} -
      +
      • {{ "policyInEffectMinComplexity" | i18n: getPasswordScoreAlertDisplay() }}
      • diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html index 9785bf05ab5..5135fb61922 100644 --- a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html @@ -1,4 +1,4 @@ -
        + {{ "creatingAccountOn" | i18n }} diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts index 268fb1cc996..d5e588cdd96 100644 --- a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts @@ -44,6 +44,7 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy { private selectedRegionFromEnv: RegionConfig | Region.SelfHosted; + hideEnvSelector = false; isDesktopOrBrowserExtension = false; private destroy$ = new Subject(); @@ -59,9 +60,15 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy { const clientType = platformUtilsService.getClientType(); this.isDesktopOrBrowserExtension = clientType === ClientType.Desktop || clientType === ClientType.Browser; + + this.hideEnvSelector = clientType === ClientType.Web && this.platformUtilsService.isSelfHost(); } async ngOnInit() { + if (this.hideEnvSelector) { + return; + } + await this.initSelectedRegionAndListenForEnvChanges(); this.listenForSelectedRegionChanges(); } diff --git a/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.spec.ts b/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.spec.ts index 94eccfce2f3..b4c8026a5e5 100644 --- a/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.spec.ts +++ b/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.spec.ts @@ -54,6 +54,7 @@ describe("DefaultRegistrationFinishService", () => { passwordInputResult = { masterKey: masterKey, masterKeyHash: "masterKeyHash", + localMasterKeyHash: "localMasterKeyHash", kdfConfig: DEFAULT_KDF_CONFIG, hint: "hint", }; diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.html b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.html index 70ca948f93d..e42ed91166a 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.html +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.html @@ -1,4 +1,4 @@ -
        +
        diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts index 580b339e1eb..03886fe88d6 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts @@ -1,7 +1,7 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Params, Router, RouterModule } from "@angular/router"; -import { Subject, from, switchMap, takeUntil, tap } from "rxjs"; +import { EMPTY, Subject, from, switchMap, takeUntil, tap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; @@ -76,6 +76,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy { return from( this.registerVerificationEmailClicked(this.email, this.emailVerificationToken), ); + } else { + // org invite flow + this.loading = false; + return EMPTY; } }), diff --git a/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts b/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts new file mode 100644 index 00000000000..e4837641ef3 --- /dev/null +++ b/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts @@ -0,0 +1,230 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { + FakeUserDecryptionOptions as UserDecryptionOptions, + InternalUserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; +import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response"; +import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { DEFAULT_KDF_CONFIG } from "@bitwarden/common/auth/models/domain/kdf-config"; +import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; +import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; +import { MasterKey, UserKey } from "@bitwarden/common/types/key"; + +import { PasswordInputResult } from "../input-password/password-input-result"; + +import { DefaultSetPasswordJitService } from "./default-set-password-jit.service"; +import { SetPasswordCredentials } from "./set-password-jit.service.abstraction"; + +describe("DefaultSetPasswordJitService", () => { + let sut: DefaultSetPasswordJitService; + + let apiService: MockProxy; + let cryptoService: MockProxy; + let i18nService: MockProxy; + let kdfConfigService: MockProxy; + let masterPasswordService: MockProxy; + let organizationApiService: MockProxy; + let organizationUserService: MockProxy; + let userDecryptionOptionsService: MockProxy; + + beforeEach(() => { + apiService = mock(); + cryptoService = mock(); + i18nService = mock(); + kdfConfigService = mock(); + masterPasswordService = mock(); + organizationApiService = mock(); + organizationUserService = mock(); + userDecryptionOptionsService = mock(); + + sut = new DefaultSetPasswordJitService( + apiService, + cryptoService, + i18nService, + kdfConfigService, + masterPasswordService, + organizationApiService, + organizationUserService, + userDecryptionOptionsService, + ); + }); + + it("should instantiate the DefaultSetPasswordJitService", () => { + expect(sut).not.toBeFalsy(); + }); + + describe("setPassword", () => { + let masterKey: MasterKey; + let userKey: UserKey; + let userKeyEncString: EncString; + let protectedUserKey: [UserKey, EncString]; + let keyPair: [string, EncString]; + let keysRequest: KeysRequest; + let organizationKeys: OrganizationKeysResponse; + let orgPublicKey: Uint8Array; + + let orgSsoIdentifier: string; + let orgId: string; + let resetPasswordAutoEnroll: boolean; + let userId: UserId; + let passwordInputResult: PasswordInputResult; + let credentials: SetPasswordCredentials; + + let userDecryptionOptionsSubject: BehaviorSubject; + let setPasswordRequest: SetPasswordRequest; + + beforeEach(() => { + masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey; + userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + userKeyEncString = new EncString("userKeyEncrypted"); + protectedUserKey = [userKey, userKeyEncString]; + keyPair = ["publicKey", new EncString("privateKey")]; + keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString); + organizationKeys = { + privateKey: "orgPrivateKey", + publicKey: "orgPublicKey", + } as OrganizationKeysResponse; + orgPublicKey = Utils.fromB64ToArray(organizationKeys.publicKey); + + orgSsoIdentifier = "orgSsoIdentifier"; + orgId = "orgId"; + resetPasswordAutoEnroll = false; + userId = "userId" as UserId; + + passwordInputResult = { + masterKey: masterKey, + masterKeyHash: "masterKeyHash", + localMasterKeyHash: "localMasterKeyHash", + hint: "hint", + kdfConfig: DEFAULT_KDF_CONFIG, + }; + + credentials = { + ...passwordInputResult, + orgSsoIdentifier, + orgId, + resetPasswordAutoEnroll, + userId, + }; + + userDecryptionOptionsSubject = new BehaviorSubject(null); + userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject; + + setPasswordRequest = new SetPasswordRequest( + passwordInputResult.masterKeyHash, + protectedUserKey[1].encryptedString, + passwordInputResult.hint, + orgSsoIdentifier, + keysRequest, + passwordInputResult.kdfConfig.kdfType, + passwordInputResult.kdfConfig.iterations, + ); + }); + + function setupSetPasswordMocks(hasUserKey = true) { + if (!hasUserKey) { + cryptoService.userKey$.mockReturnValue(of(null)); + cryptoService.makeUserKey.mockResolvedValue(protectedUserKey); + } else { + cryptoService.userKey$.mockReturnValue(of(userKey)); + cryptoService.encryptUserKeyWithMasterKey.mockResolvedValue(protectedUserKey); + } + + cryptoService.makeKeyPair.mockResolvedValue(keyPair); + + apiService.setPassword.mockResolvedValue(undefined); + masterPasswordService.setForceSetPasswordReason.mockResolvedValue(undefined); + + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + userDecryptionOptionsService.setUserDecryptionOptions.mockResolvedValue(undefined); + kdfConfigService.setKdfConfig.mockResolvedValue(undefined); + cryptoService.setUserKey.mockResolvedValue(undefined); + + cryptoService.setPrivateKey.mockResolvedValue(undefined); + + masterPasswordService.setMasterKeyHash.mockResolvedValue(undefined); + } + + function setupResetPasswordAutoEnrollMocks(organizationKeysExist = true) { + if (organizationKeysExist) { + organizationApiService.getKeys.mockResolvedValue(organizationKeys); + } else { + organizationApiService.getKeys.mockResolvedValue(null); + return; + } + + cryptoService.userKey$.mockReturnValue(of(userKey)); + cryptoService.rsaEncrypt.mockResolvedValue(userKeyEncString); + + organizationUserService.putOrganizationUserResetPasswordEnrollment.mockResolvedValue( + undefined, + ); + } + + it("should set password successfully (given a user key)", async () => { + // Arrange + setupSetPasswordMocks(); + + // Act + await sut.setPassword(credentials); + + // Assert + expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + }); + + it("should set password successfully (given no user key)", async () => { + // Arrange + setupSetPasswordMocks(false); + + // Act + await sut.setPassword(credentials); + + // Assert + expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + }); + + it("should handle reset password auto enroll", async () => { + // Arrange + credentials.resetPasswordAutoEnroll = true; + + setupSetPasswordMocks(); + setupResetPasswordAutoEnrollMocks(); + + // Act + await sut.setPassword(credentials); + + // Assert + expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(organizationApiService.getKeys).toHaveBeenCalledWith(orgId); + expect(cryptoService.rsaEncrypt).toHaveBeenCalledWith(userKey.key, orgPublicKey); + expect(organizationUserService.putOrganizationUserResetPasswordEnrollment).toHaveBeenCalled(); + }); + + it("when handling reset password auto enroll, it should throw an error if organization keys are not found", async () => { + // Arrange + credentials.resetPasswordAutoEnroll = true; + + setupSetPasswordMocks(); + setupResetPasswordAutoEnrollMocks(false); + + // Act and Assert + await expect(sut.setPassword(credentials)).rejects.toThrow(); + expect( + organizationUserService.putOrganizationUserResetPasswordEnrollment, + ).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.ts b/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.ts new file mode 100644 index 00000000000..a5c196b5c7e --- /dev/null +++ b/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.ts @@ -0,0 +1,170 @@ +import { firstValueFrom } from "rxjs"; + +import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; +import { OrganizationUserResetPasswordEnrollmentRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests"; +import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { PBKDF2KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config"; +import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; +import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { UserId } from "@bitwarden/common/types/guid"; +import { MasterKey, UserKey } from "@bitwarden/common/types/key"; + +import { + SetPasswordCredentials, + SetPasswordJitService, +} from "./set-password-jit.service.abstraction"; + +export class DefaultSetPasswordJitService implements SetPasswordJitService { + constructor( + protected apiService: ApiService, + protected cryptoService: CryptoService, + protected i18nService: I18nService, + protected kdfConfigService: KdfConfigService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, + protected organizationApiService: OrganizationApiServiceAbstraction, + protected organizationUserService: OrganizationUserService, + protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, + ) {} + + async setPassword(credentials: SetPasswordCredentials): Promise { + const { + masterKey, + masterKeyHash, + localMasterKeyHash, + hint, + kdfConfig, + orgSsoIdentifier, + orgId, + resetPasswordAutoEnroll, + userId, + } = credentials; + + for (const [key, value] of Object.entries(credentials)) { + if (value == null) { + throw new Error(`${key} not found. Could not set password.`); + } + } + + const protectedUserKey = await this.makeProtectedUserKey(masterKey, userId); + if (protectedUserKey == null) { + throw new Error("protectedUserKey not found. Could not set password."); + } + + // Since this is an existing JIT provisioned user in a MP encryption org setting first password, + // they will not already have a user asymmetric key pair so we must create it for them. + const [keyPair, keysRequest] = await this.makeKeyPairAndRequest(protectedUserKey); + + const request = new SetPasswordRequest( + masterKeyHash, + protectedUserKey[1].encryptedString, + hint, + orgSsoIdentifier, + keysRequest, + kdfConfig.kdfType, // kdfConfig is always DEFAULT_KDF_CONFIG (see InputPasswordComponent) + kdfConfig.iterations, + ); + + await this.apiService.setPassword(request); + + // Clear force set password reason to allow navigation back to vault. + await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId); + + // User now has a password so update account decryption options in state + await this.updateAccountDecryptionProperties(masterKey, kdfConfig, protectedUserKey, userId); + + await this.cryptoService.setPrivateKey(keyPair[1].encryptedString, userId); + + await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId); + + if (resetPasswordAutoEnroll) { + await this.handleResetPasswordAutoEnroll(masterKeyHash, orgId, userId); + } + } + + private async makeProtectedUserKey( + masterKey: MasterKey, + userId: UserId, + ): Promise<[UserKey, EncString]> { + let protectedUserKey: [UserKey, EncString] = null; + + const userKey = await firstValueFrom(this.cryptoService.userKey$(userId)); + + if (userKey == null) { + protectedUserKey = await this.cryptoService.makeUserKey(masterKey); + } else { + protectedUserKey = await this.cryptoService.encryptUserKeyWithMasterKey(masterKey); + } + + return protectedUserKey; + } + + private async makeKeyPairAndRequest( + protectedUserKey: [UserKey, EncString], + ): Promise<[[string, EncString], KeysRequest]> { + const keyPair = await this.cryptoService.makeKeyPair(protectedUserKey[0]); + if (keyPair == null) { + throw new Error("keyPair not found. Could not set password."); + } + const keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString); + + return [keyPair, keysRequest]; + } + + private async updateAccountDecryptionProperties( + masterKey: MasterKey, + kdfConfig: PBKDF2KdfConfig, + protectedUserKey: [UserKey, EncString], + userId: UserId, + ) { + const userDecryptionOpts = await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptions$, + ); + userDecryptionOpts.hasMasterPassword = true; + await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts); + await this.kdfConfigService.setKdfConfig(userId, kdfConfig); + await this.masterPasswordService.setMasterKey(masterKey, userId); + await this.cryptoService.setUserKey(protectedUserKey[0], userId); + } + + private async handleResetPasswordAutoEnroll( + masterKeyHash: string, + orgId: string, + userId: UserId, + ) { + const organizationKeys = await this.organizationApiService.getKeys(orgId); + + if (organizationKeys == null) { + throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); + } + + const publicKey = Utils.fromB64ToArray(organizationKeys.publicKey); + + // RSA Encrypt user key with organization public key + const userKey = await firstValueFrom(this.cryptoService.userKey$(userId)); + + if (userKey == null) { + throw new Error("userKey not found. Could not handle reset password auto enroll."); + } + + const encryptedUserKey = await this.cryptoService.rsaEncrypt(userKey.key, publicKey); + + const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest(); + resetRequest.masterPasswordHash = masterKeyHash; + resetRequest.resetPasswordKey = encryptedUserKey.encryptedString; + + await this.organizationUserService.putOrganizationUserResetPasswordEnrollment( + orgId, + userId, + resetRequest, + ); + } +} diff --git a/libs/auth/src/angular/set-password-jit/set-password-jit.component.html b/libs/auth/src/angular/set-password-jit/set-password-jit.component.html new file mode 100644 index 00000000000..aa6a1229937 --- /dev/null +++ b/libs/auth/src/angular/set-password-jit/set-password-jit.component.html @@ -0,0 +1,22 @@ + + + {{ "loading" | i18n }} + + + + + {{ "resetPasswordAutoEnrollInviteWarning" | i18n }} + + + + diff --git a/libs/auth/src/angular/set-password-jit/set-password-jit.component.ts b/libs/auth/src/angular/set-password-jit/set-password-jit.component.ts new file mode 100644 index 00000000000..80b0adc2bc2 --- /dev/null +++ b/libs/auth/src/angular/set-password-jit/set-password-jit.component.ts @@ -0,0 +1,126 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom, map } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; + +import { ToastService } from "../../../../components/src/toast"; +import { InputPasswordComponent } from "../input-password/input-password.component"; +import { PasswordInputResult } from "../input-password/password-input-result"; + +import { + SetPasswordCredentials, + SetPasswordJitService, +} from "./set-password-jit.service.abstraction"; + +@Component({ + standalone: true, + selector: "auth-set-password-jit", + templateUrl: "set-password-jit.component.html", + imports: [CommonModule, InputPasswordComponent, JslibModule], +}) +export class SetPasswordJitComponent implements OnInit { + protected email: string; + protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions; + protected orgId: string; + protected orgSsoIdentifier: string; + protected resetPasswordAutoEnroll: boolean; + protected submitting = false; + protected syncLoading = true; + protected userId: UserId; + + constructor( + private accountService: AccountService, + private activatedRoute: ActivatedRoute, + private i18nService: I18nService, + private organizationApiService: OrganizationApiServiceAbstraction, + private policyApiService: PolicyApiServiceAbstraction, + private router: Router, + private setPasswordJitService: SetPasswordJitService, + private syncService: SyncService, + private toastService: ToastService, + private validationService: ValidationService, + ) {} + + async ngOnInit() { + this.email = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.email)), + ); + + await this.syncService.fullSync(true); + this.syncLoading = false; + + await this.handleQueryParams(); + } + + private async handleQueryParams() { + const qParams = await firstValueFrom(this.activatedRoute.queryParams); + + if (qParams.identifier != null) { + try { + this.orgSsoIdentifier = qParams.identifier; + + const autoEnrollStatus = await this.organizationApiService.getAutoEnrollStatus( + this.orgSsoIdentifier, + ); + this.orgId = autoEnrollStatus.id; + this.resetPasswordAutoEnroll = autoEnrollStatus.resetPasswordEnabled; + this.masterPasswordPolicyOptions = + await this.policyApiService.getMasterPasswordPolicyOptsForOrgUser(autoEnrollStatus.id); + } catch { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("errorOccurred"), + }); + } + } + } + + protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { + this.submitting = true; + + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + + const credentials: SetPasswordCredentials = { + ...passwordInputResult, + orgSsoIdentifier: this.orgSsoIdentifier, + orgId: this.orgId, + resetPasswordAutoEnroll: this.resetPasswordAutoEnroll, + userId, + }; + + try { + await this.setPasswordJitService.setPassword(credentials); + } catch (e) { + this.validationService.showError(e); + this.submitting = false; + return; + } + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("accountSuccessfullyCreated"), + }); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("inviteAccepted"), + }); + + this.submitting = false; + + await this.router.navigate(["vault"]); + } +} diff --git a/libs/auth/src/angular/set-password-jit/set-password-jit.service.abstraction.ts b/libs/auth/src/angular/set-password-jit/set-password-jit.service.abstraction.ts new file mode 100644 index 00000000000..165b4a61805 --- /dev/null +++ b/libs/auth/src/angular/set-password-jit/set-password-jit.service.abstraction.ts @@ -0,0 +1,33 @@ +import { PBKDF2KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config"; +import { UserId } from "@bitwarden/common/types/guid"; +import { MasterKey } from "@bitwarden/common/types/key"; + +export interface SetPasswordCredentials { + masterKey: MasterKey; + masterKeyHash: string; + localMasterKeyHash: string; + kdfConfig: PBKDF2KdfConfig; + hint: string; + orgSsoIdentifier: string; + orgId: string; + resetPasswordAutoEnroll: boolean; + userId: UserId; +} + +/** + * This service handles setting a password for a "just-in-time" provisioned user. + * + * A "just-in-time" (JIT) provisioned user is a user who does not have a registered account at the + * time they first click "Login with SSO". Once they click "Login with SSO" we register the account on + * the fly ("just-in-time"). + */ +export abstract class SetPasswordJitService { + /** + * Sets the password for a JIT provisioned user. + * + * @param credentials An object of the credentials needed to set the password for a JIT provisioned user + * @throws If any property on the `credentials` object is null or undefined, or if a protectedUserKey + * or newKeyPair could not be created. + */ + setPassword: (credentials: SetPasswordCredentials) => Promise; +} diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index d5939bf9873..465f3f43484 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -30,6 +30,7 @@ import { import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response"; import { CreateAuthRequest } from "../auth/models/request/create-auth.request"; import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request"; +import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request"; import { EmailTokenRequest } from "../auth/models/request/email-token.request"; import { EmailRequest } from "../auth/models/request/email.request"; import { PasswordTokenRequest } from "../auth/models/request/identity-token/password-token.request"; @@ -323,6 +324,9 @@ export abstract class ApiService { putTwoFactorAuthenticator: ( request: UpdateTwoFactorAuthenticatorRequest, ) => Promise; + deleteTwoFactorAuthenticator: ( + request: DisableTwoFactorAuthenticatorRequest, + ) => Promise; putTwoFactorEmail: (request: UpdateTwoFactorEmailRequest) => Promise; putTwoFactorDuo: (request: UpdateTwoFactorDuoRequest) => Promise; putTwoFactorOrganizationDuo: ( diff --git a/libs/common/src/auth/models/request/disable-two-factor-authenticator.request.ts b/libs/common/src/auth/models/request/disable-two-factor-authenticator.request.ts new file mode 100644 index 00000000000..7a9d7588464 --- /dev/null +++ b/libs/common/src/auth/models/request/disable-two-factor-authenticator.request.ts @@ -0,0 +1,6 @@ +import { TwoFactorProviderRequest } from "./two-factor-provider.request"; + +export class DisableTwoFactorAuthenticatorRequest extends TwoFactorProviderRequest { + key: string; + userVerificationToken: string; +} diff --git a/libs/common/src/auth/models/request/update-two-factor-authenticator.request.ts b/libs/common/src/auth/models/request/update-two-factor-authenticator.request.ts index 7c61c6943da..d39f55fe8c2 100644 --- a/libs/common/src/auth/models/request/update-two-factor-authenticator.request.ts +++ b/libs/common/src/auth/models/request/update-two-factor-authenticator.request.ts @@ -3,4 +3,5 @@ import { SecretVerificationRequest } from "./secret-verification.request"; export class UpdateTwoFactorAuthenticatorRequest extends SecretVerificationRequest { token: string; key: string; + userVerificationToken: string; } diff --git a/libs/common/src/auth/models/response/two-factor-authenticator.response.ts b/libs/common/src/auth/models/response/two-factor-authenticator.response.ts index 05a5517eb7a..f8ca1092be9 100644 --- a/libs/common/src/auth/models/response/two-factor-authenticator.response.ts +++ b/libs/common/src/auth/models/response/two-factor-authenticator.response.ts @@ -3,10 +3,12 @@ import { BaseResponse } from "../../../models/response/base.response"; export class TwoFactorAuthenticatorResponse extends BaseResponse { enabled: boolean; key: string; + userVerificationToken: string; constructor(response: any) { super(response); this.enabled = this.getResponseProperty("Enabled"); this.key = this.getResponseProperty("Key"); + this.userVerificationToken = this.getResponseProperty("UserVerificationToken"); } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index b5a617bc7e2..0e70e6db448 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -27,6 +27,7 @@ export enum FeatureFlag { VaultBulkManagementAction = "vault-bulk-management-action", AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page", DeviceTrustLogging = "pm-8285-device-trust-logging", + AuthenticatorTwoFactorToken = "authenticator-2fa-token", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -64,6 +65,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.VaultBulkManagementAction]: FALSE, [FeatureFlag.AC2828_ProviderPortalMembersPage]: FALSE, [FeatureFlag.DeviceTrustLogging]: FALSE, + [FeatureFlag.AuthenticatorTwoFactorToken]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 04fc95a04ee..28be70221f6 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -37,6 +37,7 @@ import { SelectionReadOnlyResponse } from "../admin-console/models/response/sele import { TokenService } from "../auth/abstractions/token.service"; import { CreateAuthRequest } from "../auth/models/request/create-auth.request"; import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request"; +import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request"; import { EmailTokenRequest } from "../auth/models/request/email-token.request"; import { EmailRequest } from "../auth/models/request/email.request"; import { DeviceRequest } from "../auth/models/request/identity-token/device.request"; @@ -998,6 +999,13 @@ export class ApiService implements ApiServiceAbstraction { return new TwoFactorAuthenticatorResponse(r); } + async deleteTwoFactorAuthenticator( + request: DisableTwoFactorAuthenticatorRequest, + ): Promise { + const r = await this.send("DELETE", "/two-factor/authenticator", request, true, true); + return new TwoFactorProviderResponse(r); + } + async putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise { const r = await this.send("PUT", "/two-factor/email", request, true, true); return new TwoFactorEmailResponse(r); diff --git a/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts deleted file mode 100644 index a1d358a13f8..00000000000 --- a/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Observable } from "rxjs"; - -import { UserId } from "../../../types/guid"; -import { GeneratedCredential, GeneratorCategory } from "../history"; - -/** Tracks the history of password generations. - * Each user gets their own store. - */ -export abstract class GeneratorHistoryService { - /** Tracks a new credential. When an item with the same `credential` value - * is found, this method does nothing. When the total number of items exceeds - * {@link HistoryServiceOptions.maxTotal}, then the oldest items exceeding the total - * are deleted. - * @param userId identifies the user storing the credential. - * @param credential stored by the history service. - * @param date when the credential was generated. If this is omitted, then the generator - * uses the date the credential was added to the store instead. - * @returns a promise that completes with the added credential. If the credential - * wasn't added, then the promise completes with `null`. - * @remarks this service is not suitable for use with vault items/ciphers. It models only - * a history of an individually generated credential, while a vault item's history - * may contain several credentials that are better modelled as atomic versions of the - * vault item itself. - */ - track: ( - userId: UserId, - credential: string, - category: GeneratorCategory, - date?: Date, - ) => Promise; - - /** Removes a matching credential from the history service. - * @param userId identifies the user taking the credential. - * @param credential to match in the history service. - * @returns A promise that completes with the credential read. If the credential wasn't found, - * the promise completes with null. - * @remarks this can be used to extract an entry when a credential is stored in the vault. - */ - take: (userId: UserId, credential: string) => Promise; - - /** Deletes a user's credential history. - * @param userId identifies the user taking the credential. - * @returns A promise that completes when the history is cleared. - */ - clear: (userId: UserId) => Promise; - - /** Lists all credentials for a user. - * @param userId identifies the user listing the credential. - * @remarks This field is eventually consistent with `track` and `take` operations. - * It is not guaranteed to immediately reflect those changes. - */ - credentials$: (userId: UserId) => Observable; -} diff --git a/libs/common/src/tools/generator/abstractions/generator-navigation.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-navigation.service.abstraction.ts deleted file mode 100644 index e9fb7e0bb48..00000000000 --- a/libs/common/src/tools/generator/abstractions/generator-navigation.service.abstraction.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Observable } from "rxjs"; - -import { UserId } from "../../../types/guid"; -import { GeneratorNavigation } from "../navigation/generator-navigation"; -import { GeneratorNavigationPolicy } from "../navigation/generator-navigation-policy"; - -import { PolicyEvaluator } from "./policy-evaluator.abstraction"; - -/** Loads and stores generator navigational data - */ -export abstract class GeneratorNavigationService { - /** An observable monitoring the options saved to disk. - * The observable updates when the options are saved. - * @param userId: Identifies the user making the request - */ - options$: (userId: UserId) => Observable; - - /** Gets the default options. */ - defaults$: (userId: UserId) => Observable; - - /** An observable monitoring the options used to enforce policy. - * The observable updates when the policy changes. - * @param userId: Identifies the user making the request - */ - evaluator$: ( - userId: UserId, - ) => Observable>; - - /** Enforces the policy on the given options - * @param userId: Identifies the user making the request - * @param options the options to enforce the policy on - * @returns a new instance of the options with the policy enforced - */ - enforcePolicy: (userId: UserId, options: GeneratorNavigation) => Promise; - - /** Saves the navigation options to disk. - * @param userId: Identifies the user making the request - * @param options the options to save - * @returns a promise that resolves when the options are saved - */ - saveOptions: (userId: UserId, options: GeneratorNavigation) => Promise; -} diff --git a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts deleted file mode 100644 index 7bc0f21739f..00000000000 --- a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Observable } from "rxjs"; - -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy as AdminPolicy } from "../../../admin-console/models/domain/policy"; -import { SingleUserState } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; - -import { PolicyEvaluator } from "./policy-evaluator.abstraction"; - -/** Tailors the generator service to generate a specific kind of credentials */ -export abstract class GeneratorStrategy { - /** Retrieve application state that persists across locks. - * @param userId: identifies the user state to retrieve - * @returns the strategy's durable user state - */ - durableState: (userId: UserId) => SingleUserState; - - /** Gets the default options. */ - defaults$: (userId: UserId) => Observable; - - /** Identifies the policy enforced by the generator. */ - policy: PolicyType; - - /** Operator function that converts a policy collection observable to a single - * policy evaluator observable. - * @param policy The policy being evaluated. - * @returns the policy evaluator. If `policy` is is `null` or `undefined`, - * then the evaluator defaults to the application's limits. - * @throws when the policy's type does not match the generator's policy type. - */ - toEvaluator: () => ( - source: Observable, - ) => Observable>; - - /** Generates credentials from the given options. - * @param options The options used to generate the credentials. - * @returns a promise that resolves to the generated credentials. - */ - generate: (options: Options) => Promise; -} diff --git a/libs/common/src/tools/generator/abstractions/generator.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator.service.abstraction.ts deleted file mode 100644 index adb11655522..00000000000 --- a/libs/common/src/tools/generator/abstractions/generator.service.abstraction.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Observable } from "rxjs"; - -import { UserId } from "../../../types/guid"; - -import { PolicyEvaluator } from "./policy-evaluator.abstraction"; - -/** Generates credentials used for user authentication - * @typeParam Options the credential generation configuration - * @typeParam Policy the policy enforced by the generator - */ -export abstract class GeneratorService { - /** An observable monitoring the options saved to disk. - * The observable updates when the options are saved. - * @param userId: Identifies the user making the request - */ - options$: (userId: UserId) => Observable; - - /** An observable monitoring the options used to enforce policy. - * The observable updates when the policy changes. - * @param userId: Identifies the user making the request - */ - evaluator$: (userId: UserId) => Observable>; - - /** Gets the default options. */ - defaults$: (userId: UserId) => Observable; - - /** Enforces the policy on the given options - * @param userId: Identifies the user making the request - * @param options the options to enforce the policy on - * @returns a new instance of the options with the policy enforced - */ - enforcePolicy: (userId: UserId, options: Options) => Promise; - - /** Generates credentials - * @param options the options to generate credentials with - * @returns a promise that resolves with the generated credentials - */ - generate: (options: Options) => Promise; - - /** Saves the given options to disk. - * @param userId: Identifies the user making the request - * @param options the options to save - * @returns a promise that resolves when the options are saved - */ - saveOptions: (userId: UserId, options: Options) => Promise; -} diff --git a/libs/common/src/tools/generator/abstractions/index.ts b/libs/common/src/tools/generator/abstractions/index.ts deleted file mode 100644 index ef40dfd434f..00000000000 --- a/libs/common/src/tools/generator/abstractions/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { GeneratorHistoryService } from "./generator-history.abstraction"; -export { GeneratorNavigationService } from "./generator-navigation.service.abstraction"; -export { GeneratorService } from "./generator.service.abstraction"; -export { GeneratorStrategy } from "./generator-strategy.abstraction"; -export { PolicyEvaluator } from "./policy-evaluator.abstraction"; diff --git a/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts deleted file mode 100644 index f6b5ca9cabe..00000000000 --- a/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Observable } from "rxjs"; - -import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; -import { GeneratedPasswordHistory } from "../password/generated-password-history"; -import { PasswordGeneratorOptions } from "../password/password-generator-options"; - -/** @deprecated Use {@link GeneratorService} with a password or passphrase {@link GeneratorStrategy} instead. */ -export abstract class PasswordGenerationServiceAbstraction { - generatePassword: (options: PasswordGeneratorOptions) => Promise; - generatePassphrase: (options: PasswordGeneratorOptions) => Promise; - getOptions: () => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; - getOptions$: () => Observable<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; - enforcePasswordGeneratorPoliciesOnOptions: ( - options: PasswordGeneratorOptions, - ) => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; - saveOptions: (options: PasswordGeneratorOptions) => Promise; - getHistory: () => Promise; - addHistory: (password: string) => Promise; - clear: (userId?: string) => Promise; -} diff --git a/libs/common/src/tools/generator/abstractions/policy-evaluator.abstraction.ts b/libs/common/src/tools/generator/abstractions/policy-evaluator.abstraction.ts deleted file mode 100644 index f4e9186c9c3..00000000000 --- a/libs/common/src/tools/generator/abstractions/policy-evaluator.abstraction.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** Applies policy to a generation request */ -export abstract class PolicyEvaluator { - /** The policy to enforce */ - policy: Policy; - - /** Returns true when a policy is being enforced by the evaluator. - * @remarks `applyPolicy` should be called when a policy is not in - * effect to enforce the application's default policy. - */ - policyInEffect: boolean; - - /** Apply policy to a set of options. - * @param options The options to build from. These options are not altered. - * @returns A complete generation request with policy applied. - * @remarks This method only applies policy overrides. - * Pass the result to `sanitize` to ensure consistency. - */ - applyPolicy: (options: PolicyTarget) => PolicyTarget; - - /** Ensures internal options consistency. - * @param options The options to cascade. These options are not altered. - * @returns A new generation request with cascade applied. - * @remarks This method fills null and undefined values by looking at - * pairs of flags and values (e.g. `number` and `minNumber`). If the flag - * and value are inconsistent, the flag cascades to the value. - */ - sanitize: (options: PolicyTarget) => PolicyTarget; -} diff --git a/libs/common/src/tools/generator/abstractions/randomizer.ts b/libs/common/src/tools/generator/abstractions/randomizer.ts deleted file mode 100644 index 33222477593..00000000000 --- a/libs/common/src/tools/generator/abstractions/randomizer.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { WordOptions } from "../word-options"; - -/** Entropy source for credential generation. */ -export interface Randomizer { - /** picks a random entry from a list. - * @param list random entry source. This must have at least one entry. - * @returns a promise that resolves with a random entry from the list. - */ - pick(list: Array): Promise; - - /** picks a random word from a list. - * @param list random entry source. This must have at least one entry. - * @param options customizes the output word - * @returns a promise that resolves with a random word from the list. - */ - pickWord(list: Array, options?: WordOptions): Promise; - - /** Shuffles a list of items - * @param list random entry source. This must have at least two entries. - * @param options.copy shuffles a copy of the input when this is true. - * Defaults to true. - * @returns a promise that resolves with the randomized list. - */ - shuffle(items: Array): Promise>; - - /** Generates a string containing random lowercase ASCII characters and numbers. - * @param length the number of characters to generate - * @returns a promise that resolves with the randomized string. - */ - chars(length: number): Promise; - - /** Selects an integer value from a range by randomly choosing it from - * a uniform distribution. - * @param min the minimum value in the range, inclusive. - * @param max the minimum value in the range, inclusive. - * @returns a promise that resolves with the randomized string. - */ - uniform(min: number, max: number): Promise; -} diff --git a/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts deleted file mode 100644 index f11cbf02ed2..00000000000 --- a/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Observable } from "rxjs"; - -import { UsernameGeneratorOptions } from "../username/username-generation-options"; - -/** @deprecated Use {@link GeneratorService} with a username {@link GeneratorStrategy} instead. */ -export abstract class UsernameGenerationServiceAbstraction { - generateUsername: (options: UsernameGeneratorOptions) => Promise; - generateWord: (options: UsernameGeneratorOptions) => Promise; - generateSubaddress: (options: UsernameGeneratorOptions) => Promise; - generateCatchall: (options: UsernameGeneratorOptions) => Promise; - generateForwarded: (options: UsernameGeneratorOptions) => Promise; - getOptions: () => Promise; - getOptions$: () => Observable; - saveOptions: (options: UsernameGeneratorOptions) => Promise; -} diff --git a/libs/common/src/tools/generator/default-generator.service.spec.ts b/libs/common/src/tools/generator/default-generator.service.spec.ts deleted file mode 100644 index 94d7d62fa8f..00000000000 --- a/libs/common/src/tools/generator/default-generator.service.spec.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * include structuredClone in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ - -import { mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, map, pipe } from "rxjs"; - -import { FakeSingleUserState, awaitAsync } from "../../../spec"; -import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyType } from "../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../admin-console/models/domain/policy"; -import { SingleUserState } from "../../platform/state"; -import { UserId } from "../../types/guid"; - -import { GeneratorStrategy, PolicyEvaluator } from "./abstractions"; -import { PasswordGenerationOptions } from "./password"; - -import { DefaultGeneratorService } from "."; - -function mockPolicyService(config?: { state?: BehaviorSubject }) { - const service = mock(); - - const stateValue = config?.state ?? new BehaviorSubject([null]); - service.getAll$.mockReturnValue(stateValue); - - return service; -} - -function mockGeneratorStrategy(config?: { - userState?: SingleUserState; - policy?: PolicyType; - evaluator?: any; - defaults?: any; -}) { - const durableState = - config?.userState ?? new FakeSingleUserState(SomeUser); - const strategy = mock>({ - // intentionally arbitrary so that tests that need to check - // whether they're used properly are guaranteed to test - // the value from `config`. - durableState: jest.fn(() => durableState), - defaults$: jest.fn(() => new BehaviorSubject(config?.defaults)), - policy: config?.policy ?? PolicyType.DisableSend, - toEvaluator: jest.fn(() => - pipe(map(() => config?.evaluator ?? mock>())), - ), - }); - - return strategy; -} - -const SomeUser = "some user" as UserId; -const AnotherUser = "another user" as UserId; - -describe("Password generator service", () => { - describe("options$", () => { - it("should retrieve durable state from the service", () => { - const policy = mockPolicyService(); - const userState = new FakeSingleUserState(SomeUser); - const strategy = mockGeneratorStrategy({ userState }); - const service = new DefaultGeneratorService(strategy, policy); - - const result = service.options$(SomeUser); - - expect(strategy.durableState).toHaveBeenCalledWith(SomeUser); - expect(result).toBe(userState.state$); - }); - }); - - describe("defaults$", () => { - it("should retrieve default state from the service", async () => { - const policy = mockPolicyService(); - const defaults = {}; - const strategy = mockGeneratorStrategy({ defaults }); - const service = new DefaultGeneratorService(strategy, policy); - - const result = await firstValueFrom(service.defaults$(SomeUser)); - - expect(strategy.defaults$).toHaveBeenCalledWith(SomeUser); - expect(result).toBe(defaults); - }); - }); - - describe("saveOptions()", () => { - it("should trigger an options$ update", async () => { - const policy = mockPolicyService(); - const userState = new FakeSingleUserState(SomeUser, { length: 9 }); - const strategy = mockGeneratorStrategy({ userState }); - const service = new DefaultGeneratorService(strategy, policy); - - await service.saveOptions(SomeUser, { length: 10 }); - await awaitAsync(); - const options = await firstValueFrom(service.options$(SomeUser)); - - expect(strategy.durableState).toHaveBeenCalledWith(SomeUser); - expect(options).toEqual({ length: 10 }); - }); - }); - - describe("evaluator$", () => { - it("should initialize the password generator policy", async () => { - const policy = mockPolicyService(); - const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator }); - const service = new DefaultGeneratorService(strategy, policy); - - await firstValueFrom(service.evaluator$(SomeUser)); - - expect(policy.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); - }); - - it("should map the policy using the generation strategy", async () => { - const policyService = mockPolicyService(); - const evaluator = mock>(); - const strategy = mockGeneratorStrategy({ evaluator }); - const service = new DefaultGeneratorService(strategy, policyService); - - const policy = await firstValueFrom(service.evaluator$(SomeUser)); - - expect(policy).toBe(evaluator); - }); - - it("should update the evaluator when the password generator policy changes", async () => { - // set up dependencies - const state = new BehaviorSubject([null]); - const policy = mockPolicyService({ state }); - const strategy = mockGeneratorStrategy(); - const service = new DefaultGeneratorService(strategy, policy); - - // model responses for the observable update. The map is called multiple times, - // and the array shift ensures reference equality is maintained. - const firstEvaluator = mock>(); - const secondEvaluator = mock>(); - const evaluators = [firstEvaluator, secondEvaluator]; - strategy.toEvaluator.mockReturnValueOnce(pipe(map(() => evaluators.shift()))); - - // act - const evaluator$ = service.evaluator$(SomeUser); - const firstResult = await firstValueFrom(evaluator$); - state.next([null]); - const secondResult = await firstValueFrom(evaluator$); - - // assert - expect(firstResult).toBe(firstEvaluator); - expect(secondResult).toBe(secondEvaluator); - }); - - it("should cache the password generator policy", async () => { - const policy = mockPolicyService(); - const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator }); - const service = new DefaultGeneratorService(strategy, policy); - - await firstValueFrom(service.evaluator$(SomeUser)); - await firstValueFrom(service.evaluator$(SomeUser)); - - expect(policy.getAll$).toHaveBeenCalledTimes(1); - }); - - it("should cache the password generator policy for each user", async () => { - const policy = mockPolicyService(); - const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator }); - const service = new DefaultGeneratorService(strategy, policy); - - await firstValueFrom(service.evaluator$(SomeUser)); - await firstValueFrom(service.evaluator$(AnotherUser)); - - expect(policy.getAll$).toHaveBeenNthCalledWith(1, PolicyType.PasswordGenerator, SomeUser); - expect(policy.getAll$).toHaveBeenNthCalledWith(2, PolicyType.PasswordGenerator, AnotherUser); - }); - }); - - describe("enforcePolicy()", () => { - it("should evaluate the policy using the generation strategy", async () => { - const policy = mockPolicyService(); - const evaluator = mock>(); - const strategy = mockGeneratorStrategy({ evaluator }); - const service = new DefaultGeneratorService(strategy, policy); - - await service.enforcePolicy(SomeUser, {}); - - expect(evaluator.applyPolicy).toHaveBeenCalled(); - expect(evaluator.sanitize).toHaveBeenCalled(); - }); - }); - - describe("generate()", () => { - it("should invoke the generation strategy", async () => { - const strategy = mockGeneratorStrategy(); - const policy = mockPolicyService(); - const service = new DefaultGeneratorService(strategy, policy); - - await service.generate({}); - - expect(strategy.generate).toHaveBeenCalled(); - }); - }); -}); diff --git a/libs/common/src/tools/generator/default-generator.service.ts b/libs/common/src/tools/generator/default-generator.service.ts deleted file mode 100644 index 9decb309bed..00000000000 --- a/libs/common/src/tools/generator/default-generator.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { firstValueFrom, Observable } from "rxjs"; - -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; -import { UserId } from "../../types/guid"; - -import { GeneratorStrategy, GeneratorService, PolicyEvaluator } from "./abstractions"; - -/** {@link GeneratorServiceAbstraction} */ -export class DefaultGeneratorService implements GeneratorService { - /** Instantiates the generator service - * @param strategy tailors the service to a specific generator type - * (e.g. password, passphrase) - * @param policy provides the policy to enforce - */ - constructor( - private strategy: GeneratorStrategy, - private policy: PolicyService, - ) {} - - private _evaluators$ = new Map>>(); - - /** {@link GeneratorService.options$} */ - options$(userId: UserId) { - return this.strategy.durableState(userId).state$; - } - - /** {@link GeneratorService.defaults$} */ - defaults$(userId: UserId) { - return this.strategy.defaults$(userId); - } - - /** {@link GeneratorService.saveOptions} */ - async saveOptions(userId: UserId, options: Options): Promise { - await this.strategy.durableState(userId).update(() => options); - } - - /** {@link GeneratorService.evaluator$} */ - evaluator$(userId: UserId) { - let evaluator$ = this._evaluators$.get(userId); - - if (!evaluator$) { - evaluator$ = this.createEvaluator(userId); - this._evaluators$.set(userId, evaluator$); - } - - return evaluator$; - } - - private createEvaluator(userId: UserId) { - const evaluator$ = this.policy.getAll$(this.strategy.policy, userId).pipe( - // create the evaluator from the policies - this.strategy.toEvaluator(), - ); - - return evaluator$; - } - - /** {@link GeneratorService.enforcePolicy} */ - async enforcePolicy(userId: UserId, options: Options): Promise { - const policy = await firstValueFrom(this.evaluator$(userId)); - const evaluated = policy.applyPolicy(options); - const sanitized = policy.sanitize(evaluated); - return sanitized; - } - - /** {@link GeneratorService.generate} */ - async generate(options: Options): Promise { - return await this.strategy.generate(options); - } -} diff --git a/libs/common/src/tools/generator/default-policy-evaluator.spec.ts b/libs/common/src/tools/generator/default-policy-evaluator.spec.ts deleted file mode 100644 index d5d5e810285..00000000000 --- a/libs/common/src/tools/generator/default-policy-evaluator.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { DefaultPolicyEvaluator } from "./default-policy-evaluator"; - -describe("Password generator options builder", () => { - describe("policy", () => { - it("should return an empty object", () => { - const builder = new DefaultPolicyEvaluator(); - - expect(builder.policy).toEqual({}); - }); - }); - - describe("policyInEffect", () => { - it("should return false", () => { - const builder = new DefaultPolicyEvaluator(); - - expect(builder.policyInEffect).toEqual(false); - }); - }); - - describe("applyPolicy(options)", () => { - // All tests should freeze the options to ensure they are not modified - it("should return the input operations without altering them", () => { - const builder = new DefaultPolicyEvaluator(); - const options = Object.freeze({}); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions).toEqual(options); - }); - }); - - describe("sanitize(options)", () => { - // All tests should freeze the options to ensure they are not modified - it("should return the input options without altering them", () => { - const builder = new DefaultPolicyEvaluator(); - const options = Object.freeze({}); - - const sanitizedOptions = builder.sanitize(options); - - expect(sanitizedOptions).toEqual(options); - }); - }); -}); diff --git a/libs/common/src/tools/generator/default-policy-evaluator.ts b/libs/common/src/tools/generator/default-policy-evaluator.ts deleted file mode 100644 index d77ea2bbbc6..00000000000 --- a/libs/common/src/tools/generator/default-policy-evaluator.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { PolicyEvaluator } from "./abstractions"; -import { NoPolicy } from "./no-policy"; - -/** A policy evaluator that does not apply any policy */ -export class DefaultPolicyEvaluator - implements PolicyEvaluator -{ - /** {@link PolicyEvaluator.policy} */ - get policy() { - return {}; - } - - /** {@link PolicyEvaluator.policyInEffect} */ - get policyInEffect() { - return false; - } - - /** {@link PolicyEvaluator.applyPolicy} */ - applyPolicy(options: PolicyTarget) { - return options; - } - - /** {@link PolicyEvaluator.sanitize} */ - sanitize(options: PolicyTarget) { - return options; - } -} diff --git a/libs/common/src/tools/generator/generator-options.ts b/libs/common/src/tools/generator/generator-options.ts deleted file mode 100644 index d3d08025fae..00000000000 --- a/libs/common/src/tools/generator/generator-options.ts +++ /dev/null @@ -1,5 +0,0 @@ -// this export provided solely for backwards compatibility -export { - /** @deprecated use `GeneratorNavigation` from './navigation' instead. */ - GeneratorNavigation as GeneratorOptions, -} from "./navigation/generator-navigation"; diff --git a/libs/common/src/tools/generator/generator-type.ts b/libs/common/src/tools/generator/generator-type.ts deleted file mode 100644 index f17eeb9c92b..00000000000 --- a/libs/common/src/tools/generator/generator-type.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** The kind of credential being generated. */ -export type GeneratorType = "password" | "passphrase" | "username"; diff --git a/libs/common/src/tools/generator/history/generated-credential.spec.ts b/libs/common/src/tools/generator/history/generated-credential.spec.ts deleted file mode 100644 index 170030bad17..00000000000 --- a/libs/common/src/tools/generator/history/generated-credential.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { GeneratorCategory, GeneratedCredential } from "./"; - -describe("GeneratedCredential", () => { - describe("constructor", () => { - it("assigns credential", () => { - const result = new GeneratedCredential("example", "passphrase", new Date(100)); - - expect(result.credential).toEqual("example"); - }); - - it("assigns category", () => { - const result = new GeneratedCredential("example", "passphrase", new Date(100)); - - expect(result.category).toEqual("passphrase"); - }); - - it("passes through date parameters", () => { - const result = new GeneratedCredential("example", "password", new Date(100)); - - expect(result.generationDate).toEqual(new Date(100)); - }); - - it("converts numeric dates to Dates", () => { - const result = new GeneratedCredential("example", "password", 100); - - expect(result.generationDate).toEqual(new Date(100)); - }); - }); - - it("toJSON converts from a credential into a JSON object", () => { - const credential = new GeneratedCredential("example", "password", new Date(100)); - - const result = credential.toJSON(); - - expect(result).toEqual({ - credential: "example", - category: "password" as GeneratorCategory, - generationDate: 100, - }); - }); - - it("fromJSON converts Json objects into credentials", () => { - const jsonValue = { - credential: "example", - category: "password" as GeneratorCategory, - generationDate: 100, - }; - - const result = GeneratedCredential.fromJSON(jsonValue); - - expect(result).toBeInstanceOf(GeneratedCredential); - expect(result).toEqual({ - credential: "example", - category: "password", - generationDate: new Date(100), - }); - }); -}); diff --git a/libs/common/src/tools/generator/history/generated-credential.ts b/libs/common/src/tools/generator/history/generated-credential.ts deleted file mode 100644 index 59a9623bf7e..00000000000 --- a/libs/common/src/tools/generator/history/generated-credential.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Jsonify } from "type-fest"; - -import { GeneratorCategory } from "./options"; - -/** A credential generation result */ -export class GeneratedCredential { - /** - * Instantiates a generated credential - * @param credential The value of the generated credential (e.g. a password) - * @param category The kind of credential - * @param generationDate The date that the credential was generated. - * Numeric values should are interpreted using {@link Date.valueOf} - * semantics. - */ - constructor( - readonly credential: string, - readonly category: GeneratorCategory, - generationDate: Date | number, - ) { - if (typeof generationDate === "number") { - this.generationDate = new Date(generationDate); - } else { - this.generationDate = generationDate; - } - } - - /** The date that the credential was generated */ - generationDate: Date; - - /** Constructs a credential from its `toJSON` representation */ - static fromJSON(jsonValue: Jsonify) { - return new GeneratedCredential( - jsonValue.credential, - jsonValue.category, - jsonValue.generationDate, - ); - } - - /** Serializes a credential to a JSON-compatible object */ - toJSON() { - return { - credential: this.credential, - category: this.category, - generationDate: this.generationDate.valueOf(), - }; - } -} diff --git a/libs/common/src/tools/generator/history/index.ts b/libs/common/src/tools/generator/history/index.ts deleted file mode 100644 index 1952a849af2..00000000000 --- a/libs/common/src/tools/generator/history/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { GeneratorCategory } from "./options"; -export { GeneratedCredential } from "./generated-credential"; diff --git a/libs/common/src/tools/generator/history/legacy-password-history-decryptor.ts b/libs/common/src/tools/generator/history/legacy-password-history-decryptor.ts deleted file mode 100644 index 6c59ca837cd..00000000000 --- a/libs/common/src/tools/generator/history/legacy-password-history-decryptor.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { CryptoService } from "../../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../../platform/abstractions/encrypt.service"; -import { EncString } from "../../../platform/models/domain/enc-string"; -import { UserId } from "../../../types/guid"; -import { GeneratedPasswordHistory } from "../password/generated-password-history"; - -/** Strategy that decrypts a password history */ -export class LegacyPasswordHistoryDecryptor { - constructor( - private userId: UserId, - private cryptoService: CryptoService, - private encryptService: EncryptService, - ) {} - - /** Decrypts a password history. */ - async decrypt(history: GeneratedPasswordHistory[]): Promise { - const key = await this.cryptoService.getUserKey(this.userId); - - const promises = (history ?? []).map(async (item) => { - const encrypted = new EncString(item.password); - const decrypted = await this.encryptService.decryptToUtf8(encrypted, key); - return new GeneratedPasswordHistory(decrypted, item.date); - }); - - const decrypted = await Promise.all(promises); - - return decrypted; - } -} diff --git a/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts b/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts deleted file mode 100644 index 9640016584a..00000000000 --- a/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { mock } from "jest-mock-extended"; -import { firstValueFrom, of } from "rxjs"; - -import { FakeStateProvider, awaitAsync, mockAccountServiceWith } from "../../../../spec"; -import { CryptoService } from "../../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../../platform/abstractions/encrypt.service"; -import { EncString } from "../../../platform/models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { CsprngArray } from "../../../types/csprng"; -import { UserId } from "../../../types/guid"; -import { UserKey } from "../../../types/key"; - -import { LocalGeneratorHistoryService } from "./local-generator-history.service"; - -const SomeUser = "SomeUser" as UserId; -const AnotherUser = "AnotherUser" as UserId; - -describe("LocalGeneratorHistoryService", () => { - const encryptService = mock(); - const keyService = mock(); - const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey; - - beforeEach(() => { - encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); - encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString)); - keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey)); - keyService.getInMemoryUserKeyFor$.mockImplementation(() => of(true as unknown as UserKey)); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe("credential$", () => { - it("returns an empty list when no credentials are stored", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - - const result = await firstValueFrom(history.credentials$(SomeUser)); - - expect(result).toEqual([]); - }); - }); - - describe("track", () => { - it("stores a password", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - - await history.track(SomeUser, "example", "password"); - await awaitAsync(); - const [result] = await firstValueFrom(history.credentials$(SomeUser)); - - expect(result).toMatchObject({ credential: "example", category: "password" }); - }); - - it("stores a passphrase", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - - await history.track(SomeUser, "example", "passphrase"); - await awaitAsync(); - const [result] = await firstValueFrom(history.credentials$(SomeUser)); - - expect(result).toMatchObject({ credential: "example", category: "passphrase" }); - }); - - it("stores a specific date when one is provided", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - - await history.track(SomeUser, "example", "password", new Date(100)); - await awaitAsync(); - const [result] = await firstValueFrom(history.credentials$(SomeUser)); - - expect(result).toEqual({ - credential: "example", - category: "password", - generationDate: new Date(100), - }); - }); - - it("skips storing a credential when it's already stored (ignores category)", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - - await history.track(SomeUser, "example", "password"); - await history.track(SomeUser, "example", "password"); - await history.track(SomeUser, "example", "passphrase"); - await awaitAsync(); - const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); - - expect(firstResult).toMatchObject({ credential: "example", category: "password" }); - expect(secondResult).toBeUndefined(); - }); - - it("stores multiple credentials when the credential value is different", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - - await history.track(SomeUser, "secondResult", "password"); - await history.track(SomeUser, "firstResult", "password"); - await awaitAsync(); - const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); - - expect(firstResult).toMatchObject({ credential: "firstResult", category: "password" }); - expect(secondResult).toMatchObject({ credential: "secondResult", category: "password" }); - }); - - it("removes history items exceeding maxTotal configuration", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, { - maxTotal: 1, - }); - - await history.track(SomeUser, "removed result", "password"); - await history.track(SomeUser, "example", "password"); - await awaitAsync(); - const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); - - expect(firstResult).toMatchObject({ credential: "example", category: "password" }); - expect(secondResult).toBeUndefined(); - }); - - it("stores history items in per-user collections", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, { - maxTotal: 1, - }); - - await history.track(SomeUser, "some user example", "password"); - await history.track(AnotherUser, "another user example", "password"); - await awaitAsync(); - const [someFirstResult, someSecondResult] = await firstValueFrom( - history.credentials$(SomeUser), - ); - const [anotherFirstResult, anotherSecondResult] = await firstValueFrom( - history.credentials$(AnotherUser), - ); - - expect(someFirstResult).toMatchObject({ - credential: "some user example", - category: "password", - }); - expect(someSecondResult).toBeUndefined(); - expect(anotherFirstResult).toMatchObject({ - credential: "another user example", - category: "password", - }); - expect(anotherSecondResult).toBeUndefined(); - }); - }); - - describe("take", () => { - it("returns null when there are no credentials stored", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - - const result = await history.take(SomeUser, "example"); - - expect(result).toBeNull(); - }); - - it("returns null when the credential wasn't found", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); - - const result = await history.take(SomeUser, "not found"); - - expect(result).toBeNull(); - }); - - it("returns a matching credential", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); - - const result = await history.take(SomeUser, "example"); - - expect(result).toMatchObject({ - credential: "example", - category: "password", - }); - }); - - it("removes a matching credential", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); - - await history.take(SomeUser, "example"); - await awaitAsync(); - const results = await firstValueFrom(history.credentials$(SomeUser)); - - expect(results).toEqual([]); - }); - }); -}); diff --git a/libs/common/src/tools/generator/history/local-generator-history.service.ts b/libs/common/src/tools/generator/history/local-generator-history.service.ts deleted file mode 100644 index 85aa599946f..00000000000 --- a/libs/common/src/tools/generator/history/local-generator-history.service.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { map } from "rxjs"; - -import { CryptoService } from "../../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../../platform/abstractions/encrypt.service"; -import { SingleUserState, StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; -import { BufferedState } from "../../state/buffered-state"; -import { PaddedDataPacker } from "../../state/padded-data-packer"; -import { SecretState } from "../../state/secret-state"; -import { UserKeyEncryptor } from "../../state/user-key-encryptor"; -import { GeneratorHistoryService } from "../abstractions/generator-history.abstraction"; -import { GENERATOR_HISTORY, GENERATOR_HISTORY_BUFFER } from "../key-definitions"; - -import { GeneratedCredential } from "./generated-credential"; -import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor"; -import { GeneratorCategory, HistoryServiceOptions } from "./options"; - -const OPTIONS_FRAME_SIZE = 2048; - -/** Tracks the history of password generations local to a device. - * {@link GeneratorHistoryService} - */ -export class LocalGeneratorHistoryService extends GeneratorHistoryService { - constructor( - private readonly encryptService: EncryptService, - private readonly keyService: CryptoService, - private readonly stateProvider: StateProvider, - private readonly options: HistoryServiceOptions = { maxTotal: 100 }, - ) { - super(); - } - - private _credentialStates = new Map>(); - - /** {@link GeneratorHistoryService.track} */ - track = async (userId: UserId, credential: string, category: GeneratorCategory, date?: Date) => { - const state = this.getCredentialState(userId); - let result: GeneratedCredential = null; - - await state.update( - (credentials) => { - credentials = credentials ?? []; - - // add the result - result = new GeneratedCredential(credential, category, date ?? Date.now()); - credentials.unshift(result); - - // trim history - const removeAt = Math.max(0, this.options.maxTotal); - credentials.splice(removeAt, Infinity); - - return credentials; - }, - { - shouldUpdate: (credentials) => - !(credentials?.some((f) => f.credential === credential) ?? false), - }, - ); - - return result; - }; - - /** {@link GeneratorHistoryService.take} */ - take = async (userId: UserId, credential: string) => { - const state = this.getCredentialState(userId); - let credentialIndex: number; - let result: GeneratedCredential = null; - - await state.update( - (credentials) => { - credentials = credentials ?? []; - - [result] = credentials.splice(credentialIndex, 1); - return credentials; - }, - { - shouldUpdate: (credentials) => { - credentialIndex = credentials?.findIndex((f) => f.credential === credential) ?? -1; - return credentialIndex >= 0; - }, - }, - ); - - return result; - }; - - /** {@link GeneratorHistoryService.take} */ - clear = async (userId: UserId) => { - const state = this.getCredentialState(userId); - const result = (await state.update(() => null)) ?? []; - return result; - }; - - /** {@link GeneratorHistoryService.credentials$} */ - credentials$ = (userId: UserId) => { - return this.getCredentialState(userId).state$.pipe(map((credentials) => credentials ?? [])); - }; - - private getCredentialState(userId: UserId) { - let state = this._credentialStates.get(userId); - - if (!state) { - state = this.createSecretState(userId); - this._credentialStates.set(userId, state); - } - - return state; - } - - private createSecretState(userId: UserId): SingleUserState { - // construct the encryptor - const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); - const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); - - // construct the durable state - const state = SecretState.from< - GeneratedCredential[], - number, - GeneratedCredential, - Record, - GeneratedCredential - >(userId, GENERATOR_HISTORY, this.stateProvider, encryptor); - - // decryptor is just an algorithm, but it can't run until the key is available; - // providing it via an observable makes running it early impossible - const decryptor = new LegacyPasswordHistoryDecryptor( - userId, - this.keyService, - this.encryptService, - ); - const decryptor$ = this.keyService - .getInMemoryUserKeyFor$(userId) - .pipe(map((key) => key && decryptor)); - - // move data from the old password history once decryptor is available - const buffer = new BufferedState( - this.stateProvider, - GENERATOR_HISTORY_BUFFER, - state, - decryptor$, - ); - - return buffer; - } -} diff --git a/libs/common/src/tools/generator/history/options.ts b/libs/common/src/tools/generator/history/options.ts deleted file mode 100644 index 53716ec33ab..00000000000 --- a/libs/common/src/tools/generator/history/options.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Kinds of credentials that can be stored by the history service */ -export type GeneratorCategory = "password" | "passphrase"; - -/** Configuration options for the history service */ -export type HistoryServiceOptions = { - /** Total number of records retained across all types. - * @remarks Setting this to 0 or less disables history completely. - * */ - maxTotal: number; -}; diff --git a/libs/common/src/tools/generator/index.ts b/libs/common/src/tools/generator/index.ts deleted file mode 100644 index 9df054a502b..00000000000 --- a/libs/common/src/tools/generator/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./abstractions/index"; -export * from "./password/index"; - -export { DefaultGeneratorService } from "./default-generator.service"; -export { legacyPasswordGenerationServiceFactory } from "./legacy-password-generation.service"; -export { legacyUsernameGenerationServiceFactory } from "./legacy-username-generation.service"; diff --git a/libs/common/src/tools/generator/key-definition.spec.ts b/libs/common/src/tools/generator/key-definition.spec.ts deleted file mode 100644 index d4992af0b11..00000000000 --- a/libs/common/src/tools/generator/key-definition.spec.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { mock } from "jest-mock-extended"; - -import { GeneratedCredential } from "./history"; -import { LegacyPasswordHistoryDecryptor } from "./history/legacy-password-history-decryptor"; -import { - EFF_USERNAME_SETTINGS, - CATCHALL_SETTINGS, - SUBADDRESS_SETTINGS, - PASSPHRASE_SETTINGS, - PASSWORD_SETTINGS, - SIMPLE_LOGIN_FORWARDER, - FORWARD_EMAIL_FORWARDER, - FIREFOX_RELAY_FORWARDER, - FASTMAIL_FORWARDER, - DUCK_DUCK_GO_FORWARDER, - ADDY_IO_FORWARDER, - GENERATOR_SETTINGS, - ADDY_IO_BUFFER, - DUCK_DUCK_GO_BUFFER, - FASTMAIL_BUFFER, - FIREFOX_RELAY_BUFFER, - FORWARD_EMAIL_BUFFER, - SIMPLE_LOGIN_BUFFER, - GENERATOR_HISTORY_BUFFER, -} from "./key-definitions"; -import { GeneratedPasswordHistory } from "./password"; - -describe("Key definitions", () => { - describe("GENERATOR_SETTINGS", () => { - it("should pass through deserialization", () => { - const value = {}; - const result = GENERATOR_SETTINGS.deserializer(value); - expect(result).toBe(value); - }); - }); - - describe("PASSWORD_SETTINGS", () => { - it("should pass through deserialization", () => { - const value = {}; - const result = PASSWORD_SETTINGS.deserializer(value); - expect(result).toBe(value); - }); - }); - - describe("PASSPHRASE_SETTINGS", () => { - it("should pass through deserialization", () => { - const value = {}; - const result = PASSPHRASE_SETTINGS.deserializer(value); - expect(result).toBe(value); - }); - }); - - describe("EFF_USERNAME_SETTINGS", () => { - it("should pass through deserialization", () => { - const value = { website: null as string }; - const result = EFF_USERNAME_SETTINGS.deserializer(value); - expect(result).toBe(value); - }); - }); - - describe("CATCHALL_SETTINGS", () => { - it("should pass through deserialization", () => { - const value = { website: null as string }; - const result = CATCHALL_SETTINGS.deserializer(value); - expect(result).toBe(value); - }); - }); - - describe("SUBADDRESS_SETTINGS", () => { - it("should pass through deserialization", () => { - const value = { website: null as string }; - const result = SUBADDRESS_SETTINGS.deserializer(value); - expect(result).toBe(value); - }); - }); - - describe("ADDY_IO_FORWARDER", () => { - it("should pass through deserialization", () => { - const value: any = {}; - const result = ADDY_IO_FORWARDER.deserializer(value); - expect(result).toBe(value); - }); - }); - - describe("DUCK_DUCK_GO_FORWARDER", () => { - it("should pass through deserialization", () => { - const value: any = {}; - const result = DUCK_DUCK_GO_FORWARDER.deserializer(value); - expect(result).toBe(value); - }); - }); - - describe("FASTMAIL_FORWARDER", () => { - it("should pass through deserialization", () => { - const value: any = {}; - const result = FASTMAIL_FORWARDER.deserializer(value); - expect(result).toBe(value); - }); - }); - - describe("FIREFOX_RELAY_FORWARDER", () => { - it("should pass through deserialization", () => { - const value: any = {}; - const result = FIREFOX_RELAY_FORWARDER.deserializer(value); - expect(result).toBe(value); - }); - }); - - describe("FORWARD_EMAIL_FORWARDER", () => { - it("should pass through deserialization", () => { - const value: any = {}; - const result = FORWARD_EMAIL_FORWARDER.deserializer(value); - expect(result).toBe(value); - }); - }); - - describe("SIMPLE_LOGIN_FORWARDER", () => { - it("should pass through deserialization", () => { - const value: any = {}; - const result = SIMPLE_LOGIN_FORWARDER.deserializer(value); - expect(result).toBe(value); - }); - }); - - describe("ADDY_IO_BUFFER", () => { - it("should pass through deserialization", () => { - const value: any = {}; - - const result = ADDY_IO_BUFFER.options.deserializer(value); - - expect(result).toBe(value); - }); - }); - - describe("DUCK_DUCK_GO_BUFFER", () => { - it("should pass through deserialization", () => { - const value: any = {}; - - const result = DUCK_DUCK_GO_BUFFER.options.deserializer(value); - - expect(result).toBe(value); - }); - }); - - describe("FASTMAIL_BUFFER", () => { - it("should pass through deserialization", () => { - const value: any = {}; - - const result = FASTMAIL_BUFFER.options.deserializer(value); - - expect(result).toBe(value); - }); - }); - - describe("FIREFOX_RELAY_BUFFER", () => { - it("should pass through deserialization", () => { - const value: any = {}; - - const result = FIREFOX_RELAY_BUFFER.options.deserializer(value); - - expect(result).toBe(value); - }); - }); - - describe("FORWARD_EMAIL_BUFFER", () => { - it("should pass through deserialization", () => { - const value: any = {}; - - const result = FORWARD_EMAIL_BUFFER.options.deserializer(value); - - expect(result).toBe(value); - }); - }); - - describe("SIMPLE_LOGIN_BUFFER", () => { - it("should pass through deserialization", () => { - const value: any = {}; - - const result = SIMPLE_LOGIN_BUFFER.options.deserializer(value); - - expect(result).toBe(value); - }); - }); - - describe("GENERATOR_HISTORY_BUFFER", () => { - describe("options.deserializer", () => { - it("should deserialize generated password history", () => { - const value: any = [{ password: "foo", date: 1 }]; - - const [result] = GENERATOR_HISTORY_BUFFER.options.deserializer(value); - - expect(result).toEqual(value[0]); - expect(result).toBeInstanceOf(GeneratedPasswordHistory); - }); - - it.each([[undefined], [null]])("should ignore nullish (= %p) history", (value: any) => { - const result = GENERATOR_HISTORY_BUFFER.options.deserializer(value); - - expect(result).toEqual(undefined); - }); - }); - - it("should map generated password history to generated credentials", async () => { - const value: any = [new GeneratedPasswordHistory("foo", 1)]; - const decryptor = mock({ - decrypt(value) { - return Promise.resolve(value); - }, - }); - - const [result] = await GENERATOR_HISTORY_BUFFER.map(value, decryptor); - - expect(result).toEqual({ - credential: "foo", - category: "password", - generationDate: new Date(1), - }); - expect(result).toBeInstanceOf(GeneratedCredential); - }); - - describe("isValid", () => { - it("should accept histories with at least one entry", async () => { - const value: any = [new GeneratedPasswordHistory("foo", 1)]; - const decryptor = {} as any; - - const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor); - - expect(result).toEqual(true); - }); - - it("should reject histories with no entries", async () => { - const value: any = []; - const decryptor = {} as any; - - const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor); - - expect(result).toEqual(false); - }); - }); - }); -}); diff --git a/libs/common/src/tools/generator/key-definitions.ts b/libs/common/src/tools/generator/key-definitions.ts deleted file mode 100644 index ccf1ca0d520..00000000000 --- a/libs/common/src/tools/generator/key-definitions.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { Jsonify } from "type-fest"; - -import { GENERATOR_DISK, UserKeyDefinition } from "../../platform/state"; -import { BufferedKeyDefinition } from "../state/buffered-key-definition"; -import { SecretClassifier } from "../state/secret-classifier"; -import { SecretKeyDefinition } from "../state/secret-key-definition"; - -import { GeneratedCredential } from "./history/generated-credential"; -import { LegacyPasswordHistoryDecryptor } from "./history/legacy-password-history-decryptor"; -import { GeneratorNavigation } from "./navigation/generator-navigation"; -import { PassphraseGenerationOptions } from "./passphrase/passphrase-generation-options"; -import { GeneratedPasswordHistory } from "./password/generated-password-history"; -import { PasswordGenerationOptions } from "./password/password-generation-options"; -import { CatchallGenerationOptions } from "./username/catchall-generator-options"; -import { EffUsernameGenerationOptions } from "./username/eff-username-generator-options"; -import { - ApiOptions, - EmailDomainOptions, - EmailPrefixOptions, - SelfHostedApiOptions, -} from "./username/options/forwarder-options"; -import { SubaddressGenerationOptions } from "./username/subaddress-generator-options"; - -/** plaintext password generation options */ -export const GENERATOR_SETTINGS = new UserKeyDefinition( - GENERATOR_DISK, - "generatorSettings", - { - deserializer: (value) => value, - clearOn: ["logout"], - }, -); - -/** plaintext password generation options */ -export const PASSWORD_SETTINGS = new UserKeyDefinition( - GENERATOR_DISK, - "passwordGeneratorSettings", - { - deserializer: (value) => value, - clearOn: [], - }, -); - -/** plaintext passphrase generation options */ -export const PASSPHRASE_SETTINGS = new UserKeyDefinition( - GENERATOR_DISK, - "passphraseGeneratorSettings", - { - deserializer: (value) => value, - clearOn: [], - }, -); - -/** plaintext username generation options */ -export const EFF_USERNAME_SETTINGS = new UserKeyDefinition( - GENERATOR_DISK, - "effUsernameGeneratorSettings", - { - deserializer: (value) => value, - clearOn: [], - }, -); - -/** plaintext configuration for a domain catch-all address. */ -export const CATCHALL_SETTINGS = new UserKeyDefinition( - GENERATOR_DISK, - "catchallGeneratorSettings", - { - deserializer: (value) => value, - clearOn: [], - }, -); - -/** plaintext configuration for an email subaddress. */ -export const SUBADDRESS_SETTINGS = new UserKeyDefinition( - GENERATOR_DISK, - "subaddressGeneratorSettings", - { - deserializer: (value) => value, - clearOn: [], - }, -); - -/** backing store configuration for {@link Forwarders.AddyIo} */ -export const ADDY_IO_FORWARDER = new UserKeyDefinition( - GENERATOR_DISK, - "addyIoForwarder", - { - deserializer: (value) => value, - clearOn: [], - }, -); - -/** backing store configuration for {@link Forwarders.DuckDuckGo} */ -export const DUCK_DUCK_GO_FORWARDER = new UserKeyDefinition( - GENERATOR_DISK, - "duckDuckGoForwarder", - { - deserializer: (value) => value, - clearOn: [], - }, -); - -/** backing store configuration for {@link Forwarders.FastMail} */ -export const FASTMAIL_FORWARDER = new UserKeyDefinition( - GENERATOR_DISK, - "fastmailForwarder", - { - deserializer: (value) => value, - clearOn: [], - }, -); - -/** backing store configuration for {@link Forwarders.FireFoxRelay} */ -export const FIREFOX_RELAY_FORWARDER = new UserKeyDefinition( - GENERATOR_DISK, - "firefoxRelayForwarder", - { - deserializer: (value) => value, - clearOn: [], - }, -); - -/** backing store configuration for {@link Forwarders.ForwardEmail} */ -export const FORWARD_EMAIL_FORWARDER = new UserKeyDefinition( - GENERATOR_DISK, - "forwardEmailForwarder", - { - deserializer: (value) => value, - clearOn: [], - }, -); - -/** backing store configuration for {@link forwarders.SimpleLogin} */ -export const SIMPLE_LOGIN_FORWARDER = new UserKeyDefinition( - GENERATOR_DISK, - "simpleLoginForwarder", - { - deserializer: (value) => value, - clearOn: [], - }, -); - -/** backing store configuration for {@link Forwarders.AddyIo} */ -export const ADDY_IO_BUFFER = new BufferedKeyDefinition( - GENERATOR_DISK, - "addyIoBuffer", - { - deserializer: (value) => value, - clearOn: ["logout"], - }, -); - -/** backing store configuration for {@link Forwarders.DuckDuckGo} */ -export const DUCK_DUCK_GO_BUFFER = new BufferedKeyDefinition( - GENERATOR_DISK, - "duckDuckGoBuffer", - { - deserializer: (value) => value, - clearOn: ["logout"], - }, -); - -/** backing store configuration for {@link Forwarders.FastMail} */ -export const FASTMAIL_BUFFER = new BufferedKeyDefinition( - GENERATOR_DISK, - "fastmailBuffer", - { - deserializer: (value) => value, - clearOn: ["logout"], - }, -); - -/** backing store configuration for {@link Forwarders.FireFoxRelay} */ -export const FIREFOX_RELAY_BUFFER = new BufferedKeyDefinition( - GENERATOR_DISK, - "firefoxRelayBuffer", - { - deserializer: (value) => value, - clearOn: ["logout"], - }, -); - -/** backing store configuration for {@link Forwarders.ForwardEmail} */ -export const FORWARD_EMAIL_BUFFER = new BufferedKeyDefinition( - GENERATOR_DISK, - "forwardEmailBuffer", - { - deserializer: (value) => value, - clearOn: ["logout"], - }, -); - -/** backing store configuration for {@link forwarders.SimpleLogin} */ -export const SIMPLE_LOGIN_BUFFER = new BufferedKeyDefinition( - GENERATOR_DISK, - "simpleLoginBuffer", - { - deserializer: (value) => value, - clearOn: ["logout"], - }, -); - -/** encrypted password generation history */ -export const GENERATOR_HISTORY = SecretKeyDefinition.array( - GENERATOR_DISK, - "localGeneratorHistory", - SecretClassifier.allSecret(), - { - deserializer: GeneratedCredential.fromJSON, - clearOn: ["logout"], - }, -); - -/** encrypted password generation history subject to migration */ -export const GENERATOR_HISTORY_BUFFER = new BufferedKeyDefinition< - GeneratedPasswordHistory[], - GeneratedCredential[], - LegacyPasswordHistoryDecryptor ->(GENERATOR_DISK, "localGeneratorHistoryBuffer", { - deserializer(history) { - const items = history as Jsonify[]; - return items?.map((h) => new GeneratedPasswordHistory(h.password, h.date)); - }, - async isValid(history) { - return history.length ? true : false; - }, - async map(history, decryptor) { - const credentials = await decryptor.decrypt(history); - const mapped = credentials.map((c) => new GeneratedCredential(c.password, "password", c.date)); - return mapped; - }, - clearOn: ["logout"], -}); diff --git a/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts b/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts deleted file mode 100644 index c86bb9f8b04..00000000000 --- a/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts +++ /dev/null @@ -1,567 +0,0 @@ -/** - * include structuredClone in test environment. - * @jest-environment ../../../shared/test.environment.ts - */ -import { mock } from "jest-mock-extended"; -import { of } from "rxjs"; - -import { mockAccountServiceWith } from "../../../spec"; -import { UserId } from "../../types/guid"; - -import { - GeneratorHistoryService, - GeneratorNavigationService, - GeneratorService, -} from "./abstractions"; -import { GeneratedCredential } from "./history"; -import { LegacyPasswordGenerationService } from "./legacy-password-generation.service"; -import { DefaultGeneratorNavigation, GeneratorNavigation } from "./navigation/generator-navigation"; -import { GeneratorNavigationEvaluator } from "./navigation/generator-navigation-evaluator"; -import { GeneratorNavigationPolicy } from "./navigation/generator-navigation-policy"; -import { - DefaultPassphraseGenerationOptions, - PassphraseGenerationOptions, - PassphraseGeneratorOptionsEvaluator, - PassphraseGeneratorPolicy, -} from "./passphrase"; -import { DisabledPassphraseGeneratorPolicy } from "./passphrase/passphrase-generator-policy"; -import { - DefaultPasswordGenerationOptions, - GeneratedPasswordHistory, - PasswordGenerationOptions, - PasswordGeneratorOptions, - PasswordGeneratorOptionsEvaluator, - PasswordGeneratorPolicy, -} from "./password"; -import { DisabledPasswordGeneratorPolicy } from "./password/password-generator-policy"; - -const SomeUser = "some user" as UserId; - -function createPassphraseGenerator( - options: PassphraseGenerationOptions = {}, - policy: PassphraseGeneratorPolicy = DisabledPassphraseGeneratorPolicy, -) { - let savedOptions = options; - const generator = mock>({ - evaluator$(id: UserId) { - const evaluator = new PassphraseGeneratorOptionsEvaluator(policy); - return of(evaluator); - }, - options$(id: UserId) { - return of(savedOptions); - }, - defaults$(id: UserId) { - return of(DefaultPassphraseGenerationOptions); - }, - saveOptions(userId, options) { - savedOptions = options; - return Promise.resolve(); - }, - }); - - return generator; -} - -function createPasswordGenerator( - options: PasswordGenerationOptions = {}, - policy: PasswordGeneratorPolicy = DisabledPasswordGeneratorPolicy, -) { - let savedOptions = options; - const generator = mock>({ - evaluator$(id: UserId) { - const evaluator = new PasswordGeneratorOptionsEvaluator(policy); - return of(evaluator); - }, - options$(id: UserId) { - return of(savedOptions); - }, - defaults$(id: UserId) { - return of(DefaultPasswordGenerationOptions); - }, - saveOptions(userId, options) { - savedOptions = options; - return Promise.resolve(); - }, - }); - - return generator; -} - -function createNavigationGenerator( - options: GeneratorNavigation = {}, - policy: GeneratorNavigationPolicy = {}, -) { - let savedOptions = options; - const generator = mock({ - evaluator$(id: UserId) { - const evaluator = new GeneratorNavigationEvaluator(policy); - return of(evaluator); - }, - options$(id: UserId) { - return of(savedOptions); - }, - defaults$(id: UserId) { - return of(DefaultGeneratorNavigation); - }, - saveOptions: jest.fn((userId, options) => { - savedOptions = options; - return Promise.resolve(); - }), - }); - - return generator; -} - -describe("LegacyPasswordGenerationService", () => { - // NOTE: in all tests, `null` constructor arguments are not used by the test. - // They're set to `null` to avoid setting up unnecessary mocks. - - describe("generatePassword", () => { - it("invokes the inner password generator to generate passwords", async () => { - const innerPassword = createPasswordGenerator(); - const generator = new LegacyPasswordGenerationService(null, null, innerPassword, null, null); - const options = { type: "password" } as PasswordGeneratorOptions; - - await generator.generatePassword(options); - - expect(innerPassword.generate).toHaveBeenCalledWith(options); - }); - - it("invokes the inner passphrase generator to generate passphrases", async () => { - const innerPassphrase = createPassphraseGenerator(); - const generator = new LegacyPasswordGenerationService( - null, - null, - null, - innerPassphrase, - null, - ); - const options = { type: "passphrase" } as PasswordGeneratorOptions; - - await generator.generatePassword(options); - - expect(innerPassphrase.generate).toHaveBeenCalledWith(options); - }); - }); - - describe("generatePassphrase", () => { - it("invokes the inner passphrase generator", async () => { - const innerPassphrase = createPassphraseGenerator(); - const generator = new LegacyPasswordGenerationService( - null, - null, - null, - innerPassphrase, - null, - ); - const options = {} as PasswordGeneratorOptions; - - await generator.generatePassphrase(options); - - expect(innerPassphrase.generate).toHaveBeenCalledWith(options); - }); - }); - - describe("getOptions", () => { - it("combines options from its inner services", async () => { - const innerPassword = createPasswordGenerator({ - length: 29, - minLength: 20, - ambiguous: false, - uppercase: true, - minUppercase: 1, - lowercase: false, - minLowercase: 2, - number: true, - minNumber: 3, - special: false, - minSpecial: 0, - }); - const innerPassphrase = createPassphraseGenerator({ - numWords: 10, - wordSeparator: "-", - capitalize: true, - includeNumber: false, - }); - const navigation = createNavigationGenerator({ - type: "passphrase", - username: "word", - forwarder: "simplelogin", - }); - const accountService = mockAccountServiceWith(SomeUser); - const generator = new LegacyPasswordGenerationService( - accountService, - navigation, - innerPassword, - innerPassphrase, - null, - ); - - const [result] = await generator.getOptions(); - - expect(result).toEqual({ - type: "passphrase", - length: 29, - minLength: 5, - ambiguous: false, - uppercase: true, - minUppercase: 1, - lowercase: false, - minLowercase: 0, - number: true, - minNumber: 3, - special: false, - minSpecial: 0, - numWords: 10, - wordSeparator: "-", - capitalize: true, - includeNumber: false, - policyUpdated: true, - }); - }); - - it("sets default options when an inner service lacks a value", async () => { - const innerPassword = createPasswordGenerator(null); - const innerPassphrase = createPassphraseGenerator(null); - const navigation = createNavigationGenerator(null); - const accountService = mockAccountServiceWith(SomeUser); - const generator = new LegacyPasswordGenerationService( - accountService, - navigation, - innerPassword, - innerPassphrase, - null, - ); - - const [result] = await generator.getOptions(); - - expect(result).toEqual({ - type: DefaultGeneratorNavigation.type, - ...DefaultPassphraseGenerationOptions, - ...DefaultPasswordGenerationOptions, - minLowercase: 1, - minUppercase: 1, - policyUpdated: true, - }); - }); - - it("combines policies from its inner services", async () => { - const innerPassword = createPasswordGenerator( - {}, - { - minLength: 20, - numberCount: 10, - specialCount: 11, - useUppercase: true, - useLowercase: false, - useNumbers: true, - useSpecial: false, - }, - ); - const innerPassphrase = createPassphraseGenerator( - {}, - { - minNumberWords: 5, - capitalize: true, - includeNumber: false, - }, - ); - const accountService = mockAccountServiceWith(SomeUser); - const navigation = createNavigationGenerator( - {}, - { - defaultType: "password", - }, - ); - const generator = new LegacyPasswordGenerationService( - accountService, - navigation, - innerPassword, - innerPassphrase, - null, - ); - - const [, policy] = await generator.getOptions(); - - expect(policy).toEqual({ - defaultType: "password", - minLength: 20, - numberCount: 10, - specialCount: 11, - useUppercase: true, - useLowercase: false, - useNumbers: true, - useSpecial: false, - minNumberWords: 5, - capitalize: true, - includeNumber: false, - }); - }); - }); - - describe("enforcePasswordGeneratorPoliciesOnOptions", () => { - it("returns its options parameter with password policy applied", async () => { - const innerPassword = createPasswordGenerator( - {}, - { - minLength: 15, - numberCount: 5, - specialCount: 5, - useUppercase: true, - useLowercase: true, - useNumbers: true, - useSpecial: true, - }, - ); - const innerPassphrase = createPassphraseGenerator(); - const accountService = mockAccountServiceWith(SomeUser); - const navigation = createNavigationGenerator(); - const options = { - type: "password" as const, - }; - const generator = new LegacyPasswordGenerationService( - accountService, - navigation, - innerPassword, - innerPassphrase, - null, - ); - - const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); - - expect(result).toBe(options); - expect(result).toMatchObject({ - length: 15, - minLength: 15, - minLowercase: 1, - minNumber: 5, - minUppercase: 1, - minSpecial: 5, - uppercase: true, - lowercase: true, - number: true, - special: true, - }); - }); - - it("returns its options parameter with passphrase policy applied", async () => { - const innerPassword = createPasswordGenerator(); - const innerPassphrase = createPassphraseGenerator( - {}, - { - minNumberWords: 5, - capitalize: true, - includeNumber: true, - }, - ); - const accountService = mockAccountServiceWith(SomeUser); - const navigation = createNavigationGenerator(); - const options = { - type: "passphrase" as const, - }; - const generator = new LegacyPasswordGenerationService( - accountService, - navigation, - innerPassword, - innerPassphrase, - null, - ); - - const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); - - expect(result).toBe(options); - expect(result).toMatchObject({ - numWords: 5, - capitalize: true, - includeNumber: true, - }); - }); - - it("returns the applied policy", async () => { - const innerPassword = createPasswordGenerator( - {}, - { - minLength: 20, - numberCount: 10, - specialCount: 11, - useUppercase: true, - useLowercase: false, - useNumbers: true, - useSpecial: false, - }, - ); - const innerPassphrase = createPassphraseGenerator( - {}, - { - minNumberWords: 5, - capitalize: true, - includeNumber: false, - }, - ); - const accountService = mockAccountServiceWith(SomeUser); - const navigation = createNavigationGenerator( - {}, - { - defaultType: "password", - }, - ); - const generator = new LegacyPasswordGenerationService( - accountService, - navigation, - innerPassword, - innerPassphrase, - null, - ); - - const [, policy] = await generator.enforcePasswordGeneratorPoliciesOnOptions({}); - - expect(policy).toEqual({ - defaultType: "password", - minLength: 20, - numberCount: 10, - specialCount: 11, - useUppercase: true, - useLowercase: false, - useNumbers: true, - useSpecial: false, - minNumberWords: 5, - capitalize: true, - includeNumber: false, - }); - }); - }); - - describe("saveOptions", () => { - it("loads saved password options", async () => { - const innerPassword = createPasswordGenerator(); - const innerPassphrase = createPassphraseGenerator(); - const navigation = createNavigationGenerator(); - const accountService = mockAccountServiceWith(SomeUser); - const generator = new LegacyPasswordGenerationService( - accountService, - navigation, - innerPassword, - innerPassphrase, - null, - ); - const options = { - type: "password" as const, - length: 29, - minLength: 5, - ambiguous: false, - uppercase: true, - minUppercase: 1, - lowercase: false, - minLowercase: 0, - number: true, - minNumber: 3, - special: false, - minSpecial: 0, - }; - await generator.saveOptions(options); - - const [result] = await generator.getOptions(); - - expect(result).toMatchObject(options); - }); - - it("loads saved passphrase options", async () => { - const innerPassword = createPasswordGenerator(); - const innerPassphrase = createPassphraseGenerator(); - const navigation = createNavigationGenerator(); - const accountService = mockAccountServiceWith(SomeUser); - const generator = new LegacyPasswordGenerationService( - accountService, - navigation, - innerPassword, - innerPassphrase, - null, - ); - const options = { - type: "passphrase" as const, - numWords: 10, - wordSeparator: "-", - capitalize: true, - includeNumber: false, - }; - await generator.saveOptions(options); - - const [result] = await generator.getOptions(); - - expect(result).toMatchObject(options); - }); - - it("preserves saved navigation options", async () => { - const innerPassword = createPasswordGenerator(); - const innerPassphrase = createPassphraseGenerator(); - const navigation = createNavigationGenerator({ - type: "password", - username: "forwarded", - forwarder: "firefoxrelay", - }); - const accountService = mockAccountServiceWith(SomeUser); - const generator = new LegacyPasswordGenerationService( - accountService, - navigation, - innerPassword, - innerPassphrase, - null, - ); - const options = { - type: "passphrase" as const, - numWords: 10, - wordSeparator: "-", - capitalize: true, - includeNumber: false, - }; - - await generator.saveOptions(options); - - expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, { - type: "passphrase", - username: "forwarded", - forwarder: "firefoxrelay", - }); - }); - }); - - describe("getHistory", () => { - it("gets the active user's history from the history service", async () => { - const history = mock(); - history.credentials$.mockReturnValue( - of([new GeneratedCredential("foo", "password", new Date(100))]), - ); - const accountService = mockAccountServiceWith(SomeUser); - const generator = new LegacyPasswordGenerationService( - accountService, - null, - null, - null, - history, - ); - - const result = await generator.getHistory(); - - expect(history.credentials$).toHaveBeenCalledWith(SomeUser); - expect(result).toEqual([new GeneratedPasswordHistory("foo", 100)]); - }); - }); - - describe("addHistory", () => { - it("adds a history item as a password credential", async () => { - const history = mock(); - const accountService = mockAccountServiceWith(SomeUser); - const generator = new LegacyPasswordGenerationService( - accountService, - null, - null, - null, - history, - ); - - await generator.addHistory("foo"); - - expect(history.track).toHaveBeenCalledWith(SomeUser, "foo", "password"); - }); - }); -}); diff --git a/libs/common/src/tools/generator/legacy-password-generation.service.ts b/libs/common/src/tools/generator/legacy-password-generation.service.ts deleted file mode 100644 index d69d4d2dc06..00000000000 --- a/libs/common/src/tools/generator/legacy-password-generation.service.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { - concatMap, - zip, - map, - firstValueFrom, - combineLatest, - pairwise, - of, - concat, - Observable, - filter, - timeout, -} from "rxjs"; - -import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; -import { PasswordGeneratorPolicyOptions } from "../../admin-console/models/domain/password-generator-policy-options"; -import { AccountService } from "../../auth/abstractions/account.service"; -import { CryptoService } from "../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../platform/abstractions/encrypt.service"; -import { StateProvider } from "../../platform/state"; - -import { - GeneratorHistoryService, - GeneratorService, - GeneratorNavigationService, - PolicyEvaluator, -} from "./abstractions"; -import { PasswordGenerationServiceAbstraction } from "./abstractions/password-generation.service.abstraction"; -import { DefaultGeneratorService } from "./default-generator.service"; -import { GeneratedCredential } from "./history"; -import { LocalGeneratorHistoryService } from "./history/local-generator-history.service"; -import { GeneratorNavigation } from "./navigation"; -import { DefaultGeneratorNavigationService } from "./navigation/default-generator-navigation.service"; -import { GeneratorNavigationPolicy } from "./navigation/generator-navigation-policy"; -import { - PassphraseGenerationOptions, - PassphraseGeneratorPolicy, - PassphraseGeneratorStrategy, -} from "./passphrase"; -import { - GeneratedPasswordHistory, - PasswordGenerationOptions, - PasswordGeneratorOptions, - PasswordGeneratorPolicy, - PasswordGeneratorStrategy, -} from "./password"; -import { CryptoServiceRandomizer } from "./random"; - -type MappedOptions = { - generator: GeneratorNavigation; - password: PasswordGenerationOptions; - passphrase: PassphraseGenerationOptions; - policyUpdated: boolean; -}; - -export function legacyPasswordGenerationServiceFactory( - encryptService: EncryptService, - cryptoService: CryptoService, - policyService: PolicyService, - accountService: AccountService, - stateProvider: StateProvider, -): PasswordGenerationServiceAbstraction { - const randomizer = new CryptoServiceRandomizer(cryptoService); - - const passwords = new DefaultGeneratorService( - new PasswordGeneratorStrategy(randomizer, stateProvider), - policyService, - ); - - const passphrases = new DefaultGeneratorService( - new PassphraseGeneratorStrategy(randomizer, stateProvider), - policyService, - ); - - const navigation = new DefaultGeneratorNavigationService(stateProvider, policyService); - - const history = new LocalGeneratorHistoryService(encryptService, cryptoService, stateProvider); - - return new LegacyPasswordGenerationService( - accountService, - navigation, - passwords, - passphrases, - history, - ); -} - -/** Adapts the generator 2.0 design to 1.0 angular services. */ -export class LegacyPasswordGenerationService implements PasswordGenerationServiceAbstraction { - constructor( - private readonly accountService: AccountService, - private readonly navigation: GeneratorNavigationService, - private readonly passwords: GeneratorService< - PasswordGenerationOptions, - PasswordGeneratorPolicy - >, - private readonly passphrases: GeneratorService< - PassphraseGenerationOptions, - PassphraseGeneratorPolicy - >, - private readonly history: GeneratorHistoryService, - ) {} - - generatePassword(options: PasswordGeneratorOptions) { - if (options.type === "password") { - return this.passwords.generate(options); - } else { - return this.passphrases.generate(options); - } - } - - generatePassphrase(options: PasswordGeneratorOptions) { - return this.passphrases.generate(options); - } - - private getRawOptions$() { - // give the typechecker a nudge to avoid "implicit any" errors - type RawOptionsIntermediateType = [ - PasswordGenerationOptions, - PasswordGenerationOptions, - [PolicyEvaluator, number], - PassphraseGenerationOptions, - PassphraseGenerationOptions, - [PolicyEvaluator, number], - GeneratorNavigation, - GeneratorNavigation, - [PolicyEvaluator, number], - ]; - - function withSequenceNumber(observable$: Observable) { - return observable$.pipe(map((evaluator, i) => [evaluator, i] as const)); - } - - // initial array ensures that destructuring never fails; sequence numbers - // set to `-1` so that the first update reflects that the policy changed from - // "unknown" to "whatever was provided by the service". This needs to be called - // each time the active user changes or the `concat` will block. - function initial$() { - const initial: RawOptionsIntermediateType = [ - null, - null, - [null, -1], - null, - null, - [null, -1], - null, - null, - [null, -1], - ]; - - return of(initial); - } - - function intermediatePairsToRawOptions([previous, current]: [ - RawOptionsIntermediateType, - RawOptionsIntermediateType, - ]) { - const [, , [, passwordPrevious], , , [, passphrasePrevious], , , [, generatorPrevious]] = - previous; - const [ - passwordOptions, - passwordDefaults, - [passwordEvaluator, passwordCurrent], - passphraseOptions, - passphraseDefaults, - [passphraseEvaluator, passphraseCurrent], - generatorOptions, - generatorDefaults, - [generatorEvaluator, generatorCurrent], - ] = current; - - // when any of the sequence numbers change, the emission occurs as the result of - // a policy update - const policyEmitted = - passwordPrevious < passwordCurrent || - passphrasePrevious < passphraseCurrent || - generatorPrevious < generatorCurrent; - - const result = [ - passwordOptions, - passwordDefaults, - passwordEvaluator, - passphraseOptions, - passphraseDefaults, - passphraseEvaluator, - generatorOptions, - generatorDefaults, - generatorEvaluator, - policyEmitted, - ] as const; - - return result; - } - - // look upon my works, ye mighty, and despair! - const rawOptions$ = this.accountService.activeAccount$.pipe( - concatMap((activeUser) => - concat( - initial$(), - combineLatest([ - this.passwords.options$(activeUser.id), - this.passwords.defaults$(activeUser.id), - withSequenceNumber(this.passwords.evaluator$(activeUser.id)), - this.passphrases.options$(activeUser.id), - this.passphrases.defaults$(activeUser.id), - withSequenceNumber(this.passphrases.evaluator$(activeUser.id)), - this.navigation.options$(activeUser.id), - this.navigation.defaults$(activeUser.id), - withSequenceNumber(this.navigation.evaluator$(activeUser.id)), - ]), - ), - ), - pairwise(), - map(intermediatePairsToRawOptions), - ); - - return rawOptions$; - } - - getOptions$() { - const options$ = this.getRawOptions$().pipe( - map( - ([ - passwordOptions, - passwordDefaults, - passwordEvaluator, - passphraseOptions, - passphraseDefaults, - passphraseEvaluator, - generatorOptions, - generatorDefaults, - generatorEvaluator, - policyUpdated, - ]) => { - const passwordOptionsWithPolicy = passwordEvaluator.applyPolicy( - passwordOptions ?? passwordDefaults, - ); - const passphraseOptionsWithPolicy = passphraseEvaluator.applyPolicy( - passphraseOptions ?? passphraseDefaults, - ); - const generatorOptionsWithPolicy = generatorEvaluator.applyPolicy( - generatorOptions ?? generatorDefaults, - ); - - const options = this.toPasswordGeneratorOptions({ - password: passwordEvaluator.sanitize(passwordOptionsWithPolicy), - passphrase: passphraseEvaluator.sanitize(passphraseOptionsWithPolicy), - generator: generatorEvaluator.sanitize(generatorOptionsWithPolicy), - policyUpdated, - }); - - const policy = Object.assign( - new PasswordGeneratorPolicyOptions(), - passwordEvaluator.policy, - passphraseEvaluator.policy, - generatorEvaluator.policy, - ); - - return [options, policy] as [PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]; - }, - ), - ); - - return options$; - } - - async getOptions() { - return await firstValueFrom(this.getOptions$()); - } - - async enforcePasswordGeneratorPoliciesOnOptions(options: PasswordGeneratorOptions) { - const options$ = this.accountService.activeAccount$.pipe( - concatMap((activeUser) => - zip( - this.passwords.evaluator$(activeUser.id), - this.passphrases.evaluator$(activeUser.id), - this.navigation.evaluator$(activeUser.id), - ), - ), - map(([passwordEvaluator, passphraseEvaluator, navigationEvaluator]) => { - const policy = Object.assign( - new PasswordGeneratorPolicyOptions(), - passwordEvaluator.policy, - passphraseEvaluator.policy, - navigationEvaluator.policy, - ); - - const navigationApplied = navigationEvaluator.applyPolicy(options); - const navigationSanitized = { - ...options, - ...navigationEvaluator.sanitize(navigationApplied), - }; - if (options.type === "password") { - const applied = passwordEvaluator.applyPolicy(navigationSanitized); - const sanitized = passwordEvaluator.sanitize(applied); - return [sanitized, policy]; - } else { - const applied = passphraseEvaluator.applyPolicy(navigationSanitized); - const sanitized = passphraseEvaluator.sanitize(applied); - return [sanitized, policy]; - } - }), - ); - - const [sanitized, policy] = await firstValueFrom(options$); - return [ - // callers assume this function updates the options parameter - Object.assign(options, sanitized), - policy, - ] as [PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]; - } - - async saveOptions(options: PasswordGeneratorOptions) { - const stored = this.toStoredOptions(options); - const activeAccount = await firstValueFrom(this.accountService.activeAccount$); - - // generator settings needs to preserve whether password or passphrase is selected, - // so `navigationOptions` is mutated. - const navigationOptions$ = zip( - this.navigation.options$(activeAccount.id), - this.navigation.defaults$(activeAccount.id), - ).pipe(map(([options, defaults]) => options ?? defaults)); - let navigationOptions = await firstValueFrom(navigationOptions$); - navigationOptions = Object.assign(navigationOptions, stored.generator); - await this.navigation.saveOptions(activeAccount.id, navigationOptions); - - // overwrite all other settings with latest values - await this.passwords.saveOptions(activeAccount.id, stored.password); - await this.passphrases.saveOptions(activeAccount.id, stored.passphrase); - } - - private toStoredOptions(options: PasswordGeneratorOptions): MappedOptions { - return { - generator: { - type: options.type, - }, - password: { - length: options.length, - minLength: options.minLength, - ambiguous: options.ambiguous, - uppercase: options.uppercase, - minUppercase: options.minUppercase, - lowercase: options.lowercase, - minLowercase: options.minLowercase, - number: options.number, - minNumber: options.minNumber, - special: options.special, - minSpecial: options.minSpecial, - }, - passphrase: { - numWords: options.numWords, - wordSeparator: options.wordSeparator, - capitalize: options.capitalize, - includeNumber: options.includeNumber, - }, - policyUpdated: false, - }; - } - - private toPasswordGeneratorOptions(options: MappedOptions): PasswordGeneratorOptions { - return { - type: options.generator.type, - length: options.password.length, - minLength: options.password.minLength, - ambiguous: options.password.ambiguous, - uppercase: options.password.uppercase, - minUppercase: options.password.minUppercase, - lowercase: options.password.lowercase, - minLowercase: options.password.minLowercase, - number: options.password.number, - minNumber: options.password.minNumber, - special: options.password.special, - minSpecial: options.password.minSpecial, - numWords: options.passphrase.numWords, - wordSeparator: options.passphrase.wordSeparator, - capitalize: options.passphrase.capitalize, - includeNumber: options.passphrase.includeNumber, - policyUpdated: options.policyUpdated, - }; - } - - getHistory() { - const history = this.accountService.activeAccount$.pipe( - concatMap((account) => this.history.credentials$(account.id)), - timeout({ - // timeout after 1 second - each: 1000, - with() { - return []; - }, - }), - map((history) => history.map(toGeneratedPasswordHistory)), - ); - - return firstValueFrom(history); - } - - async addHistory(password: string) { - const account = await firstValueFrom(this.accountService.activeAccount$); - if (account?.id) { - // legacy service doesn't distinguish credential types - await this.history.track(account.id, password, "password"); - } - } - - clear() { - const history$ = this.accountService.activeAccount$.pipe( - filter((account) => !!account?.id), - concatMap((account) => this.history.clear(account.id)), - timeout({ - // timeout after 1 second - each: 1000, - with() { - return []; - }, - }), - map((history) => history.map(toGeneratedPasswordHistory)), - ); - - return firstValueFrom(history$); - } -} - -function toGeneratedPasswordHistory(value: GeneratedCredential) { - return new GeneratedPasswordHistory(value.credential, value.generationDate.valueOf()); -} diff --git a/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts b/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts deleted file mode 100644 index 8b5c8b81e5a..00000000000 --- a/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts +++ /dev/null @@ -1,748 +0,0 @@ -import { mock } from "jest-mock-extended"; -import { of } from "rxjs"; - -import { mockAccountServiceWith } from "../../../spec"; -import { UserId } from "../../types/guid"; - -import { GeneratorNavigationService, GeneratorService } from "./abstractions"; -import { DefaultPolicyEvaluator } from "./default-policy-evaluator"; -import { LegacyUsernameGenerationService } from "./legacy-username-generation.service"; -import { DefaultGeneratorNavigation, GeneratorNavigation } from "./navigation/generator-navigation"; -import { GeneratorNavigationEvaluator } from "./navigation/generator-navigation-evaluator"; -import { GeneratorNavigationPolicy } from "./navigation/generator-navigation-policy"; -import { NoPolicy } from "./no-policy"; -import { UsernameGeneratorOptions } from "./username"; -import { - CatchallGenerationOptions, - DefaultCatchallOptions, -} from "./username/catchall-generator-options"; -import { - DefaultEffUsernameOptions, - EffUsernameGenerationOptions, -} from "./username/eff-username-generator-options"; -import { DefaultAddyIoOptions } from "./username/forwarders/addy-io"; -import { DefaultDuckDuckGoOptions } from "./username/forwarders/duck-duck-go"; -import { DefaultFastmailOptions } from "./username/forwarders/fastmail"; -import { DefaultFirefoxRelayOptions } from "./username/forwarders/firefox-relay"; -import { DefaultForwardEmailOptions } from "./username/forwarders/forward-email"; -import { DefaultSimpleLoginOptions } from "./username/forwarders/simple-login"; -import { Forwarders } from "./username/options/constants"; -import { - ApiOptions, - EmailDomainOptions, - EmailPrefixOptions, - SelfHostedApiOptions, -} from "./username/options/forwarder-options"; -import { - DefaultSubaddressOptions, - SubaddressGenerationOptions, -} from "./username/subaddress-generator-options"; - -const SomeUser = "userId" as UserId; - -function createGenerator(options: Options, defaults: Options) { - let savedOptions = options; - const generator = mock>({ - evaluator$(id: UserId) { - const evaluator = new DefaultPolicyEvaluator(); - return of(evaluator); - }, - options$(id: UserId) { - return of(savedOptions); - }, - defaults$(id: UserId) { - return of(defaults); - }, - saveOptions: jest.fn((userId, options) => { - savedOptions = options; - return Promise.resolve(); - }), - }); - - return generator; -} - -function createNavigationGenerator( - options: GeneratorNavigation = {}, - policy: GeneratorNavigationPolicy = {}, -) { - let savedOptions = options; - const generator = mock({ - evaluator$(id: UserId) { - const evaluator = new GeneratorNavigationEvaluator(policy); - return of(evaluator); - }, - options$(id: UserId) { - return of(savedOptions); - }, - defaults$(id: UserId) { - return of(DefaultGeneratorNavigation); - }, - saveOptions: jest.fn((userId, options) => { - savedOptions = options; - return Promise.resolve(); - }), - }); - - return generator; -} - -describe("LegacyUsernameGenerationService", () => { - // NOTE: in all tests, `null` constructor arguments are not used by the test. - // They're set to `null` to avoid setting up unnecessary mocks. - describe("generateUserName", () => { - it("should generate a catchall username", async () => { - const options = { type: "catchall" } as UsernameGeneratorOptions; - const catchall = createGenerator(null, null); - catchall.generate.mockResolvedValue("catchall@example.com"); - const generator = new LegacyUsernameGenerationService( - null, - null, - catchall, - null, - null, - null, - null, - null, - null, - null, - null, - ); - - const result = await generator.generateUsername(options); - - expect(catchall.generate).toHaveBeenCalledWith(options); - expect(result).toBe("catchall@example.com"); - }); - - it("should generate an EFF word username", async () => { - const options = { type: "word" } as UsernameGeneratorOptions; - const effWord = createGenerator(null, null); - effWord.generate.mockResolvedValue("eff word"); - const generator = new LegacyUsernameGenerationService( - null, - null, - null, - effWord, - null, - null, - null, - null, - null, - null, - null, - ); - - const result = await generator.generateUsername(options); - - expect(effWord.generate).toHaveBeenCalledWith(options); - expect(result).toBe("eff word"); - }); - - it("should generate a subaddress username", async () => { - const options = { type: "subaddress" } as UsernameGeneratorOptions; - const subaddress = createGenerator(null, null); - subaddress.generate.mockResolvedValue("subaddress@example.com"); - const generator = new LegacyUsernameGenerationService( - null, - null, - null, - null, - subaddress, - null, - null, - null, - null, - null, - null, - ); - - const result = await generator.generateUsername(options); - - expect(subaddress.generate).toHaveBeenCalledWith(options); - expect(result).toBe("subaddress@example.com"); - }); - - it("should generate a forwarder username", async () => { - // set up an arbitrary forwarder for the username test; all forwarders tested in their own tests - const options = { - type: "forwarded", - forwardedService: Forwarders.AddyIo.id, - } as UsernameGeneratorOptions; - const addyIo = createGenerator(null, null); - addyIo.generate.mockResolvedValue("addyio@example.com"); - const generator = new LegacyUsernameGenerationService( - null, - null, - null, - null, - null, - addyIo, - null, - null, - null, - null, - null, - ); - - const result = await generator.generateUsername(options); - - expect(addyIo.generate).toHaveBeenCalledWith({}); - expect(result).toBe("addyio@example.com"); - }); - }); - - describe("generateCatchall", () => { - it("should generate a catchall username", async () => { - const options = { type: "catchall" } as UsernameGeneratorOptions; - const catchall = createGenerator(null, null); - catchall.generate.mockResolvedValue("catchall@example.com"); - const generator = new LegacyUsernameGenerationService( - null, - null, - catchall, - null, - null, - null, - null, - null, - null, - null, - null, - ); - - const result = await generator.generateCatchall(options); - - expect(catchall.generate).toHaveBeenCalledWith(options); - expect(result).toBe("catchall@example.com"); - }); - }); - - describe("generateSubaddress", () => { - it("should generate a subaddress username", async () => { - const options = { type: "subaddress" } as UsernameGeneratorOptions; - const subaddress = createGenerator(null, null); - subaddress.generate.mockResolvedValue("subaddress@example.com"); - const generator = new LegacyUsernameGenerationService( - null, - null, - null, - null, - subaddress, - null, - null, - null, - null, - null, - null, - ); - - const result = await generator.generateSubaddress(options); - - expect(subaddress.generate).toHaveBeenCalledWith(options); - expect(result).toBe("subaddress@example.com"); - }); - }); - - describe("generateForwarded", () => { - it("should generate a AddyIo username", async () => { - const options = { - forwardedService: Forwarders.AddyIo.id, - forwardedAnonAddyApiToken: "token", - forwardedAnonAddyBaseUrl: "https://example.com", - forwardedAnonAddyDomain: "example.com", - website: "example.com", - } as UsernameGeneratorOptions; - const addyIo = createGenerator(null, null); - addyIo.generate.mockResolvedValue("addyio@example.com"); - const generator = new LegacyUsernameGenerationService( - null, - null, - null, - null, - null, - addyIo, - null, - null, - null, - null, - null, - ); - - const result = await generator.generateForwarded(options); - - expect(addyIo.generate).toHaveBeenCalledWith({ - token: "token", - baseUrl: "https://example.com", - domain: "example.com", - website: "example.com", - }); - expect(result).toBe("addyio@example.com"); - }); - - it("should generate a DuckDuckGo username", async () => { - const options = { - forwardedService: Forwarders.DuckDuckGo.id, - forwardedDuckDuckGoToken: "token", - website: "example.com", - } as UsernameGeneratorOptions; - const duckDuckGo = createGenerator(null, null); - duckDuckGo.generate.mockResolvedValue("ddg@example.com"); - const generator = new LegacyUsernameGenerationService( - null, - null, - null, - null, - null, - null, - duckDuckGo, - null, - null, - null, - null, - ); - - const result = await generator.generateForwarded(options); - - expect(duckDuckGo.generate).toHaveBeenCalledWith({ - token: "token", - website: "example.com", - }); - expect(result).toBe("ddg@example.com"); - }); - - it("should generate a Fastmail username", async () => { - const options = { - forwardedService: Forwarders.Fastmail.id, - forwardedFastmailApiToken: "token", - website: "example.com", - } as UsernameGeneratorOptions; - const fastmail = createGenerator(null, null); - fastmail.generate.mockResolvedValue("fastmail@example.com"); - const generator = new LegacyUsernameGenerationService( - null, - null, - null, - null, - null, - null, - null, - fastmail, - null, - null, - null, - ); - - const result = await generator.generateForwarded(options); - - expect(fastmail.generate).toHaveBeenCalledWith({ - token: "token", - website: "example.com", - }); - expect(result).toBe("fastmail@example.com"); - }); - - it("should generate a FirefoxRelay username", async () => { - const options = { - forwardedService: Forwarders.FirefoxRelay.id, - forwardedFirefoxApiToken: "token", - website: "example.com", - } as UsernameGeneratorOptions; - const firefoxRelay = createGenerator(null, null); - firefoxRelay.generate.mockResolvedValue("firefoxrelay@example.com"); - const generator = new LegacyUsernameGenerationService( - null, - null, - null, - null, - null, - null, - null, - null, - firefoxRelay, - null, - null, - ); - - const result = await generator.generateForwarded(options); - - expect(firefoxRelay.generate).toHaveBeenCalledWith({ - token: "token", - website: "example.com", - }); - expect(result).toBe("firefoxrelay@example.com"); - }); - - it("should generate a ForwardEmail username", async () => { - const options = { - forwardedService: Forwarders.ForwardEmail.id, - forwardedForwardEmailApiToken: "token", - forwardedForwardEmailDomain: "example.com", - website: "example.com", - } as UsernameGeneratorOptions; - const forwardEmail = createGenerator(null, null); - forwardEmail.generate.mockResolvedValue("forwardemail@example.com"); - const generator = new LegacyUsernameGenerationService( - null, - null, - null, - null, - null, - null, - null, - null, - null, - forwardEmail, - null, - ); - - const result = await generator.generateForwarded(options); - - expect(forwardEmail.generate).toHaveBeenCalledWith({ - token: "token", - domain: "example.com", - website: "example.com", - }); - expect(result).toBe("forwardemail@example.com"); - }); - - it("should generate a SimpleLogin username", async () => { - const options = { - forwardedService: Forwarders.SimpleLogin.id, - forwardedSimpleLoginApiKey: "token", - forwardedSimpleLoginBaseUrl: "https://example.com", - website: "example.com", - } as UsernameGeneratorOptions; - const simpleLogin = createGenerator(null, null); - simpleLogin.generate.mockResolvedValue("simplelogin@example.com"); - const generator = new LegacyUsernameGenerationService( - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - simpleLogin, - ); - - const result = await generator.generateForwarded(options); - - expect(simpleLogin.generate).toHaveBeenCalledWith({ - token: "token", - baseUrl: "https://example.com", - website: "example.com", - }); - expect(result).toBe("simplelogin@example.com"); - }); - }); - - describe("getOptions", () => { - it("combines options from its inner generators", async () => { - const account = mockAccountServiceWith(SomeUser); - - const navigation = createNavigationGenerator({ - type: "username", - username: "catchall", - forwarder: Forwarders.AddyIo.id, - }); - - const catchall = createGenerator( - { - catchallDomain: "example.com", - catchallType: "random", - website: null, - }, - null, - ); - - const effUsername = createGenerator( - { - wordCapitalize: true, - wordIncludeNumber: false, - website: null, - }, - null, - ); - - const subaddress = createGenerator( - { - subaddressType: "random", - subaddressEmail: "foo@example.com", - website: null, - }, - null, - ); - - const addyIo = createGenerator( - { - token: "addyIoToken", - domain: "addyio.example.com", - baseUrl: "https://addyio.api.example.com", - website: null, - }, - null, - ); - - const duckDuckGo = createGenerator( - { - token: "ddgToken", - website: null, - }, - null, - ); - - const fastmail = createGenerator( - { - token: "fastmailToken", - domain: "fastmail.example.com", - prefix: "foo", - website: null, - }, - null, - ); - - const firefoxRelay = createGenerator( - { - token: "firefoxToken", - website: null, - }, - null, - ); - - const forwardEmail = createGenerator( - { - token: "forwardEmailToken", - domain: "example.com", - website: null, - }, - null, - ); - - const simpleLogin = createGenerator( - { - token: "simpleLoginToken", - baseUrl: "https://simplelogin.api.example.com", - website: null, - }, - null, - ); - - const generator = new LegacyUsernameGenerationService( - account, - navigation, - catchall, - effUsername, - subaddress, - addyIo, - duckDuckGo, - fastmail, - firefoxRelay, - forwardEmail, - simpleLogin, - ); - - const result = await generator.getOptions(); - - expect(result).toEqual({ - type: "catchall", - wordCapitalize: true, - wordIncludeNumber: false, - subaddressType: "random", - subaddressEmail: "foo@example.com", - catchallType: "random", - catchallDomain: "example.com", - forwardedService: Forwarders.AddyIo.id, - forwardedAnonAddyApiToken: "addyIoToken", - forwardedAnonAddyDomain: "addyio.example.com", - forwardedAnonAddyBaseUrl: "https://addyio.api.example.com", - forwardedDuckDuckGoToken: "ddgToken", - forwardedFirefoxApiToken: "firefoxToken", - forwardedFastmailApiToken: "fastmailToken", - forwardedForwardEmailApiToken: "forwardEmailToken", - forwardedForwardEmailDomain: "example.com", - forwardedSimpleLoginApiKey: "simpleLoginToken", - forwardedSimpleLoginBaseUrl: "https://simplelogin.api.example.com", - }); - }); - - it("sets default options when an inner service lacks a value", async () => { - const account = mockAccountServiceWith(SomeUser); - const navigation = createNavigationGenerator(null); - const catchall = createGenerator(null, DefaultCatchallOptions); - const effUsername = createGenerator( - null, - DefaultEffUsernameOptions, - ); - const subaddress = createGenerator( - null, - DefaultSubaddressOptions, - ); - const addyIo = createGenerator( - null, - DefaultAddyIoOptions, - ); - const duckDuckGo = createGenerator(null, DefaultDuckDuckGoOptions); - const fastmail = createGenerator( - null, - DefaultFastmailOptions, - ); - const firefoxRelay = createGenerator(null, DefaultFirefoxRelayOptions); - const forwardEmail = createGenerator( - null, - DefaultForwardEmailOptions, - ); - const simpleLogin = createGenerator(null, DefaultSimpleLoginOptions); - - const generator = new LegacyUsernameGenerationService( - account, - navigation, - catchall, - effUsername, - subaddress, - addyIo, - duckDuckGo, - fastmail, - firefoxRelay, - forwardEmail, - simpleLogin, - ); - - const result = await generator.getOptions(); - - expect(result).toEqual({ - type: DefaultGeneratorNavigation.username, - catchallType: DefaultCatchallOptions.catchallType, - catchallDomain: DefaultCatchallOptions.catchallDomain, - wordCapitalize: DefaultEffUsernameOptions.wordCapitalize, - wordIncludeNumber: DefaultEffUsernameOptions.wordIncludeNumber, - subaddressType: DefaultSubaddressOptions.subaddressType, - subaddressEmail: DefaultSubaddressOptions.subaddressEmail, - forwardedService: DefaultGeneratorNavigation.forwarder, - forwardedAnonAddyApiToken: DefaultAddyIoOptions.token, - forwardedAnonAddyDomain: DefaultAddyIoOptions.domain, - forwardedAnonAddyBaseUrl: DefaultAddyIoOptions.baseUrl, - forwardedDuckDuckGoToken: DefaultDuckDuckGoOptions.token, - forwardedFastmailApiToken: DefaultFastmailOptions.token, - forwardedFirefoxApiToken: DefaultFirefoxRelayOptions.token, - forwardedForwardEmailApiToken: DefaultForwardEmailOptions.token, - forwardedForwardEmailDomain: DefaultForwardEmailOptions.domain, - forwardedSimpleLoginApiKey: DefaultSimpleLoginOptions.token, - forwardedSimpleLoginBaseUrl: DefaultSimpleLoginOptions.baseUrl, - }); - }); - }); - - describe("saveOptions", () => { - it("saves option sets to its inner generators", async () => { - const account = mockAccountServiceWith(SomeUser); - const navigation = createNavigationGenerator({ type: "password" }); - const catchall = createGenerator(null, null); - const effUsername = createGenerator(null, null); - const subaddress = createGenerator(null, null); - const addyIo = createGenerator(null, null); - const duckDuckGo = createGenerator(null, null); - const fastmail = createGenerator(null, null); - const firefoxRelay = createGenerator(null, null); - const forwardEmail = createGenerator(null, null); - const simpleLogin = createGenerator(null, null); - - const generator = new LegacyUsernameGenerationService( - account, - navigation, - catchall, - effUsername, - subaddress, - addyIo, - duckDuckGo, - fastmail, - firefoxRelay, - forwardEmail, - simpleLogin, - ); - - await generator.saveOptions({ - type: "catchall", - wordCapitalize: true, - wordIncludeNumber: false, - subaddressType: "random", - subaddressEmail: "foo@example.com", - catchallType: "random", - catchallDomain: "example.com", - forwardedService: Forwarders.AddyIo.id, - forwardedAnonAddyApiToken: "addyIoToken", - forwardedAnonAddyDomain: "addyio.example.com", - forwardedAnonAddyBaseUrl: "https://addyio.api.example.com", - forwardedDuckDuckGoToken: "ddgToken", - forwardedFirefoxApiToken: "firefoxToken", - forwardedFastmailApiToken: "fastmailToken", - forwardedForwardEmailApiToken: "forwardEmailToken", - forwardedForwardEmailDomain: "example.com", - forwardedSimpleLoginApiKey: "simpleLoginToken", - forwardedSimpleLoginBaseUrl: "https://simplelogin.api.example.com", - website: null, - }); - - expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, { - type: "password", - username: "catchall", - forwarder: Forwarders.AddyIo.id, - }); - - expect(catchall.saveOptions).toHaveBeenCalledWith(SomeUser, { - catchallDomain: "example.com", - catchallType: "random", - website: null, - }); - - expect(effUsername.saveOptions).toHaveBeenCalledWith(SomeUser, { - wordCapitalize: true, - wordIncludeNumber: false, - website: null, - }); - - expect(subaddress.saveOptions).toHaveBeenCalledWith(SomeUser, { - subaddressType: "random", - subaddressEmail: "foo@example.com", - website: null, - }); - - expect(addyIo.saveOptions).toHaveBeenCalledWith(SomeUser, { - token: "addyIoToken", - domain: "addyio.example.com", - baseUrl: "https://addyio.api.example.com", - website: null, - }); - - expect(duckDuckGo.saveOptions).toHaveBeenCalledWith(SomeUser, { - token: "ddgToken", - website: null, - }); - - expect(fastmail.saveOptions).toHaveBeenCalledWith(SomeUser, { - token: "fastmailToken", - website: null, - }); - - expect(firefoxRelay.saveOptions).toHaveBeenCalledWith(SomeUser, { - token: "firefoxToken", - website: null, - }); - - expect(forwardEmail.saveOptions).toHaveBeenCalledWith(SomeUser, { - token: "forwardEmailToken", - domain: "example.com", - website: null, - }); - - expect(simpleLogin.saveOptions).toHaveBeenCalledWith(SomeUser, { - token: "simpleLoginToken", - baseUrl: "https://simplelogin.api.example.com", - website: null, - }); - }); - }); -}); diff --git a/libs/common/src/tools/generator/legacy-username-generation.service.ts b/libs/common/src/tools/generator/legacy-username-generation.service.ts deleted file mode 100644 index aaa6bc2c806..00000000000 --- a/libs/common/src/tools/generator/legacy-username-generation.service.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs"; - -import { ApiService } from "../../abstractions/api.service"; -import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; -import { AccountService } from "../../auth/abstractions/account.service"; -import { CryptoService } from "../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../platform/abstractions/encrypt.service"; -import { I18nService } from "../../platform/abstractions/i18n.service"; -import { StateProvider } from "../../platform/state"; - -import { GeneratorService, GeneratorNavigationService } from "./abstractions"; -import { UsernameGenerationServiceAbstraction } from "./abstractions/username-generation.service.abstraction"; -import { DefaultGeneratorService } from "./default-generator.service"; -import { DefaultGeneratorNavigationService } from "./navigation/default-generator-navigation.service"; -import { GeneratorNavigation } from "./navigation/generator-navigation"; -import { NoPolicy } from "./no-policy"; -import { CryptoServiceRandomizer } from "./random"; -import { - CatchallGeneratorStrategy, - SubaddressGeneratorStrategy, - EffUsernameGeneratorStrategy, -} from "./username"; -import { CatchallGenerationOptions } from "./username/catchall-generator-options"; -import { EffUsernameGenerationOptions } from "./username/eff-username-generator-options"; -import { AddyIoForwarder } from "./username/forwarders/addy-io"; -import { DuckDuckGoForwarder } from "./username/forwarders/duck-duck-go"; -import { FastmailForwarder } from "./username/forwarders/fastmail"; -import { FirefoxRelayForwarder } from "./username/forwarders/firefox-relay"; -import { ForwardEmailForwarder } from "./username/forwarders/forward-email"; -import { SimpleLoginForwarder } from "./username/forwarders/simple-login"; -import { Forwarders } from "./username/options/constants"; -import { - ApiOptions, - EmailDomainOptions, - EmailPrefixOptions, - RequestOptions, - SelfHostedApiOptions, -} from "./username/options/forwarder-options"; -import { SubaddressGenerationOptions } from "./username/subaddress-generator-options"; -import { UsernameGeneratorOptions } from "./username/username-generation-options"; - -type MappedOptions = { - generator: GeneratorNavigation; - algorithms: { - catchall: CatchallGenerationOptions; - effUsername: EffUsernameGenerationOptions; - subaddress: SubaddressGenerationOptions; - }; - forwarders: { - addyIo: SelfHostedApiOptions & EmailDomainOptions & RequestOptions; - duckDuckGo: ApiOptions & RequestOptions; - fastmail: ApiOptions & EmailPrefixOptions & RequestOptions; - firefoxRelay: ApiOptions & RequestOptions; - forwardEmail: ApiOptions & EmailDomainOptions & RequestOptions; - simpleLogin: SelfHostedApiOptions & RequestOptions; - }; -}; - -export function legacyUsernameGenerationServiceFactory( - apiService: ApiService, - i18nService: I18nService, - cryptoService: CryptoService, - encryptService: EncryptService, - policyService: PolicyService, - accountService: AccountService, - stateProvider: StateProvider, -): UsernameGenerationServiceAbstraction { - const randomizer = new CryptoServiceRandomizer(cryptoService); - - const effUsername = new DefaultGeneratorService( - new EffUsernameGeneratorStrategy(randomizer, stateProvider), - policyService, - ); - - const subaddress = new DefaultGeneratorService( - new SubaddressGeneratorStrategy(randomizer, stateProvider), - policyService, - ); - - const catchall = new DefaultGeneratorService( - new CatchallGeneratorStrategy(randomizer, stateProvider), - policyService, - ); - - const addyIo = new DefaultGeneratorService( - new AddyIoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), - policyService, - ); - - const duckDuckGo = new DefaultGeneratorService( - new DuckDuckGoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), - policyService, - ); - - const fastmail = new DefaultGeneratorService( - new FastmailForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), - policyService, - ); - - const firefoxRelay = new DefaultGeneratorService( - new FirefoxRelayForwarder( - apiService, - i18nService, - encryptService, - cryptoService, - stateProvider, - ), - policyService, - ); - - const forwardEmail = new DefaultGeneratorService( - new ForwardEmailForwarder( - apiService, - i18nService, - encryptService, - cryptoService, - stateProvider, - ), - policyService, - ); - - const simpleLogin = new DefaultGeneratorService( - new SimpleLoginForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), - policyService, - ); - - const navigation = new DefaultGeneratorNavigationService(stateProvider, policyService); - - return new LegacyUsernameGenerationService( - accountService, - navigation, - catchall, - effUsername, - subaddress, - addyIo, - duckDuckGo, - fastmail, - firefoxRelay, - forwardEmail, - simpleLogin, - ); -} - -/** Adapts the generator 2.0 design to 1.0 angular services. */ -export class LegacyUsernameGenerationService implements UsernameGenerationServiceAbstraction { - constructor( - private readonly accountService: AccountService, - private readonly navigation: GeneratorNavigationService, - private readonly catchall: GeneratorService, - private readonly effUsername: GeneratorService, - private readonly subaddress: GeneratorService, - private readonly addyIo: GeneratorService, - private readonly duckDuckGo: GeneratorService, - private readonly fastmail: GeneratorService, - private readonly firefoxRelay: GeneratorService, - private readonly forwardEmail: GeneratorService, - private readonly simpleLogin: GeneratorService, - ) {} - - generateUsername(options: UsernameGeneratorOptions) { - if (options.type === "catchall") { - return this.generateCatchall(options); - } else if (options.type === "subaddress") { - return this.generateSubaddress(options); - } else if (options.type === "forwarded") { - return this.generateForwarded(options); - } else { - return this.generateWord(options); - } - } - - generateWord(options: UsernameGeneratorOptions) { - return this.effUsername.generate(options); - } - - generateSubaddress(options: UsernameGeneratorOptions) { - return this.subaddress.generate(options); - } - - generateCatchall(options: UsernameGeneratorOptions) { - return this.catchall.generate(options); - } - - generateForwarded(options: UsernameGeneratorOptions) { - if (!options.forwardedService) { - return null; - } - - const stored = this.toStoredOptions(options); - switch (options.forwardedService) { - case Forwarders.AddyIo.id: - return this.addyIo.generate(stored.forwarders.addyIo); - case Forwarders.DuckDuckGo.id: - return this.duckDuckGo.generate(stored.forwarders.duckDuckGo); - case Forwarders.Fastmail.id: - return this.fastmail.generate(stored.forwarders.fastmail); - case Forwarders.FirefoxRelay.id: - return this.firefoxRelay.generate(stored.forwarders.firefoxRelay); - case Forwarders.ForwardEmail.id: - return this.forwardEmail.generate(stored.forwarders.forwardEmail); - case Forwarders.SimpleLogin.id: - return this.simpleLogin.generate(stored.forwarders.simpleLogin); - } - } - - getOptions$() { - // look upon my works, ye mighty, and despair! - const options$ = this.accountService.activeAccount$.pipe( - concatMap((account) => - combineLatest([ - this.navigation.options$(account.id), - this.navigation.defaults$(account.id), - this.catchall.options$(account.id), - this.catchall.defaults$(account.id), - this.effUsername.options$(account.id), - this.effUsername.defaults$(account.id), - this.subaddress.options$(account.id), - this.subaddress.defaults$(account.id), - this.addyIo.options$(account.id), - this.addyIo.defaults$(account.id), - this.duckDuckGo.options$(account.id), - this.duckDuckGo.defaults$(account.id), - this.fastmail.options$(account.id), - this.fastmail.defaults$(account.id), - this.firefoxRelay.options$(account.id), - this.firefoxRelay.defaults$(account.id), - this.forwardEmail.options$(account.id), - this.forwardEmail.defaults$(account.id), - this.simpleLogin.options$(account.id), - this.simpleLogin.defaults$(account.id), - ]), - ), - map( - ([ - generatorOptions, - generatorDefaults, - catchallOptions, - catchallDefaults, - effUsernameOptions, - effUsernameDefaults, - subaddressOptions, - subaddressDefaults, - addyIoOptions, - addyIoDefaults, - duckDuckGoOptions, - duckDuckGoDefaults, - fastmailOptions, - fastmailDefaults, - firefoxRelayOptions, - firefoxRelayDefaults, - forwardEmailOptions, - forwardEmailDefaults, - simpleLoginOptions, - simpleLoginDefaults, - ]) => - this.toUsernameOptions({ - generator: generatorOptions ?? generatorDefaults, - algorithms: { - catchall: catchallOptions ?? catchallDefaults, - effUsername: effUsernameOptions ?? effUsernameDefaults, - subaddress: subaddressOptions ?? subaddressDefaults, - }, - forwarders: { - addyIo: addyIoOptions ?? addyIoDefaults, - duckDuckGo: duckDuckGoOptions ?? duckDuckGoDefaults, - fastmail: fastmailOptions ?? fastmailDefaults, - firefoxRelay: firefoxRelayOptions ?? firefoxRelayDefaults, - forwardEmail: forwardEmailOptions ?? forwardEmailDefaults, - simpleLogin: simpleLoginOptions ?? simpleLoginDefaults, - }, - }), - ), - ); - - return options$; - } - - getOptions() { - return firstValueFrom(this.getOptions$()); - } - - async saveOptions(options: UsernameGeneratorOptions) { - const stored = this.toStoredOptions(options); - const activeAccount = await firstValueFrom(this.accountService.activeAccount$); - - // generator settings needs to preserve whether password or passphrase is selected, - // so `navigationOptions` is mutated. - const navigationOptions$ = zip( - this.navigation.options$(activeAccount.id), - this.navigation.defaults$(activeAccount.id), - ).pipe(map(([options, defaults]) => options ?? defaults)); - let navigationOptions = await firstValueFrom(navigationOptions$); - navigationOptions = Object.assign(navigationOptions, stored.generator); - await this.navigation.saveOptions(activeAccount.id, navigationOptions); - - // overwrite all other settings with latest values - await Promise.all([ - this.catchall.saveOptions(activeAccount.id, stored.algorithms.catchall), - this.effUsername.saveOptions(activeAccount.id, stored.algorithms.effUsername), - this.subaddress.saveOptions(activeAccount.id, stored.algorithms.subaddress), - this.addyIo.saveOptions(activeAccount.id, stored.forwarders.addyIo), - this.duckDuckGo.saveOptions(activeAccount.id, stored.forwarders.duckDuckGo), - this.fastmail.saveOptions(activeAccount.id, stored.forwarders.fastmail), - this.firefoxRelay.saveOptions(activeAccount.id, stored.forwarders.firefoxRelay), - this.forwardEmail.saveOptions(activeAccount.id, stored.forwarders.forwardEmail), - this.simpleLogin.saveOptions(activeAccount.id, stored.forwarders.simpleLogin), - ]); - } - - private toStoredOptions(options: UsernameGeneratorOptions) { - const forwarders = { - addyIo: { - baseUrl: options.forwardedAnonAddyBaseUrl, - token: options.forwardedAnonAddyApiToken, - domain: options.forwardedAnonAddyDomain, - website: options.website, - }, - duckDuckGo: { - token: options.forwardedDuckDuckGoToken, - website: options.website, - }, - fastmail: { - token: options.forwardedFastmailApiToken, - website: options.website, - }, - firefoxRelay: { - token: options.forwardedFirefoxApiToken, - website: options.website, - }, - forwardEmail: { - token: options.forwardedForwardEmailApiToken, - domain: options.forwardedForwardEmailDomain, - website: options.website, - }, - simpleLogin: { - token: options.forwardedSimpleLoginApiKey, - baseUrl: options.forwardedSimpleLoginBaseUrl, - website: options.website, - }, - }; - - const generator = { - username: options.type, - forwarder: options.forwardedService, - }; - - const algorithms = { - effUsername: { - wordCapitalize: options.wordCapitalize, - wordIncludeNumber: options.wordIncludeNumber, - website: options.website, - }, - subaddress: { - subaddressType: options.subaddressType, - subaddressEmail: options.subaddressEmail, - website: options.website, - }, - catchall: { - catchallType: options.catchallType, - catchallDomain: options.catchallDomain, - website: options.website, - }, - }; - - return { generator, algorithms, forwarders } as MappedOptions; - } - - private toUsernameOptions(options: MappedOptions) { - return { - type: options.generator.username, - wordCapitalize: options.algorithms.effUsername.wordCapitalize, - wordIncludeNumber: options.algorithms.effUsername.wordIncludeNumber, - subaddressType: options.algorithms.subaddress.subaddressType, - subaddressEmail: options.algorithms.subaddress.subaddressEmail, - catchallType: options.algorithms.catchall.catchallType, - catchallDomain: options.algorithms.catchall.catchallDomain, - forwardedService: options.generator.forwarder, - forwardedAnonAddyApiToken: options.forwarders.addyIo.token, - forwardedAnonAddyDomain: options.forwarders.addyIo.domain, - forwardedAnonAddyBaseUrl: options.forwarders.addyIo.baseUrl, - forwardedDuckDuckGoToken: options.forwarders.duckDuckGo.token, - forwardedFirefoxApiToken: options.forwarders.firefoxRelay.token, - forwardedFastmailApiToken: options.forwarders.fastmail.token, - forwardedForwardEmailApiToken: options.forwarders.forwardEmail.token, - forwardedForwardEmailDomain: options.forwarders.forwardEmail.domain, - forwardedSimpleLoginApiKey: options.forwarders.simpleLogin.token, - forwardedSimpleLoginBaseUrl: options.forwarders.simpleLogin.baseUrl, - } as UsernameGeneratorOptions; - } -} diff --git a/libs/common/src/tools/generator/navigation/default-generator-nativation.service.spec.ts b/libs/common/src/tools/generator/navigation/default-generator-nativation.service.spec.ts deleted file mode 100644 index 6853542bb7a..00000000000 --- a/libs/common/src/tools/generator/navigation/default-generator-nativation.service.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * include structuredClone in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ -import { mock } from "jest-mock-extended"; -import { firstValueFrom, of } from "rxjs"; - -import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; -import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; -import { UserId } from "../../../types/guid"; -import { GENERATOR_SETTINGS } from "../key-definitions"; - -import { - GeneratorNavigationEvaluator, - DefaultGeneratorNavigationService, - DefaultGeneratorNavigation, -} from "./"; - -const SomeUser = "some user" as UserId; - -describe("DefaultGeneratorNavigationService", () => { - describe("options$", () => { - it("emits options", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const settings = { type: "password" as const }; - await stateProvider.setUserState(GENERATOR_SETTINGS, settings, SomeUser); - const navigation = new DefaultGeneratorNavigationService(stateProvider, null); - - const result = await firstValueFrom(navigation.options$(SomeUser)); - - expect(result).toEqual(settings); - }); - }); - - describe("defaults$", () => { - it("emits default options", async () => { - const navigation = new DefaultGeneratorNavigationService(null, null); - - const result = await firstValueFrom(navigation.defaults$(SomeUser)); - - expect(result).toEqual(DefaultGeneratorNavigation); - }); - }); - - describe("evaluator$", () => { - it("emits a GeneratorNavigationEvaluator", async () => { - const policyService = mock({ - getAll$() { - return of([]); - }, - }); - const navigation = new DefaultGeneratorNavigationService(null, policyService); - - const result = await firstValueFrom(navigation.evaluator$(SomeUser)); - - expect(result).toBeInstanceOf(GeneratorNavigationEvaluator); - }); - }); - - describe("enforcePolicy", () => { - it("applies policy", async () => { - const policyService = mock({ - getAll$(_type: PolicyType, _user: UserId) { - return of([ - new Policy({ - id: "" as any, - organizationId: "" as any, - enabled: true, - type: PolicyType.PasswordGenerator, - data: { defaultType: "password" }, - }), - ]); - }, - }); - const navigation = new DefaultGeneratorNavigationService(null, policyService); - const options = {}; - - const result = await navigation.enforcePolicy(SomeUser, options); - - expect(result).toMatchObject({ type: "password" }); - }); - }); - - describe("saveOptions", () => { - it("updates options$", async () => { - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - const navigation = new DefaultGeneratorNavigationService(stateProvider, null); - const settings = { type: "password" as const }; - - await navigation.saveOptions(SomeUser, settings); - const result = await firstValueFrom(navigation.options$(SomeUser)); - - expect(result).toEqual(settings); - }); - }); -}); diff --git a/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts b/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts deleted file mode 100644 index a24f8012711..00000000000 --- a/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { BehaviorSubject, Observable, firstValueFrom, map } from "rxjs"; - -import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyType } from "../../../admin-console/enums"; -import { StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; -import { distinctIfShallowMatch, reduceCollection } from "../../rx"; -import { GeneratorNavigationService } from "../abstractions/generator-navigation.service.abstraction"; -import { GENERATOR_SETTINGS } from "../key-definitions"; - -import { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation"; -import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator"; -import { DisabledGeneratorNavigationPolicy, preferPassword } from "./generator-navigation-policy"; - -export class DefaultGeneratorNavigationService implements GeneratorNavigationService { - /** instantiates the password generator strategy. - * @param stateProvider provides durable state - * @param policy provides the policy to enforce - */ - constructor( - private readonly stateProvider: StateProvider, - private readonly policy: PolicyService, - ) {} - - /** An observable monitoring the options saved to disk. - * The observable updates when the options are saved. - * @param userId: Identifies the user making the request - */ - options$(userId: UserId): Observable { - return this.stateProvider.getUserState$(GENERATOR_SETTINGS, userId); - } - - /** Gets the default options. */ - defaults$(userId: UserId): Observable { - return new BehaviorSubject({ ...DefaultGeneratorNavigation }); - } - - /** An observable monitoring the options used to enforce policy. - * The observable updates when the policy changes. - * @param userId: Identifies the user making the request - */ - evaluator$(userId: UserId) { - const evaluator$ = this.policy.getAll$(PolicyType.PasswordGenerator, userId).pipe( - reduceCollection(preferPassword, DisabledGeneratorNavigationPolicy), - distinctIfShallowMatch(), - map((policy) => new GeneratorNavigationEvaluator(policy)), - ); - - return evaluator$; - } - - /** Enforces the policy on the given options - * @param userId: Identifies the user making the request - * @param options the options to enforce the policy on - * @returns a new instance of the options with the policy enforced - */ - async enforcePolicy(userId: UserId, options: GeneratorNavigation) { - const evaluator = await firstValueFrom(this.evaluator$(userId)); - const applied = evaluator.applyPolicy(options); - const sanitized = evaluator.sanitize(applied); - return sanitized; - } - - /** Saves the navigation options to disk. - * @param userId: Identifies the user making the request - * @param options the options to save - * @returns a promise that resolves when the options are saved - */ - async saveOptions(userId: UserId, options: GeneratorNavigation): Promise { - await this.stateProvider.setUserState(GENERATOR_SETTINGS, options, userId); - } -} diff --git a/libs/common/src/tools/generator/navigation/generator-navigation-evaluator.spec.ts b/libs/common/src/tools/generator/navigation/generator-navigation-evaluator.spec.ts deleted file mode 100644 index 58560fb5a04..00000000000 --- a/libs/common/src/tools/generator/navigation/generator-navigation-evaluator.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { DefaultGeneratorNavigation } from "./generator-navigation"; -import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator"; - -describe("GeneratorNavigationEvaluator", () => { - describe("policyInEffect", () => { - it.each([["passphrase"], ["password"]] as const)( - "returns true if the policy has a defaultType (= %p)", - (defaultType) => { - const evaluator = new GeneratorNavigationEvaluator({ defaultType }); - - expect(evaluator.policyInEffect).toEqual(true); - }, - ); - - it.each([[undefined], [null], ["" as any]])( - "returns false if the policy has a falsy defaultType (= %p)", - (defaultType) => { - const evaluator = new GeneratorNavigationEvaluator({ defaultType }); - - expect(evaluator.policyInEffect).toEqual(false); - }, - ); - }); - - describe("applyPolicy", () => { - it("returns the input options", () => { - const evaluator = new GeneratorNavigationEvaluator(null); - const options = { type: "password" as const }; - - const result = evaluator.applyPolicy(options); - - expect(result).toEqual(options); - }); - }); - - describe("sanitize", () => { - it.each([["passphrase"], ["password"]] as const)( - "defaults options to the policy's default type (= %p) when a policy is in effect", - (defaultType) => { - const evaluator = new GeneratorNavigationEvaluator({ defaultType }); - - const result = evaluator.sanitize({}); - - expect(result).toEqual({ type: defaultType }); - }, - ); - - it("defaults options to the default generator navigation type when a policy is not in effect", () => { - const evaluator = new GeneratorNavigationEvaluator(null); - - const result = evaluator.sanitize({}); - - expect(result.type).toEqual(DefaultGeneratorNavigation.type); - }); - - it("retains the options type when it is set", () => { - const evaluator = new GeneratorNavigationEvaluator({ defaultType: "passphrase" }); - - const result = evaluator.sanitize({ type: "password" }); - - expect(result).toEqual({ type: "password" }); - }); - }); -}); diff --git a/libs/common/src/tools/generator/navigation/generator-navigation-evaluator.ts b/libs/common/src/tools/generator/navigation/generator-navigation-evaluator.ts deleted file mode 100644 index e580f130b53..00000000000 --- a/libs/common/src/tools/generator/navigation/generator-navigation-evaluator.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { PolicyEvaluator } from "../abstractions"; - -import { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation"; -import { GeneratorNavigationPolicy } from "./generator-navigation-policy"; - -/** Enforces policy for generator navigation options. - */ -export class GeneratorNavigationEvaluator - implements PolicyEvaluator -{ - /** Instantiates the evaluator. - * @param policy The policy applied by the evaluator. When this conflicts with - * the defaults, the policy takes precedence. - */ - constructor(readonly policy: GeneratorNavigationPolicy) {} - - /** {@link PolicyEvaluator.policyInEffect} */ - get policyInEffect(): boolean { - return this.policy?.defaultType ? true : false; - } - - /** Apply policy to the input options. - * @param options The options to build from. These options are not altered. - * @returns A new password generation request with policy applied. - */ - applyPolicy(options: GeneratorNavigation): GeneratorNavigation { - return options; - } - - /** Ensures internal options consistency. - * @param options The options to cascade. These options are not altered. - * @returns A passphrase generation request with cascade applied. - */ - sanitize(options: GeneratorNavigation): GeneratorNavigation { - const defaultType = this.policyInEffect - ? this.policy.defaultType - : DefaultGeneratorNavigation.type; - return { - ...options, - type: options.type ?? defaultType, - }; - } -} diff --git a/libs/common/src/tools/generator/navigation/generator-navigation-policy.spec.ts b/libs/common/src/tools/generator/navigation/generator-navigation-policy.spec.ts deleted file mode 100644 index ed8fe731a75..00000000000 --- a/libs/common/src/tools/generator/navigation/generator-navigation-policy.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; -import { PolicyId } from "../../../types/guid"; - -import { DisabledGeneratorNavigationPolicy, preferPassword } from "./generator-navigation-policy"; - -function createPolicy( - data: any, - type: PolicyType = PolicyType.PasswordGenerator, - enabled: boolean = true, -) { - return new Policy({ - id: "id" as PolicyId, - organizationId: "organizationId", - data, - enabled, - type, - }); -} - -describe("leastPrivilege", () => { - it("should return the accumulator when the policy type does not apply", () => { - const policy = createPolicy({}, PolicyType.RequireSso); - - const result = preferPassword(DisabledGeneratorNavigationPolicy, policy); - - expect(result).toEqual(DisabledGeneratorNavigationPolicy); - }); - - it("should return the accumulator when the policy is not enabled", () => { - const policy = createPolicy({}, PolicyType.PasswordGenerator, false); - - const result = preferPassword(DisabledGeneratorNavigationPolicy, policy); - - expect(result).toEqual(DisabledGeneratorNavigationPolicy); - }); - - it("should take the %p from the policy", () => { - const policy = createPolicy({ defaultType: "passphrase" }); - - const result = preferPassword({ ...DisabledGeneratorNavigationPolicy }, policy); - - expect(result).toEqual({ defaultType: "passphrase" }); - }); - - it("should override passphrase with password", () => { - const policy = createPolicy({ defaultType: "password" }); - - const result = preferPassword({ defaultType: "passphrase" }, policy); - - expect(result).toEqual({ defaultType: "password" }); - }); - - it("should not override password", () => { - const policy = createPolicy({ defaultType: "passphrase" }); - - const result = preferPassword({ defaultType: "password" }, policy); - - expect(result).toEqual({ defaultType: "password" }); - }); -}); diff --git a/libs/common/src/tools/generator/navigation/generator-navigation-policy.ts b/libs/common/src/tools/generator/navigation/generator-navigation-policy.ts deleted file mode 100644 index 25c2a73337e..00000000000 --- a/libs/common/src/tools/generator/navigation/generator-navigation-policy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; -import { GeneratorType } from "../generator-type"; - -/** Policy settings affecting password generator navigation */ -export type GeneratorNavigationPolicy = { - /** The type of generator that should be shown by default when opening - * the password generator. - */ - defaultType?: GeneratorType; -}; - -/** Reduces a policy into an accumulator by preferring the password generator - * type to other generator types. - * @param acc the accumulator - * @param policy the policy to reduce - * @returns the resulting `GeneratorNavigationPolicy` - */ -export function preferPassword( - acc: GeneratorNavigationPolicy, - policy: Policy, -): GeneratorNavigationPolicy { - const isEnabled = policy.type === PolicyType.PasswordGenerator && policy.enabled; - if (!isEnabled) { - return acc; - } - - const isOverridable = acc.defaultType !== "password" && policy.data.defaultType; - const result = isOverridable ? { ...acc, defaultType: policy.data.defaultType } : acc; - - return result; -} - -/** The default options for password generation policy. */ -export const DisabledGeneratorNavigationPolicy: GeneratorNavigationPolicy = Object.freeze({ - defaultType: undefined, -}); diff --git a/libs/common/src/tools/generator/navigation/generator-navigation.ts b/libs/common/src/tools/generator/navigation/generator-navigation.ts deleted file mode 100644 index 6a07385286d..00000000000 --- a/libs/common/src/tools/generator/navigation/generator-navigation.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { GeneratorType } from "../generator-type"; -import { ForwarderId } from "../username/options"; -import { UsernameGeneratorType } from "../username/options/generator-options"; - -/** Stores credential generator UI state. */ - -export type GeneratorNavigation = { - /** The kind of credential being generated. - * @remarks The legacy generator only supports "password" and "passphrase". - * The componentized generator supports all values. - */ - type?: GeneratorType; - - /** When `type === "username"`, this stores the username algorithm. */ - username?: UsernameGeneratorType; - - /** When `username === "forwarded"`, this stores the forwarder implementation. */ - forwarder?: ForwarderId | ""; -}; -/** The default options for password generation. */ - -export const DefaultGeneratorNavigation: Partial = Object.freeze({ - type: "password", - username: "word", - forwarder: "", -}); diff --git a/libs/common/src/tools/generator/navigation/index.ts b/libs/common/src/tools/generator/navigation/index.ts deleted file mode 100644 index 86194f471af..00000000000 --- a/libs/common/src/tools/generator/navigation/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator"; -export { DefaultGeneratorNavigationService } from "./default-generator-navigation.service"; -export { GeneratorNavigation, DefaultGeneratorNavigation } from "./generator-navigation"; diff --git a/libs/common/src/tools/generator/no-policy.ts b/libs/common/src/tools/generator/no-policy.ts deleted file mode 100644 index 00ffc6098c2..00000000000 --- a/libs/common/src/tools/generator/no-policy.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Type representing an absence of policy. */ -export type NoPolicy = Record; diff --git a/libs/common/src/tools/generator/passphrase/index.ts b/libs/common/src/tools/generator/passphrase/index.ts deleted file mode 100644 index 3bbe9253017..00000000000 --- a/libs/common/src/tools/generator/passphrase/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// password generator "v2" interfaces -export { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; -export { PassphraseGeneratorPolicy } from "./passphrase-generator-policy"; -export { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy"; -export { - DefaultPassphraseGenerationOptions, - PassphraseGenerationOptions, -} from "./passphrase-generation-options"; diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generation-options.ts b/libs/common/src/tools/generator/passphrase/passphrase-generation-options.ts deleted file mode 100644 index 8d6e8eedabd..00000000000 --- a/libs/common/src/tools/generator/passphrase/passphrase-generation-options.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** Request format for passphrase credential generation. - * The members of this type may be `undefined` when the user is - * generating a password. - */ -export type PassphraseGenerationOptions = { - /** The number of words to include in the passphrase. - * This value defaults to 3. - */ - numWords?: number; - - /** The ASCII separator character to use between words in the passphrase. - * This value defaults to a dash. - * If multiple characters appear in the string, only the first character is used. - */ - wordSeparator?: string; - - /** `true` when the first character of every word should be capitalized. - * This value defaults to `false`. - */ - capitalize?: boolean; - - /** `true` when a number should be included in the passphrase. - * This value defaults to `false`. - */ - includeNumber?: boolean; -}; - -/** The default options for passphrase generation. */ -export const DefaultPassphraseGenerationOptions: Partial = - Object.freeze({ - numWords: 3, - wordSeparator: "-", - capitalize: false, - includeNumber: false, - }); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-options-evaluator.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-options-evaluator.spec.ts deleted file mode 100644 index b587afbd6e4..00000000000 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-options-evaluator.spec.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * include structuredClone in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ -import { PassphraseGenerationOptions } from "./passphrase-generation-options"; -import { - DefaultBoundaries, - PassphraseGeneratorOptionsEvaluator, -} from "./passphrase-generator-options-evaluator"; -import { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy"; - -describe("Password generator options builder", () => { - describe("constructor()", () => { - it("should set the policy object to a copy of the input policy", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - policy.minNumberWords = 10; // arbitrary change for deep equality check - - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - - expect(builder.policy).toEqual(policy); - expect(builder.policy).not.toBe(policy); - }); - - it("should set default boundaries when a default policy is used", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - - expect(builder.numWords).toEqual(DefaultBoundaries.numWords); - }); - - it.each([1, 2])( - "should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)", - (minNumberWords) => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - policy.minNumberWords = minNumberWords; - - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - - expect(builder.numWords).toEqual(DefaultBoundaries.numWords); - }, - ); - - it.each([8, 12, 18])( - "should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words", - (minNumberWords) => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - policy.minNumberWords = minNumberWords; - - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - - expect(builder.numWords.min).toEqual(minNumberWords); - expect(builder.numWords.max).toEqual(DefaultBoundaries.numWords.max); - }, - ); - - it.each([150, 300, 9000])( - "should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries", - (minNumberWords) => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - policy.minNumberWords = minNumberWords; - - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - - expect(builder.numWords.min).toEqual(minNumberWords); - expect(builder.numWords.max).toEqual(minNumberWords); - }, - ); - }); - - describe("policyInEffect", () => { - it("should return false when the policy has no effect", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - - expect(builder.policyInEffect).toEqual(false); - }); - - it("should return true when the policy has a numWords greater than the default boundary", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - policy.minNumberWords = DefaultBoundaries.numWords.min + 1; - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - - expect(builder.policyInEffect).toEqual(true); - }); - - it("should return true when the policy has capitalize enabled", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - policy.capitalize = true; - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - - expect(builder.policyInEffect).toEqual(true); - }); - - it("should return true when the policy has includeNumber enabled", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - policy.includeNumber = true; - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - - expect(builder.policyInEffect).toEqual(true); - }); - }); - - describe("applyPolicy(options)", () => { - // All tests should freeze the options to ensure they are not modified - - it("should set `capitalize` to `false` when the policy does not override it", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({}); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.capitalize).toBe(false); - }); - - it("should set `capitalize` to `true` when the policy overrides it", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - policy.capitalize = true; - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ capitalize: false }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.capitalize).toBe(true); - }); - - it("should set `includeNumber` to false when the policy does not override it", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({}); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.includeNumber).toBe(false); - }); - - it("should set `includeNumber` to true when the policy overrides it", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - policy.includeNumber = true; - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ includeNumber: false }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.includeNumber).toBe(true); - }); - - it("should set `numWords` to the minimum value when it isn't supplied", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({}); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.numWords).toBe(builder.numWords.min); - }); - - it.each([1, 2])( - "should set `numWords` (= %i) to the minimum value when it is less than the minimum", - (numWords) => { - expect(numWords).toBeLessThan(DefaultBoundaries.numWords.min); - - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ numWords }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.numWords).toBe(builder.numWords.min); - }, - ); - - it.each([3, 8, 18, 20])( - "should set `numWords` (= %i) to the input value when it is within the boundaries", - (numWords) => { - expect(numWords).toBeGreaterThanOrEqual(DefaultBoundaries.numWords.min); - expect(numWords).toBeLessThanOrEqual(DefaultBoundaries.numWords.max); - - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ numWords }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.numWords).toBe(numWords); - }, - ); - - it.each([21, 30, 50, 100])( - "should set `numWords` (= %i) to the maximum value when it is greater than the maximum", - (numWords) => { - expect(numWords).toBeGreaterThan(DefaultBoundaries.numWords.max); - - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ numWords }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.numWords).toBe(builder.numWords.max); - }, - ); - - it("should preserve unknown properties", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ - unknown: "property", - another: "unknown property", - }) as PassphraseGenerationOptions; - - const sanitizedOptions: any = builder.applyPolicy(options); - - expect(sanitizedOptions.unknown).toEqual("property"); - expect(sanitizedOptions.another).toEqual("unknown property"); - }); - }); - - describe("sanitize(options)", () => { - // All tests should freeze the options to ensure they are not modified - - it("should return the input options without altering them", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ wordSeparator: "%" }); - - const sanitizedOptions = builder.sanitize(options); - - expect(sanitizedOptions).toEqual(options); - }); - - it("should set `wordSeparator` to '-' when it isn't supplied and there is no policy override", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({}); - - const sanitizedOptions = builder.sanitize(options); - - expect(sanitizedOptions.wordSeparator).toEqual("-"); - }); - - it("should leave `wordSeparator` as the empty string '' when it is the empty string", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ wordSeparator: "" }); - - const sanitizedOptions = builder.sanitize(options); - - expect(sanitizedOptions.wordSeparator).toEqual(""); - }); - - it("should preserve unknown properties", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); - const builder = new PassphraseGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ - unknown: "property", - another: "unknown property", - }) as PassphraseGenerationOptions; - - const sanitizedOptions: any = builder.sanitize(options); - - expect(sanitizedOptions.unknown).toEqual("property"); - expect(sanitizedOptions.another).toEqual("unknown property"); - }); - }); -}); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-options-evaluator.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-options-evaluator.ts deleted file mode 100644 index 207ffe8675e..00000000000 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-options-evaluator.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { PolicyEvaluator } from "../abstractions/policy-evaluator.abstraction"; - -import { - DefaultPassphraseGenerationOptions, - PassphraseGenerationOptions, -} from "./passphrase-generation-options"; -import { PassphraseGeneratorPolicy } from "./passphrase-generator-policy"; - -type Boundary = { - readonly min: number; - readonly max: number; -}; - -function initializeBoundaries() { - const numWords = Object.freeze({ - min: 3, - max: 20, - }); - - return Object.freeze({ - numWords, - }); -} - -/** Immutable default boundaries for passphrase generation. - * These are used when the policy does not override a value. - */ -export const DefaultBoundaries = initializeBoundaries(); - -/** Enforces policy for passphrase generation options. - */ -export class PassphraseGeneratorOptionsEvaluator - implements PolicyEvaluator -{ - // This design is not ideal, but it is a step towards a more robust passphrase - // generator. Ideally, `sanitize` would be implemented on an options class, - // and `applyPolicy` would be implemented on a policy class, "mise en place". - // - // The current design of the passphrase generator, unfortunately, would require - // a substantial rewrite to make this feasible. Hopefully this change can be - // applied when the passphrase generator is ported to rust. - - /** Policy applied by the evaluator. - */ - readonly policy: PassphraseGeneratorPolicy; - - /** Boundaries for the number of words allowed in the password. - */ - readonly numWords: Boundary; - - /** Instantiates the evaluator. - * @param policy The policy applied by the evaluator. When this conflicts with - * the defaults, the policy takes precedence. - */ - constructor(policy: PassphraseGeneratorPolicy) { - function createBoundary(value: number, defaultBoundary: Boundary): Boundary { - const boundary = { - min: Math.max(defaultBoundary.min, value), - max: Math.max(defaultBoundary.max, value), - }; - - return boundary; - } - - this.policy = structuredClone(policy); - this.numWords = createBoundary(policy.minNumberWords, DefaultBoundaries.numWords); - } - - /** {@link PolicyEvaluator.policyInEffect} */ - get policyInEffect(): boolean { - const policies = [ - this.policy.capitalize, - this.policy.includeNumber, - this.policy.minNumberWords > DefaultBoundaries.numWords.min, - ]; - - return policies.includes(true); - } - - /** Apply policy to the input options. - * @param options The options to build from. These options are not altered. - * @returns A new password generation request with policy applied. - */ - applyPolicy(options: PassphraseGenerationOptions): PassphraseGenerationOptions { - function fitToBounds(value: number, boundaries: Boundary) { - const { min, max } = boundaries; - - const withUpperBound = Math.min(value ?? boundaries.min, max); - const withLowerBound = Math.max(withUpperBound, min); - - return withLowerBound; - } - - // apply policy overrides - const capitalize = this.policy.capitalize || options.capitalize || false; - const includeNumber = this.policy.includeNumber || options.includeNumber || false; - - // apply boundaries - const numWords = fitToBounds(options.numWords, this.numWords); - - return { - ...options, - numWords, - capitalize, - includeNumber, - }; - } - - /** Ensures internal options consistency. - * @param options The options to cascade. These options are not altered. - * @returns A passphrase generation request with cascade applied. - */ - sanitize(options: PassphraseGenerationOptions): PassphraseGenerationOptions { - // ensure words are separated by a single character or the empty string - const wordSeparator = - options.wordSeparator === "" - ? "" - : options.wordSeparator?.[0] ?? DefaultPassphraseGenerationOptions.wordSeparator; - - return { - ...options, - wordSeparator, - }; - } -} diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.spec.ts deleted file mode 100644 index 991b2ae3024..00000000000 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; -import { PolicyId } from "../../../types/guid"; - -import { DisabledPassphraseGeneratorPolicy, leastPrivilege } from "./passphrase-generator-policy"; - -function createPolicy( - data: any, - type: PolicyType = PolicyType.PasswordGenerator, - enabled: boolean = true, -) { - return new Policy({ - id: "id" as PolicyId, - organizationId: "organizationId", - data, - enabled, - type, - }); -} - -describe("leastPrivilege", () => { - it("should return the accumulator when the policy type does not apply", () => { - const policy = createPolicy({}, PolicyType.RequireSso); - - const result = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy); - - expect(result).toEqual(DisabledPassphraseGeneratorPolicy); - }); - - it("should return the accumulator when the policy is not enabled", () => { - const policy = createPolicy({}, PolicyType.PasswordGenerator, false); - - const result = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy); - - expect(result).toEqual(DisabledPassphraseGeneratorPolicy); - }); - - it.each([ - ["minNumberWords", 10], - ["capitalize", true], - ["includeNumber", true], - ])("should take the %p from the policy", (input, value) => { - const policy = createPolicy({ [input]: value }); - - const result = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy); - - expect(result).toEqual({ ...DisabledPassphraseGeneratorPolicy, [input]: value }); - }); -}); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts deleted file mode 100644 index db616f16c05..00000000000 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; - -/** Policy options enforced during passphrase generation. */ -export type PassphraseGeneratorPolicy = { - minNumberWords: number; - capitalize: boolean; - includeNumber: boolean; -}; - -/** The default options for password generation policy. */ -export const DisabledPassphraseGeneratorPolicy: PassphraseGeneratorPolicy = Object.freeze({ - minNumberWords: 0, - capitalize: false, - includeNumber: false, -}); - -/** Reduces a policy into an accumulator by accepting the most restrictive - * values from each policy. - * @param acc the accumulator - * @param policy the policy to reduce - * @returns the most restrictive values between the policy and accumulator. - */ -export function leastPrivilege( - acc: PassphraseGeneratorPolicy, - policy: Policy, -): PassphraseGeneratorPolicy { - if (policy.type !== PolicyType.PasswordGenerator) { - return acc; - } - - return { - minNumberWords: Math.max(acc.minNumberWords, policy.data.minNumberWords ?? acc.minNumberWords), - capitalize: policy.data.capitalize || acc.capitalize, - includeNumber: policy.data.includeNumber || acc.includeNumber, - }; -} diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts deleted file mode 100644 index 429f81175a8..00000000000 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * include structuredClone in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ -import { mock } from "jest-mock-extended"; -import { of, firstValueFrom } from "rxjs"; - -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; -import { StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; -import { Randomizer } from "../abstractions/randomizer"; -import { PASSPHRASE_SETTINGS } from "../key-definitions"; - -import { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy"; - -import { - DefaultPassphraseGenerationOptions, - PassphraseGeneratorOptionsEvaluator, - PassphraseGeneratorStrategy, -} from "."; - -const SomeUser = "some user" as UserId; - -describe("Password generation strategy", () => { - describe("toEvaluator()", () => { - it("should map to the policy evaluator", async () => { - const strategy = new PassphraseGeneratorStrategy(null, null); - const policy = mock({ - type: PolicyType.PasswordGenerator, - data: { - minNumberWords: 10, - capitalize: true, - includeNumber: true, - }, - }); - - const evaluator$ = of([policy]).pipe(strategy.toEvaluator()); - const evaluator = await firstValueFrom(evaluator$); - - expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject({ - minNumberWords: 10, - capitalize: true, - includeNumber: true, - }); - }); - - it.each([[[]], [null], [undefined]])( - "should map `%p` to a disabled password policy evaluator", - async (policies) => { - const strategy = new PassphraseGeneratorStrategy(null, null); - - const evaluator$ = of(policies).pipe(strategy.toEvaluator()); - const evaluator = await firstValueFrom(evaluator$); - - expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject(DisabledPassphraseGeneratorPolicy); - }, - ); - }); - - describe("durableState", () => { - it("should use password settings key", () => { - const provider = mock(); - const randomizer = mock(); - const strategy = new PassphraseGeneratorStrategy(randomizer, provider); - - strategy.durableState(SomeUser); - - expect(provider.getUser).toHaveBeenCalledWith(SomeUser, PASSPHRASE_SETTINGS); - }); - }); - - describe("defaults$", () => { - it("should return the default subaddress options", async () => { - const strategy = new PassphraseGeneratorStrategy(null, null); - - const result = await firstValueFrom(strategy.defaults$(SomeUser)); - - expect(result).toEqual(DefaultPassphraseGenerationOptions); - }); - }); - - describe("policy", () => { - it("should use password generator policy", () => { - const randomizer = mock(); - const strategy = new PassphraseGeneratorStrategy(randomizer, null); - - expect(strategy.policy).toBe(PolicyType.PasswordGenerator); - }); - }); - - describe("generate()", () => { - it.todo("should generate a password using the given options"); - }); -}); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts deleted file mode 100644 index bf381845704..00000000000 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { GeneratorStrategy } from ".."; -import { PolicyType } from "../../../admin-console/enums"; -import { EFFLongWordList } from "../../../platform/misc/wordlist"; -import { StateProvider } from "../../../platform/state"; -import { Randomizer } from "../abstractions/randomizer"; -import { PASSPHRASE_SETTINGS } from "../key-definitions"; -import { Policies } from "../policies"; -import { mapPolicyToEvaluator } from "../rx-operators"; -import { clone$PerUserId, sharedStateByUserId } from "../util"; - -import { - PassphraseGenerationOptions, - DefaultPassphraseGenerationOptions, -} from "./passphrase-generation-options"; -import { PassphraseGeneratorPolicy } from "./passphrase-generator-policy"; - -/** Generates passphrases composed of random words */ -export class PassphraseGeneratorStrategy - implements GeneratorStrategy -{ - /** instantiates the password generator strategy. - * @param legacy generates the passphrase - * @param stateProvider provides durable state - */ - constructor( - private randomizer: Randomizer, - private stateProvider: StateProvider, - ) {} - - // configuration - durableState = sharedStateByUserId(PASSPHRASE_SETTINGS, this.stateProvider); - defaults$ = clone$PerUserId(DefaultPassphraseGenerationOptions); - readonly policy = PolicyType.PasswordGenerator; - toEvaluator() { - return mapPolicyToEvaluator(Policies.Passphrase); - } - - // algorithm - async generate(options: PassphraseGenerationOptions): Promise { - const o = { ...DefaultPassphraseGenerationOptions, ...options }; - if (o.numWords == null || o.numWords <= 2) { - o.numWords = DefaultPassphraseGenerationOptions.numWords; - } - if (o.capitalize == null) { - o.capitalize = false; - } - if (o.includeNumber == null) { - o.includeNumber = false; - } - - // select which word gets the number, if any - let luckyNumber = -1; - if (o.includeNumber) { - luckyNumber = await this.randomizer.uniform(0, o.numWords - 1); - } - - // generate the passphrase - const wordList = new Array(o.numWords); - for (let i = 0; i < o.numWords; i++) { - const word = await this.randomizer.pickWord(EFFLongWordList, { - titleCase: o.capitalize, - number: i === luckyNumber, - }); - - wordList[i] = word; - } - - return wordList.join(o.wordSeparator); - } -} diff --git a/libs/common/src/tools/generator/password/generated-password-history.ts b/libs/common/src/tools/generator/password/generated-password-history.ts deleted file mode 100644 index b4cc9b22fa6..00000000000 --- a/libs/common/src/tools/generator/password/generated-password-history.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class GeneratedPasswordHistory { - password: string; - date: number; - - constructor(password: string, date: number) { - this.password = password; - this.date = date; - } -} diff --git a/libs/common/src/tools/generator/password/index.ts b/libs/common/src/tools/generator/password/index.ts deleted file mode 100644 index 7e16a2c442a..00000000000 --- a/libs/common/src/tools/generator/password/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// password generator "v2" interfaces -export * from "./password-generation-options"; -export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; -export { PasswordGeneratorPolicy } from "./password-generator-policy"; -export { PasswordGeneratorStrategy } from "./password-generator-strategy"; - -// legacy interfaces -export { PasswordGeneratorOptions } from "./password-generator-options"; -export { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; -export { GeneratedPasswordHistory } from "./generated-password-history"; diff --git a/libs/common/src/tools/generator/password/password-generation-options.ts b/libs/common/src/tools/generator/password/password-generation-options.ts deleted file mode 100644 index a48eeb77c6e..00000000000 --- a/libs/common/src/tools/generator/password/password-generation-options.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { DefaultBoundaries } from "./password-generator-options-evaluator"; - -/** Request format for password credential generation. - * All members of this type may be `undefined` when the user is - * generating a passphrase. - * - * @remarks The name of this type is a bit of a misnomer. This type - * it is used with the "password generator" types. The name - * `PasswordGeneratorOptions` is already in use by legacy code. - */ -export type PasswordGenerationOptions = { - /** The length of the password selected by the user */ - length?: number; - - /** The minimum length of the password. This defaults to 5, and increases - * to ensure `minLength` is at least as large as the sum of the other minimums. - */ - minLength?: number; - - /** `true` when ambiguous characters may be included in the output. - * `false` when ambiguous characters should not be included in the output. - */ - ambiguous?: boolean; - - /** `true` when uppercase ASCII characters should be included in the output - * This value defaults to `false. - */ - uppercase?: boolean; - - /** The minimum number of uppercase characters to include in the output. - * The value is ignored when `uppercase` is `false`. - * The value defaults to 1 when `uppercase` is `true`. - */ - minUppercase?: number; - - /** `true` when lowercase ASCII characters should be included in the output. - * This value defaults to `false`. - */ - lowercase?: boolean; - - /** The minimum number of lowercase characters to include in the output. - * The value defaults to 1 when `lowercase` is `true`. - * The value defaults to 0 when `lowercase` is `false`. - */ - minLowercase?: number; - - /** Whether or not to include ASCII digits in the output - * This value defaults to `true` when `minNumber` is at least 1. - * This value defaults to `false` when `minNumber` is less than 1. - */ - number?: boolean; - - /** The minimum number of digits to include in the output. - * The value defaults to 1 when `number` is `true`. - * The value defaults to 0 when `number` is `false`. - */ - minNumber?: number; - - /** Whether or not to include special characters in the output. - * This value defaults to `true` when `minSpecial` is at least 1. - * This value defaults to `false` when `minSpecial` is less than 1. - */ - special?: boolean; - - /** The minimum number of special characters to include in the output. - * This value defaults to 1 when `special` is `true`. - * This value defaults to 0 when `special` is `false`. - */ - minSpecial?: number; -}; - -/** The default options for password generation. */ -export const DefaultPasswordGenerationOptions: Partial = Object.freeze({ - length: 14, - minLength: DefaultBoundaries.length.min, - ambiguous: true, - uppercase: true, - lowercase: true, - number: true, - minNumber: 1, - special: false, - minSpecial: 0, -}); diff --git a/libs/common/src/tools/generator/password/password-generator-options-evaluator.spec.ts b/libs/common/src/tools/generator/password/password-generator-options-evaluator.spec.ts deleted file mode 100644 index 1b3f2289204..00000000000 --- a/libs/common/src/tools/generator/password/password-generator-options-evaluator.spec.ts +++ /dev/null @@ -1,770 +0,0 @@ -/** - * include structuredClone in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ - -import { DefaultBoundaries } from "./password-generator-options-evaluator"; -import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy"; - -import { PasswordGenerationOptions, PasswordGeneratorOptionsEvaluator } from "."; - -describe("Password generator options builder", () => { - const defaultOptions = Object.freeze({ minLength: 0 }); - - describe("constructor()", () => { - it("should set the policy object to a copy of the input policy", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.minLength = 10; // arbitrary change for deep equality check - - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.policy).toEqual(policy); - expect(builder.policy).not.toBe(policy); - }); - - it("should set default boundaries when a default policy is used", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.length).toEqual(DefaultBoundaries.length); - expect(builder.minDigits).toEqual(DefaultBoundaries.minDigits); - expect(builder.minSpecialCharacters).toEqual(DefaultBoundaries.minSpecialCharacters); - }); - - it.each([1, 2, 3, 4])( - "should use the default length boundaries when they are greater than `policy.minLength` (= %i)", - (minLength) => { - expect(minLength).toBeLessThan(DefaultBoundaries.length.min); - - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.minLength = minLength; - - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.length).toEqual(DefaultBoundaries.length); - }, - ); - - it.each([8, 20, 100])( - "should use `policy.minLength` (= %i) when it is greater than the default minimum length", - (expectedLength) => { - expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.min); - expect(expectedLength).toBeLessThanOrEqual(DefaultBoundaries.length.max); - - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.minLength = expectedLength; - - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.length.min).toEqual(expectedLength); - expect(builder.length.max).toEqual(DefaultBoundaries.length.max); - }, - ); - - it.each([150, 300, 9000])( - "should use `policy.minLength` (= %i) when it is greater than the default boundaries", - (expectedLength) => { - expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.max); - - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.minLength = expectedLength; - - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.length.min).toEqual(expectedLength); - expect(builder.length.max).toEqual(expectedLength); - }, - ); - - it.each([3, 5, 8, 9])( - "should use `policy.numberCount` (= %i) when it is greater than the default minimum digits", - (expectedMinDigits) => { - expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.min); - expect(expectedMinDigits).toBeLessThanOrEqual(DefaultBoundaries.minDigits.max); - - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.numberCount = expectedMinDigits; - - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.minDigits.min).toEqual(expectedMinDigits); - expect(builder.minDigits.max).toEqual(DefaultBoundaries.minDigits.max); - }, - ); - - it.each([10, 20, 400])( - "should use `policy.numberCount` (= %i) when it is greater than the default digit boundaries", - (expectedMinDigits) => { - expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.max); - - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.numberCount = expectedMinDigits; - - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.minDigits.min).toEqual(expectedMinDigits); - expect(builder.minDigits.max).toEqual(expectedMinDigits); - }, - ); - - it.each([2, 4, 6])( - "should use `policy.specialCount` (= %i) when it is greater than the default minimum special characters", - (expectedSpecialCharacters) => { - expect(expectedSpecialCharacters).toBeGreaterThan( - DefaultBoundaries.minSpecialCharacters.min, - ); - expect(expectedSpecialCharacters).toBeLessThanOrEqual( - DefaultBoundaries.minSpecialCharacters.max, - ); - - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.specialCount = expectedSpecialCharacters; - - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters); - expect(builder.minSpecialCharacters.max).toEqual( - DefaultBoundaries.minSpecialCharacters.max, - ); - }, - ); - - it.each([10, 20, 400])( - "should use `policy.specialCount` (= %i) when it is greater than the default special characters boundaries", - (expectedSpecialCharacters) => { - expect(expectedSpecialCharacters).toBeGreaterThan( - DefaultBoundaries.minSpecialCharacters.max, - ); - - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.specialCount = expectedSpecialCharacters; - - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters); - expect(builder.minSpecialCharacters.max).toEqual(expectedSpecialCharacters); - }, - ); - - it.each([ - [8, 6, 2], - [6, 2, 4], - [16, 8, 8], - ])( - "should ensure the minimum length (= %i) is at least the sum of minimums (= %i + %i)", - (expectedLength, numberCount, specialCount) => { - expect(expectedLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min); - - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.numberCount = numberCount; - policy.specialCount = specialCount; - - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.length.min).toBeGreaterThanOrEqual(expectedLength); - }, - ); - }); - - describe("policyInEffect", () => { - it("should return false when the policy has no effect", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.policyInEffect).toEqual(false); - }); - - it("should return true when the policy has a minlength greater than the default boundary", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.minLength = DefaultBoundaries.length.min + 1; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.policyInEffect).toEqual(true); - }); - - it("should return true when the policy has a number count greater than the default boundary", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.numberCount = DefaultBoundaries.minDigits.min + 1; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.policyInEffect).toEqual(true); - }); - - it("should return true when the policy has a special character count greater than the default boundary", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.specialCount = DefaultBoundaries.minSpecialCharacters.min + 1; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.policyInEffect).toEqual(true); - }); - - it("should return true when the policy has uppercase enabled", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.useUppercase = true; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.policyInEffect).toEqual(true); - }); - - it("should return true when the policy has lowercase enabled", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.useLowercase = true; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.policyInEffect).toEqual(true); - }); - - it("should return true when the policy has numbers enabled", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.useNumbers = true; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.policyInEffect).toEqual(true); - }); - - it("should return true when the policy has special characters enabled", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.useSpecial = true; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - - expect(builder.policyInEffect).toEqual(true); - }); - }); - - describe("applyPolicy(options)", () => { - // All tests should freeze the options to ensure they are not modified - - it.each([ - [false, false], - [true, true], - [false, undefined], - ])( - "should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'", - (expectedUppercase, uppercase) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.useUppercase = false; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, uppercase }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.uppercase).toEqual(expectedUppercase); - }, - ); - - it.each([false, true, undefined])( - "should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true", - (uppercase) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.useUppercase = true; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, uppercase }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.uppercase).toEqual(true); - }, - ); - - it.each([ - [false, false], - [true, true], - [false, undefined], - ])( - "should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'", - (expectedLowercase, lowercase) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.useLowercase = false; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, lowercase }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.lowercase).toEqual(expectedLowercase); - }, - ); - - it.each([false, true, undefined])( - "should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true", - (lowercase) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.useLowercase = true; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, lowercase }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.lowercase).toEqual(true); - }, - ); - - it.each([ - [false, false], - [true, true], - [false, undefined], - ])( - "should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'", - (expectedNumber, number) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.useNumbers = false; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, number }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.number).toEqual(expectedNumber); - }, - ); - - it.each([false, true, undefined])( - "should set `options.number` (= %s) to true when `policy.useNumbers` is true", - (number) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.useNumbers = true; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, number }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.number).toEqual(true); - }, - ); - - it.each([ - [false, false], - [true, true], - [false, undefined], - ])( - "should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'", - (expectedSpecial, special) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.useSpecial = false; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, special }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.special).toEqual(expectedSpecial); - }, - ); - - it.each([false, true, undefined])( - "should set `options.special` (= %s) to true when `policy.useSpecial` is true", - (special) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.useSpecial = true; - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, special }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.special).toEqual(true); - }, - ); - - it.each([1, 2, 3, 4])( - "should set `options.length` (= %i) to the minimum it is less than the minimum length", - (length) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - expect(length).toBeLessThan(builder.length.min); - - const options = Object.freeze({ ...defaultOptions, length }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.length).toEqual(builder.length.min); - }, - ); - - it.each([5, 10, 50, 100, 128])( - "should not change `options.length` (= %i) when it is within the boundaries", - (length) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - expect(length).toBeGreaterThanOrEqual(builder.length.min); - expect(length).toBeLessThanOrEqual(builder.length.max); - - const options = Object.freeze({ ...defaultOptions, length }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.length).toEqual(length); - }, - ); - - it.each([129, 500, 9000])( - "should set `options.length` (= %i) to the maximum length when it is exceeded", - (length) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - expect(length).toBeGreaterThan(builder.length.max); - - const options = Object.freeze({ ...defaultOptions, length }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.length).toEqual(builder.length.max); - }, - ); - - it.each([ - [true, 1], - [true, 3], - [true, 600], - [false, 0], - [false, -2], - [false, -600], - ])( - "should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0", - (expectedNumber, minNumber) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, minNumber }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.number).toEqual(expectedNumber); - }, - ); - - it("should set `options.minNumber` to the minimum value when `options.number` is true", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, number: true }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min); - }); - - it("should set `options.minNumber` to 0 when `options.number` is false", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, number: false }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.minNumber).toEqual(0); - }); - - it.each([1, 2, 3, 4])( - "should set `options.minNumber` (= %i) to the minimum it is less than the minimum number", - (minNumber) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.numberCount = 5; // arbitrary value greater than minNumber - expect(minNumber).toBeLessThan(policy.numberCount); - - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, minNumber }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min); - }, - ); - - it.each([1, 3, 5, 7, 9])( - "should not change `options.minNumber` (= %i) when it is within the boundaries", - (minNumber) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min); - expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max); - - const options = Object.freeze({ ...defaultOptions, minNumber }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.minNumber).toEqual(minNumber); - }, - ); - - it.each([10, 20, 400])( - "should set `options.minNumber` (= %i) to the maximum digit boundary when it is exceeded", - (minNumber) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - expect(minNumber).toBeGreaterThan(builder.minDigits.max); - - const options = Object.freeze({ ...defaultOptions, minNumber }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.max); - }, - ); - - it.each([ - [true, 1], - [true, 3], - [true, 600], - [false, 0], - [false, -2], - [false, -600], - ])( - "should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0", - (expectedSpecial, minSpecial) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, minSpecial }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.special).toEqual(expectedSpecial); - }, - ); - - it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, special: true }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.minSpecial).toEqual(builder.minDigits.min); - }); - - it("should set `options.minSpecial` to 0 when `options.special` is false", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, special: false }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.minSpecial).toEqual(0); - }); - - it.each([1, 2, 3, 4])( - "should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters", - (minSpecial) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - policy.specialCount = 5; // arbitrary value greater than minSpecial - expect(minSpecial).toBeLessThan(policy.specialCount); - - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ ...defaultOptions, minSpecial }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.min); - }, - ); - - it.each([1, 3, 5, 7, 9])( - "should not change `options.minSpecial` (= %i) when it is within the boundaries", - (minSpecial) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min); - expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max); - - const options = Object.freeze({ ...defaultOptions, minSpecial }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.minSpecial).toEqual(minSpecial); - }, - ); - - it.each([10, 20, 400])( - "should set `options.minSpecial` (= %i) to the maximum special character boundary when it is exceeded", - (minSpecial) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max); - - const options = Object.freeze({ ...defaultOptions, minSpecial }); - - const sanitizedOptions = builder.applyPolicy(options); - - expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.max); - }, - ); - - it("should preserve unknown properties", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ - unknown: "property", - another: "unknown property", - }) as PasswordGenerationOptions; - - const sanitizedOptions: any = builder.applyPolicy(options); - - expect(sanitizedOptions.unknown).toEqual("property"); - expect(sanitizedOptions.another).toEqual("unknown property"); - }); - }); - - describe("sanitize(options)", () => { - // All tests should freeze the options to ensure they are not modified - - it.each([ - [1, true], - [0, false], - ])( - "should output `options.minLowercase === %i` when `options.lowercase` is %s", - (expectedMinLowercase, lowercase) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ lowercase, ...defaultOptions }); - - const actual = builder.sanitize(options); - - expect(actual.minLowercase).toEqual(expectedMinLowercase); - }, - ); - - it.each([ - [1, true], - [0, false], - ])( - "should output `options.minUppercase === %i` when `options.uppercase` is %s", - (expectedMinUppercase, uppercase) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ uppercase, ...defaultOptions }); - - const actual = builder.sanitize(options); - - expect(actual.minUppercase).toEqual(expectedMinUppercase); - }, - ); - - it.each([ - [1, true], - [0, false], - ])( - "should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set", - (expectedMinNumber, number) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ number, ...defaultOptions }); - - const actual = builder.sanitize(options); - - expect(actual.minNumber).toEqual(expectedMinNumber); - }, - ); - - it.each([ - [true, 3], - [true, 2], - [true, 1], - [false, 0], - ])( - "should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set", - (expectedNumber, minNumber) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ minNumber, ...defaultOptions }); - - const actual = builder.sanitize(options); - - expect(actual.number).toEqual(expectedNumber); - }, - ); - - it.each([ - [true, 1], - [false, 0], - ])( - "should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set", - (special, expectedMinSpecial) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ special, ...defaultOptions }); - - const actual = builder.sanitize(options); - - expect(actual.minSpecial).toEqual(expectedMinSpecial); - }, - ); - - it.each([ - [3, true], - [2, true], - [1, true], - [0, false], - ])( - "should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set", - (minSpecial, expectedSpecial) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ minSpecial, ...defaultOptions }); - - const actual = builder.sanitize(options); - - expect(actual.special).toEqual(expectedSpecial); - }, - ); - - it.each([ - [0, 0, 0, 0], - [1, 1, 0, 0], - [0, 0, 1, 1], - [1, 1, 1, 1], - ])( - "should set `options.minLength` to the minimum boundary when the sum of minimums (%i + %i + %i + %i) is less than the default minimum length.", - (minLowercase, minUppercase, minNumber, minSpecial) => { - const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial; - expect(sumOfMinimums).toBeLessThan(DefaultBoundaries.length.min); - - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ - minLowercase, - minUppercase, - minNumber, - minSpecial, - ...defaultOptions, - }); - - const actual = builder.sanitize(options); - - expect(actual.minLength).toEqual(builder.length.min); - }, - ); - - it.each([ - [12, 3, 3, 3, 3], - [8, 2, 2, 2, 2], - [9, 3, 3, 3, 0], - ])( - "should set `options.minLength === %i` to the sum of minimums (%i + %i + %i + %i) when the sum is at least the default minimum length.", - (expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => { - expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min); - - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ - minLowercase, - minUppercase, - minNumber, - minSpecial, - ...defaultOptions, - }); - - const actual = builder.sanitize(options); - - expect(actual.minLength).toEqual(expectedMinLength); - }, - ); - - it("should preserve unknown properties", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); - const builder = new PasswordGeneratorOptionsEvaluator(policy); - const options = Object.freeze({ - unknown: "property", - another: "unknown property", - }) as PasswordGenerationOptions; - - const sanitizedOptions: any = builder.sanitize(options); - - expect(sanitizedOptions.unknown).toEqual("property"); - expect(sanitizedOptions.another).toEqual("unknown property"); - }); - }); -}); diff --git a/libs/common/src/tools/generator/password/password-generator-options-evaluator.ts b/libs/common/src/tools/generator/password/password-generator-options-evaluator.ts deleted file mode 100644 index 79cb0a9b8ee..00000000000 --- a/libs/common/src/tools/generator/password/password-generator-options-evaluator.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { PolicyEvaluator } from "../abstractions/policy-evaluator.abstraction"; - -import { PasswordGenerationOptions } from "./password-generation-options"; -import { PasswordGeneratorPolicy } from "./password-generator-policy"; - -function initializeBoundaries() { - const length = Object.freeze({ - min: 5, - max: 128, - }); - - const minDigits = Object.freeze({ - min: 0, - max: 9, - }); - - const minSpecialCharacters = Object.freeze({ - min: 0, - max: 9, - }); - - return Object.freeze({ - length, - minDigits, - minSpecialCharacters, - }); -} - -/** Immutable default boundaries for password generation. - * These are used when the policy does not override a value. - */ -export const DefaultBoundaries = initializeBoundaries(); - -type Boundary = { - readonly min: number; - readonly max: number; -}; - -/** Enforces policy for password generation. - */ -export class PasswordGeneratorOptionsEvaluator - implements PolicyEvaluator -{ - // This design is not ideal, but it is a step towards a more robust password - // generator. Ideally, `sanitize` would be implemented on an options class, - // and `applyPolicy` would be implemented on a policy class, "mise en place". - // - // The current design of the password generator, unfortunately, would require - // a substantial rewrite to make this feasible. Hopefully this change can be - // applied when the password generator is ported to rust. - - /** Boundaries for the password length. This is always large enough - * to accommodate the minimum number of digits and special characters. - */ - readonly length: Boundary; - - /** Boundaries for the minimum number of digits allowed in the password. - */ - readonly minDigits: Boundary; - - /** Boundaries for the minimum number of special characters allowed - * in the password. - */ - readonly minSpecialCharacters: Boundary; - - /** Policy applied by the evaluator. - */ - readonly policy: PasswordGeneratorPolicy; - - /** Instantiates the evaluator. - * @param policy The policy applied by the evaluator. When this conflicts with - * the defaults, the policy takes precedence. - */ - constructor(policy: PasswordGeneratorPolicy) { - function createBoundary(value: number, defaultBoundary: Boundary): Boundary { - const boundary = { - min: Math.max(defaultBoundary.min, value), - max: Math.max(defaultBoundary.max, value), - }; - - return boundary; - } - - this.policy = structuredClone(policy); - this.minDigits = createBoundary(policy.numberCount, DefaultBoundaries.minDigits); - this.minSpecialCharacters = createBoundary( - policy.specialCount, - DefaultBoundaries.minSpecialCharacters, - ); - - // the overall length should be at least as long as the sum of the minimums - const minConsistentLength = this.minDigits.min + this.minSpecialCharacters.min; - const minPolicyLength = policy.minLength > 0 ? policy.minLength : DefaultBoundaries.length.min; - const minLength = Math.max(minPolicyLength, minConsistentLength, DefaultBoundaries.length.min); - - this.length = { - min: minLength, - max: Math.max(DefaultBoundaries.length.max, minLength), - }; - } - - /** {@link PolicyEvaluator.policyInEffect} */ - get policyInEffect(): boolean { - const policies = [ - this.policy.useUppercase, - this.policy.useLowercase, - this.policy.useNumbers, - this.policy.useSpecial, - this.policy.minLength > DefaultBoundaries.length.min, - this.policy.numberCount > DefaultBoundaries.minDigits.min, - this.policy.specialCount > DefaultBoundaries.minSpecialCharacters.min, - ]; - - return policies.includes(true); - } - - /** {@link PolicyEvaluator.applyPolicy} */ - applyPolicy(options: PasswordGenerationOptions): PasswordGenerationOptions { - function fitToBounds(value: number, boundaries: Boundary) { - const { min, max } = boundaries; - - const withUpperBound = Math.min(value || 0, max); - const withLowerBound = Math.max(withUpperBound, min); - - return withLowerBound; - } - - // apply policy overrides - const uppercase = this.policy.useUppercase || options.uppercase || false; - const lowercase = this.policy.useLowercase || options.lowercase || false; - - // these overrides can cascade numeric fields to boolean fields - const number = this.policy.useNumbers || options.number || options.minNumber > 0; - const special = this.policy.useSpecial || options.special || options.minSpecial > 0; - - // apply boundaries; the boundaries can cascade boolean fields to numeric fields - const length = fitToBounds(options.length, this.length); - const minNumber = fitToBounds(options.minNumber, this.minDigits); - const minSpecial = fitToBounds(options.minSpecial, this.minSpecialCharacters); - - return { - ...options, - length, - uppercase, - lowercase, - number, - minNumber, - special, - minSpecial, - }; - } - - /** {@link PolicyEvaluator.sanitize} */ - sanitize(options: PasswordGenerationOptions): PasswordGenerationOptions { - function cascade(enabled: boolean, value: number): [boolean, number] { - const enabledResult = enabled ?? value > 0; - const valueResult = enabledResult ? value || 1 : 0; - - return [enabledResult, valueResult]; - } - - const [lowercase, minLowercase] = cascade(options.lowercase, options.minLowercase); - const [uppercase, minUppercase] = cascade(options.uppercase, options.minUppercase); - const [number, minNumber] = cascade(options.number, options.minNumber); - const [special, minSpecial] = cascade(options.special, options.minSpecial); - - // minimums can only increase the length - const minConsistentLength = minLowercase + minUppercase + minNumber + minSpecial; - const minLength = Math.max(minConsistentLength, this.length.min); - const length = Math.max(options.length ?? minLength, minLength); - - return { - ...options, - length, - minLength, - lowercase, - minLowercase, - uppercase, - minUppercase, - number, - minNumber, - special, - minSpecial, - }; - } -} diff --git a/libs/common/src/tools/generator/password/password-generator-options.ts b/libs/common/src/tools/generator/password/password-generator-options.ts deleted file mode 100644 index 04a2f8c77a6..00000000000 --- a/libs/common/src/tools/generator/password/password-generator-options.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { GeneratorNavigation } from "../navigation/generator-navigation"; -import { PassphraseGenerationOptions } from "../passphrase/passphrase-generation-options"; - -import { PasswordGenerationOptions } from "./password-generation-options"; - -/** Request format for credential generation. - * This type includes all properties suitable for reactive data binding. - */ -export type PasswordGeneratorOptions = PasswordGenerationOptions & - PassphraseGenerationOptions & - GeneratorNavigation & { policyUpdated?: boolean }; diff --git a/libs/common/src/tools/generator/password/password-generator-policy.spec.ts b/libs/common/src/tools/generator/password/password-generator-policy.spec.ts deleted file mode 100644 index 206d88741b0..00000000000 --- a/libs/common/src/tools/generator/password/password-generator-policy.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; -import { PolicyId } from "../../../types/guid"; - -import { DisabledPasswordGeneratorPolicy, leastPrivilege } from "./password-generator-policy"; - -function createPolicy( - data: any, - type: PolicyType = PolicyType.PasswordGenerator, - enabled: boolean = true, -) { - return new Policy({ - id: "id" as PolicyId, - organizationId: "organizationId", - data, - enabled, - type, - }); -} - -describe("leastPrivilege", () => { - it("should return the accumulator when the policy type does not apply", () => { - const policy = createPolicy({}, PolicyType.RequireSso); - - const result = leastPrivilege(DisabledPasswordGeneratorPolicy, policy); - - expect(result).toEqual(DisabledPasswordGeneratorPolicy); - }); - - it("should return the accumulator when the policy is not enabled", () => { - const policy = createPolicy({}, PolicyType.PasswordGenerator, false); - - const result = leastPrivilege(DisabledPasswordGeneratorPolicy, policy); - - expect(result).toEqual(DisabledPasswordGeneratorPolicy); - }); - - it.each([ - ["minLength", 10, "minLength"], - ["useUpper", true, "useUppercase"], - ["useLower", true, "useLowercase"], - ["useNumbers", true, "useNumbers"], - ["minNumbers", 10, "numberCount"], - ["useSpecial", true, "useSpecial"], - ["minSpecial", 10, "specialCount"], - ])("should take the %p from the policy", (input, value, expected) => { - const policy = createPolicy({ [input]: value }); - - const result = leastPrivilege(DisabledPasswordGeneratorPolicy, policy); - - expect(result).toEqual({ ...DisabledPasswordGeneratorPolicy, [expected]: value }); - }); -}); diff --git a/libs/common/src/tools/generator/password/password-generator-policy.ts b/libs/common/src/tools/generator/password/password-generator-policy.ts deleted file mode 100644 index 7de6b49788d..00000000000 --- a/libs/common/src/tools/generator/password/password-generator-policy.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; - -/** Policy options enforced during password generation. */ -export type PasswordGeneratorPolicy = { - /** The minimum length of generated passwords. - * When this is less than or equal to zero, it is ignored. - * If this is less than the total number of characters required by - * the policy's other settings, then it is ignored. - */ - minLength: number; - - /** When this is true, an uppercase character must be part of - * the generated password. - */ - useUppercase: boolean; - - /** When this is true, a lowercase character must be part of - * the generated password. - */ - useLowercase: boolean; - - /** When this is true, at least one digit must be part of the generated - * password. - */ - useNumbers: boolean; - - /** The quantity of digits to include in the generated password. - * When this is less than or equal to zero, it is ignored. - */ - numberCount: number; - - /** When this is true, at least one digit must be part of the generated - * password. - */ - useSpecial: boolean; - - /** The quantity of special characters to include in the generated - * password. When this is less than or equal to zero, it is ignored. - */ - specialCount: number; -}; - -/** The default options for password generation policy. */ -export const DisabledPasswordGeneratorPolicy: PasswordGeneratorPolicy = Object.freeze({ - minLength: 0, - useUppercase: false, - useLowercase: false, - useNumbers: false, - numberCount: 0, - useSpecial: false, - specialCount: 0, -}); - -/** Reduces a policy into an accumulator by accepting the most restrictive - * values from each policy. - * @param acc the accumulator - * @param policy the policy to reduce - * @returns the most restrictive values between the policy and accumulator. - */ -export function leastPrivilege(acc: PasswordGeneratorPolicy, policy: Policy) { - if (policy.type !== PolicyType.PasswordGenerator || !policy.enabled) { - return acc; - } - - return { - minLength: Math.max(acc.minLength, policy.data.minLength ?? acc.minLength), - useUppercase: policy.data.useUpper || acc.useUppercase, - useLowercase: policy.data.useLower || acc.useLowercase, - useNumbers: policy.data.useNumbers || acc.useNumbers, - numberCount: Math.max(acc.numberCount, policy.data.minNumbers ?? acc.numberCount), - useSpecial: policy.data.useSpecial || acc.useSpecial, - specialCount: Math.max(acc.specialCount, policy.data.minSpecial ?? acc.specialCount), - }; -} diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts deleted file mode 100644 index 668dd818e25..00000000000 --- a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * include structuredClone in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ - -import { mock } from "jest-mock-extended"; -import { of, firstValueFrom } from "rxjs"; - -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; -import { StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; -import { Randomizer } from "../abstractions/randomizer"; -import { PASSWORD_SETTINGS } from "../key-definitions"; - -import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy"; - -import { - DefaultPasswordGenerationOptions, - PasswordGeneratorOptionsEvaluator, - PasswordGeneratorStrategy, -} from "."; - -const SomeUser = "some user" as UserId; - -describe("Password generation strategy", () => { - describe("toEvaluator()", () => { - it("should map to a password policy evaluator", async () => { - const strategy = new PasswordGeneratorStrategy(null, null); - const policy = mock({ - type: PolicyType.PasswordGenerator, - data: { - minLength: 10, - useUpper: true, - useLower: true, - useNumbers: true, - minNumbers: 1, - useSpecial: true, - minSpecial: 1, - }, - }); - - const evaluator$ = of([policy]).pipe(strategy.toEvaluator()); - const evaluator = await firstValueFrom(evaluator$); - - expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject({ - minLength: 10, - useUppercase: true, - useLowercase: true, - useNumbers: true, - numberCount: 1, - useSpecial: true, - specialCount: 1, - }); - }); - - it.each([[[]], [null], [undefined]])( - "should map `%p` to a disabled password policy evaluator", - async (policies) => { - const strategy = new PasswordGeneratorStrategy(null, null); - - const evaluator$ = of(policies).pipe(strategy.toEvaluator()); - const evaluator = await firstValueFrom(evaluator$); - - expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject(DisabledPasswordGeneratorPolicy); - }, - ); - }); - - describe("durableState", () => { - it("should use password settings key", () => { - const provider = mock(); - const randomizer = mock(); - const strategy = new PasswordGeneratorStrategy(randomizer, provider); - - strategy.durableState(SomeUser); - - expect(provider.getUser).toHaveBeenCalledWith(SomeUser, PASSWORD_SETTINGS); - }); - }); - - describe("defaults$", () => { - it("should return the default subaddress options", async () => { - const strategy = new PasswordGeneratorStrategy(null, null); - - const result = await firstValueFrom(strategy.defaults$(SomeUser)); - - expect(result).toEqual(DefaultPasswordGenerationOptions); - }); - }); - - describe("policy", () => { - it("should use password generator policy", () => { - const randomizer = mock(); - const strategy = new PasswordGeneratorStrategy(randomizer, null); - - expect(strategy.policy).toBe(PolicyType.PasswordGenerator); - }); - }); - - describe("generate()", () => { - it.todo("should generate a password using the given options"); - }); -}); diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.ts b/libs/common/src/tools/generator/password/password-generator-strategy.ts deleted file mode 100644 index 075c331e066..00000000000 --- a/libs/common/src/tools/generator/password/password-generator-strategy.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { GeneratorStrategy } from ".."; -import { PolicyType } from "../../../admin-console/enums"; -import { StateProvider } from "../../../platform/state"; -import { Randomizer } from "../abstractions/randomizer"; -import { PASSWORD_SETTINGS } from "../key-definitions"; -import { Policies } from "../policies"; -import { mapPolicyToEvaluator } from "../rx-operators"; -import { clone$PerUserId, sharedStateByUserId } from "../util"; - -import { - DefaultPasswordGenerationOptions, - PasswordGenerationOptions, -} from "./password-generation-options"; -import { PasswordGeneratorPolicy } from "./password-generator-policy"; - -/** Generates passwords composed of random characters */ -export class PasswordGeneratorStrategy - implements GeneratorStrategy -{ - /** instantiates the password generator strategy. - * @param legacy generates the password - */ - constructor( - private randomizer: Randomizer, - private stateProvider: StateProvider, - ) {} - - // configuration - durableState = sharedStateByUserId(PASSWORD_SETTINGS, this.stateProvider); - defaults$ = clone$PerUserId(DefaultPasswordGenerationOptions); - readonly policy = PolicyType.PasswordGenerator; - toEvaluator() { - return mapPolicyToEvaluator(Policies.Password); - } - - // algorithm - async generate(options: PasswordGenerationOptions): Promise { - const o = { ...DefaultPasswordGenerationOptions, ...options }; - let positions: string[] = []; - if (o.lowercase && o.minLowercase > 0) { - for (let i = 0; i < o.minLowercase; i++) { - positions.push("l"); - } - } - if (o.uppercase && o.minUppercase > 0) { - for (let i = 0; i < o.minUppercase; i++) { - positions.push("u"); - } - } - if (o.number && o.minNumber > 0) { - for (let i = 0; i < o.minNumber; i++) { - positions.push("n"); - } - } - if (o.special && o.minSpecial > 0) { - for (let i = 0; i < o.minSpecial; i++) { - positions.push("s"); - } - } - while (positions.length < o.length) { - positions.push("a"); - } - - // shuffle - positions = await this.randomizer.shuffle(positions); - - // build out the char sets - let allCharSet = ""; - - let lowercaseCharSet = "abcdefghijkmnopqrstuvwxyz"; - if (o.ambiguous) { - lowercaseCharSet += "l"; - } - if (o.lowercase) { - allCharSet += lowercaseCharSet; - } - - let uppercaseCharSet = "ABCDEFGHJKLMNPQRSTUVWXYZ"; - if (o.ambiguous) { - uppercaseCharSet += "IO"; - } - if (o.uppercase) { - allCharSet += uppercaseCharSet; - } - - let numberCharSet = "23456789"; - if (o.ambiguous) { - numberCharSet += "01"; - } - if (o.number) { - allCharSet += numberCharSet; - } - - const specialCharSet = "!@#$%^&*"; - if (o.special) { - allCharSet += specialCharSet; - } - - let password = ""; - for (let i = 0; i < o.length; i++) { - let positionChars: string; - switch (positions[i]) { - case "l": - positionChars = lowercaseCharSet; - break; - case "u": - positionChars = uppercaseCharSet; - break; - case "n": - positionChars = numberCharSet; - break; - case "s": - positionChars = specialCharSet; - break; - case "a": - positionChars = allCharSet; - break; - default: - break; - } - - const randomCharIndex = await this.randomizer.uniform(0, positionChars.length - 1); - password += positionChars.charAt(randomCharIndex); - } - - return password; - } -} diff --git a/libs/common/src/tools/generator/policies.ts b/libs/common/src/tools/generator/policies.ts deleted file mode 100644 index 27521f0eebe..00000000000 --- a/libs/common/src/tools/generator/policies.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy"; - -import { PassphraseGeneratorOptionsEvaluator, PassphraseGeneratorPolicy } from "./passphrase"; -import { - DisabledPassphraseGeneratorPolicy, - leastPrivilege as passphraseLeastPrivilege, -} from "./passphrase/passphrase-generator-policy"; -import { PasswordGeneratorOptionsEvaluator, PasswordGeneratorPolicy } from "./password"; -import { - DisabledPasswordGeneratorPolicy, - leastPrivilege as passwordLeastPrivilege, -} from "./password/password-generator-policy"; - -/** Determines how to construct a password generator policy */ -export type PolicyConfiguration = { - /** The value of the policy when it is not in effect. */ - disabledValue: Policy; - - /** Combines multiple policies set by the administrative console into - * a single policy. - */ - combine: (acc: Policy, policy: AdminPolicy) => Policy; - - /** Converts policy service data into an actionable policy. - */ - createEvaluator: (policy: Policy) => Evaluator; -}; - -const PASSPHRASE = Object.freeze({ - disabledValue: DisabledPassphraseGeneratorPolicy, - combine: passphraseLeastPrivilege, - createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), -} as PolicyConfiguration); - -const PASSWORD = Object.freeze({ - disabledValue: DisabledPasswordGeneratorPolicy, - combine: passwordLeastPrivilege, - createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), -} as PolicyConfiguration); - -/** Policy configurations */ -export const Policies = Object.freeze({ - /** Passphrase policy configuration */ - Passphrase: PASSPHRASE, - - /** Passphrase policy configuration */ - Password: PASSWORD, -}); diff --git a/libs/common/src/tools/generator/random.ts b/libs/common/src/tools/generator/random.ts deleted file mode 100644 index 1400ed11758..00000000000 --- a/libs/common/src/tools/generator/random.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; - -import { Randomizer } from "./abstractions/randomizer"; -import { WordOptions } from "./word-options"; - -/** A randomizer backed by a CryptoService. */ -export class CryptoServiceRandomizer implements Randomizer { - constructor(private crypto: CryptoService) {} - - async pick(list: Array) { - const index = await this.uniform(0, list.length - 1); - return list[index]; - } - - async pickWord(list: Array, options?: WordOptions) { - let word = await this.pick(list); - - if (options?.titleCase ?? false) { - word = word.charAt(0).toUpperCase() + word.slice(1); - } - - if (options?.number ?? false) { - const num = await this.crypto.randomNumber(1, 9); - word = word + num.toString(); - } - - return word; - } - - // ref: https://stackoverflow.com/a/12646864/1090359 - async shuffle(items: Array, options?: { copy?: boolean }) { - const shuffled = options?.copy ?? true ? [...items] : items; - - for (let i = shuffled.length - 1; i > 0; i--) { - const j = await this.uniform(0, i); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - - return shuffled; - } - - async chars(length: number) { - let str = ""; - const charSet = "abcdefghijklmnopqrstuvwxyz1234567890"; - for (let i = 0; i < length; i++) { - const randomCharIndex = await this.uniform(0, charSet.length - 1); - str += charSet.charAt(randomCharIndex); - } - return str; - } - - async uniform(min: number, max: number) { - return this.crypto.randomNumber(min, max); - } - - // ref: https://stackoverflow.com/a/10073788 - private zeroPad(number: string, width: number) { - return number.length >= width - ? number - : new Array(width - number.length + 1).join("0") + number; - } -} diff --git a/libs/common/src/tools/generator/rx-operators.ts b/libs/common/src/tools/generator/rx-operators.ts deleted file mode 100644 index 77934953447..00000000000 --- a/libs/common/src/tools/generator/rx-operators.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { map, pipe } from "rxjs"; - -import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx"; - -import { DefaultPolicyEvaluator } from "./default-policy-evaluator"; -import { PolicyConfiguration } from "./policies"; - -/** Maps an administrative console policy to a policy evaluator using the provided configuration. - * @param configuration the configuration that constructs the evaluator. - */ -export function mapPolicyToEvaluator( - configuration: PolicyConfiguration, -) { - return pipe( - reduceCollection(configuration.combine, configuration.disabledValue), - distinctIfShallowMatch(), - map(configuration.createEvaluator), - ); -} - -/** Constructs a method that maps a policy to the default (no-op) policy. */ -export function newDefaultEvaluator() { - return () => { - return pipe(map((_) => new DefaultPolicyEvaluator())); - }; -} diff --git a/libs/common/src/tools/generator/username/catchall-generator-options.ts b/libs/common/src/tools/generator/username/catchall-generator-options.ts deleted file mode 100644 index bddf98f7576..00000000000 --- a/libs/common/src/tools/generator/username/catchall-generator-options.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { RequestOptions } from "./options/forwarder-options"; -import { UsernameGenerationMode } from "./options/generator-options"; - -/** Settings supported when generating an email subaddress */ -export type CatchallGenerationOptions = { - /** selects the generation algorithm for the catchall email address. */ - catchallType?: UsernameGenerationMode; - - /** The domain part of the generated email address. - * @example If the domain is `domain.io` and the generated username - * is `jd`, then the generated email address will be `jd@mydomain.io` - */ - catchallDomain?: string; -} & RequestOptions; - -/** The default options for catchall address generation. */ -export const DefaultCatchallOptions: CatchallGenerationOptions = Object.freeze({ - catchallType: "random", - catchallDomain: "", - website: null, -}); diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts deleted file mode 100644 index 45e87160817..00000000000 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { mock } from "jest-mock-extended"; -import { of, firstValueFrom } from "rxjs"; - -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; -import { StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; -import { Randomizer } from "../abstractions/randomizer"; -import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; -import { CATCHALL_SETTINGS } from "../key-definitions"; - -import { DefaultCatchallOptions } from "./catchall-generator-options"; - -import { CatchallGeneratorStrategy } from "."; - -const SomeUser = "some user" as UserId; -const SomePolicy = mock({ - type: PolicyType.PasswordGenerator, - data: { - minLength: 10, - }, -}); - -describe("Email subaddress list generation strategy", () => { - describe("toEvaluator()", () => { - it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( - "should map any input (= %p) to the default policy evaluator", - async (policies) => { - const strategy = new CatchallGeneratorStrategy(null, null); - - const evaluator$ = of(policies).pipe(strategy.toEvaluator()); - const evaluator = await firstValueFrom(evaluator$); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - }, - ); - }); - - describe("durableState", () => { - it("should use password settings key", () => { - const provider = mock(); - const randomizer = mock(); - const strategy = new CatchallGeneratorStrategy(randomizer, provider); - - strategy.durableState(SomeUser); - - expect(provider.getUser).toHaveBeenCalledWith(SomeUser, CATCHALL_SETTINGS); - }); - }); - - describe("defaults$", () => { - it("should return the default subaddress options", async () => { - const strategy = new CatchallGeneratorStrategy(null, null); - - const result = await firstValueFrom(strategy.defaults$(SomeUser)); - - expect(result).toEqual(DefaultCatchallOptions); - }); - }); - - describe("policy", () => { - it("should use password generator policy", () => { - const randomizer = mock(); - const strategy = new CatchallGeneratorStrategy(randomizer, null); - - expect(strategy.policy).toBe(PolicyType.PasswordGenerator); - }); - }); - - describe("generate()", () => { - it.todo("generate catchall email addresses"); - }); -}); diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts deleted file mode 100644 index fb015a596ff..00000000000 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { PolicyType } from "../../../admin-console/enums"; -import { StateProvider } from "../../../platform/state"; -import { GeneratorStrategy } from "../abstractions"; -import { Randomizer } from "../abstractions/randomizer"; -import { CATCHALL_SETTINGS } from "../key-definitions"; -import { NoPolicy } from "../no-policy"; -import { newDefaultEvaluator } from "../rx-operators"; -import { clone$PerUserId, sharedStateByUserId } from "../util"; - -import { CatchallGenerationOptions, DefaultCatchallOptions } from "./catchall-generator-options"; - -/** Strategy for creating usernames using a catchall email address */ -export class CatchallGeneratorStrategy - implements GeneratorStrategy -{ - /** Instantiates the generation strategy - * @param usernameService generates a catchall address for a domain - */ - constructor( - private random: Randomizer, - private stateProvider: StateProvider, - private defaultOptions: CatchallGenerationOptions = DefaultCatchallOptions, - ) {} - - // configuration - durableState = sharedStateByUserId(CATCHALL_SETTINGS, this.stateProvider); - defaults$ = clone$PerUserId(this.defaultOptions); - toEvaluator = newDefaultEvaluator(); - readonly policy = PolicyType.PasswordGenerator; - - // algorithm - async generate(options: CatchallGenerationOptions) { - const o = Object.assign({}, DefaultCatchallOptions, options); - - if (o.catchallDomain == null || o.catchallDomain === "") { - return null; - } - if (o.catchallType == null) { - o.catchallType = "random"; - } - - let startString = ""; - if (o.catchallType === "random") { - startString = await this.random.chars(8); - } else if (o.catchallType === "website-name") { - startString = o.website; - } - return startString + "@" + o.catchallDomain; - } -} diff --git a/libs/common/src/tools/generator/username/eff-username-generator-options.ts b/libs/common/src/tools/generator/username/eff-username-generator-options.ts deleted file mode 100644 index 07890b3d55e..00000000000 --- a/libs/common/src/tools/generator/username/eff-username-generator-options.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { RequestOptions } from "./options/forwarder-options"; - -/** Settings supported when generating a username using the EFF word list */ -export type EffUsernameGenerationOptions = { - /** when true, the word is capitalized */ - wordCapitalize?: boolean; - - /** when true, a random number is appended to the username */ - wordIncludeNumber?: boolean; -} & RequestOptions; - -/** The default options for EFF long word generation. */ -export const DefaultEffUsernameOptions: EffUsernameGenerationOptions = Object.freeze({ - wordCapitalize: false, - wordIncludeNumber: false, - website: null, -}); diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts deleted file mode 100644 index 128b69e6734..00000000000 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { mock } from "jest-mock-extended"; -import { of, firstValueFrom } from "rxjs"; - -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; -import { StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; -import { Randomizer } from "../abstractions/randomizer"; -import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; -import { EFF_USERNAME_SETTINGS } from "../key-definitions"; - -import { DefaultEffUsernameOptions } from "./eff-username-generator-options"; - -import { EffUsernameGeneratorStrategy } from "."; - -const SomeUser = "some user" as UserId; -const SomePolicy = mock({ - type: PolicyType.PasswordGenerator, - data: { - minLength: 10, - }, -}); - -describe("EFF long word list generation strategy", () => { - describe("toEvaluator()", () => { - it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( - "should map any input (= %p) to the default policy evaluator", - async (policies) => { - const strategy = new EffUsernameGeneratorStrategy(null, null); - - const evaluator$ = of(policies).pipe(strategy.toEvaluator()); - const evaluator = await firstValueFrom(evaluator$); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - }, - ); - }); - - describe("durableState", () => { - it("should use password settings key", () => { - const provider = mock(); - const randomizer = mock(); - const strategy = new EffUsernameGeneratorStrategy(randomizer, provider); - - strategy.durableState(SomeUser); - - expect(provider.getUser).toHaveBeenCalledWith(SomeUser, EFF_USERNAME_SETTINGS); - }); - }); - - describe("defaults$", () => { - it("should return the default subaddress options", async () => { - const strategy = new EffUsernameGeneratorStrategy(null, null); - - const result = await firstValueFrom(strategy.defaults$(SomeUser)); - - expect(result).toEqual(DefaultEffUsernameOptions); - }); - }); - - describe("policy", () => { - it("should use password generator policy", () => { - const randomizer = mock(); - const strategy = new EffUsernameGeneratorStrategy(randomizer, null); - - expect(strategy.policy).toBe(PolicyType.PasswordGenerator); - }); - }); - - describe("generate()", () => { - it.todo("generate username tests"); - }); -}); diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts deleted file mode 100644 index abd8e6b226c..00000000000 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; - -import { PolicyType } from "../../../admin-console/enums"; -import { StateProvider } from "../../../platform/state"; -import { GeneratorStrategy } from "../abstractions"; -import { Randomizer } from "../abstractions/randomizer"; -import { EFF_USERNAME_SETTINGS } from "../key-definitions"; -import { NoPolicy } from "../no-policy"; -import { newDefaultEvaluator } from "../rx-operators"; -import { clone$PerUserId, sharedStateByUserId } from "../util"; - -import { - DefaultEffUsernameOptions, - EffUsernameGenerationOptions, -} from "./eff-username-generator-options"; - -/** Strategy for creating usernames from the EFF wordlist */ -export class EffUsernameGeneratorStrategy - implements GeneratorStrategy -{ - /** Instantiates the generation strategy - * @param usernameService generates a username from EFF word list - */ - constructor( - private random: Randomizer, - private stateProvider: StateProvider, - private defaultOptions: EffUsernameGenerationOptions = DefaultEffUsernameOptions, - ) {} - - // configuration - durableState = sharedStateByUserId(EFF_USERNAME_SETTINGS, this.stateProvider); - defaults$ = clone$PerUserId(this.defaultOptions); - toEvaluator = newDefaultEvaluator(); - readonly policy = PolicyType.PasswordGenerator; - - // algorithm - async generate(options: EffUsernameGenerationOptions) { - const word = await this.random.pickWord(EFFLongWordList, { - titleCase: options.wordCapitalize ?? DefaultEffUsernameOptions.wordCapitalize, - number: options.wordIncludeNumber ?? DefaultEffUsernameOptions.wordIncludeNumber, - }); - return word; - } -} diff --git a/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts deleted file mode 100644 index c50326e1fa4..00000000000 --- a/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; - -import { Forwarder } from "./forwarder"; -import { ForwarderOptions } from "./forwarder-options"; - -export class AnonAddyForwarder implements Forwarder { - async generate(apiService: ApiService, options: ForwarderOptions): Promise { - if (options.apiKey == null || options.apiKey === "") { - throw "Invalid addy.io API token."; - } - if (options.anonaddy?.domain == null || options.anonaddy.domain === "") { - throw "Invalid addy.io domain."; - } - if (options.anonaddy?.baseUrl == null || options.anonaddy.baseUrl === "") { - throw "Invalid addy.io url."; - } - - const requestInit: RequestInit = { - redirect: "manual", - cache: "no-store", - method: "POST", - headers: new Headers({ - Authorization: "Bearer " + options.apiKey, - "Content-Type": "application/json", - "X-Requested-With": "XMLHttpRequest", - }), - }; - const url = options.anonaddy.baseUrl + "/api/v1/aliases"; - requestInit.body = JSON.stringify({ - domain: options.anonaddy.domain, - description: - (options.website != null ? "Website: " + options.website + ". " : "") + - "Generated by Bitwarden.", - }); - const request = new Request(url, requestInit); - const response = await apiService.nativeFetch(request); - if (response.status === 200 || response.status === 201) { - const json = await response.json(); - return json?.data?.email; - } - if (response.status === 401) { - throw "Invalid addy.io API token."; - } - if (response?.statusText != null) { - throw "addy.io error:\n" + response.statusText; - } - throw "Unknown addy.io error occurred."; - } -} diff --git a/libs/common/src/tools/generator/username/email-forwarders/duck-duck-go-forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/duck-duck-go-forwarder.ts deleted file mode 100644 index 45e4f8f3a40..00000000000 --- a/libs/common/src/tools/generator/username/email-forwarders/duck-duck-go-forwarder.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; - -import { Forwarder } from "./forwarder"; -import { ForwarderOptions } from "./forwarder-options"; - -export class DuckDuckGoForwarder implements Forwarder { - async generate(apiService: ApiService, options: ForwarderOptions): Promise { - if (options.apiKey == null || options.apiKey === "") { - throw "Invalid DuckDuckGo API token."; - } - const requestInit: RequestInit = { - redirect: "manual", - cache: "no-store", - method: "POST", - headers: new Headers({ - Authorization: "Bearer " + options.apiKey, - "Content-Type": "application/json", - }), - }; - const url = "https://quack.duckduckgo.com/api/email/addresses"; - const request = new Request(url, requestInit); - const response = await apiService.nativeFetch(request); - if (response.status === 200 || response.status === 201) { - const json = await response.json(); - if (json.address) { - return `${json.address}@duck.com`; - } - } else if (response.status === 401) { - throw "Invalid DuckDuckGo API token."; - } - throw "Unknown DuckDuckGo error occurred."; - } -} diff --git a/libs/common/src/tools/generator/username/email-forwarders/fastmail-forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/fastmail-forwarder.ts deleted file mode 100644 index 09e518c3d70..00000000000 --- a/libs/common/src/tools/generator/username/email-forwarders/fastmail-forwarder.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; - -import { Forwarder } from "./forwarder"; -import { ForwarderOptions } from "./forwarder-options"; - -export class FastmailForwarder implements Forwarder { - async generate(apiService: ApiService, options: ForwarderOptions): Promise { - if (options.apiKey == null || options.apiKey === "") { - throw "Invalid Fastmail API token."; - } - - const accountId = await this.getAccountId(apiService, options); - if (accountId == null || accountId === "") { - throw "Unable to obtain Fastmail masked email account ID."; - } - - const forDomain = options.website || ""; - - const requestInit: RequestInit = { - redirect: "manual", - cache: "no-store", - method: "POST", - headers: new Headers({ - Authorization: "Bearer " + options.apiKey, - "Content-Type": "application/json", - }), - }; - const url = "https://api.fastmail.com/jmap/api/"; - requestInit.body = JSON.stringify({ - using: ["https://www.fastmail.com/dev/maskedemail", "urn:ietf:params:jmap:core"], - methodCalls: [ - [ - "MaskedEmail/set", - { - accountId: accountId, - create: { - "new-masked-email": { - state: "enabled", - description: "", - forDomain: forDomain, - emailPrefix: options.fastmail.prefix, - }, - }, - }, - "0", - ], - ], - }); - const request = new Request(url, requestInit); - const response = await apiService.nativeFetch(request); - if (response.status === 200) { - const json = await response.json(); - if ( - json.methodResponses != null && - json.methodResponses.length > 0 && - json.methodResponses[0].length > 0 - ) { - if (json.methodResponses[0][0] === "MaskedEmail/set") { - if (json.methodResponses[0][1]?.created?.["new-masked-email"] != null) { - return json.methodResponses[0][1]?.created?.["new-masked-email"]?.email; - } - if (json.methodResponses[0][1]?.notCreated?.["new-masked-email"] != null) { - throw ( - "Fastmail error: " + - json.methodResponses[0][1]?.notCreated?.["new-masked-email"]?.description - ); - } - } else if (json.methodResponses[0][0] === "error") { - throw "Fastmail error: " + json.methodResponses[0][1]?.description; - } - } - } - if (response.status === 401 || response.status === 403) { - throw "Invalid Fastmail API token."; - } - throw "Unknown Fastmail error occurred."; - } - - private async getAccountId(apiService: ApiService, options: ForwarderOptions): Promise { - const requestInit: RequestInit = { - cache: "no-store", - method: "GET", - headers: new Headers({ - Authorization: "Bearer " + options.apiKey, - }), - }; - const url = "https://api.fastmail.com/.well-known/jmap"; - const request = new Request(url, requestInit); - const response = await apiService.nativeFetch(request); - if (response.status === 200) { - const json = await response.json(); - if (json.primaryAccounts != null) { - return json.primaryAccounts["https://www.fastmail.com/dev/maskedemail"]; - } - } - return null; - } -} diff --git a/libs/common/src/tools/generator/username/email-forwarders/firefox-relay-forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/firefox-relay-forwarder.ts deleted file mode 100644 index b15a912eabc..00000000000 --- a/libs/common/src/tools/generator/username/email-forwarders/firefox-relay-forwarder.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; - -import { Forwarder } from "./forwarder"; -import { ForwarderOptions } from "./forwarder-options"; - -export class FirefoxRelayForwarder implements Forwarder { - async generate(apiService: ApiService, options: ForwarderOptions): Promise { - if (options.apiKey == null || options.apiKey === "") { - throw "Invalid Firefox Relay API token."; - } - const requestInit: RequestInit = { - redirect: "manual", - cache: "no-store", - method: "POST", - headers: new Headers({ - Authorization: "Token " + options.apiKey, - "Content-Type": "application/json", - }), - }; - const url = "https://relay.firefox.com/api/v1/relayaddresses/"; - requestInit.body = JSON.stringify({ - enabled: true, - generated_for: options.website, - description: - (options.website != null ? options.website + " - " : "") + "Generated by Bitwarden.", - }); - const request = new Request(url, requestInit); - const response = await apiService.nativeFetch(request); - if (response.status === 200 || response.status === 201) { - const json = await response.json(); - return json?.full_address; - } - if (response.status === 401) { - throw "Invalid Firefox Relay API token."; - } - throw "Unknown Firefox Relay error occurred."; - } -} diff --git a/libs/common/src/tools/generator/username/email-forwarders/forward-email-forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/forward-email-forwarder.ts deleted file mode 100644 index 98801c9e3da..00000000000 --- a/libs/common/src/tools/generator/username/email-forwarders/forward-email-forwarder.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; -import { Utils } from "../../../../platform/misc/utils"; - -import { Forwarder } from "./forwarder"; -import { ForwarderOptions } from "./forwarder-options"; - -export class ForwardEmailForwarder implements Forwarder { - async generate(apiService: ApiService, options: ForwarderOptions): Promise { - if (options.apiKey == null || options.apiKey === "") { - throw "Invalid Forward Email API key."; - } - if (options.forwardemail?.domain == null || options.forwardemail.domain === "") { - throw "Invalid Forward Email domain."; - } - const requestInit: RequestInit = { - redirect: "manual", - cache: "no-store", - method: "POST", - headers: new Headers({ - Authorization: "Basic " + Utils.fromUtf8ToB64(options.apiKey + ":"), - "Content-Type": "application/json", - }), - }; - const url = `https://api.forwardemail.net/v1/domains/${options.forwardemail.domain}/aliases`; - requestInit.body = JSON.stringify({ - labels: options.website, - description: - (options.website != null ? "Website: " + options.website + ". " : "") + - "Generated by Bitwarden.", - }); - const request = new Request(url, requestInit); - const response = await apiService.nativeFetch(request); - if (response.status === 200 || response.status === 201) { - const json = await response.json(); - return json?.name + "@" + (json?.domain?.name || options.forwardemail.domain); - } - if (response.status === 401) { - throw "Invalid Forward Email API key."; - } - const json = await response.json(); - if (json?.message != null) { - throw "Forward Email error:\n" + json.message; - } - if (json?.error != null) { - throw "Forward Email error:\n" + json.error; - } - throw "Unknown Forward Email error occurred."; - } -} diff --git a/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts b/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts deleted file mode 100644 index 00d1717bf60..00000000000 --- a/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts +++ /dev/null @@ -1,25 +0,0 @@ -export class ForwarderOptions { - apiKey: string; - website: string; - fastmail = new FastmailForwarderOptions(); - anonaddy = new AnonAddyForwarderOptions(); - forwardemail = new ForwardEmailForwarderOptions(); - simplelogin = new SimpleLoginForwarderOptions(); -} - -export class FastmailForwarderOptions { - prefix: string; -} - -export class AnonAddyForwarderOptions { - domain: string; - baseUrl: string; -} - -export class ForwardEmailForwarderOptions { - domain: string; -} - -export class SimpleLoginForwarderOptions { - baseUrl: string; -} diff --git a/libs/common/src/tools/generator/username/email-forwarders/forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/forwarder.ts deleted file mode 100644 index e51fb35c3d9..00000000000 --- a/libs/common/src/tools/generator/username/email-forwarders/forwarder.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; - -import { ForwarderOptions } from "./forwarder-options"; - -export interface Forwarder { - generate(apiService: ApiService, options: ForwarderOptions): Promise; -} diff --git a/libs/common/src/tools/generator/username/email-forwarders/index.ts b/libs/common/src/tools/generator/username/email-forwarders/index.ts deleted file mode 100644 index d102cc236ee..00000000000 --- a/libs/common/src/tools/generator/username/email-forwarders/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { AnonAddyForwarder } from "./anon-addy-forwarder"; -export { DuckDuckGoForwarder } from "./duck-duck-go-forwarder"; -export { FastmailForwarder } from "./fastmail-forwarder"; -export { FirefoxRelayForwarder } from "./firefox-relay-forwarder"; -export { Forwarder } from "./forwarder"; -export { ForwarderOptions } from "./forwarder-options"; -export { SimpleLoginForwarder } from "./simple-login-forwarder"; -export { ForwardEmailForwarder } from "./forward-email-forwarder"; diff --git a/libs/common/src/tools/generator/username/email-forwarders/simple-login-forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/simple-login-forwarder.ts deleted file mode 100644 index 4d5b7749d49..00000000000 --- a/libs/common/src/tools/generator/username/email-forwarders/simple-login-forwarder.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; - -import { Forwarder } from "./forwarder"; -import { ForwarderOptions } from "./forwarder-options"; - -export class SimpleLoginForwarder implements Forwarder { - async generate(apiService: ApiService, options: ForwarderOptions): Promise { - if (options.apiKey == null || options.apiKey === "") { - throw "Invalid SimpleLogin API key."; - } - const requestInit: RequestInit = { - redirect: "manual", - cache: "no-store", - method: "POST", - headers: new Headers({ - Authentication: options.apiKey, - "Content-Type": "application/json", - }), - }; - let url = options.simplelogin.baseUrl + "/api/alias/random/new"; - if (options.website != null) { - url += "?hostname=" + options.website; - } - requestInit.body = JSON.stringify({ - note: - (options.website != null ? "Website: " + options.website + ". " : "") + - "Generated by Bitwarden.", - }); - const request = new Request(url, requestInit); - const response = await apiService.nativeFetch(request); - if (response.status === 200 || response.status === 201) { - const json = await response.json(); - return json.alias; - } - if (response.status === 401) { - throw "Invalid SimpleLogin API key."; - } - const json = await response.json(); - if (json?.error != null) { - throw "SimpleLogin error:" + json.error; - } - throw "Unknown SimpleLogin error occurred."; - } -} diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts deleted file mode 100644 index e78b432bfb6..00000000000 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { mock } from "jest-mock-extended"; -import { of, firstValueFrom } from "rxjs"; - -import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; -import { CryptoService } from "../../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../../platform/abstractions/encrypt.service"; -import { StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; -import { UserKey } from "../../../types/key"; -import { BufferedState } from "../../state/buffered-state"; -import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; -import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../key-definitions"; - -import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy"; -import { DefaultDuckDuckGoOptions } from "./forwarders/duck-duck-go"; -import { ApiOptions } from "./options/forwarder-options"; - -class TestForwarder extends ForwarderGeneratorStrategy { - constructor( - encryptService: EncryptService, - keyService: CryptoService, - stateProvider: StateProvider, - ) { - super(encryptService, keyService, stateProvider, { website: null, token: "" }); - } - - get key() { - // arbitrary. - return DUCK_DUCK_GO_FORWARDER; - } - - get rolloverKey() { - return DUCK_DUCK_GO_BUFFER; - } - - defaults$ = (userId: UserId) => { - return of(DefaultDuckDuckGoOptions); - }; -} - -const SomeUser = "some user" as UserId; -const AnotherUser = "another user" as UserId; -const SomePolicy = mock({ - type: PolicyType.PasswordGenerator, - data: { - minLength: 10, - }, -}); - -describe("ForwarderGeneratorStrategy", () => { - const encryptService = mock(); - const keyService = mock(); - const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); - - beforeEach(() => { - const keyAvailable = of({} as UserKey); - keyService.getInMemoryUserKeyFor$.mockReturnValue(keyAvailable); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe("durableState", () => { - it("constructs a secret state", () => { - const strategy = new TestForwarder(encryptService, keyService, stateProvider); - - const result = strategy.durableState(SomeUser); - - expect(result).toBeInstanceOf(BufferedState); - }); - - it("returns the same secret state for a single user", () => { - const strategy = new TestForwarder(encryptService, keyService, stateProvider); - - const firstResult = strategy.durableState(SomeUser); - const secondResult = strategy.durableState(SomeUser); - - expect(firstResult).toBe(secondResult); - }); - - it("returns a different secret state for a different user", () => { - const strategy = new TestForwarder(encryptService, keyService, stateProvider); - - const firstResult = strategy.durableState(SomeUser); - const secondResult = strategy.durableState(AnotherUser); - - expect(firstResult).not.toBe(secondResult); - }); - }); - - describe("toEvaluator()", () => { - it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( - "should map any input (= %p) to the default policy evaluator", - async (policies) => { - const strategy = new TestForwarder(encryptService, keyService, stateProvider); - - const evaluator$ = of(policies).pipe(strategy.toEvaluator()); - const evaluator = await firstValueFrom(evaluator$); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - }, - ); - }); -}); diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts deleted file mode 100644 index 4655a3fb72a..00000000000 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { map } from "rxjs"; - -import { PolicyType } from "../../../admin-console/enums"; -import { CryptoService } from "../../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../../platform/abstractions/encrypt.service"; -import { SingleUserState, StateProvider, UserKeyDefinition } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; -import { BufferedKeyDefinition } from "../../state/buffered-key-definition"; -import { BufferedState } from "../../state/buffered-state"; -import { PaddedDataPacker } from "../../state/padded-data-packer"; -import { SecretClassifier } from "../../state/secret-classifier"; -import { SecretKeyDefinition } from "../../state/secret-key-definition"; -import { SecretState } from "../../state/secret-state"; -import { UserKeyEncryptor } from "../../state/user-key-encryptor"; -import { GeneratorStrategy } from "../abstractions"; -import { NoPolicy } from "../no-policy"; -import { newDefaultEvaluator } from "../rx-operators"; -import { clone$PerUserId, sharedByUserId } from "../util"; - -import { ApiOptions } from "./options/forwarder-options"; - -const OPTIONS_FRAME_SIZE = 512; - -/** An email forwarding service configurable through an API. */ -export abstract class ForwarderGeneratorStrategy< - Options extends ApiOptions, -> extends GeneratorStrategy { - /** Initializes the generator strategy - * @param encryptService protects sensitive forwarder options - * @param keyService looks up the user key when protecting data. - * @param stateProvider creates the durable state for options storage - */ - constructor( - private readonly encryptService: EncryptService, - private readonly keyService: CryptoService, - private stateProvider: StateProvider, - private readonly defaultOptions: Options, - ) { - super(); - } - - /** configures forwarder secret storage */ - protected abstract readonly key: UserKeyDefinition; - - /** configures forwarder import buffer */ - protected abstract readonly rolloverKey: BufferedKeyDefinition; - - // configuration - readonly policy = PolicyType.PasswordGenerator; - defaults$ = clone$PerUserId(this.defaultOptions); - toEvaluator = newDefaultEvaluator(); - durableState = sharedByUserId((userId) => this.getUserSecrets(userId)); - - // per-user encrypted state - private getUserSecrets(userId: UserId): SingleUserState { - // construct the encryptor - const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); - const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); - - // always exclude request properties - const classifier = SecretClassifier.allSecret().exclude("website"); - - // Derive the secret key definition - const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, { - deserializer: (d) => this.key.deserializer(d), - cleanupDelayMs: this.key.cleanupDelayMs, - clearOn: this.key.clearOn, - }); - - // the type parameter is explicit because type inference fails for `Omit` - const secretState = SecretState.from< - Options, - void, - Options, - Record, - Omit - >(userId, key, this.stateProvider, encryptor); - - // rollover should occur once the user key is available for decryption - const canDecrypt$ = this.keyService - .getInMemoryUserKeyFor$(userId) - .pipe(map((key) => key !== null)); - const rolloverState = new BufferedState( - this.stateProvider, - this.rolloverKey, - secretState, - canDecrypt$, - ); - - return rolloverState; - } -} diff --git a/libs/common/src/tools/generator/username/forwarders/addy-io.spec.ts b/libs/common/src/tools/generator/username/forwarders/addy-io.spec.ts deleted file mode 100644 index f42ca23c113..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/addy-io.spec.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * include Request in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ -import { firstValueFrom } from "rxjs"; - -import { UserId } from "../../../../types/guid"; -import { ADDY_IO_FORWARDER } from "../../key-definitions"; -import { Forwarders } from "../options/constants"; - -import { AddyIoForwarder, DefaultAddyIoOptions } from "./addy-io"; -import { mockApiService, mockI18nService } from "./mocks.jest"; - -const SomeUser = "some user" as UserId; - -describe("Addy.io Forwarder", () => { - it("key returns the Addy IO forwarder key", () => { - const forwarder = new AddyIoForwarder(null, null, null, null, null); - - expect(forwarder.key).toBe(ADDY_IO_FORWARDER); - }); - - describe("defaults$", () => { - it("should return the default subaddress options", async () => { - const strategy = new AddyIoForwarder(null, null, null, null, null); - - const result = await firstValueFrom(strategy.defaults$(SomeUser)); - - expect(result).toEqual(DefaultAddyIoOptions); - }); - }); - - describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { - it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token, - domain: "example.com", - baseUrl: "https://api.example.com", - }), - ).rejects.toEqual("forwaderInvalidToken"); - - expect(apiService.nativeFetch).not.toHaveBeenCalled(); - expect(i18nService.t).toHaveBeenCalledWith("forwaderInvalidToken", Forwarders.AddyIo.name); - }); - - it.each([null, ""])( - "throws an error if the domain is missing (domain = %p)", - async (domain) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain, - baseUrl: "https://api.example.com", - }), - ).rejects.toEqual("forwarderNoDomain"); - - expect(apiService.nativeFetch).not.toHaveBeenCalled(); - expect(i18nService.t).toHaveBeenCalledWith("forwarderNoDomain", Forwarders.AddyIo.name); - }, - ); - - it.each([null, ""])( - "throws an error if the baseUrl is missing (baseUrl = %p)", - async (baseUrl) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - baseUrl, - }), - ).rejects.toEqual("forwarderNoUrl"); - - expect(apiService.nativeFetch).not.toHaveBeenCalled(); - expect(i18nService.t).toHaveBeenCalledWith("forwarderNoUrl", Forwarders.AddyIo.name); - }, - ); - - it.each([ - ["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"], - ["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"], - ["forwarderGeneratedBy", "not provided", null, ""], - ["forwarderGeneratedBy", "not provided", "", ""], - ])( - "describes the website with %p when the website is %s (= %p)", - async (translationKey, _ignored, website, expectedWebsite) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); - - await forwarder.generate({ - website, - token: "token", - domain: "example.com", - baseUrl: "https://api.example.com", - }); - - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite); - }, - ); - - it.each([ - ["jane.doe@example.com", 201], - ["john.doe@example.com", 201], - ["jane.doe@example.com", 200], - ["john.doe@example.com", 200], - ])( - "returns the generated email address (= %p) if the request is successful (status = %p)", - async (email, status) => { - const apiService = mockApiService(status, { data: { email } }); - const i18nService = mockI18nService(); - - const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); - - const result = await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - baseUrl: "https://api.example.com", - }); - - expect(result).toEqual(email); - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - }, - ); - - it("throws an invalid token error if the request fails with a 401", async () => { - const apiService = mockApiService(401, {}); - const i18nService = mockI18nService(); - - const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - baseUrl: "https://api.example.com", - }), - ).rejects.toEqual("forwaderInvalidToken"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwaderInvalidToken", - Forwarders.AddyIo.name, - ); - }); - - it("throws an unknown error if the request fails and no status is provided", async () => { - const apiService = mockApiService(500, {}); - const i18nService = mockI18nService(); - - const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - baseUrl: "https://api.example.com", - }), - ).rejects.toEqual("forwarderUnknownError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwarderUnknownError", - Forwarders.AddyIo.name, - ); - }); - - it.each([ - [100, "Continue"], - [202, "Accepted"], - [300, "Multiple Choices"], - [418, "I'm a teapot"], - [500, "Internal Server Error"], - [600, "Unknown Status"], - ])( - "throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided", - async (statusCode, statusText) => { - const apiService = mockApiService(statusCode, {}, statusText); - const i18nService = mockI18nService(); - - const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - baseUrl: "https://api.example.com", - }), - ).rejects.toEqual("forwarderError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwarderError", - Forwarders.AddyIo.name, - statusText, - ); - }, - ); - }); -}); diff --git a/libs/common/src/tools/generator/username/forwarders/addy-io.ts b/libs/common/src/tools/generator/username/forwarders/addy-io.ts deleted file mode 100644 index ecf60da195c..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/addy-io.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; -import { CryptoService } from "../../../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; -import { I18nService } from "../../../../platform/abstractions/i18n.service"; -import { StateProvider } from "../../../../platform/state"; -import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../../key-definitions"; -import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; -import { Forwarders } from "../options/constants"; -import { EmailDomainOptions, SelfHostedApiOptions } from "../options/forwarder-options"; - -export const DefaultAddyIoOptions: SelfHostedApiOptions & EmailDomainOptions = Object.freeze({ - website: null, - baseUrl: "https://app.addy.io", - token: "", - domain: "", -}); - -/** Generates a forwarding address for addy.io (formerly anon addy) */ -export class AddyIoForwarder extends ForwarderGeneratorStrategy< - SelfHostedApiOptions & EmailDomainOptions -> { - /** Instantiates the forwarder - * @param apiService used for ajax requests to the forwarding service - * @param i18nService used to look up error strings - * @param encryptService protects sensitive forwarder options - * @param keyService looks up the user key when protecting data. - * @param stateProvider creates the durable state for options storage - */ - constructor( - private apiService: ApiService, - private i18nService: I18nService, - encryptService: EncryptService, - keyService: CryptoService, - stateProvider: StateProvider, - ) { - super(encryptService, keyService, stateProvider, DefaultAddyIoOptions); - } - - // configuration - readonly key = ADDY_IO_FORWARDER; - readonly rolloverKey = ADDY_IO_BUFFER; - - // request - generate = async (options: SelfHostedApiOptions & EmailDomainOptions) => { - if (!options.token || options.token === "") { - const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name); - throw error; - } - if (!options.domain || options.domain === "") { - const error = this.i18nService.t("forwarderNoDomain", Forwarders.AddyIo.name); - throw error; - } - if (!options.baseUrl || options.baseUrl === "") { - const error = this.i18nService.t("forwarderNoUrl", Forwarders.AddyIo.name); - throw error; - } - - let descriptionId = "forwarderGeneratedByWithWebsite"; - if (!options.website || options.website === "") { - descriptionId = "forwarderGeneratedBy"; - } - const description = this.i18nService.t(descriptionId, options.website ?? ""); - - const url = options.baseUrl + "/api/v1/aliases"; - const request = new Request(url, { - redirect: "manual", - cache: "no-store", - method: "POST", - headers: new Headers({ - Authorization: "Bearer " + options.token, - "Content-Type": "application/json", - "X-Requested-With": "XMLHttpRequest", - }), - body: JSON.stringify({ - domain: options.domain, - description, - }), - }); - - const response = await this.apiService.nativeFetch(request); - if (response.status === 200 || response.status === 201) { - const json = await response.json(); - return json?.data?.email; - } else if (response.status === 401) { - const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name); - throw error; - } else if (response?.statusText) { - const error = this.i18nService.t( - "forwarderError", - Forwarders.AddyIo.name, - response.statusText, - ); - throw error; - } else { - const error = this.i18nService.t("forwarderUnknownError", Forwarders.AddyIo.name); - throw error; - } - }; -} - -export const DefaultOptions = Object.freeze({ - website: null, - baseUrl: "https://app.addy.io", - domain: "", - token: "", -}); diff --git a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.spec.ts b/libs/common/src/tools/generator/username/forwarders/duck-duck-go.spec.ts deleted file mode 100644 index b836ca2bef7..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * include Request in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ -import { firstValueFrom } from "rxjs"; - -import { UserId } from "../../../../types/guid"; -import { DUCK_DUCK_GO_FORWARDER } from "../../key-definitions"; -import { Forwarders } from "../options/constants"; - -import { DuckDuckGoForwarder, DefaultDuckDuckGoOptions } from "./duck-duck-go"; -import { mockApiService, mockI18nService } from "./mocks.jest"; - -const SomeUser = "some user" as UserId; - -describe("DuckDuckGo Forwarder", () => { - it("key returns the Duck Duck Go forwarder key", () => { - const forwarder = new DuckDuckGoForwarder(null, null, null, null, null); - - expect(forwarder.key).toBe(DUCK_DUCK_GO_FORWARDER); - }); - - describe("defaults$", () => { - it("should return the default subaddress options", async () => { - const strategy = new DuckDuckGoForwarder(null, null, null, null, null); - - const result = await firstValueFrom(strategy.defaults$(SomeUser)); - - expect(result).toEqual(DefaultDuckDuckGoOptions); - }); - }); - - describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { - it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token, - }), - ).rejects.toEqual("forwaderInvalidToken"); - - expect(apiService.nativeFetch).not.toHaveBeenCalled(); - expect(i18nService.t).toHaveBeenCalledWith( - "forwaderInvalidToken", - Forwarders.DuckDuckGo.name, - ); - }); - - it.each([ - ["jane.doe@duck.com", 201, "jane.doe"], - ["john.doe@duck.com", 201, "john.doe"], - ["jane.doe@duck.com", 200, "jane.doe"], - ["john.doe@duck.com", 200, "john.doe"], - ])( - "returns the generated email address (= %p) if the request is successful (status = %p)", - async (email, status, address) => { - const apiService = mockApiService(status, { address }); - const i18nService = mockI18nService(); - - const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null); - - const result = await forwarder.generate({ - website: null, - token: "token", - }); - - expect(result).toEqual(email); - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - }, - ); - - it("throws an invalid token error if the request fails with a 401", async () => { - const apiService = mockApiService(401, {}); - const i18nService = mockI18nService(); - - const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - }), - ).rejects.toEqual("forwaderInvalidToken"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenCalledWith( - "forwaderInvalidToken", - Forwarders.DuckDuckGo.name, - ); - }); - - it("throws an unknown error if the request is successful but an address isn't present", async () => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - }), - ).rejects.toEqual("forwarderUnknownError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenCalledWith( - "forwarderUnknownError", - Forwarders.DuckDuckGo.name, - ); - }); - - it.each([100, 202, 300, 418, 500, 600])( - "throws an unknown error if the request returns any other status code (= %i)", - async (statusCode) => { - const apiService = mockApiService(statusCode, {}); - const i18nService = mockI18nService(); - - const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - }), - ).rejects.toEqual("forwarderUnknownError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenCalledWith( - "forwarderUnknownError", - Forwarders.DuckDuckGo.name, - ); - }, - ); - }); -}); diff --git a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts b/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts deleted file mode 100644 index 492105dfdfd..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; -import { CryptoService } from "../../../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; -import { I18nService } from "../../../../platform/abstractions/i18n.service"; -import { StateProvider } from "../../../../platform/state"; -import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../../key-definitions"; -import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; -import { Forwarders } from "../options/constants"; -import { ApiOptions } from "../options/forwarder-options"; - -export const DefaultDuckDuckGoOptions: ApiOptions = Object.freeze({ - website: null, - token: "", -}); - -/** Generates a forwarding address for DuckDuckGo */ -export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy { - /** Instantiates the forwarder - * @param apiService used for ajax requests to the forwarding service - * @param i18nService used to look up error strings - * @param encryptService protects sensitive forwarder options - * @param keyService looks up the user key when protecting data. - * @param stateProvider creates the durable state for options storage - */ - constructor( - private apiService: ApiService, - private i18nService: I18nService, - encryptService: EncryptService, - keyService: CryptoService, - stateProvider: StateProvider, - ) { - super(encryptService, keyService, stateProvider, DefaultDuckDuckGoOptions); - } - - // configuration - readonly key = DUCK_DUCK_GO_FORWARDER; - readonly rolloverKey = DUCK_DUCK_GO_BUFFER; - - // request - generate = async (options: ApiOptions): Promise => { - if (!options.token || options.token === "") { - const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name); - throw error; - } - - const url = "https://quack.duckduckgo.com/api/email/addresses"; - const request = new Request(url, { - redirect: "manual", - cache: "no-store", - method: "POST", - headers: new Headers({ - Authorization: "Bearer " + options.token, - "Content-Type": "application/json", - }), - }); - - const response = await this.apiService.nativeFetch(request); - if (response.status === 200 || response.status === 201) { - const json = await response.json(); - if (json.address) { - return `${json.address}@duck.com`; - } else { - const error = this.i18nService.t("forwarderUnknownError", Forwarders.DuckDuckGo.name); - throw error; - } - } else if (response.status === 401) { - const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name); - throw error; - } else { - const error = this.i18nService.t("forwarderUnknownError", Forwarders.DuckDuckGo.name); - throw error; - } - }; -} - -export const DefaultOptions = Object.freeze({ - website: null, - token: "", -}); diff --git a/libs/common/src/tools/generator/username/forwarders/fastmail.spec.ts b/libs/common/src/tools/generator/username/forwarders/fastmail.spec.ts deleted file mode 100644 index 895f32f7eeb..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/fastmail.spec.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * include Request in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ -import { firstValueFrom } from "rxjs"; - -import { ApiService } from "../../../../abstractions/api.service"; -import { UserId } from "../../../../types/guid"; -import { FASTMAIL_FORWARDER } from "../../key-definitions"; -import { Forwarders } from "../options/constants"; - -import { FastmailForwarder, DefaultFastmailOptions } from "./fastmail"; -import { mockI18nService } from "./mocks.jest"; - -const SomeUser = "some user" as UserId; - -type MockResponse = { status: number; body: any }; - -// fastmail calls nativeFetch first to resolve the accountId, -// then it calls nativeFetch again to create the forwarding address. -// The common mock doesn't work here, because this test needs to return multiple responses -function mockApiService(accountId: MockResponse, forwardingAddress: MockResponse) { - function response(r: MockResponse) { - return { - status: r.status, - json: jest.fn().mockImplementation(() => Promise.resolve(r.body)), - }; - } - - return { - nativeFetch: jest - .fn() - .mockImplementationOnce((r: Request) => response(accountId)) - .mockImplementationOnce((r: Request) => response(forwardingAddress)), - } as unknown as ApiService; -} - -const EmptyResponse: MockResponse = Object.freeze({ - status: 200, - body: Object.freeze({}), -}); - -const AccountIdSuccess: MockResponse = Object.freeze({ - status: 200, - body: Object.freeze({ - primaryAccounts: Object.freeze({ - "https://www.fastmail.com/dev/maskedemail": "accountId", - }), - }), -}); - -// the tests -describe("Fastmail Forwarder", () => { - it("key returns the Fastmail forwarder key", () => { - const forwarder = new FastmailForwarder(null, null, null, null, null); - - expect(forwarder.key).toBe(FASTMAIL_FORWARDER); - }); - - describe("defaults$", () => { - it("should return the default subaddress options", async () => { - const strategy = new FastmailForwarder(null, null, null, null, null); - - const result = await firstValueFrom(strategy.defaults$(SomeUser)); - - expect(result).toEqual(DefaultFastmailOptions); - }); - }); - - describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { - it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { - const apiService = mockApiService(AccountIdSuccess, EmptyResponse); - const i18nService = mockI18nService(); - - const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token, - domain: "example.com", - prefix: "prefix", - }), - ).rejects.toEqual("forwaderInvalidToken"); - - expect(apiService.nativeFetch).not.toHaveBeenCalled(); - expect(i18nService.t).toHaveBeenCalledWith("forwaderInvalidToken", Forwarders.Fastmail.name); - }); - - it.each([401, 403])( - "throws a no account id error if the accountId request responds with a status other than 200", - async (status) => { - const apiService = mockApiService({ status, body: {} }, EmptyResponse); - const i18nService = mockI18nService(); - - const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - prefix: "prefix", - }), - ).rejects.toEqual("forwarderNoAccountId"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenCalledWith( - "forwarderNoAccountId", - Forwarders.Fastmail.name, - ); - }, - ); - - it.each([ - ["jane.doe@example.com", 200], - ["john.doe@example.com", 200], - ])( - "returns the generated email address (= %p) if both requests are successful (status = %p)", - async (email, status) => { - const apiService = mockApiService(AccountIdSuccess, { - status, - body: { - methodResponses: [["MaskedEmail/set", { created: { "new-masked-email": { email } } }]], - }, - }); - const i18nService = mockI18nService(); - - const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); - - const result = await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - prefix: "prefix", - }); - - expect(result).toEqual(email); - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - }, - ); - - it.each([ - [ - "It turned inside out!", - [ - "MaskedEmail/set", - { notCreated: { "new-masked-email": { description: "It turned inside out!" } } }, - ], - ], - ["And then it exploded!", ["error", { description: "And then it exploded!" }]], - ])( - "throws a forwarder error (= %p) if both requests are successful (status = %p) but masked email creation fails", - async (description, response) => { - const apiService = mockApiService(AccountIdSuccess, { - status: 200, - body: { - methodResponses: [response], - }, - }); - const i18nService = mockI18nService(); - - const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - prefix: "prefix", - }), - ).rejects.toEqual("forwarderError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenCalledWith( - "forwarderError", - Forwarders.Fastmail.name, - description, - ); - }, - ); - - it.each([401, 403])( - "throws an invalid token error if the jmap request fails with a %i", - async (status) => { - const apiService = mockApiService(AccountIdSuccess, { status, body: {} }); - const i18nService = mockI18nService(); - - const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - prefix: "prefix", - }), - ).rejects.toEqual("forwaderInvalidToken"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenCalledWith( - "forwaderInvalidToken", - Forwarders.Fastmail.name, - ); - }, - ); - - it.each([ - null, - [], - [[]], - [["MaskedEmail/not-a-real-op"]], - [["MaskedEmail/set", null]], - [["MaskedEmail/set", { created: null }]], - [["MaskedEmail/set", { created: { "new-masked-email": null } }]], - [["MaskedEmail/set", { notCreated: null }]], - [["MaskedEmail/set", { notCreated: { "new-masked-email": null } }]], - ])( - "throws an unknown error if the jmap request is malformed (= %p)", - async (responses: any) => { - const apiService = mockApiService(AccountIdSuccess, { - status: 200, - body: { - methodResponses: responses, - }, - }); - const i18nService = mockI18nService(); - - const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - prefix: "prefix", - }), - ).rejects.toEqual("forwarderUnknownError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenCalledWith( - "forwarderUnknownError", - Forwarders.Fastmail.name, - ); - }, - ); - - it.each([100, 202, 300, 418, 500, 600])( - "throws an unknown error if the request returns any other status code (= %i)", - async (statusCode) => { - const apiService = mockApiService(AccountIdSuccess, { status: statusCode, body: {} }); - const i18nService = mockI18nService(); - - const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - prefix: "prefix", - }), - ).rejects.toEqual("forwarderUnknownError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenCalledWith( - "forwarderUnknownError", - Forwarders.Fastmail.name, - ); - }, - ); - }); -}); diff --git a/libs/common/src/tools/generator/username/forwarders/fastmail.ts b/libs/common/src/tools/generator/username/forwarders/fastmail.ts deleted file mode 100644 index 0c4e0e2cfd2..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/fastmail.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; -import { CryptoService } from "../../../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; -import { I18nService } from "../../../../platform/abstractions/i18n.service"; -import { StateProvider } from "../../../../platform/state"; -import { FASTMAIL_FORWARDER, FASTMAIL_BUFFER } from "../../key-definitions"; -import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; -import { Forwarders } from "../options/constants"; -import { EmailPrefixOptions, ApiOptions } from "../options/forwarder-options"; - -export const DefaultFastmailOptions: ApiOptions & EmailPrefixOptions = Object.freeze({ - website: "", - domain: "", - prefix: "", - token: "", -}); - -/** Generates a forwarding address for Fastmail */ -export class FastmailForwarder extends ForwarderGeneratorStrategy { - /** Instantiates the forwarder - * @param apiService used for ajax requests to the forwarding service - * @param i18nService used to look up error strings - * @param encryptService protects sensitive forwarder options - * @param keyService looks up the user key when protecting data. - * @param stateProvider creates the durable state for options storage - */ - constructor( - private apiService: ApiService, - private i18nService: I18nService, - encryptService: EncryptService, - keyService: CryptoService, - stateProvider: StateProvider, - ) { - super(encryptService, keyService, stateProvider, DefaultFastmailOptions); - } - - // configuration - readonly key = FASTMAIL_FORWARDER; - readonly rolloverKey = FASTMAIL_BUFFER; - - // request - generate = async (options: ApiOptions & EmailPrefixOptions) => { - if (!options.token || options.token === "") { - const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name); - throw error; - } - - const accountId = await this.getAccountId(options); - if (!accountId || accountId === "") { - const error = this.i18nService.t("forwarderNoAccountId", Forwarders.Fastmail.name); - throw error; - } - - const body = JSON.stringify({ - using: ["https://www.fastmail.com/dev/maskedemail", "urn:ietf:params:jmap:core"], - methodCalls: [ - [ - "MaskedEmail/set", - { - accountId: accountId, - create: { - "new-masked-email": { - state: "enabled", - description: "", - forDomain: options.website ?? "", - emailPrefix: options.prefix, - }, - }, - }, - "0", - ], - ], - }); - - const requestInit: RequestInit = { - redirect: "manual", - cache: "no-store", - method: "POST", - headers: new Headers({ - Authorization: "Bearer " + options.token, - "Content-Type": "application/json", - }), - body, - }; - - const url = "https://api.fastmail.com/jmap/api/"; - const request = new Request(url, requestInit); - - const response = await this.apiService.nativeFetch(request); - if (response.status === 200) { - const json = await response.json(); - if ( - json.methodResponses != null && - json.methodResponses.length > 0 && - json.methodResponses[0].length > 0 - ) { - if (json.methodResponses[0][0] === "MaskedEmail/set") { - if (json.methodResponses[0][1]?.created?.["new-masked-email"] != null) { - return json.methodResponses[0][1]?.created?.["new-masked-email"]?.email; - } - if (json.methodResponses[0][1]?.notCreated?.["new-masked-email"] != null) { - const errorDescription = - json.methodResponses[0][1]?.notCreated?.["new-masked-email"]?.description; - const error = this.i18nService.t( - "forwarderError", - Forwarders.Fastmail.name, - errorDescription, - ); - throw error; - } - } else if (json.methodResponses[0][0] === "error") { - const errorDescription = json.methodResponses[0][1]?.description; - const error = this.i18nService.t( - "forwarderError", - Forwarders.Fastmail.name, - errorDescription, - ); - throw error; - } - } - } else if (response.status === 401 || response.status === 403) { - const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name); - throw error; - } - - const error = this.i18nService.t("forwarderUnknownError", Forwarders.Fastmail.name); - throw error; - }; - - private async getAccountId(options: ApiOptions): Promise { - const requestInit: RequestInit = { - cache: "no-store", - method: "GET", - headers: new Headers({ - Authorization: "Bearer " + options.token, - }), - }; - const url = "https://api.fastmail.com/.well-known/jmap"; - const request = new Request(url, requestInit); - const response = await this.apiService.nativeFetch(request); - if (response.status === 200) { - const json = await response.json(); - if (json.primaryAccounts != null) { - return json.primaryAccounts["https://www.fastmail.com/dev/maskedemail"]; - } - } - return null; - } -} - -export const DefaultOptions = Object.freeze({ - website: null, - domain: "", - prefix: "", - token: "", -}); diff --git a/libs/common/src/tools/generator/username/forwarders/firefox-relay.spec.ts b/libs/common/src/tools/generator/username/forwarders/firefox-relay.spec.ts deleted file mode 100644 index 7d712f73322..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/firefox-relay.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * include Request in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ -import { firstValueFrom } from "rxjs"; - -import { UserId } from "../../../../types/guid"; -import { FIREFOX_RELAY_FORWARDER } from "../../key-definitions"; -import { Forwarders } from "../options/constants"; - -import { FirefoxRelayForwarder, DefaultFirefoxRelayOptions } from "./firefox-relay"; -import { mockApiService, mockI18nService } from "./mocks.jest"; - -const SomeUser = "some user" as UserId; - -describe("Firefox Relay Forwarder", () => { - it("key returns the Firefox Relay forwarder key", () => { - const forwarder = new FirefoxRelayForwarder(null, null, null, null, null); - - expect(forwarder.key).toBe(FIREFOX_RELAY_FORWARDER); - }); - - describe("defaults$", () => { - it("should return the default subaddress options", async () => { - const strategy = new FirefoxRelayForwarder(null, null, null, null, null); - - const result = await firstValueFrom(strategy.defaults$(SomeUser)); - - expect(result).toEqual(DefaultFirefoxRelayOptions); - }); - }); - - describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { - it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token, - }), - ).rejects.toEqual("forwaderInvalidToken"); - - expect(apiService.nativeFetch).not.toHaveBeenCalled(); - expect(i18nService.t).toHaveBeenCalledWith( - "forwaderInvalidToken", - Forwarders.FirefoxRelay.name, - ); - }); - - it.each([ - ["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"], - ["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"], - ["forwarderGeneratedBy", "not provided", null, ""], - ["forwarderGeneratedBy", "not provided", "", ""], - ])( - "describes the website with %p when the website is %s (= %p)", - async (translationKey, _ignored, website, expectedWebsite) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null); - - await forwarder.generate({ - website, - token: "token", - }); - - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite); - }, - ); - - it.each([ - ["jane.doe@duck.com", 201], - ["john.doe@duck.com", 201], - ["jane.doe@duck.com", 200], - ["john.doe@duck.com", 200], - ])( - "returns the generated email address (= %p) if the request is successful (status = %p)", - async (full_address, status) => { - const apiService = mockApiService(status, { full_address }); - const i18nService = mockI18nService(); - - const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null); - - const result = await forwarder.generate({ - website: null, - token: "token", - }); - - expect(result).toEqual(full_address); - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - }, - ); - - it("throws an invalid token error if the request fails with a 401", async () => { - const apiService = mockApiService(401, {}); - const i18nService = mockI18nService(); - - const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - }), - ).rejects.toEqual("forwaderInvalidToken"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwaderInvalidToken", - Forwarders.FirefoxRelay.name, - ); - }); - - it.each([100, 202, 300, 418, 500, 600])( - "throws an unknown error if the request returns any other status code (= %i)", - async (statusCode) => { - const apiService = mockApiService(statusCode, {}); - const i18nService = mockI18nService(); - - const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - }), - ).rejects.toEqual("forwarderUnknownError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwarderUnknownError", - Forwarders.FirefoxRelay.name, - ); - }, - ); - }); -}); diff --git a/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts b/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts deleted file mode 100644 index 1beb2dde4f8..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; -import { CryptoService } from "../../../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; -import { I18nService } from "../../../../platform/abstractions/i18n.service"; -import { StateProvider } from "../../../../platform/state"; -import { FIREFOX_RELAY_FORWARDER, FIREFOX_RELAY_BUFFER } from "../../key-definitions"; -import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; -import { Forwarders } from "../options/constants"; -import { ApiOptions } from "../options/forwarder-options"; - -export const DefaultFirefoxRelayOptions: ApiOptions = Object.freeze({ - website: null, - token: "", -}); - -/** Generates a forwarding address for Firefox Relay */ -export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy { - /** Instantiates the forwarder - * @param apiService used for ajax requests to the forwarding service - * @param i18nService used to look up error strings - * @param encryptService protects sensitive forwarder options - * @param keyService looks up the user key when protecting data. - * @param stateProvider creates the durable state for options storage - */ - constructor( - private apiService: ApiService, - private i18nService: I18nService, - encryptService: EncryptService, - keyService: CryptoService, - stateProvider: StateProvider, - ) { - super(encryptService, keyService, stateProvider, DefaultFirefoxRelayOptions); - } - - // configuration - readonly key = FIREFOX_RELAY_FORWARDER; - readonly rolloverKey = FIREFOX_RELAY_BUFFER; - - // request - generate = async (options: ApiOptions) => { - if (!options.token || options.token === "") { - const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name); - throw error; - } - - const url = "https://relay.firefox.com/api/v1/relayaddresses/"; - - let descriptionId = "forwarderGeneratedByWithWebsite"; - if (!options.website || options.website === "") { - descriptionId = "forwarderGeneratedBy"; - } - const description = this.i18nService.t(descriptionId, options.website ?? ""); - - const request = new Request(url, { - redirect: "manual", - cache: "no-store", - method: "POST", - headers: new Headers({ - Authorization: "Token " + options.token, - "Content-Type": "application/json", - }), - body: JSON.stringify({ - enabled: true, - generated_for: options.website, - description, - }), - }); - - const response = await this.apiService.nativeFetch(request); - if (response.status === 401) { - const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name); - throw error; - } else if (response.status === 200 || response.status === 201) { - const json = await response.json(); - return json.full_address; - } else { - const error = this.i18nService.t("forwarderUnknownError", Forwarders.FirefoxRelay.name); - throw error; - } - }; -} - -export const DefaultOptions = Object.freeze({ - website: null, - token: "", -}); diff --git a/libs/common/src/tools/generator/username/forwarders/forward-email.spec.ts b/libs/common/src/tools/generator/username/forwarders/forward-email.spec.ts deleted file mode 100644 index 23c4bef64ab..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/forward-email.spec.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * include Request in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ -import { firstValueFrom } from "rxjs"; - -import { UserId } from "../../../../types/guid"; -import { FORWARD_EMAIL_FORWARDER } from "../../key-definitions"; -import { Forwarders } from "../options/constants"; - -import { ForwardEmailForwarder, DefaultForwardEmailOptions } from "./forward-email"; -import { mockApiService, mockI18nService } from "./mocks.jest"; - -const SomeUser = "some user" as UserId; - -describe("ForwardEmail Forwarder", () => { - it("key returns the Forward Email forwarder key", () => { - const forwarder = new ForwardEmailForwarder(null, null, null, null, null); - - expect(forwarder.key).toBe(FORWARD_EMAIL_FORWARDER); - }); - - describe("defaults$", () => { - it("should return the default subaddress options", async () => { - const strategy = new ForwardEmailForwarder(null, null, null, null, null); - - const result = await firstValueFrom(strategy.defaults$(SomeUser)); - - expect(result).toEqual(DefaultForwardEmailOptions); - }); - }); - - describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { - it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token, - domain: "example.com", - }), - ).rejects.toEqual("forwaderInvalidToken"); - - expect(apiService.nativeFetch).not.toHaveBeenCalled(); - expect(i18nService.t).toHaveBeenCalledWith( - "forwaderInvalidToken", - Forwarders.ForwardEmail.name, - ); - }); - - it.each([null, ""])( - "throws an error if the domain is missing (domain = %p)", - async (domain) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain, - }), - ).rejects.toEqual("forwarderNoDomain"); - - expect(apiService.nativeFetch).not.toHaveBeenCalled(); - expect(i18nService.t).toHaveBeenCalledWith( - "forwarderNoDomain", - Forwarders.ForwardEmail.name, - ); - }, - ); - - it.each([ - ["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"], - ["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"], - ["forwarderGeneratedBy", "not provided", null, ""], - ["forwarderGeneratedBy", "not provided", "", ""], - ])( - "describes the website with %p when the website is %s (= %p)", - async (translationKey, _ignored, website, expectedWebsite) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); - - await forwarder.generate({ - website, - token: "token", - domain: "example.com", - }); - - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite); - }, - ); - - it.each([ - ["jane.doe@example.com", 201, { name: "jane.doe", domain: { name: "example.com" } }], - ["jane.doe@example.com", 201, { name: "jane.doe" }], - ["john.doe@example.com", 201, { name: "john.doe", domain: { name: "example.com" } }], - ["john.doe@example.com", 201, { name: "john.doe" }], - ["jane.doe@example.com", 200, { name: "jane.doe", domain: { name: "example.com" } }], - ["jane.doe@example.com", 200, { name: "jane.doe" }], - ["john.doe@example.com", 200, { name: "john.doe", domain: { name: "example.com" } }], - ["john.doe@example.com", 200, { name: "john.doe" }], - ])( - "returns the generated email address (= %p) if the request is successful (status = %p)", - async (email, status, response) => { - const apiService = mockApiService(status, response); - const i18nService = mockI18nService(); - - const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); - - const result = await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - }); - - expect(result).toEqual(email); - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - }, - ); - - it("throws an invalid token error if the request fails with a 401", async () => { - const apiService = mockApiService(401, {}); - const i18nService = mockI18nService(); - - const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - }), - ).rejects.toEqual("forwaderInvalidToken"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwaderInvalidToken", - Forwarders.ForwardEmail.name, - undefined, - ); - }); - - it("throws an invalid token error with a message if the request fails with a 401 and message", async () => { - const apiService = mockApiService(401, { message: "A message" }); - const i18nService = mockI18nService(); - - const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - }), - ).rejects.toEqual("forwaderInvalidTokenWithMessage"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwaderInvalidTokenWithMessage", - Forwarders.ForwardEmail.name, - "A message", - ); - }); - - it.each([{}, null])( - "throws an unknown error if the request fails and no status (= %p) is provided", - async (json) => { - const apiService = mockApiService(500, json); - const i18nService = mockI18nService(); - - const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - }), - ).rejects.toEqual("forwarderUnknownError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwarderUnknownError", - Forwarders.ForwardEmail.name, - ); - }, - ); - - it.each([ - [100, "Continue"], - [202, "Accepted"], - [300, "Multiple Choices"], - [418, "I'm a teapot"], - [500, "Internal Server Error"], - [600, "Unknown Status"], - ])( - "throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided", - async (statusCode, message) => { - const apiService = mockApiService(statusCode, { message }); - const i18nService = mockI18nService(); - - const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - }), - ).rejects.toEqual("forwarderError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwarderError", - Forwarders.ForwardEmail.name, - message, - ); - }, - ); - - it.each([ - [100, "Continue"], - [202, "Accepted"], - [300, "Multiple Choices"], - [418, "I'm a teapot"], - [500, "Internal Server Error"], - [600, "Unknown Status"], - ])( - "throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided", - async (statusCode, error) => { - const apiService = mockApiService(statusCode, { error }); - const i18nService = mockI18nService(); - - const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - domain: "example.com", - }), - ).rejects.toEqual("forwarderError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwarderError", - Forwarders.ForwardEmail.name, - error, - ); - }, - ); - }); -}); diff --git a/libs/common/src/tools/generator/username/forwarders/forward-email.ts b/libs/common/src/tools/generator/username/forwarders/forward-email.ts deleted file mode 100644 index 20dfe012915..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/forward-email.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; -import { CryptoService } from "../../../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; -import { I18nService } from "../../../../platform/abstractions/i18n.service"; -import { Utils } from "../../../../platform/misc/utils"; -import { StateProvider } from "../../../../platform/state"; -import { FORWARD_EMAIL_FORWARDER, FORWARD_EMAIL_BUFFER } from "../../key-definitions"; -import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; -import { Forwarders } from "../options/constants"; -import { EmailDomainOptions, ApiOptions } from "../options/forwarder-options"; - -export const DefaultForwardEmailOptions: ApiOptions & EmailDomainOptions = Object.freeze({ - website: null, - token: "", - domain: "", -}); - -/** Generates a forwarding address for Forward Email */ -export class ForwardEmailForwarder extends ForwarderGeneratorStrategy< - ApiOptions & EmailDomainOptions -> { - /** Instantiates the forwarder - * @param apiService used for ajax requests to the forwarding service - * @param i18nService used to look up error strings - * @param encryptService protects sensitive forwarder options - * @param keyService looks up the user key when protecting data. - * @param stateProvider creates the durable state for options storage - */ - constructor( - private apiService: ApiService, - private i18nService: I18nService, - encryptService: EncryptService, - keyService: CryptoService, - stateProvider: StateProvider, - ) { - super(encryptService, keyService, stateProvider, DefaultForwardEmailOptions); - } - - // configuration - readonly key = FORWARD_EMAIL_FORWARDER; - readonly rolloverKey = FORWARD_EMAIL_BUFFER; - - // request - generate = async (options: ApiOptions & EmailDomainOptions) => { - if (!options.token || options.token === "") { - const error = this.i18nService.t("forwaderInvalidToken", Forwarders.ForwardEmail.name); - throw error; - } - if (!options.domain || options.domain === "") { - const error = this.i18nService.t("forwarderNoDomain", Forwarders.ForwardEmail.name); - throw error; - } - - const url = `https://api.forwardemail.net/v1/domains/${options.domain}/aliases`; - - let descriptionId = "forwarderGeneratedByWithWebsite"; - if (!options.website || options.website === "") { - descriptionId = "forwarderGeneratedBy"; - } - const description = this.i18nService.t(descriptionId, options.website ?? ""); - - const request = new Request(url, { - redirect: "manual", - cache: "no-store", - method: "POST", - headers: new Headers({ - Authorization: "Basic " + Utils.fromUtf8ToB64(options.token + ":"), - "Content-Type": "application/json", - }), - body: JSON.stringify({ - labels: options.website, - description, - }), - }); - - const response = await this.apiService.nativeFetch(request); - const json = await response.json(); - - if (response.status === 401) { - const messageKey = - "message" in json ? "forwaderInvalidTokenWithMessage" : "forwaderInvalidToken"; - const error = this.i18nService.t(messageKey, Forwarders.ForwardEmail.name, json.message); - throw error; - } else if (response.status === 200 || response.status === 201) { - const { name, domain } = await response.json(); - const domainPart = domain?.name || options.domain; - return `${name}@${domainPart}`; - } else if (json?.message) { - const error = this.i18nService.t( - "forwarderError", - Forwarders.ForwardEmail.name, - json.message, - ); - throw error; - } else if (json?.error) { - const error = this.i18nService.t("forwarderError", Forwarders.ForwardEmail.name, json.error); - throw error; - } else { - const error = this.i18nService.t("forwarderUnknownError", Forwarders.ForwardEmail.name); - throw error; - } - }; -} - -export const DefaultOptions = Object.freeze({ - website: null, - token: "", - domain: "", -}); diff --git a/libs/common/src/tools/generator/username/forwarders/mocks.jest.ts b/libs/common/src/tools/generator/username/forwarders/mocks.jest.ts deleted file mode 100644 index 768014a77d7..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/mocks.jest.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; -import { I18nService } from "../../../../platform/abstractions/i18n.service"; - -/** a mock {@link ApiService} that returns a fetch-like response with a given status and body */ -export function mockApiService(status: number, body: any, statusText?: string) { - return { - nativeFetch: jest.fn().mockImplementation((r: Request) => { - return { - status, - statusText, - json: jest.fn().mockImplementation(() => Promise.resolve(body)), - }; - }), - } as unknown as ApiService; -} - -/** a mock {@link I18nService} that returns the translation key */ -export function mockI18nService() { - return { - t: jest.fn().mockImplementation((key: string) => key), - } as unknown as I18nService; -} diff --git a/libs/common/src/tools/generator/username/forwarders/simple-login.spec.ts b/libs/common/src/tools/generator/username/forwarders/simple-login.spec.ts deleted file mode 100644 index c53e7832706..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/simple-login.spec.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * include Request in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ -import { firstValueFrom } from "rxjs"; - -import { UserId } from "../../../../types/guid"; -import { SIMPLE_LOGIN_FORWARDER } from "../../key-definitions"; -import { Forwarders } from "../options/constants"; - -import { mockApiService, mockI18nService } from "./mocks.jest"; -import { SimpleLoginForwarder, DefaultSimpleLoginOptions } from "./simple-login"; - -const SomeUser = "some user" as UserId; - -describe("SimpleLogin Forwarder", () => { - it("key returns the Simple Login forwarder key", () => { - const forwarder = new SimpleLoginForwarder(null, null, null, null, null); - - expect(forwarder.key).toBe(SIMPLE_LOGIN_FORWARDER); - }); - - describe("defaults$", () => { - it("should return the default subaddress options", async () => { - const strategy = new SimpleLoginForwarder(null, null, null, null, null); - - const result = await firstValueFrom(strategy.defaults$(SomeUser)); - - expect(result).toEqual(DefaultSimpleLoginOptions); - }); - }); - - describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { - it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token, - baseUrl: "https://api.example.com", - }), - ).rejects.toEqual("forwaderInvalidToken"); - - expect(apiService.nativeFetch).not.toHaveBeenCalled(); - expect(i18nService.t).toHaveBeenCalledWith( - "forwaderInvalidToken", - Forwarders.SimpleLogin.name, - ); - }); - - it.each([null, ""])( - "throws an error if the baseUrl is missing (baseUrl = %p)", - async (baseUrl) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - baseUrl, - }), - ).rejects.toEqual("forwarderNoUrl"); - - expect(apiService.nativeFetch).not.toHaveBeenCalled(); - expect(i18nService.t).toHaveBeenCalledWith("forwarderNoUrl", Forwarders.SimpleLogin.name); - }, - ); - - it.each([ - ["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"], - ["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"], - ["forwarderGeneratedBy", "not provided", null, ""], - ["forwarderGeneratedBy", "not provided", "", ""], - ])( - "describes the website with %p when the website is %s (= %p)", - async (translationKey, _ignored, website, expectedWebsite) => { - const apiService = mockApiService(200, {}); - const i18nService = mockI18nService(); - - const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); - - await forwarder.generate({ - website, - token: "token", - baseUrl: "https://api.example.com", - }); - - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite); - }, - ); - - it.each([ - ["jane.doe@example.com", 201], - ["john.doe@example.com", 201], - ["jane.doe@example.com", 200], - ["john.doe@example.com", 200], - ])( - "returns the generated email address (= %p) if the request is successful (status = %p)", - async (alias, status) => { - const apiService = mockApiService(status, { alias }); - const i18nService = mockI18nService(); - - const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); - - const result = await forwarder.generate({ - website: null, - token: "token", - baseUrl: "https://api.example.com", - }); - - expect(result).toEqual(alias); - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - }, - ); - - it("throws an invalid token error if the request fails with a 401", async () => { - const apiService = mockApiService(401, {}); - const i18nService = mockI18nService(); - - const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - baseUrl: "https://api.example.com", - }), - ).rejects.toEqual("forwaderInvalidToken"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwaderInvalidToken", - Forwarders.SimpleLogin.name, - ); - }); - - it.each([{}, null])( - "throws an unknown error if the request fails and no status (=%p) is provided", - async (body) => { - const apiService = mockApiService(500, body); - const i18nService = mockI18nService(); - - const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - baseUrl: "https://api.example.com", - }), - ).rejects.toEqual("forwarderUnknownError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwarderUnknownError", - Forwarders.SimpleLogin.name, - ); - }, - ); - - it.each([ - [100, "Continue"], - [202, "Accepted"], - [300, "Multiple Choices"], - [418, "I'm a teapot"], - [500, "Internal Server Error"], - [600, "Unknown Status"], - ])( - "throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided", - async (statusCode, error) => { - const apiService = mockApiService(statusCode, { error }); - const i18nService = mockI18nService(); - - const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); - - await expect( - async () => - await forwarder.generate({ - website: null, - token: "token", - baseUrl: "https://api.example.com", - }), - ).rejects.toEqual("forwarderError"); - - expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); - // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this - expect(i18nService.t).toHaveBeenNthCalledWith( - 2, - "forwarderError", - Forwarders.SimpleLogin.name, - error, - ); - }, - ); - }); -}); diff --git a/libs/common/src/tools/generator/username/forwarders/simple-login.ts b/libs/common/src/tools/generator/username/forwarders/simple-login.ts deleted file mode 100644 index 593c7346419..00000000000 --- a/libs/common/src/tools/generator/username/forwarders/simple-login.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { ApiService } from "../../../../abstractions/api.service"; -import { CryptoService } from "../../../../platform/abstractions/crypto.service"; -import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; -import { I18nService } from "../../../../platform/abstractions/i18n.service"; -import { StateProvider } from "../../../../platform/state"; -import { SIMPLE_LOGIN_FORWARDER, SIMPLE_LOGIN_BUFFER } from "../../key-definitions"; -import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; -import { Forwarders } from "../options/constants"; -import { SelfHostedApiOptions } from "../options/forwarder-options"; - -export const DefaultSimpleLoginOptions: SelfHostedApiOptions = Object.freeze({ - website: null, - baseUrl: "https://app.simplelogin.io", - token: "", -}); - -/** Generates a forwarding address for Simple Login */ -export class SimpleLoginForwarder extends ForwarderGeneratorStrategy { - /** Instantiates the forwarder - * @param apiService used for ajax requests to the forwarding service - * @param i18nService used to look up error strings - * @param encryptService protects sensitive forwarder options - * @param keyService looks up the user key when protecting data. - * @param stateProvider creates the durable state for options storage - */ - constructor( - private apiService: ApiService, - private i18nService: I18nService, - encryptService: EncryptService, - keyService: CryptoService, - stateProvider: StateProvider, - ) { - super(encryptService, keyService, stateProvider, DefaultSimpleLoginOptions); - } - - // configuration - readonly key = SIMPLE_LOGIN_FORWARDER; - readonly rolloverKey = SIMPLE_LOGIN_BUFFER; - - // request - generate = async (options: SelfHostedApiOptions) => { - if (!options.token || options.token === "") { - const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name); - throw error; - } - if (!options.baseUrl || options.baseUrl === "") { - const error = this.i18nService.t("forwarderNoUrl", Forwarders.SimpleLogin.name); - throw error; - } - - let url = options.baseUrl + "/api/alias/random/new"; - let noteId = "forwarderGeneratedBy"; - if (options.website && options.website !== "") { - url += "?hostname=" + options.website; - noteId = "forwarderGeneratedByWithWebsite"; - } - const note = this.i18nService.t(noteId, options.website ?? ""); - - const request = new Request(url, { - redirect: "manual", - cache: "no-store", - method: "POST", - headers: new Headers({ - Authentication: options.token, - "Content-Type": "application/json", - }), - body: JSON.stringify({ note }), - }); - - const response = await this.apiService.nativeFetch(request); - if (response.status === 401) { - const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name); - throw error; - } - - const json = await response.json(); - if (response.status === 200 || response.status === 201) { - return json.alias; - } else if (json?.error) { - const error = this.i18nService.t("forwarderError", Forwarders.SimpleLogin.name, json.error); - throw error; - } else { - const error = this.i18nService.t("forwarderUnknownError", Forwarders.SimpleLogin.name); - throw error; - } - }; -} - -export const DefaultOptions = Object.freeze({ - website: null, - baseUrl: "https://app.simplelogin.io", - token: "", -}); diff --git a/libs/common/src/tools/generator/username/index.ts b/libs/common/src/tools/generator/username/index.ts deleted file mode 100644 index a9d8e676086..00000000000 --- a/libs/common/src/tools/generator/username/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { EffUsernameGeneratorStrategy } from "./eff-username-generator-strategy"; -export { CatchallGeneratorStrategy } from "./catchall-generator-strategy"; -export { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy"; -export { UsernameGeneratorOptions } from "./username-generation-options"; -export { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; diff --git a/libs/common/src/tools/generator/username/options/constants.ts b/libs/common/src/tools/generator/username/options/constants.ts deleted file mode 100644 index ab584effd58..00000000000 --- a/libs/common/src/tools/generator/username/options/constants.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ForwarderMetadata } from "./forwarder-options"; - -/** Metadata about an email forwarding service. - * @remarks This is used to populate the forwarder selection list - * and to identify forwarding services in error messages. - */ -export const Forwarders = Object.freeze({ - /** For https://addy.io/ */ - AddyIo: Object.freeze({ - id: "anonaddy", - name: "Addy.io", - validForSelfHosted: true, - } as ForwarderMetadata), - - /** For https://duckduckgo.com/email/ */ - DuckDuckGo: Object.freeze({ - id: "duckduckgo", - name: "DuckDuckGo", - validForSelfHosted: false, - } as ForwarderMetadata), - - /** For https://www.fastmail.com. */ - Fastmail: Object.freeze({ - id: "fastmail", - name: "Fastmail", - validForSelfHosted: true, - } as ForwarderMetadata), - - /** For https://relay.firefox.com/ */ - FirefoxRelay: Object.freeze({ - id: "firefoxrelay", - name: "Firefox Relay", - validForSelfHosted: false, - } as ForwarderMetadata), - - /** For https://forwardemail.net/ */ - ForwardEmail: Object.freeze({ - id: "forwardemail", - name: "Forward Email", - validForSelfHosted: true, - } as ForwarderMetadata), - - /** For https://simplelogin.io/ */ - SimpleLogin: Object.freeze({ - id: "simplelogin", - name: "SimpleLogin", - validForSelfHosted: true, - } as ForwarderMetadata), -}); diff --git a/libs/common/src/tools/generator/username/options/forwarder-options.ts b/libs/common/src/tools/generator/username/options/forwarder-options.ts deleted file mode 100644 index f36a58a0db4..00000000000 --- a/libs/common/src/tools/generator/username/options/forwarder-options.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** Identifiers for email forwarding services. - * @remarks These are used to select forwarder-specific options. - * The must be kept in sync with the forwarder implementations. - */ -export type ForwarderId = - | "anonaddy" - | "duckduckgo" - | "fastmail" - | "firefoxrelay" - | "forwardemail" - | "simplelogin"; - -/** Metadata format for email forwarding services. */ -export type ForwarderMetadata = { - /** The unique identifier for the forwarder. */ - id: ForwarderId; - - /** The name of the service the forwarder queries. */ - name: string; - - /** Whether the forwarder is valid for self-hosted instances of Bitwarden. */ - validForSelfHosted: boolean; -}; - -/** Options common to all forwarder APIs */ -export type ApiOptions = { - /** bearer token that authenticates bitwarden to the forwarder. - * This is required to issue an API request. - */ - token?: string; -} & RequestOptions; - -/** Options that provide contextual information about the application state - * when a forwarder is invoked. - * @remarks these fields should always be omitted when saving options. - */ -export type RequestOptions = { - /** @param website The domain of the website the generated email is used - * within. This should be set to `null` when the request is not specific - * to any website. - */ - website: string | null; -}; - -/** Api configuration for forwarders that support self-hosted installations. */ -export type SelfHostedApiOptions = ApiOptions & { - /** The base URL of the forwarder's API. - * When this is empty, the forwarder's default production API is used. - */ - baseUrl: string; -}; - -/** Api configuration for forwarders that support custom domains. */ -export type EmailDomainOptions = { - /** The domain part of the generated email address. - * @remarks The domain should be authorized by the forwarder before - * submitting a request through bitwarden. - * @example If the domain is `domain.io` and the generated username - * is `jd`, then the generated email address will be `jd@mydomain.io` - */ - domain: string; -}; - -/** Api configuration for forwarders that support custom email parts. */ -export type EmailPrefixOptions = EmailDomainOptions & { - /** A prefix joined to the generated email address' username. - * @example If the prefix is `foo`, the generated username is `bar`, - * and the domain is `domain.io`, then the generated email address is ` - * then the generated username is `foobar@domain.io`. - */ - prefix: string; -}; diff --git a/libs/common/src/tools/generator/username/options/generator-options.ts b/libs/common/src/tools/generator/username/options/generator-options.ts deleted file mode 100644 index 3df5709ed32..00000000000 --- a/libs/common/src/tools/generator/username/options/generator-options.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** ways you can generate usernames - * "word" generates a username from the eff word list - * "subaddress" creates a subaddress of an email. - * "catchall" uses a domain's catchall address - * "forwarded" uses an email forwarding service - */ -export type UsernameGeneratorType = "word" | "subaddress" | "catchall" | "forwarded"; - -/** Several username generators support two generation modes - * "random" selects one or more random words from the EFF word list - * "website-name" includes the domain in the generated username - */ -export type UsernameGenerationMode = "random" | "website-name"; diff --git a/libs/common/src/tools/generator/username/options/index.ts b/libs/common/src/tools/generator/username/options/index.ts deleted file mode 100644 index b2d4066c871..00000000000 --- a/libs/common/src/tools/generator/username/options/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ForwarderId, ForwarderMetadata } from "./forwarder-options"; diff --git a/libs/common/src/tools/generator/username/subaddress-generator-options.ts b/libs/common/src/tools/generator/username/subaddress-generator-options.ts deleted file mode 100644 index dc38b2a6ea0..00000000000 --- a/libs/common/src/tools/generator/username/subaddress-generator-options.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { RequestOptions } from "./options/forwarder-options"; -import { UsernameGenerationMode } from "./options/generator-options"; - -/** Settings supported when generating an email subaddress */ -export type SubaddressGenerationOptions = { - /** selects the generation algorithm for the catchall email address. */ - subaddressType?: UsernameGenerationMode; - - /** the email address the subaddress is applied to. */ - subaddressEmail?: string; -} & RequestOptions; - -/** The default options for email subaddress generation. */ -export const DefaultSubaddressOptions: SubaddressGenerationOptions = Object.freeze({ - subaddressType: "random", - subaddressEmail: "", - website: null, -}); diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts deleted file mode 100644 index ba1d5aa2b8f..00000000000 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { mock } from "jest-mock-extended"; -import { of, firstValueFrom } from "rxjs"; - -import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; -import { StateProvider } from "../../../platform/state"; -import { UserId } from "../../../types/guid"; -import { Randomizer } from "../abstractions/randomizer"; -import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; -import { SUBADDRESS_SETTINGS } from "../key-definitions"; - -import { DefaultSubaddressOptions } from "./subaddress-generator-options"; - -import { SubaddressGeneratorStrategy } from "."; - -const SomeUser = "some user" as UserId; -const SomePolicy = mock({ - type: PolicyType.PasswordGenerator, - data: { - minLength: 10, - }, -}); - -describe("Email subaddress list generation strategy", () => { - describe("toEvaluator()", () => { - it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( - "should map any input (= %p) to the default policy evaluator", - async (policies) => { - const strategy = new SubaddressGeneratorStrategy(null, null); - - const evaluator$ = of(policies).pipe(strategy.toEvaluator()); - const evaluator = await firstValueFrom(evaluator$); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - }, - ); - }); - - describe("durableState", () => { - it("should use password settings key", () => { - const provider = mock(); - const randomizer = mock(); - const strategy = new SubaddressGeneratorStrategy(randomizer, provider); - - strategy.durableState(SomeUser); - - expect(provider.getUser).toHaveBeenCalledWith(SomeUser, SUBADDRESS_SETTINGS); - }); - }); - - describe("defaults$", () => { - it("should return the default subaddress options", async () => { - const strategy = new SubaddressGeneratorStrategy(null, null); - - const result = await firstValueFrom(strategy.defaults$(SomeUser)); - - expect(result).toEqual(DefaultSubaddressOptions); - }); - }); - - describe("policy", () => { - it("should use password generator policy", () => { - const randomizer = mock(); - const strategy = new SubaddressGeneratorStrategy(randomizer, null); - - expect(strategy.policy).toBe(PolicyType.PasswordGenerator); - }); - }); - - describe("generate()", () => { - it.todo("generate email subaddress tests"); - }); -}); diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts deleted file mode 100644 index e44735c2131..00000000000 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { PolicyType } from "../../../admin-console/enums"; -import { StateProvider } from "../../../platform/state"; -import { GeneratorStrategy } from "../abstractions"; -import { Randomizer } from "../abstractions/randomizer"; -import { SUBADDRESS_SETTINGS } from "../key-definitions"; -import { NoPolicy } from "../no-policy"; -import { newDefaultEvaluator } from "../rx-operators"; -import { clone$PerUserId, sharedStateByUserId } from "../util"; - -import { - DefaultSubaddressOptions, - SubaddressGenerationOptions, -} from "./subaddress-generator-options"; - -/** Strategy for creating an email subaddress - * @remarks The subaddress is the part following the `+`. - * For example, if the email address is `jd+xyz@domain.io`, - * the subaddress is `xyz`. - */ -export class SubaddressGeneratorStrategy - implements GeneratorStrategy -{ - /** Instantiates the generation strategy - * @param usernameService generates an email subaddress from an email address - */ - constructor( - private random: Randomizer, - private stateProvider: StateProvider, - private defaultOptions: SubaddressGenerationOptions = DefaultSubaddressOptions, - ) {} - - // configuration - durableState = sharedStateByUserId(SUBADDRESS_SETTINGS, this.stateProvider); - defaults$ = clone$PerUserId(this.defaultOptions); - toEvaluator = newDefaultEvaluator(); - readonly policy = PolicyType.PasswordGenerator; - - // algorithm - async generate(options: SubaddressGenerationOptions) { - const o = Object.assign({}, DefaultSubaddressOptions, options); - - const subaddressEmail = o.subaddressEmail; - if (subaddressEmail == null || subaddressEmail.length < 3) { - return o.subaddressEmail; - } - const atIndex = subaddressEmail.indexOf("@"); - if (atIndex < 1 || atIndex >= subaddressEmail.length - 1) { - return subaddressEmail; - } - if (o.subaddressType == null) { - o.subaddressType = "random"; - } - - const emailBeginning = subaddressEmail.substr(0, atIndex); - const emailEnding = subaddressEmail.substr(atIndex + 1, subaddressEmail.length); - - let subaddressString = ""; - if (o.subaddressType === "random") { - subaddressString = await this.random.chars(8); - } else if (o.subaddressType === "website-name") { - subaddressString = o.website; - } - return emailBeginning + "+" + subaddressString + "@" + emailEnding; - } -} diff --git a/libs/common/src/tools/generator/username/username-generation-options.ts b/libs/common/src/tools/generator/username/username-generation-options.ts deleted file mode 100644 index b52b4c0848b..00000000000 --- a/libs/common/src/tools/generator/username/username-generation-options.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { CatchallGenerationOptions } from "./catchall-generator-options"; -import { EffUsernameGenerationOptions } from "./eff-username-generator-options"; -import { ForwarderId, RequestOptions } from "./options/forwarder-options"; -import { UsernameGeneratorType } from "./options/generator-options"; -import { SubaddressGenerationOptions } from "./subaddress-generator-options"; - -export type UsernameGeneratorOptions = EffUsernameGenerationOptions & - SubaddressGenerationOptions & - CatchallGenerationOptions & - RequestOptions & { - type?: UsernameGeneratorType; - forwardedService?: ForwarderId | ""; - forwardedAnonAddyApiToken?: string; - forwardedAnonAddyDomain?: string; - forwardedAnonAddyBaseUrl?: string; - forwardedDuckDuckGoToken?: string; - forwardedFirefoxApiToken?: string; - forwardedFastmailApiToken?: string; - forwardedForwardEmailApiToken?: string; - forwardedForwardEmailDomain?: string; - forwardedSimpleLoginApiKey?: string; - forwardedSimpleLoginBaseUrl?: string; - }; diff --git a/libs/common/src/tools/generator/util.ts b/libs/common/src/tools/generator/util.ts deleted file mode 100644 index ee526fc6786..00000000000 --- a/libs/common/src/tools/generator/util.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { BehaviorSubject } from "rxjs"; - -import { SingleUserState, StateProvider, UserKeyDefinition } from "../../platform/state"; -import { UserId } from "../../types/guid"; - -/** construct a method that outputs a copy of `defaultValue` as an observable. */ -export function clone$PerUserId(defaultValue: Value) { - const _subjects = new Map>(); - - return (key: UserId) => { - let value = _subjects.get(key); - - if (value === undefined) { - value = new BehaviorSubject({ ...defaultValue }); - _subjects.set(key, value); - } - - return value.asObservable(); - }; -} - -/** construct a method that caches user-specific states by userid. */ -export function sharedByUserId(create: (userId: UserId) => SingleUserState) { - const _subjects = new Map>(); - - return (key: UserId) => { - let value = _subjects.get(key); - - if (value === undefined) { - value = create(key); - _subjects.set(key, value); - } - - return value; - }; -} - -/** construct a method that loads a user-specific state from the provider. */ -export function sharedStateByUserId(key: UserKeyDefinition, provider: StateProvider) { - return (id: UserId) => provider.getUser(id, key); -} diff --git a/libs/common/src/tools/generator/word-options.ts b/libs/common/src/tools/generator/word-options.ts deleted file mode 100644 index 1c98d0bac8d..00000000000 --- a/libs/common/src/tools/generator/word-options.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type WordOptions = { - /** set the first letter uppercase */ - titleCase?: boolean; - /** append a number */ - number?: boolean; -}; diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index cf6577c403e..34f128b3f0e 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -24,7 +24,6 @@ import { FileDownloadService } from "@bitwarden/common/platform/abstractions/fil import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { EncryptedExportType } from "@bitwarden/common/tools/enums/encrypted-export-type.enum"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { AsyncActionsModule, @@ -40,6 +39,8 @@ import { } from "@bitwarden/components"; import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; +import { EncryptedExportType } from "../enums/encrypted-export-type.enum"; + import { ExportScopeCalloutComponent } from "./export-scope-callout.component"; @Component({ diff --git a/libs/common/src/tools/enums/encrypted-export-type.enum.ts b/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts similarity index 100% rename from libs/common/src/tools/enums/encrypted-export-type.enum.ts rename to libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts diff --git a/libs/vault/src/cipher-form/cipher-form-container.ts b/libs/vault/src/cipher-form/cipher-form-container.ts index eb21f3561da..9e93472cf50 100644 --- a/libs/vault/src/cipher-form/cipher-form-container.ts +++ b/libs/vault/src/cipher-form/cipher-form-container.ts @@ -46,5 +46,9 @@ export abstract class CipherFormContainer { group: Exclude, ): void; - abstract patchCipher(cipher: Partial): void; + /** + * Method to update the cipherView with the new values. This method should be called by the child form components + * @param updateFn - A function that takes the current cipherView and returns the updated cipherView + */ + abstract patchCipher(updateFn: (current: CipherView) => CipherView): void; } diff --git a/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.spec.ts b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.spec.ts index d488fc9db91..15784f1ca06 100644 --- a/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.spec.ts @@ -4,6 +4,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordRepromptService } from "../../../services/password-reprompt.service"; @@ -73,10 +74,16 @@ describe("AdditionalOptionsSectionComponent", () => { reprompt: true, }); - expect(cipherFormProvider.patchCipher).toHaveBeenCalledWith({ - notes: "new notes", - reprompt: 1, - }); + const expectedCipher = new CipherView(); + expectedCipher.notes = "new notes"; + expectedCipher.reprompt = CipherRepromptType.Password; + + expect(cipherFormProvider.patchCipher).toHaveBeenCalled(); + const patchFn = cipherFormProvider.patchCipher.mock.lastCall[0]; + + const updated = patchFn(new CipherView()); + + expect(updated).toEqual(expectedCipher); }); it("disables 'additionalOptionsForm' when in partial-edit mode", () => { diff --git a/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.ts b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.ts index 6c061e1eeab..4d402b98e38 100644 --- a/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.ts +++ b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.ts @@ -66,9 +66,10 @@ export class AdditionalOptionsSectionComponent implements OnInit { this.cipherFormContainer.registerChildForm("additionalOptions", this.additionalOptionsForm); this.additionalOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { - this.cipherFormContainer.patchCipher({ - notes: value.notes, - reprompt: value.reprompt ? CipherRepromptType.Password : CipherRepromptType.None, + this.cipherFormContainer.patchCipher((cipher) => { + cipher.notes = value.notes; + cipher.reprompt = value.reprompt ? CipherRepromptType.Password : CipherRepromptType.None; + return cipher; }); }); } diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts index bcc86a3d2e0..196be144fdd 100644 --- a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts @@ -62,9 +62,11 @@ describe("CardDetailsSectionComponent", () => { cardView.number = "4242 4242 4242 4242"; cardView.brand = "Visa"; - expect(patchCipherSpy).toHaveBeenCalledWith({ - card: cardView, - }); + expect(patchCipherSpy).toHaveBeenCalled(); + const patchFn = patchCipherSpy.mock.lastCall[0]; + + const updateCipher = patchFn(new CipherView()); + expect(updateCipher.card).toEqual(cardView); }); it("it converts the year integer to a string", () => { @@ -75,9 +77,11 @@ describe("CardDetailsSectionComponent", () => { const cardView = new CardView(); cardView.expYear = "2022"; - expect(patchCipherSpy).toHaveBeenCalledWith({ - card: cardView, - }); + expect(patchCipherSpy).toHaveBeenCalled(); + const patchFn = patchCipherSpy.mock.lastCall[0]; + + const updatedCipher = patchFn(new CipherView()); + expect(updatedCipher.card).toEqual(cardView); }); it('disables `cardDetailsForm` when "disabled" is true', () => { diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts index ecbddc3655f..a80954a0445 100644 --- a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts @@ -90,9 +90,6 @@ export class CardDetailsSectionComponent implements OnInit { { name: "12 - " + this.i18nService.t("december"), value: "12" }, ]; - /** Local CardView, either created empty or set to the existing card instance */ - private cardView: CardView; - constructor( private cipherFormContainer: CipherFormContainer, private formBuilder: FormBuilder, @@ -103,21 +100,21 @@ export class CardDetailsSectionComponent implements OnInit { this.cardDetailsForm.valueChanges .pipe(takeUntilDestroyed()) .subscribe(({ cardholderName, number, brand, expMonth, expYear, code }) => { - // The input[type="number"] is returning a number, convert it to a string - // An empty field returns null, avoid casting `"null"` to a string - const expirationYear = expYear !== null ? `${expYear}` : null; + this.cipherFormContainer.patchCipher((cipher) => { + // The input[type="number"] is returning a number, convert it to a string + // An empty field returns null, avoid casting `"null"` to a string + const expirationYear = expYear !== null ? `${expYear}` : null; - const patchedCard = Object.assign(this.cardView, { - cardholderName, - number, - brand, - expMonth, - expYear: expirationYear, - code, - }); + Object.assign(cipher.card, { + cardholderName, + number, + brand, + expMonth, + expYear: expirationYear, + code, + }); - this.cipherFormContainer.patchCipher({ - card: patchedCard, + return cipher; }); }); @@ -133,9 +130,6 @@ export class CardDetailsSectionComponent implements OnInit { } ngOnInit() { - // If the original cipher has a card, use it. Otherwise, create a new card instance - this.cardView = this.originalCipherView?.card ?? new CardView(); - if (this.originalCipherView?.card) { this.setInitialValues(); } diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index 0f107b247e4..b666e6833bd 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -143,12 +143,11 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci } /** - * Patches the updated cipher with the provided partial cipher. Used by child components to update the cipher - * as their form values change. - * @param cipher + * Method to update the cipherView with the new values. This method should be called by the child form components + * @param updateFn - A function that takes the current cipherView and returns the updated cipherView */ - patchCipher(cipher: Partial): void { - this.updatedCipherView = Object.assign(this.updatedCipherView, cipher); + patchCipher(updateFn: (current: CipherView) => CipherView): void { + this.updatedCipherView = updateFn(this.updatedCipherView); } /** diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.spec.ts b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.spec.ts index 9ae7f10e8f8..036a59672e0 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.spec.ts +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.spec.ts @@ -270,7 +270,11 @@ describe("CustomFieldsComponent", () => { fieldView.value = "new text value"; fieldView.type = FieldType.Text; - expect(patchCipher).toHaveBeenCalledWith({ fields: [fieldView] }); + expect(patchCipher).toHaveBeenCalled(); + const patchFn = patchCipher.mock.lastCall[0]; + + const updatedCipher = patchFn(new CipherView()); + expect(updatedCipher.fields).toEqual([fieldView]); }); it("updates the label", () => { @@ -281,7 +285,11 @@ describe("CustomFieldsComponent", () => { fieldView.value = "text value"; fieldView.type = FieldType.Text; - expect(patchCipher).toHaveBeenCalledWith({ fields: [fieldView] }); + expect(patchCipher).toHaveBeenCalled(); + const patchFn = patchCipher.mock.lastCall[0]; + + const updatedCipher = patchFn(new CipherView()); + expect(updatedCipher.fields).toEqual([fieldView]); }); }); @@ -295,7 +303,11 @@ describe("CustomFieldsComponent", () => { it("removes the field", () => { component.removeField(0); - expect(patchCipher).toHaveBeenCalledWith({ fields: [] }); + expect(patchCipher).toHaveBeenCalled(); + const patchFn = patchCipher.mock.lastCall[0]; + + const updatedCipher = patchFn(new CipherView()); + expect(updatedCipher.fields).toEqual([]); }); }); @@ -325,9 +337,12 @@ describe("CustomFieldsComponent", () => { // Move second field to first component.drop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop); - const latestCallParams = patchCipher.mock.lastCall[0]; + expect(patchCipher).toHaveBeenCalled(); + const patchFn = patchCipher.mock.lastCall[0]; - expect(latestCallParams.fields.map((f: FieldView) => f.name)).toEqual([ + const updatedCipher = patchFn(new CipherView()); + + expect(updatedCipher.fields.map((f: FieldView) => f.name)).toEqual([ "hidden label", "text label", "boolean label", @@ -342,9 +357,12 @@ describe("CustomFieldsComponent", () => { preventDefault: jest.fn(), }); - const latestCallParams = patchCipher.mock.lastCall[0]; + expect(patchCipher).toHaveBeenCalled(); + const patchFn = patchCipher.mock.lastCall[0]; - expect(latestCallParams.fields.map((f: FieldView) => f.name)).toEqual([ + const updatedCipher = patchFn(new CipherView()); + + expect(updatedCipher.fields.map((f: FieldView) => f.name)).toEqual([ "text label", "hidden label", "linked label", @@ -356,9 +374,12 @@ describe("CustomFieldsComponent", () => { // Move 2nd item (hidden label) up to 1st toggleItems[1].triggerEventHandler("keydown", { key: "ArrowUp", preventDefault: jest.fn() }); - const latestCallParams = patchCipher.mock.lastCall[0]; + expect(patchCipher).toHaveBeenCalled(); + const patchFn = patchCipher.mock.lastCall[0]; - expect(latestCallParams.fields.map((f: FieldView) => f.name)).toEqual([ + const updatedCipher = patchFn(new CipherView()); + + expect(updatedCipher.fields.map((f: FieldView) => f.name)).toEqual([ "hidden label", "text label", "boolean label", diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts index 44464fa324c..e2aa118b883 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts @@ -8,11 +8,11 @@ import { DestroyRef, ElementRef, EventEmitter, + inject, OnInit, Output, QueryList, ViewChildren, - inject, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormArray, FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms"; @@ -26,16 +26,16 @@ import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { + CardComponent, + CheckboxModule, DialogService, + FormFieldModule, + IconButtonModule, + LinkModule, SectionComponent, SectionHeaderComponent, - FormFieldModule, - TypographyModule, - CardComponent, - IconButtonModule, - CheckboxModule, SelectModule, - LinkModule, + TypographyModule, } from "@bitwarden/components"; import { CipherFormContainer } from "../../cipher-form-container"; @@ -344,8 +344,9 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit { this.numberOfFieldsChange.emit(newFields.length); - this.cipherFormContainer.patchCipher({ - fields: newFields, + this.cipherFormContainer.patchCipher((cipher) => { + cipher.fields = newFields; + return cipher; }); } } diff --git a/libs/vault/src/cipher-form/components/identity/identity.component.ts b/libs/vault/src/cipher-form/components/identity/identity.component.ts index bc5823dcd10..9e84f8ea6c7 100644 --- a/libs/vault/src/cipher-form/components/identity/identity.component.ts +++ b/libs/vault/src/cipher-form/components/identity/identity.component.ts @@ -9,11 +9,11 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; import { ButtonModule, - SectionComponent, - SectionHeaderComponent, CardComponent, FormFieldModule, IconButtonModule, + SectionComponent, + SectionHeaderComponent, SelectModule, TypographyModule, } from "@bitwarden/components"; @@ -98,8 +98,9 @@ export class IdentitySectionComponent implements OnInit { data.postalCode = value.postalCode; data.country = value.country; - this.cipherFormContainer.patchCipher({ - identity: data, + this.cipherFormContainer.patchCipher((cipher) => { + cipher.identity = data; + return cipher; }); }); } diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index 8ec42f807aa..a0a1b4e83f7 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -62,13 +62,17 @@ describe("ItemDetailsSectionComponent", () => { component.config.organizations = [{ id: "org1" } as Organization]; await component.ngOnInit(); tick(); - expect(cipherFormProvider.patchCipher).toHaveBeenLastCalledWith({ - name: "", - organizationId: null, - folderId: null, - collectionIds: [], - favorite: false, - }); + + expect(cipherFormProvider.patchCipher).toHaveBeenCalled(); + const patchFn = cipherFormProvider.patchCipher.mock.lastCall[0]; + + const updatedCipher = patchFn(new CipherView()); + + expect(updatedCipher.name).toBe(""); + expect(updatedCipher.organizationId).toBeNull(); + expect(updatedCipher.folderId).toBeNull(); + expect(updatedCipher.collectionIds).toEqual([]); + expect(updatedCipher.favorite).toBe(false); })); it("should initialize form with values from originalCipher if provided", fakeAsync(async () => { @@ -88,13 +92,16 @@ describe("ItemDetailsSectionComponent", () => { await component.ngOnInit(); tick(); - expect(cipherFormProvider.patchCipher).toHaveBeenLastCalledWith({ - name: "cipher1", - organizationId: "org1", - folderId: "folder1", - collectionIds: ["col1"], - favorite: true, - }); + expect(cipherFormProvider.patchCipher).toHaveBeenCalled(); + const patchFn = cipherFormProvider.patchCipher.mock.lastCall[0]; + + const updatedCipher = patchFn(new CipherView()); + + expect(updatedCipher.name).toBe("cipher1"); + expect(updatedCipher.organizationId).toBe("org1"); + expect(updatedCipher.folderId).toBe("folder1"); + expect(updatedCipher.collectionIds).toEqual(["col1"]); + expect(updatedCipher.favorite).toBe(true); })); it("should disable organizationId control if ownership change is not allowed", async () => { @@ -294,11 +301,12 @@ describe("ItemDetailsSectionComponent", () => { fixture.detectChanges(); await fixture.whenStable(); - expect(cipherFormProvider.patchCipher).toHaveBeenLastCalledWith( - expect.objectContaining({ - collectionIds: ["col1", "col2"], - }), - ); + expect(cipherFormProvider.patchCipher).toHaveBeenCalled(); + const patchFn = cipherFormProvider.patchCipher.mock.lastCall[0]; + + const updatedCipher = patchFn(new CipherView()); + + expect(updatedCipher.collectionIds).toEqual(["col1", "col2"]); }); it("should automatically select the first collection if only one is available", async () => { diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index bb0300cb8f6..75cb160c927 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -110,12 +110,15 @@ export class ItemDetailsSectionComponent implements OnInit { map(() => this.itemDetailsForm.getRawValue()), ) .subscribe((value) => { - this.cipherFormContainer.patchCipher({ - name: value.name, - organizationId: value.organizationId, - folderId: value.folderId, - collectionIds: value.collectionIds?.map((c) => c.id) || [], - favorite: value.favorite, + this.cipherFormContainer.patchCipher((cipher) => { + Object.assign(cipher, { + name: value.name, + organizationId: value.organizationId, + folderId: value.folderId, + collectionIds: value.collectionIds?.map((c) => c.id) || [], + favorite: value.favorite, + } as CipherView); + return cipher; }); }); } @@ -212,7 +215,6 @@ export class ItemDetailsSectionComponent implements OnInit { this.itemDetailsForm.controls.favorite.enable(); this.itemDetailsForm.controls.folderId.enable(); } else if (this.config.mode === "edit") { - // this.readOnlyCollections = this.collections .filter( (c) => c.readOnly && this.originalCipherView.collectionIds.includes(c.id as CollectionId), diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts index 610c451c95e..0fe4b128d3f 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts @@ -70,13 +70,14 @@ describe("LoginDetailsSectionComponent", () => { totp: "123456", }); - expect(cipherFormContainer.patchCipher).toHaveBeenLastCalledWith({ - login: expect.objectContaining({ - username: "new-username", - password: "secret-password", - totp: "123456", - }), - }); + expect(cipherFormContainer.patchCipher).toHaveBeenCalled(); + const patchFn = cipherFormContainer.patchCipher.mock.lastCall[0]; + + const updatedCipher = patchFn(new CipherView()); + + expect(updatedCipher.login.username).toBe("new-username"); + expect(updatedCipher.login.password).toBe("secret-password"); + expect(updatedCipher.login.totp).toBe("123456"); }); it("disables 'loginDetailsForm' when in partial-edit mode", async () => { @@ -154,12 +155,13 @@ describe("LoginDetailsSectionComponent", () => { username: "new-username", }); - expect(cipherFormContainer.patchCipher).toHaveBeenLastCalledWith({ - login: expect.objectContaining({ - username: "new-username", - password: "original-password", - }), - }); + expect(cipherFormContainer.patchCipher).toHaveBeenCalled(); + const patchFn = cipherFormContainer.patchCipher.mock.lastCall[0]; + + const updatedCipher = patchFn(new CipherView()); + + expect(updatedCipher.login.username).toBe("new-username"); + expect(updatedCipher.login.password).toBe("original-password"); }); }); @@ -493,11 +495,13 @@ describe("LoginDetailsSectionComponent", () => { tick(); - expect(cipherFormContainer.patchCipher).toHaveBeenLastCalledWith({ - login: expect.objectContaining({ - fido2Credentials: null, - }), - }); + expect(cipherFormContainer.patchCipher).toHaveBeenCalled(); + const patchFn = cipherFormContainer.patchCipher.mock.lastCall[0]; + + const updatedCipher = patchFn(new CipherView()); + + expect(updatedCipher.login.fido2Credentials).toBeNull(); + expect(component.hasPasskey).toBe(false); })); }); }); diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts index e88e1c0a5fc..61a51fadaa8 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts @@ -7,6 +7,7 @@ import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { AsyncActionsModule, @@ -58,16 +59,21 @@ export class LoginDetailsSectionComponent implements OnInit { private datePipe = inject(DatePipe); - private loginView: LoginView; + /** + * A local reference to the Fido2 credentials for an existing login being edited. + * These cannot be created in the form and thus have no form control. + * @private + */ + private existingFido2Credentials?: Fido2CredentialView[]; get hasPasskey(): boolean { - return this.loginView?.hasFido2Credentials; + return this.existingFido2Credentials != null && this.existingFido2Credentials.length > 0; } get fido2CredentialCreationDateValue(): string { const dateCreated = this.i18nService.t("dateCreated"); const creationDate = this.datePipe.transform( - this.loginView?.fido2Credentials?.[0]?.creationDate, + this.existingFido2Credentials?.[0]?.creationDate, "short", ); return `${dateCreated} ${creationDate}`; @@ -98,20 +104,19 @@ export class LoginDetailsSectionComponent implements OnInit { map(() => this.loginDetailsForm.getRawValue()), ) .subscribe((value) => { - Object.assign(this.loginView, { - username: value.username, - password: value.password, - totp: value.totp, - } as LoginView); + this.cipherFormContainer.patchCipher((cipher) => { + Object.assign(cipher.login, { + username: value.username, + password: value.password, + totp: value.totp, + } as LoginView); - this.cipherFormContainer.patchCipher({ - login: this.loginView, + return cipher; }); }); } async ngOnInit() { - this.loginView = new LoginView(); if (this.cipherFormContainer.originalCipherView?.login) { this.initFromExistingCipher(this.cipherFormContainer.originalCipherView.login); } else { @@ -124,15 +129,14 @@ export class LoginDetailsSectionComponent implements OnInit { } private initFromExistingCipher(existingLogin: LoginView) { - // Note: this.loginView will still contain references to the existing login's Uri and Fido2Credential arrays. - // We may need to deep clone these in the future. - Object.assign(this.loginView, existingLogin); this.loginDetailsForm.patchValue({ - username: this.loginView.username, - password: this.loginView.password, - totp: this.loginView.totp, + username: existingLogin.username, + password: existingLogin.password, + totp: existingLogin.totp, }); + this.existingFido2Credentials = existingLogin.fido2Credentials; + if (!this.viewHiddenFields) { this.loginDetailsForm.controls.password.disable(); this.loginDetailsForm.controls.totp.disable(); @@ -170,9 +174,10 @@ export class LoginDetailsSectionComponent implements OnInit { removePasskey = async () => { // Fido2Credentials do not have a form control, so update directly - this.loginView.fido2Credentials = null; - this.cipherFormContainer.patchCipher({ - login: this.loginView, + this.existingFido2Credentials = null; + this.cipherFormContainer.patchCipher((cipher) => { + cipher.login.fido2Credentials = null; + return cipher; }); }; diff --git a/package-lock.json b/package-lock.json index 0b4f18f2421..49d59ba1986 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "https-proxy-agent": "7.0.2", "inquirer": "8.2.6", "jquery": "3.7.1", - "jsdom": "23.0.1", + "jsdom": "24.1.1", "jszip": "3.10.1", "koa": "2.15.0", "koa-bodyparser": "4.4.1", @@ -100,7 +100,7 @@ "@types/inquirer": "8.2.10", "@types/jest": "29.5.12", "@types/jquery": "3.5.30", - "@types/jsdom": "21.1.6", + "@types/jsdom": "21.1.7", "@types/koa": "2.14.0", "@types/koa__multer": "2.0.7", "@types/koa__router": "12.0.4", @@ -130,7 +130,7 @@ "copy-webpack-plugin": "12.0.2", "cross-env": "7.0.3", "css-loader": "6.10.0", - "electron": "31.2.1", + "electron": "31.3.0", "electron-builder": "24.13.3", "electron-log": "5.0.1", "electron-reload": "2.0.0-alpha.1", @@ -162,8 +162,8 @@ "node-ipc": "9.2.1", "postcss": "8.4.38", "postcss-loader": "8.1.1", - "prettier": "3.2.2", - "prettier-plugin-tailwindcss": "0.5.14", + "prettier": "3.3.3", + "prettier-plugin-tailwindcss": "0.6.5", "process": "0.11.10", "react": "18.3.1", "react-dom": "18.3.1", @@ -212,7 +212,7 @@ "form-data": "4.0.0", "https-proxy-agent": "7.0.2", "inquirer": "8.2.6", - "jsdom": "23.0.1", + "jsdom": "24.1.1", "jszip": "3.10.1", "koa": "2.15.0", "koa-bodyparser": "4.4.1", @@ -11623,10 +11623,11 @@ } }, "node_modules/@types/jsdom": { - "version": "21.1.6", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.6.tgz", - "integrity": "sha512-/7kkMsC+/kMs7gAYmmBR9P0vGTnOoLhQhyhQJSlXGI5bzTHp6xdo0TtKWQAsz6pmSAeVqKSbqeyP6hytqr9FDw==", + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", @@ -17327,14 +17328,15 @@ "dev": true }, "node_modules/cssstyle": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", - "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", + "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", + "license": "MIT", "dependencies": { "rrweb-cssom": "^0.6.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/csstype": { @@ -18443,9 +18445,9 @@ } }, "node_modules/electron": { - "version": "31.2.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-31.2.1.tgz", - "integrity": "sha512-g3CLKjl4yuXt6VWm/KpgEjYYhFiCl19RgUn8lOC8zV/56ZXAS3+mqV4wWzicE/7vSYXs6GRO7vkYRwrwhX3Gaw==", + "version": "31.3.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-31.3.0.tgz", + "integrity": "sha512-3LMRMmK4UK0A+jYSLGLYdfhc20TgY2v5jD3iGmhRZlDYj0gn7xBj/waRjlNalysZ0D2rgPvoes0wHuf5e/Bguw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -26537,30 +26539,31 @@ } }, "node_modules/jsdom": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.0.1.tgz", - "integrity": "sha512-2i27vgvlUsGEBO9+/kJQRbtqtm+191b5zAZrU/UezVmnC2dlDAFLgDYJvAEi94T4kjsRKkezEtLQTgsNEsW2lQ==", + "version": "24.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.1.tgz", + "integrity": "sha512-5O1wWV99Jhq4DV7rCLIoZ/UIhyQeDR7wHVyZAHAshbrvZsLs+Xzz7gtwnlJTJDjleiTKh54F4dXrX70vJQTyJQ==", + "license": "MIT", "dependencies": { - "cssstyle": "^3.0.0", + "cssstyle": "^4.0.1", "data-urls": "^5.0.0", "decimal.js": "^10.4.3", "form-data": "^4.0.0", "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.2", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.7", + "nwsapi": "^2.2.12", "parse5": "^7.1.2", - "rrweb-cssom": "^0.6.0", + "rrweb-cssom": "^0.7.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.3", + "tough-cookie": "^4.1.4", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0", - "ws": "^8.14.2", + "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "engines": { @@ -26576,9 +26579,10 @@ } }, "node_modules/jsdom/node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "license": "MIT", "dependencies": { "debug": "^4.3.4" }, @@ -26590,6 +26594,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -26598,6 +26603,25 @@ "node": ">= 14" } }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "license": "MIT" + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -30770,9 +30794,10 @@ } }, "node_modules/nwsapi": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", - "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==" + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz", + "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==", + "license": "MIT" }, "node_modules/nypm": { "version": "0.3.8", @@ -32687,10 +32712,11 @@ } }, "node_modules/prettier": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.2.tgz", - "integrity": "sha512-HTByuKZzw7utPiDO523Tt2pLtEyK7OibUD9suEJQrPUCYQqrHr74GGX6VidMrovbf/I50mPqr8j/II6oBAuc5A==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -32702,10 +32728,11 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.5.14", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.14.tgz", - "integrity": "sha512-Puaz+wPUAhFp8Lo9HuciYKM2Y2XExESjeT+9NQoVFXZsPPnc9VYss2SpxdQ6vbatmt8/4+SN0oe0I1cPDABg9Q==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.5.tgz", + "integrity": "sha512-axfeOArc/RiGHjOIy9HytehlC0ZLeMaqY09mm8YCkMzznKiDkwFzOpBvtuhuv3xG5qB73+Mj7OCe2j/L1ryfuQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.21.3" }, @@ -37604,9 +37631,10 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", - "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -40199,9 +40227,10 @@ } }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 97b11715bd9..3fa152891fb 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@types/inquirer": "8.2.10", "@types/jest": "29.5.12", "@types/jquery": "3.5.30", - "@types/jsdom": "21.1.6", + "@types/jsdom": "21.1.7", "@types/koa": "2.14.0", "@types/koa__multer": "2.0.7", "@types/koa__router": "12.0.4", @@ -92,7 +92,7 @@ "copy-webpack-plugin": "12.0.2", "cross-env": "7.0.3", "css-loader": "6.10.0", - "electron": "31.2.1", + "electron": "31.3.0", "electron-builder": "24.13.3", "electron-log": "5.0.1", "electron-reload": "2.0.0-alpha.1", @@ -124,8 +124,8 @@ "node-ipc": "9.2.1", "postcss": "8.4.38", "postcss-loader": "8.1.1", - "prettier": "3.2.2", - "prettier-plugin-tailwindcss": "0.5.14", + "prettier": "3.3.3", + "prettier-plugin-tailwindcss": "0.6.5", "process": "0.11.10", "react": "18.3.1", "react-dom": "18.3.1", @@ -179,7 +179,7 @@ "https-proxy-agent": "7.0.2", "inquirer": "8.2.6", "jquery": "3.7.1", - "jsdom": "23.0.1", + "jsdom": "24.1.1", "jszip": "3.10.1", "koa": "2.15.0", "koa-bodyparser": "4.4.1",