diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 69600b5da7ca..9a69d5f1085e 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3926,6 +3926,18 @@ "data": { "message": "Data" }, + "passkeys": { + "message": "Passkeys", + "description": "A section header for a list of passkeys. Used in the inline menu list." + }, + "passwords": { + "message": "Passwords", + "description": "A section header for a list of passwords. Used in the inline menu list." + }, + "logInWithPasskeyAriaLabel": { + "message": "Log in with passkey", + "description": "ARIA label for the inline menu button that logs in with a passkey." + }, "assign": { "message": "Assign" }, diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index 8122f5c4ed95..950f3b8e275c 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -1,5 +1,6 @@ import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import AutofillPageDetails from "../../models/autofill-page-details"; import { PageDetail } from "../../services/abstractions/autofill.service"; @@ -132,6 +133,7 @@ export type OverlayPortMessage = { direction?: string; inlineMenuCipherId?: string; addNewCipherType?: CipherType; + usePasskey?: boolean; }; export type InlineMenuCipherData = { @@ -142,7 +144,13 @@ export type InlineMenuCipherData = { favorite: boolean; icon: WebsiteIconData; accountCreationFieldType?: string; - login?: { username: string }; + login?: { + username: string; + passkey: { + rpName: string; + userName: string; + } | null; + }; card?: string; identity?: { fullName: string; @@ -150,6 +158,15 @@ export type InlineMenuCipherData = { }; }; +export type BuildCipherDataParams = { + inlineMenuCipherId: string; + cipher: CipherView; + showFavicons?: boolean; + showInlineMenuAccountCreation?: boolean; + hasPasskey?: boolean; + identityData?: { fullName: string; username?: string }; +}; + export type BackgroundMessageParam = { message: OverlayBackgroundExtensionMessage; }; diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index fe1188686288..e29cc8331a2f 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -17,6 +17,7 @@ import { EnvironmentService, Region, } from "@bitwarden/common/platform/abstractions/environment.service"; +import { Fido2ClientService } from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; @@ -32,6 +33,7 @@ import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserPlatformUtilsService } from "../../platform/services/platform-utils/browser-platform-utils.service"; @@ -85,6 +87,8 @@ describe("OverlayBackground", () => { let autofillSettingsService: MockProxy; let i18nService: MockProxy; let platformUtilsService: MockProxy; + let availableAutofillCredentialsMock$: BehaviorSubject; + let fido2ClientService: MockProxy; let selectedThemeMock$: BehaviorSubject; let themeStateService: MockProxy; let overlayBackground: OverlayBackground; @@ -151,6 +155,10 @@ describe("OverlayBackground", () => { autofillSettingsService.inlineMenuVisibility$ = inlineMenuVisibilityMock$; i18nService = mock(); platformUtilsService = mock(); + availableAutofillCredentialsMock$ = new BehaviorSubject([]); + fido2ClientService = mock({ + availableAutofillCredentials$: (_tabId) => availableAutofillCredentialsMock$, + }); selectedThemeMock$ = new BehaviorSubject(ThemeType.Light); themeStateService = mock(); themeStateService.selectedTheme$ = selectedThemeMock$; @@ -164,6 +172,7 @@ describe("OverlayBackground", () => { autofillSettingsService, i18nService, platformUtilsService, + fido2ClientService, themeStateService, ); portKeyForTabSpy = overlayBackground["portKeyForTab"]; @@ -699,28 +708,28 @@ describe("OverlayBackground", () => { describe("updating the overlay ciphers", () => { const url = "https://jest-testing-website.com"; const tab = createChromeTabMock({ url }); - const cipher1 = mock({ + const loginCipher1 = mock({ id: "id-1", localData: { lastUsedDate: 222 }, name: "name-1", type: CipherType.Login, login: { username: "username-1", uri: url }, }); - const cipher2 = mock({ + const cardCipher = mock({ id: "id-2", localData: { lastUsedDate: 222 }, name: "name-2", type: CipherType.Card, card: { subTitle: "subtitle-2" }, }); - const cipher3 = mock({ + const loginCipher2 = mock({ id: "id-3", localData: { lastUsedDate: 222 }, name: "name-3", type: CipherType.Login, login: { username: "username-3", uri: url }, }); - const cipher4 = mock({ + const identityCipher = mock({ id: "id-4", localData: { lastUsedDate: 222 }, name: "name-4", @@ -732,6 +741,23 @@ describe("OverlayBackground", () => { email: "email@example.com", }, }); + const passkeyCipher = mock({ + id: "id-5", + localData: { lastUsedDate: 222 }, + name: "name-5", + type: CipherType.Login, + login: { + username: "username-5", + uri: url, + fido2Credentials: [ + mock({ + credentialId: "credential-id", + rpName: "credential-name", + userName: "credential-username", + }), + ], + }, + }); beforeEach(async () => { activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -764,7 +790,7 @@ describe("OverlayBackground", () => { it("closes the inline menu on the focused field's tab if current tab is different", async () => { getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); - cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.getAllDecryptedForUrl.mockResolvedValue([loginCipher1, cardCipher]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); const previousTab = mock({ id: 15 }); overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: 15 }); @@ -781,7 +807,7 @@ describe("OverlayBackground", () => { 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.getAllDecryptedForUrl.mockResolvedValue([loginCipher1, cardCipher]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); await overlayBackground.updateOverlayCiphers(); @@ -794,8 +820,8 @@ describe("OverlayBackground", () => { expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled(); expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual( new Map([ - ["inline-menu-cipher-0", cipher2], - ["inline-menu-cipher-1", cipher1], + ["inline-menu-cipher-0", cardCipher], + ["inline-menu-cipher-1", loginCipher1], ]), ); }); @@ -803,7 +829,7 @@ describe("OverlayBackground", () => { 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.getAllDecryptedForUrl.mockResolvedValue([loginCipher2, loginCipher1]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); await overlayBackground.updateOverlayCiphers(false); @@ -813,15 +839,15 @@ describe("OverlayBackground", () => { expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled(); expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual( new Map([ - ["inline-menu-cipher-0", cipher1], - ["inline-menu-cipher-1", cipher3], + ["inline-menu-cipher-0", loginCipher1], + ["inline-menu-cipher-1", loginCipher2], ]), ); }); 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.getAllDecryptedForUrl.mockResolvedValue([loginCipher1, cardCipher]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); await overlayBackground.updateOverlayCiphers(false); @@ -834,15 +860,15 @@ describe("OverlayBackground", () => { expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled(); expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual( new Map([ - ["inline-menu-cipher-0", cipher2], - ["inline-menu-cipher-1", cipher1], + ["inline-menu-cipher-0", cardCipher], + ["inline-menu-cipher-1", loginCipher1], ]), ); }); 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["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id }); - cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1]); + cipherService.getAllDecryptedForUrl.mockResolvedValue([loginCipher1]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); @@ -851,10 +877,11 @@ describe("OverlayBackground", () => { expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", showInlineMenuAccountCreation: false, + showPasskeysLabels: false, ciphers: [ { accountCreationFieldType: undefined, - favorite: cipher1.favorite, + favorite: loginCipher1.favorite, icon: { fallbackImage: "images/bwi-globe.png", icon: "bwi-globe", @@ -864,9 +891,10 @@ describe("OverlayBackground", () => { id: "inline-menu-cipher-0", login: { username: "username-1", + passkey: null, }, name: "name-1", - reprompt: cipher1.reprompt, + reprompt: loginCipher1.reprompt, type: CipherType.Login, }, ], @@ -878,7 +906,7 @@ describe("OverlayBackground", () => { tabId: tab.id, filledByCipherType: CipherType.Card, }); - cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.getAllDecryptedForUrl.mockResolvedValue([loginCipher1, cardCipher]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); @@ -887,10 +915,11 @@ describe("OverlayBackground", () => { expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", showInlineMenuAccountCreation: false, + showPasskeysLabels: false, ciphers: [ { accountCreationFieldType: undefined, - favorite: cipher2.favorite, + favorite: cardCipher.favorite, icon: { fallbackImage: "", icon: "bwi-credit-card", @@ -898,9 +927,9 @@ describe("OverlayBackground", () => { imageEnabled: true, }, id: "inline-menu-cipher-0", - card: cipher2.card.subTitle, - name: cipher2.name, - reprompt: cipher2.reprompt, + card: cardCipher.card.subTitle, + name: cardCipher.name, + reprompt: cardCipher.reprompt, type: CipherType.Card, }, ], @@ -914,7 +943,7 @@ describe("OverlayBackground", () => { accountCreationFieldType: "text", showInlineMenuAccountCreation: true, }); - cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher4, cipher2]); + cipherService.getAllDecryptedForUrl.mockResolvedValue([identityCipher, cardCipher]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); @@ -923,10 +952,11 @@ describe("OverlayBackground", () => { expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", showInlineMenuAccountCreation: true, + showPasskeysLabels: false, ciphers: [ { accountCreationFieldType: "text", - favorite: cipher4.favorite, + favorite: identityCipher.favorite, icon: { fallbackImage: "", icon: "bwi-id-card", @@ -934,12 +964,12 @@ describe("OverlayBackground", () => { imageEnabled: true, }, id: "inline-menu-cipher-1", - name: cipher4.name, - reprompt: cipher4.reprompt, + name: identityCipher.name, + reprompt: identityCipher.reprompt, type: CipherType.Identity, identity: { - fullName: `${cipher4.identity.firstName} ${cipher4.identity.lastName}`, - username: cipher4.identity.username, + fullName: `${identityCipher.identity.firstName} ${identityCipher.identity.lastName}`, + username: identityCipher.identity.username, }, }, ], @@ -952,7 +982,7 @@ describe("OverlayBackground", () => { accountCreationFieldType: "text", showInlineMenuAccountCreation: true, }); - cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher4]); + cipherService.getAllDecryptedForUrl.mockResolvedValue([loginCipher1, identityCipher]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); @@ -961,10 +991,11 @@ describe("OverlayBackground", () => { expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", showInlineMenuAccountCreation: true, + showPasskeysLabels: false, ciphers: [ { accountCreationFieldType: "text", - favorite: cipher4.favorite, + favorite: identityCipher.favorite, icon: { fallbackImage: "", icon: "bwi-id-card", @@ -972,17 +1003,17 @@ describe("OverlayBackground", () => { imageEnabled: true, }, id: "inline-menu-cipher-0", - name: cipher4.name, - reprompt: cipher4.reprompt, + name: identityCipher.name, + reprompt: identityCipher.reprompt, type: CipherType.Identity, identity: { - fullName: `${cipher4.identity.firstName} ${cipher4.identity.lastName}`, - username: cipher4.identity.username, + fullName: `${identityCipher.identity.firstName} ${identityCipher.identity.lastName}`, + username: identityCipher.identity.username, }, }, { accountCreationFieldType: "text", - favorite: cipher1.favorite, + favorite: loginCipher1.favorite, icon: { fallbackImage: "images/bwi-globe.png", icon: "bwi-globe", @@ -991,10 +1022,11 @@ describe("OverlayBackground", () => { }, id: "inline-menu-cipher-1", login: { - username: cipher1.login.username, + username: loginCipher1.login.username, + passkey: null, }, - name: cipher1.name, - reprompt: cipher1.reprompt, + name: loginCipher1.name, + reprompt: loginCipher1.reprompt, type: CipherType.Login, }, ], @@ -1018,7 +1050,7 @@ describe("OverlayBackground", () => { }, }); cipherService.getAllDecryptedForUrl.mockResolvedValue([ - cipher4, + identityCipher, identityCipherWithoutUsername, ]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); @@ -1029,10 +1061,11 @@ describe("OverlayBackground", () => { expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", showInlineMenuAccountCreation: true, + showPasskeysLabels: false, ciphers: [ { accountCreationFieldType: "email", - favorite: cipher4.favorite, + favorite: identityCipher.favorite, icon: { fallbackImage: "", icon: "bwi-id-card", @@ -1040,12 +1073,12 @@ describe("OverlayBackground", () => { imageEnabled: true, }, id: "inline-menu-cipher-1", - name: cipher4.name, - reprompt: cipher4.reprompt, + name: identityCipher.name, + reprompt: identityCipher.reprompt, type: CipherType.Identity, identity: { - fullName: `${cipher4.identity.firstName} ${cipher4.identity.lastName}`, - username: cipher4.identity.email, + fullName: `${identityCipher.identity.firstName} ${identityCipher.identity.lastName}`, + username: identityCipher.identity.email, }, }, ], @@ -1058,7 +1091,7 @@ describe("OverlayBackground", () => { accountCreationFieldType: "password", showInlineMenuAccountCreation: true, }); - cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher4]); + cipherService.getAllDecryptedForUrl.mockResolvedValue([identityCipher]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); @@ -1067,10 +1100,89 @@ describe("OverlayBackground", () => { expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "updateAutofillInlineMenuListCiphers", showInlineMenuAccountCreation: true, + showPasskeysLabels: false, ciphers: [], }); }); }); + + it("adds available passkey ciphers to the inline menu", async () => { + availableAutofillCredentialsMock$.next(passkeyCipher.login.fido2Credentials); + overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ + tabId: tab.id, + filledByCipherType: CipherType.Login, + }); + cipherService.getAllDecryptedForUrl.mockResolvedValue([loginCipher1, passkeyCipher]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); + + await overlayBackground.updateOverlayCiphers(); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateAutofillInlineMenuListCiphers", + ciphers: [ + { + id: "inline-menu-cipher-0", + name: passkeyCipher.name, + type: CipherType.Login, + reprompt: passkeyCipher.reprompt, + favorite: passkeyCipher.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + accountCreationFieldType: undefined, + login: { + username: passkeyCipher.login.username, + passkey: { + rpName: passkeyCipher.login.fido2Credentials[0].rpName, + userName: passkeyCipher.login.fido2Credentials[0].userName, + }, + }, + }, + { + id: "inline-menu-cipher-0", + name: passkeyCipher.name, + type: CipherType.Login, + reprompt: passkeyCipher.reprompt, + favorite: passkeyCipher.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + accountCreationFieldType: undefined, + login: { + username: passkeyCipher.login.username, + passkey: null, + }, + }, + { + id: "inline-menu-cipher-1", + name: loginCipher1.name, + type: CipherType.Login, + reprompt: loginCipher1.reprompt, + favorite: loginCipher1.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + accountCreationFieldType: undefined, + login: { + username: loginCipher1.login.username, + passkey: null, + }, + }, + ], + showInlineMenuAccountCreation: false, + showPasskeysLabels: true, + }); + }); }); describe("extension message handlers", () => { @@ -1562,6 +1674,7 @@ describe("OverlayBackground", () => { command: "updateAutofillInlineMenuListCiphers", ciphers: [], showInlineMenuAccountCreation: true, + showPasskeysLabels: false, }); }); @@ -2660,6 +2773,41 @@ describe("OverlayBackground", () => { expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code"); }); + + it("triggers passkey authentication through mediated conditional UI", async () => { + const fido2Credential = mock({ credentialId: "credential-id" }); + const cipher1 = mock({ + id: "inline-menu-cipher-1", + login: { + username: "username1", + password: "password1", + fido2Credentials: [fido2Credential], + }, + }); + overlayBackground["inlineMenuCiphers"] = new Map([["inline-menu-cipher-1", cipher1]]); + const pageDetailsForTab = { + frameId: sender.frameId, + tab: sender.tab, + details: pageDetails, + }; + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, pageDetailsForTab], + ]); + autofillService.isPasswordRepromptRequired.mockResolvedValue(false); + + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + usePasskey: true, + portKey, + }); + await flushPromises(); + + expect(fido2ClientService.autofillCredential).toHaveBeenCalledWith( + sender.tab.id, + fido2Credential.credentialId, + ); + }); }); describe("addNewVaultItem message handler", () => { diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 8c4dac56d500..3bb80b09b2e5 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1,5 +1,12 @@ -import { firstValueFrom, merge, Subject, throttleTime } from "rxjs"; -import { debounceTime, switchMap } from "rxjs/operators"; +import { + firstValueFrom, + merge, + ReplaySubject, + Subject, + throttleTime, + switchMap, + debounceTime, +} from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -11,6 +18,7 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { Fido2ClientService } from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction"; 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"; @@ -21,6 +29,7 @@ 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 { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view"; import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; @@ -41,6 +50,7 @@ import { generateRandomChars } from "../utils"; import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background"; import { + BuildCipherDataParams, CloseInlineMenuMessage, CurrentAddNewItemData, FocusedFieldData, @@ -66,6 +76,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { private readonly openUnlockPopout = openUnlockPopout; private readonly openViewVaultItemPopout = openViewVaultItemPopout; private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout; + private readonly storeInlineMenuFido2CredentialsSubject = new ReplaySubject(1); private pageDetailsForTab: PageDetailsForTab = {}; private subFrameOffsetsForTab: SubFrameOffsetsForTab = {}; private portKeyForTab: Record = {}; @@ -73,6 +84,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { private inlineMenuButtonPort: chrome.runtime.Port; private inlineMenuListPort: chrome.runtime.Port; private inlineMenuCiphers: Map = new Map(); + private inlineMenuFido2Credentials: Set = new Set(); private inlineMenuPageTranslations: Record; private inlineMenuPosition: InlineMenuPosition = {}; private cardAndIdentityCiphers: Set | null = null; @@ -91,6 +103,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { private isFieldCurrentlyFilling: boolean = false; private isInlineMenuButtonVisible: boolean = false; private isInlineMenuListVisible: boolean = false; + private showPasskeysLabelsWithinInlineMenu: boolean = false; private iconsServerUrl: string; private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = { autofillOverlayElementClosed: ({ message, sender }) => @@ -159,6 +172,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { private autofillSettingsService: AutofillSettingsServiceAbstraction, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, + private fido2ClientService: Fido2ClientService, private themeStateService: ThemeStateService, ) { this.initOverlayEventObservables(); @@ -178,6 +192,9 @@ export class OverlayBackground implements OverlayBackgroundInterface { * Initializes event observables that handle events which affect the overlay's behavior. */ private initOverlayEventObservables() { + this.storeInlineMenuFido2CredentialsSubject + .pipe(switchMap((tabId) => this.fido2ClientService.availableAutofillCredentials$(tabId))) + .subscribe((credentials) => this.storeInlineMenuFido2Credentials(credentials)); this.repositionInlineMenuSubject .pipe( debounceTime(1000), @@ -252,6 +269,13 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.closeInlineMenuAfterCiphersUpdate().catch((error) => this.logService.error(error)); } + if (!currentTab) { + return; + } + + this.inlineMenuFido2Credentials.clear(); + this.storeInlineMenuFido2CredentialsSubject.next(currentTab.id); + this.inlineMenuCiphers = new Map(); const ciphersViews = await this.getCipherViews(currentTab, updateAllCipherTypes); for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) { @@ -263,6 +287,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { command: "updateAutofillInlineMenuListCiphers", ciphers, showInlineMenuAccountCreation: this.showInlineMenuAccountCreation(), + showPasskeysLabels: this.showPasskeysLabelsWithinInlineMenu, }); } @@ -280,9 +305,9 @@ export class OverlayBackground implements OverlayBackgroundInterface { return this.getAllCipherTypeViews(currentTab); } - const cipherViews = ( - await this.cipherService.getAllDecryptedForUrl(currentTab?.url || "") - ).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)); + const cipherViews = (await this.cipherService.getAllDecryptedForUrl(currentTab.url || "")).sort( + (a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b), + ); return this.cardAndIdentityCiphers ? cipherViews.concat(...this.cardAndIdentityCiphers) @@ -301,7 +326,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.cardAndIdentityCiphers.clear(); const cipherViews = ( - await this.cipherService.getAllDecryptedForUrl(currentTab.url, [ + await this.cipherService.getAllDecryptedForUrl(currentTab.url || "", [ CipherType.Card, CipherType.Identity, ]) @@ -331,6 +356,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$); const inlineMenuCiphersArray = Array.from(this.inlineMenuCiphers); let inlineMenuCipherData: InlineMenuCipherData[]; + this.showPasskeysLabelsWithinInlineMenu = false; if (this.showInlineMenuAccountCreation()) { inlineMenuCipherData = this.buildInlineMenuAccountCreationCiphers( @@ -363,7 +389,12 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (cipher.type === CipherType.Login) { accountCreationLoginCiphers.push( - this.buildCipherData(inlineMenuCipherId, cipher, showFavicons, true), + this.buildCipherData({ + inlineMenuCipherId, + cipher, + showFavicons, + showInlineMenuAccountCreation: true, + }), ); continue; } @@ -378,7 +409,13 @@ export class OverlayBackground implements OverlayBackgroundInterface { } inlineMenuCipherData.push( - this.buildCipherData(inlineMenuCipherId, cipher, showFavicons, true, identity), + this.buildCipherData({ + inlineMenuCipherId, + cipher, + showFavicons, + showInlineMenuAccountCreation: true, + identityData: identity, + }), ); } @@ -400,6 +437,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { showFavicons: boolean, ) { const inlineMenuCipherData: InlineMenuCipherData[] = []; + const passkeyCipherData: InlineMenuCipherData[] = []; for (let cipherIndex = 0; cipherIndex < inlineMenuCiphersArray.length; cipherIndex++) { const [inlineMenuCipherId, cipher] = inlineMenuCiphersArray[cipherIndex]; @@ -407,12 +445,43 @@ export class OverlayBackground implements OverlayBackgroundInterface { continue; } - inlineMenuCipherData.push(this.buildCipherData(inlineMenuCipherId, cipher, showFavicons)); + if (this.showCipherAsPasskey(cipher)) { + passkeyCipherData.push( + this.buildCipherData({ + inlineMenuCipherId, + cipher, + showFavicons, + hasPasskey: true, + }), + ); + } + + inlineMenuCipherData.push(this.buildCipherData({ inlineMenuCipherId, cipher, showFavicons })); + } + + if (passkeyCipherData.length) { + this.showPasskeysLabelsWithinInlineMenu = + passkeyCipherData.length > 0 && inlineMenuCipherData.length > 0; + return passkeyCipherData.concat(inlineMenuCipherData); } return inlineMenuCipherData; } + /** + * Identifies whether we should show the cipher as a passkey in the inline menu list. + * + * @param cipher - The cipher to check + */ + private showCipherAsPasskey(cipher: CipherView): boolean { + return ( + cipher.type === CipherType.Login && + cipher.login.fido2Credentials?.length > 0 && + (this.inlineMenuFido2Credentials.size === 0 || + this.inlineMenuFido2Credentials.has(cipher.login.fido2Credentials[0].credentialId)) + ); + } + /** * Builds the cipher data for the inline menu list. * @@ -420,15 +489,17 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param cipher - The cipher to build data for * @param showFavicons - Identifies whether favicons should be shown * @param showInlineMenuAccountCreation - Identifies whether the inline menu is for account creation + * @param hasPasskey - Identifies whether the cipher has a FIDO2 credential * @param identityData - Pre-created identity data */ - private buildCipherData( - inlineMenuCipherId: string, - cipher: CipherView, - showFavicons: boolean, - showInlineMenuAccountCreation: boolean = false, - identityData?: { fullName: string; username?: string }, - ): InlineMenuCipherData { + private buildCipherData({ + inlineMenuCipherId, + cipher, + showFavicons, + showInlineMenuAccountCreation, + hasPasskey, + identityData, + }: BuildCipherDataParams): InlineMenuCipherData { const inlineMenuData: InlineMenuCipherData = { id: inlineMenuCipherId, name: cipher.name, @@ -440,7 +511,15 @@ export class OverlayBackground implements OverlayBackgroundInterface { }; if (cipher.type === CipherType.Login) { - inlineMenuData.login = { username: cipher.login.username }; + inlineMenuData.login = { + username: cipher.login.username, + passkey: hasPasskey + ? { + rpName: cipher.login.fido2Credentials[0].rpName, + userName: cipher.login.fido2Credentials[0].userName, + } + : null, + }; return inlineMenuData; } @@ -512,6 +591,17 @@ export class OverlayBackground implements OverlayBackgroundInterface { return this.inlineMenuCiphers.size === 0; } + /** + * Stores the credential ids associated with a FIDO2 conditional mediated ui request. + * + * @param credentials - The FIDO2 credentials to store + */ + private storeInlineMenuFido2Credentials(credentials: Fido2CredentialView[]) { + credentials + .map((credential) => credential.credentialId) + .forEach((credentialId) => this.inlineMenuFido2Credentials.add(credentialId)); + } + /** * Gets the currently focused field and closes the inline menu on that tab. */ @@ -749,10 +839,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { * the selected cipher at the top of the list of ciphers. * * @param inlineMenuCipherId - Cipher ID corresponding to the inlineMenuCiphers map. Does not correspond to the actual cipher's ID. + * @param usePasskey - Identifies whether the cipher has a FIDO2 credential * @param sender - The sender of the port message */ private async fillInlineMenuCipher( - { inlineMenuCipherId }: OverlayPortMessage, + { inlineMenuCipherId, usePasskey }: OverlayPortMessage, { sender }: chrome.runtime.Port, ) { const pageDetails = this.pageDetailsForTab[sender.tab.id]; @@ -762,6 +853,16 @@ export class OverlayBackground implements OverlayBackgroundInterface { const cipher = this.inlineMenuCiphers.get(inlineMenuCipherId); + if (usePasskey && cipher.login?.hasFido2Credentials) { + await this.fido2ClientService.autofillCredential( + sender.tab.id, + cipher.login.fido2Credentials[0].credentialId, + ); + this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher); + + return; + } + if (await this.autofillService.isPasswordRepromptRequired(cipher, sender.tab)) { return; } @@ -777,6 +878,16 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.platformUtilsService.copyToClipboard(totpCode); } + this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher); + } + + /** + * Sets the most recently used cipher at the top of the list of ciphers. + * + * @param inlineMenuCipherId - The ID of the inline menu cipher + * @param cipher - The cipher to set as the most recently used + */ + private updateLastUsedInlineMenuCipher(inlineMenuCipherId: string, cipher: CipherView) { this.inlineMenuCiphers = new Map([[inlineMenuCipherId, cipher], ...this.inlineMenuCiphers]); } @@ -1163,6 +1274,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { command: "updateAutofillInlineMenuListCiphers", ciphers: await this.getInlineMenuCipherData(), showInlineMenuAccountCreation: this.showInlineMenuAccountCreation(), + showPasskeysLabels: this.showPasskeysLabelsWithinInlineMenu, }); } @@ -1214,6 +1326,9 @@ export class OverlayBackground implements OverlayBackgroundInterface { private async openInlineMenu(isFocusingFieldElement = false, isOpeningFullInlineMenu = false) { this.clearDelayedInlineMenuClosure(); const currentTab = await BrowserApi.getTabFromCurrentWindowId(); + if (!currentTab) { + return; + } await BrowserApi.tabSendMessage( currentTab, @@ -1224,8 +1339,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { authStatus: await this.getAuthStatus(), }, { - frameId: - this.focusedFieldData?.tabId === currentTab?.id ? this.focusedFieldData.frameId : 0, + frameId: this.focusedFieldData?.tabId === currentTab.id ? this.focusedFieldData.frameId : 0, }, ); } @@ -1367,6 +1481,9 @@ export class OverlayBackground implements OverlayBackgroundInterface { newIdentity: this.i18nService.translate("newIdentity"), addNewIdentityItem: this.i18nService.translate("addNewIdentityItemAria"), cardNumberEndsWith: this.i18nService.translate("cardNumberEndsWith"), + passkeys: this.i18nService.translate("passkeys"), + passwords: this.i18nService.translate("passwords"), + logInWithPasskey: this.i18nService.translate("logInWithPasskeyAriaLabel"), }; } @@ -2064,6 +2181,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { : AutofillOverlayPort.ButtonMessageConnector, filledByCipherType: this.focusedFieldData?.filledByCipherType, showInlineMenuAccountCreation: this.showInlineMenuAccountCreation(), + showPasskeysLabels: this.showPasskeysLabelsWithinInlineMenu, }); this.updateInlineMenuPosition( { diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts index 5f91e6c08130..7275ced37bad 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts @@ -1,5 +1,3 @@ -import { FallbackRequestedError } from "@bitwarden/common/platform/abstractions/fido2/fido2-client.service.abstraction"; - import { WebauthnUtils } from "../../../vault/fido2/webauthn-utils"; import { MessageType } from "./messaging/message"; @@ -126,13 +124,47 @@ import { Messenger } from "./messaging/messenger"; return await browserCredentials.get(options); } + const abortSignal = options?.signal || new AbortController().signal; const fallbackSupported = browserNativeWebauthnSupport; - try { - if (options?.mediation && options.mediation !== "optional") { - throw new FallbackRequestedError(); - } + if (options?.mediation && options.mediation === "conditional") { + const internalAbortControllers = [new AbortController(), new AbortController()]; + const bitwardenResponse = async (internalAbortController: AbortController) => { + try { + const response = await messenger.request( + { + type: MessageType.CredentialGetRequest, + data: WebauthnUtils.mapCredentialRequestOptions(options, fallbackSupported), + }, + internalAbortController.signal, + ); + if (response.type !== MessageType.CredentialGetResponse) { + throw new Error("Something went wrong."); + } + + return WebauthnUtils.mapCredentialAssertResult(response.result); + } catch { + // Ignoring error + } + }; + const browserResponse = (internalAbortController: AbortController) => + browserCredentials.get({ ...options, signal: internalAbortController.signal }); + const abortListener = () => { + internalAbortControllers.forEach((controller) => controller.abort()); + }; + abortSignal.addEventListener("abort", abortListener); + + const response = await Promise.race([ + bitwardenResponse(internalAbortControllers[0]), + browserResponse(internalAbortControllers[1]), + ]); + abortSignal.removeEventListener("abort", abortListener); + internalAbortControllers.forEach((controller) => controller.abort()); + return response; + } + + try { const response = await messenger.request( { type: MessageType.CredentialGetRequest, diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script.webauthn-supported.spec.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script.webauthn-supported.spec.ts index 292d0e011828..21f5a1d701a6 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script.webauthn-supported.spec.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script.webauthn-supported.spec.ts @@ -128,6 +128,17 @@ describe("Fido2 page script with native WebAuthn support", () => { mockCredentialAssertResult, ); }); + + it("initiates a conditional mediated webauth request", async () => { + mockCredentialRequestOptions.mediation = "conditional"; + mockCredentialRequestOptions.signal = new AbortController().signal; + + await navigator.credentials.get(mockCredentialRequestOptions); + + expect(WebauthnUtils.mapCredentialAssertResult).toHaveBeenCalledWith( + mockCredentialAssertResult, + ); + }); }); describe("destroy", () => { 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 090fb7887c95..ea584165b4de 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 @@ -18,6 +18,7 @@ export type InitAutofillInlineMenuListMessage = AutofillInlineMenuListMessage & ciphers?: InlineMenuCipherData[]; filledByCipherType?: CipherType; showInlineMenuAccountCreation?: boolean; + showPasskeysLabels?: boolean; 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 a8a4d5c4a788..93d757fc51e4 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 @@ -478,7 +478,6 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f class="cipher-container" > + + + +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • + + +`; + exports[`AutofillInlineMenuList initAutofillInlineMenuList the locked inline menu for an unauthenticated user creates the views for the locked inline menu 1`] = `
    { fillCipherButton.dispatchEvent(new Event("click")); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "fillAutofillInlineMenuCipher", inlineMenuCipherId: "1", portKey }, + { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "1", + usePasskey: false, + portKey, + }, "*", ); }); @@ -504,6 +510,178 @@ describe("AutofillInlineMenuList", () => { }); }); }); + + describe("creating a list of passkeys", () => { + let passkeyCipher1: InlineMenuCipherData; + let passkeyCipher2: InlineMenuCipherData; + let passkeyCipher3: InlineMenuCipherData; + let loginCipher1: InlineMenuCipherData; + let loginCipher2: InlineMenuCipherData; + let loginCipher3: InlineMenuCipherData; + let loginCipher4: InlineMenuCipherData; + const borderClass = "inline-menu-list-heading--bordered"; + + beforeEach(() => { + passkeyCipher1 = createAutofillOverlayCipherDataMock(1, { + name: "https://example.com", + login: { + username: "username1", + passkey: { + rpName: "https://example.com", + userName: "username1", + }, + }, + }); + passkeyCipher2 = createAutofillOverlayCipherDataMock(2, { + name: "https://example.com", + login: { + username: "", + passkey: { + rpName: "https://example.com", + userName: "username2", + }, + }, + }); + passkeyCipher3 = createAutofillOverlayCipherDataMock(3, { + login: { + username: "username3", + passkey: { + rpName: "https://example.com", + userName: "username3", + }, + }, + }); + loginCipher1 = createAutofillOverlayCipherDataMock(1, { + login: { + username: "username1", + passkey: null, + }, + }); + loginCipher2 = createAutofillOverlayCipherDataMock(2, { + login: { + username: "username2", + passkey: null, + }, + }); + loginCipher3 = createAutofillOverlayCipherDataMock(3, { + login: { + username: "username3", + passkey: null, + }, + }); + loginCipher4 = createAutofillOverlayCipherDataMock(4, { + login: { + username: "username4", + passkey: null, + }, + }); + postWindowMessage( + createInitAutofillInlineMenuListMessageMock({ + ciphers: [ + passkeyCipher1, + passkeyCipher2, + passkeyCipher3, + loginCipher1, + loginCipher2, + loginCipher3, + loginCipher4, + ], + showPasskeysLabels: true, + portKey, + }), + ); + }); + + it("renders the passkeys list item views", () => { + expect(autofillInlineMenuList["inlineMenuListContainer"]).toMatchSnapshot(); + }); + + describe("passkeys headings on scroll", () => { + it("adds a border class to the passkeys and login headings when the user scrolls the cipher list container", () => { + autofillInlineMenuList["ciphersList"].scrollTop = 300; + + autofillInlineMenuList["ciphersList"].dispatchEvent(new Event("scroll")); + + expect( + autofillInlineMenuList["passkeysHeadingElement"].classList.contains(borderClass), + ).toBe(true); + expect(autofillInlineMenuList["passkeysHeadingElement"].style.position).toBe( + "relative", + ); + expect( + autofillInlineMenuList["loginHeadingElement"].classList.contains(borderClass), + ).toBe(true); + }); + + it("removes the border class from the passkeys and login headings when the user scrolls the cipher list container to the top", () => { + jest.useFakeTimers(); + autofillInlineMenuList["ciphersList"].scrollTop = 300; + + autofillInlineMenuList["ciphersList"].dispatchEvent(new Event("scroll")); + jest.advanceTimersByTime(75); + + autofillInlineMenuList["ciphersList"].scrollTop = -1; + autofillInlineMenuList["ciphersList"].dispatchEvent(new Event("scroll")); + + expect( + autofillInlineMenuList["passkeysHeadingElement"].classList.contains(borderClass), + ).toBe(false); + expect(autofillInlineMenuList["passkeysHeadingElement"].style.position).toBe(""); + expect( + autofillInlineMenuList["loginHeadingElement"].classList.contains(borderClass), + ).toBe(false); + }); + + it("loads each page of ciphers until the list of updated ciphers is exhausted", () => { + jest.useFakeTimers(); + autofillInlineMenuList["ciphersList"].scrollTop = 10; + jest.spyOn(autofillInlineMenuList as any, "loadPageOfCiphers"); + + autofillInlineMenuList["ciphersList"].dispatchEvent(new Event("scroll")); + jest.advanceTimersByTime(1000); + autofillInlineMenuList["ciphersList"].dispatchEvent(new Event("scroll")); + jest.runAllTimers(); + + expect(autofillInlineMenuList["loadPageOfCiphers"]).toHaveBeenCalledTimes(1); + }); + }); + + it("skips the logins heading when the user presses ArrowDown to focus the next list item", () => { + const cipherContainerElements = + autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll("li"); + const viewCipherButton = cipherContainerElements[3].querySelector(".view-cipher-button"); + const fillCipherButton = cipherContainerElements[5].querySelector(".fill-cipher-button"); + jest.spyOn(fillCipherButton as HTMLElement, "focus"); + + viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" })); + + expect((fillCipherButton as HTMLElement).focus).toBeCalled(); + }); + + it("skips the passkeys heading when the user presses ArrowDown to focus the first list item", () => { + const cipherContainerElements = + autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll("li"); + const viewCipherButton = cipherContainerElements[7].querySelector(".view-cipher-button"); + const fillCipherButton = cipherContainerElements[1].querySelector(".fill-cipher-button"); + jest.spyOn(fillCipherButton as HTMLElement, "focus"); + + viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" })); + + expect((fillCipherButton as HTMLElement).focus).toBeCalled(); + }); + + it("skips the logins heading when the user presses ArrowUp to focus the previous list item", () => { + const cipherContainerElements = + autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll("li"); + const viewCipherButton = cipherContainerElements[5].querySelector(".view-cipher-button"); + const fillCipherButton = cipherContainerElements[3].querySelector(".fill-cipher-button"); + jest.spyOn(fillCipherButton as HTMLElement, "focus"); + + viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" })); + + expect((fillCipherButton as HTMLElement).focus).toBeCalled(); + }); + }); }); }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts index 8bccf9aae47c..6ec0bc839918 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts @@ -1,12 +1,18 @@ import "@webcomponents/custom-elements"; import "lit/polyfill-support.js"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { EVENTS } from "@bitwarden/common/autofill/constants"; +import { EVENTS, UPDATE_PASSKEYS_HEADINGS_ON_SCROLL } from "@bitwarden/common/autofill/constants"; import { CipherType } from "@bitwarden/common/vault/enums"; import { InlineMenuCipherData } from "../../../../background/abstractions/overlay.background"; -import { buildSvgDomElement } from "../../../../utils"; -import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../../utils/svg-icons"; +import { buildSvgDomElement, throttle } from "../../../../utils"; +import { + globeIcon, + lockIcon, + plusIcon, + viewCipherIcon, + passkeyIcon, +} from "../../../../utils/svg-icons"; import { AutofillInlineMenuListWindowMessageHandlers, InitAutofillInlineMenuListMessage, @@ -24,8 +30,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private currentCipherIndex = 0; private filledByCipherType: CipherType; private showInlineMenuAccountCreation: boolean; - private readonly showCiphersPerPage = 6; + private showPasskeysLabels: boolean; private newItemButtonElement: HTMLButtonElement; + private passkeysHeadingElement: HTMLLIElement; + private loginHeadingElement: HTMLLIElement; + private lastPasskeysListItem: HTMLLIElement; + private passkeysHeadingHeight: number; + private lastPasskeysListItemHeight: number; + private ciphersListHeight: number; + private readonly showCiphersPerPage = 6; + private readonly headingBorderClass = "inline-menu-list-heading--bordered"; private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers = { initAutofillInlineMenuList: ({ message }) => this.initAutofillInlineMenuList(message), @@ -53,6 +67,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param portKey - Background generated key that allows the port to communicate with the background. * @param filledByCipherType - The type of cipher that fills the current field. * @param showInlineMenuAccountCreation - Whether identity ciphers are shown on login fields. + * @param showPasskeysLabels - Whether passkeys labels are shown in the inline menu list. */ private async initAutofillInlineMenuList({ translations, @@ -63,6 +78,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { portKey, filledByCipherType, showInlineMenuAccountCreation, + showPasskeysLabels, }: InitAutofillInlineMenuListMessage) { const linkElement = await this.initAutofillInlineMenuPage( "list", @@ -72,6 +88,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { ); this.filledByCipherType = filledByCipherType; + this.showPasskeysLabels = showPasskeysLabels; const themeClass = `theme_${theme}`; globalThis.document.documentElement.classList.add(themeClass); @@ -155,9 +172,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { this.ciphersList = globalThis.document.createElement("ul"); this.ciphersList.classList.add("inline-menu-list-actions"); this.ciphersList.setAttribute("role", "list"); - this.ciphersList.addEventListener(EVENTS.SCROLL, this.handleCiphersListScrollEvent, { - passive: true, - }); + this.setupCipherListScrollListeners(); this.loadPageOfCiphers(); @@ -288,8 +303,35 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { this.currentCipherIndex++; } - if (this.currentCipherIndex >= this.ciphers.length) { - this.ciphersList.removeEventListener(EVENTS.SCROLL, this.handleCiphersListScrollEvent); + if (!this.showPasskeysLabels && this.allCiphersLoaded()) { + this.ciphersList.removeEventListener(EVENTS.SCROLL, this.updateCiphersListOnScroll); + } + } + + /** + * Validates whether the list of ciphers has been fully loaded. + */ + private allCiphersLoaded() { + return this.currentCipherIndex >= this.ciphers.length; + } + + /** + * Sets up the scroll listeners for the ciphers list. These are used to trigger an update of + * the list of ciphers when the user scrolls to the bottom of the list. Also sets up the + * scroll listeners that reposition the passkeys and login headings when the user scrolls. + */ + private setupCipherListScrollListeners() { + const options = { passive: true }; + this.ciphersList.addEventListener(EVENTS.SCROLL, this.updateCiphersListOnScroll, options); + if (this.showPasskeysLabels) { + this.ciphersList.addEventListener( + EVENTS.SCROLL, + this.useEventHandlersMemo( + throttle(() => this.updatePasskeysHeadingsOnScroll(this.ciphersList.scrollTop), 50), + UPDATE_PASSKEYS_HEADINGS_ON_SCROLL, + ), + options, + ); } } @@ -297,7 +339,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles updating the list of ciphers when the * user scrolls to the bottom of the list. */ - private handleCiphersListScrollEvent = () => { + private updateCiphersListOnScroll = () => { if (this.cipherListScrollIsDebounced) { return; } @@ -318,22 +360,109 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { */ private handleDebouncedScrollEvent = () => { this.cipherListScrollIsDebounced = false; + const cipherListScrollTop = this.ciphersList.scrollTop; + + this.updatePasskeysHeadingsOnScroll(cipherListScrollTop); + + if (this.allCiphersLoaded()) { + return; + } + + if (!this.ciphersListHeight) { + this.ciphersListHeight = this.ciphersList.offsetHeight; + } const scrollPercentage = - (this.ciphersList.scrollTop / - (this.ciphersList.scrollHeight - this.ciphersList.offsetHeight)) * - 100; + (cipherListScrollTop / (this.ciphersList.scrollHeight - this.ciphersListHeight)) * 100; if (scrollPercentage >= 80) { this.loadPageOfCiphers(); } }; + /** + * Updates the passkeys and login headings when the user scrolls the ciphers list. + * + * @param cipherListScrollTop - The current scroll top position of the ciphers list. + */ + private updatePasskeysHeadingsOnScroll = (cipherListScrollTop: number) => { + if (!this.showPasskeysLabels) { + return; + } + + if (this.passkeysHeadingElement) { + this.togglePasskeysHeadingAnchored(cipherListScrollTop); + this.togglePasskeysHeadingBorder(cipherListScrollTop); + } + + if (this.loginHeadingElement) { + this.toggleLoginHeadingBorder(cipherListScrollTop); + } + }; + + /** + * Anchors the passkeys heading to the top of the last passkey item when the user scrolls. + * + * @param cipherListScrollTop - The current scroll top position of the ciphers list. + */ + private togglePasskeysHeadingAnchored(cipherListScrollTop: number) { + if (!this.passkeysHeadingHeight) { + this.passkeysHeadingHeight = this.passkeysHeadingElement.offsetHeight; + } + + const passkeysHeadingOffset = this.lastPasskeysListItem.offsetTop - this.passkeysHeadingHeight; + if (cipherListScrollTop >= passkeysHeadingOffset) { + this.passkeysHeadingElement.style.position = "relative"; + this.passkeysHeadingElement.style.top = `${passkeysHeadingOffset}px`; + + return; + } + + this.passkeysHeadingElement.setAttribute("style", ""); + } + + /** + * Toggles a border on the passkeys heading on scroll, adding it when the user has + * scrolled at all and removing it once the user scrolls back to the top. + * + * @param cipherListScrollTop - The current scroll top position of the ciphers list. + */ + private togglePasskeysHeadingBorder(cipherListScrollTop: number) { + if (cipherListScrollTop < 1) { + this.passkeysHeadingElement.classList.remove(this.headingBorderClass); + return; + } + + this.passkeysHeadingElement.classList.add(this.headingBorderClass); + } + + /** + * Toggles a border on the login heading on scroll, adding it when the user has + * scrolled past the last passkey item and removing it once the user scrolls back up. + * + * @param cipherListScrollTop - The current scroll top position of the ciphers list. + */ + private toggleLoginHeadingBorder(cipherListScrollTop: number) { + if (!this.lastPasskeysListItemHeight) { + this.lastPasskeysListItemHeight = this.lastPasskeysListItem.offsetHeight; + } + + const lastPasskeyOffset = this.lastPasskeysListItem.offsetTop + this.lastPasskeysListItemHeight; + if (cipherListScrollTop < lastPasskeyOffset) { + this.loginHeadingElement.classList.remove(this.headingBorderClass); + return; + } + + this.loginHeadingElement.classList.add(this.headingBorderClass); + } + /** * Builds the list item for a given cipher. * * @param cipher - The cipher to build the list item for. */ private buildInlineMenuListActionsItem(cipher: InlineMenuCipherData) { + this.buildPasskeysHeadingElements(cipher); + const fillCipherElement = this.buildFillCipherElement(cipher); const viewCipherElement = this.buildViewCipherElement(cipher); @@ -346,9 +475,43 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { inlineMenuListActionsItem.classList.add("inline-menu-list-actions-item"); inlineMenuListActionsItem.appendChild(cipherContainerElement); + if (this.showPasskeysLabels && cipher.login?.passkey) { + this.lastPasskeysListItem = inlineMenuListActionsItem; + } + return inlineMenuListActionsItem; } + /** + * Builds the passkeys and login headings for the list of cipher items. + * + * @param cipher - The cipher that will follow the heading. + */ + private buildPasskeysHeadingElements(cipher: InlineMenuCipherData) { + if (!this.showPasskeysLabels || (this.passkeysHeadingElement && this.loginHeadingElement)) { + return; + } + + const passkeyData = cipher.login?.passkey; + if (!this.passkeysHeadingElement && passkeyData) { + this.passkeysHeadingElement = globalThis.document.createElement("li"); + this.passkeysHeadingElement.classList.add("inline-menu-list-heading"); + this.passkeysHeadingElement.textContent = this.getTranslation("passkeys"); + this.ciphersList.appendChild(this.passkeysHeadingElement); + + return; + } + + if (!this.passkeysHeadingElement || this.loginHeadingElement || passkeyData) { + return; + } + + this.loginHeadingElement = globalThis.document.createElement("li"); + this.loginHeadingElement.classList.add("inline-menu-list-heading"); + this.loginHeadingElement.textContent = this.getTranslation("passwords"); + this.ciphersList.appendChild(this.loginHeadingElement); + } + /** * Builds the fill cipher button for a given cipher. * Wraps the cipher icon and details. @@ -364,8 +527,13 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { fillCipherElement.classList.add("fill-cipher-button", "inline-menu-list-action"); fillCipherElement.setAttribute( "aria-label", - `${this.getTranslation("fillCredentialsFor")} ${cipher.name}`, + `${ + cipher.login?.passkey + ? this.getTranslation("logInWithPasskey") + : this.getTranslation("fillCredentialsFor") + } ${cipher.name}`, ); + this.addFillCipherElementAriaDescription(fillCipherElement, cipher); fillCipherElement.append(cipherIcon, cipherDetailsElement); fillCipherElement.addEventListener(EVENTS.CLICK, this.handleFillCipherClickEvent(cipher)); @@ -385,10 +553,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { cipher: InlineMenuCipherData, ) { if (cipher.login) { - fillCipherElement.setAttribute( - "aria-description", - `${this.getTranslation("username")}: ${cipher.login.username}`, - ); + const passkeyUserName = cipher.login.passkey?.userName || ""; + const username = cipher.login.username || passkeyUserName; + if (username) { + fillCipherElement.setAttribute( + "aria-description", + `${this.getTranslation("username")}: ${username}`, + ); + } return; } @@ -419,13 +591,15 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param cipher - The cipher to fill. */ private handleFillCipherClickEvent = (cipher: InlineMenuCipherData) => { + const usePasskey = !!cipher.login?.passkey; return this.useEventHandlersMemo( () => this.postMessageToParent({ command: "fillAutofillInlineMenuCipher", inlineMenuCipherId: cipher.id, + usePasskey, }), - `${cipher.id}-fill-cipher-button-click-handler`, + `${cipher.id}-fill-cipher-button-click-handler-${usePasskey ? "passkey" : ""}`, ); }; @@ -599,14 +773,20 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param cipher - The cipher to build the details for. */ private buildCipherDetailsElement(cipher: InlineMenuCipherData) { - const cipherNameElement = this.buildCipherNameElement(cipher); - const cipherSubtitleElement = this.buildCipherSubtitleElement(cipher); - const cipherDetailsElement = globalThis.document.createElement("span"); cipherDetailsElement.classList.add("cipher-details"); + + const cipherNameElement = this.buildCipherNameElement(cipher); if (cipherNameElement) { cipherDetailsElement.appendChild(cipherNameElement); } + + if (cipher.login?.passkey) { + return this.buildPasskeysCipherDetailsElement(cipher, cipherDetailsElement); + } + + const subTitleText = this.getSubTitleText(cipher); + const cipherSubtitleElement = this.buildCipherSubtitleElement(subTitleText); if (cipherSubtitleElement) { cipherDetailsElement.appendChild(cipherSubtitleElement); } @@ -635,10 +815,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { /** * Builds the subtitle element for a given cipher. * - * @param cipher - The cipher to build the username login element for. + * @param subTitleText - The subtitle text to display. */ - private buildCipherSubtitleElement(cipher: InlineMenuCipherData): HTMLSpanElement | null { - const subTitleText = this.getSubTitleText(cipher); + private buildCipherSubtitleElement(subTitleText: string): HTMLSpanElement | null { if (!subTitleText) { return null; } @@ -651,6 +830,52 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { return cipherSubtitleElement; } + /** + * Builds the passkeys details for a given cipher. Includes the passkey name and username. + * + * @param cipher - The cipher to build the passkey details for. + * @param cipherDetailsElement - The cipher details element to append the passkey details to. + */ + private buildPasskeysCipherDetailsElement( + cipher: InlineMenuCipherData, + cipherDetailsElement: HTMLSpanElement, + ): HTMLSpanElement { + let rpNameSubtitle: HTMLSpanElement; + + if (cipher.name !== cipher.login.passkey.rpName) { + rpNameSubtitle = this.buildCipherSubtitleElement(cipher.login.passkey.rpName); + if (rpNameSubtitle) { + rpNameSubtitle.prepend(buildSvgDomElement(passkeyIcon)); + rpNameSubtitle.classList.add("cipher-subtitle--passkey"); + cipherDetailsElement.appendChild(rpNameSubtitle); + } + } + + if (cipher.login.username) { + const usernameSubtitle = this.buildCipherSubtitleElement(cipher.login.username); + if (usernameSubtitle) { + if (!rpNameSubtitle) { + usernameSubtitle.prepend(buildSvgDomElement(passkeyIcon)); + usernameSubtitle.classList.add("cipher-subtitle--passkey"); + } + cipherDetailsElement.appendChild(usernameSubtitle); + } + + return cipherDetailsElement; + } + + const passkeySubtitle = this.buildCipherSubtitleElement(cipher.login.passkey.userName); + if (passkeySubtitle) { + if (!rpNameSubtitle) { + passkeySubtitle.prepend(buildSvgDomElement(passkeyIcon)); + passkeySubtitle.classList.add("cipher-subtitle--passkey"); + } + cipherDetailsElement.appendChild(passkeySubtitle); + } + + return cipherDetailsElement; + } + /** * Gets the subtitle text for a given cipher. * @@ -779,7 +1004,11 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param currentListItem - The current list item. */ private focusNextListItem(currentListItem: HTMLElement) { - const nextListItem = currentListItem.nextSibling as HTMLElement; + let nextListItem = currentListItem.nextSibling as HTMLElement; + if (this.listItemIsHeadingElement(nextListItem)) { + nextListItem = nextListItem.nextSibling as HTMLElement; + } + const nextSibling = nextListItem?.querySelector(".inline-menu-list-action") as HTMLElement; if (nextSibling) { nextSibling.focus(); @@ -791,7 +1020,11 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { return; } - const firstListItem = currentListItem.parentElement?.firstChild as HTMLElement; + let firstListItem = currentListItem.parentElement?.firstChild as HTMLElement; + if (this.listItemIsHeadingElement(firstListItem)) { + firstListItem = firstListItem.nextSibling as HTMLElement; + } + const firstSibling = firstListItem?.querySelector(".inline-menu-list-action") as HTMLElement; firstSibling?.focus(); } @@ -803,7 +1036,11 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param currentListItem - The current list item. */ private focusPreviousListItem(currentListItem: HTMLElement) { - const previousListItem = currentListItem.previousSibling as HTMLElement; + let previousListItem = currentListItem.previousSibling as HTMLElement; + if (this.listItemIsHeadingElement(previousListItem)) { + previousListItem = previousListItem.previousSibling as HTMLElement; + } + const previousSibling = previousListItem?.querySelector( ".inline-menu-list-action", ) as HTMLElement; @@ -856,4 +1093,13 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private isFilledByIdentityCipher = () => { return this.filledByCipherType === CipherType.Identity; }; + + /** + * Identifies if the passed list item is a heading element. + * + * @param listItem - The list item to check. + */ + private listItemIsHeadingElement = (listItem: HTMLElement) => { + return listItem === this.passkeysHeadingElement || listItem === this.loginHeadingElement; + }; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss b/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss index a63a4bd91cac..fe38ce9933fc 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss @@ -166,6 +166,35 @@ body { } } +.inline-menu-list-heading { + position: sticky; + top: 0; + z-index: 1; + font-family: $font-family-sans-serif; + font-weight: 600; + font-size: 1rem; + line-height: 1.3; + letter-spacing: 0.025rem; + width: 100%; + padding: 0.6rem 0.8rem; + will-change: transform; + border-bottom: 0.1rem solid; + + @include themify($themes) { + color: themed("textColor"); + background-color: themed("backgroundColor"); + border-bottom-color: themed("backgroundColor"); + } + + &--bordered { + transition: border-bottom-color 0.15s ease; + + @include themify($themes) { + border-bottom-color: themed("borderColor"); + } + } +} + .inline-menu-list-container--with-new-item-button { .inline-menu-list-actions { max-height: 13.8rem; @@ -340,5 +369,28 @@ body { @include themify($themes) { color: themed("mutedTextColor"); } + + &--passkey { + display: flex; + align-content: center; + align-items: center; + justify-content: flex-start; + + svg { + width: 1.5rem; + height: 1.5rem; + margin-right: 0.2rem; + + @include themify($themes) { + fill: themed("mutedTextColor") !important; + } + + path { + @include themify($themes) { + fill: themed("mutedTextColor") !important; + } + } + } + } } } diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index 955334e3fa0b..bd03be3fccc1 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -20,9 +20,11 @@ export class InlineMenuFieldQualificationService private usernameFieldTypes = new Set(["text", "email", "number", "tel"]); private usernameAutocompleteValue = "username"; private emailAutocompleteValue = "email"; + private webAuthnAutocompleteValue = "webauthn"; private loginUsernameAutocompleteValues = new Set([ this.usernameAutocompleteValue, this.emailAutocompleteValue, + this.webAuthnAutocompleteValue, ]); private fieldIgnoreListString = AutoFillConstants.FieldIgnoreList.join(","); private passwordFieldExcludeListString = AutoFillConstants.PasswordFieldExcludeList.join(","); diff --git a/apps/browser/src/autofill/spec/autofill-mocks.ts b/apps/browser/src/autofill/spec/autofill-mocks.ts index 2e1202b4a634..c29b8900280f 100644 --- a/apps/browser/src/autofill/spec/autofill-mocks.ts +++ b/apps/browser/src/autofill/spec/autofill-mocks.ts @@ -187,7 +187,10 @@ export function createAutofillOverlayCipherDataMock( return { id: String(index), name: `website login ${index}`, - login: { username: `username${index}` }, + login: { + username: `username${index}`, + passkey: null, + }, type: CipherType.Login, reprompt: CipherRepromptType.None, favorite: false, diff --git a/apps/browser/src/autofill/utils/svg-icons.ts b/apps/browser/src/autofill/utils/svg-icons.ts index 1df140d37d09..eec5aaae078f 100644 --- a/apps/browser/src/autofill/utils/svg-icons.ts +++ b/apps/browser/src/autofill/utils/svg-icons.ts @@ -1,19 +1,20 @@ -const logoIcon = +export const logoIcon = ''; -const logoLockedIcon = +export const logoLockedIcon = ''; -const globeIcon = +export const globeIcon = ''; -const lockIcon = +export const lockIcon = ''; -const plusIcon = +export const plusIcon = ''; -const viewCipherIcon = +export const viewCipherIcon = ''; -export { logoIcon, logoLockedIcon, globeIcon, lockIcon, plusIcon, viewCipherIcon }; +export const passkeyIcon = + ''; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index db3055b4c68c..27007e20214c 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -116,6 +116,7 @@ import { BulkEncryptServiceImplementation } from "@bitwarden/common/platform/ser import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; import { FallbackBulkEncryptService } from "@bitwarden/common/platform/services/cryptography/fallback-bulk-encrypt.service"; import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation"; +import { Fido2ActiveRequestManager } from "@bitwarden/common/platform/services/fido2/fido2-active-request-manager"; import { Fido2AuthenticatorService } from "@bitwarden/common/platform/services/fido2/fido2-authenticator.service"; import { Fido2ClientService } from "@bitwarden/common/platform/services/fido2/fido2-client.service"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; @@ -984,6 +985,7 @@ export default class MainBackground { this.syncService, this.logService, ); + const fido2ActiveRequestManager = new Fido2ActiveRequestManager(); this.fido2ClientService = new Fido2ClientService( this.fido2AuthenticatorService, this.configService, @@ -991,6 +993,7 @@ export default class MainBackground { this.vaultSettingsService, this.domainSettingsService, this.taskSchedulerService, + fido2ActiveRequestManager, this.logService, ); @@ -1536,6 +1539,7 @@ export default class MainBackground { this.autofillSettingsService, this.i18nService, this.platformUtilsService, + this.fido2ClientService, this.themeStateService, ); } diff --git a/apps/browser/src/vault/fido2/webauthn-utils.ts b/apps/browser/src/vault/fido2/webauthn-utils.ts index df8e5a8fb201..b880b3c790f4 100644 --- a/apps/browser/src/vault/fido2/webauthn-utils.ts +++ b/apps/browser/src/vault/fido2/webauthn-utils.ts @@ -111,6 +111,7 @@ export class WebauthnUtils { rpId: keyOptions.rpId, userVerification: keyOptions.userVerification, timeout: keyOptions.timeout, + mediation: options.mediation, fallbackSupported, }; } diff --git a/libs/common/src/autofill/constants/index.ts b/libs/common/src/autofill/constants/index.ts index 215998a560c7..b838ff64e9d5 100644 --- a/libs/common/src/autofill/constants/index.ts +++ b/libs/common/src/autofill/constants/index.ts @@ -56,6 +56,8 @@ export const NOTIFICATION_BAR_LIFESPAN_MS = 150000; // 150 seconds export const AUTOFILL_OVERLAY_HANDLE_REPOSITION = "autofill-overlay-handle-reposition-event"; +export const UPDATE_PASSKEYS_HEADINGS_ON_SCROLL = "update-passkeys-headings-on-scroll"; + export const AutofillOverlayVisibility = { Off: 0, OnButtonClick: 1, diff --git a/libs/common/src/platform/abstractions/fido2/fido2-active-request-manager.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-active-request-manager.abstraction.ts new file mode 100644 index 000000000000..4e164c4577c4 --- /dev/null +++ b/libs/common/src/platform/abstractions/fido2/fido2-active-request-manager.abstraction.ts @@ -0,0 +1,21 @@ +import { Observable, Subject } from "rxjs"; + +import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; + +export interface ActiveRequest { + credentials: Fido2CredentialView[]; + subject: Subject; +} + +export type RequestCollection = Readonly<{ [tabId: number]: ActiveRequest }>; + +export abstract class Fido2ActiveRequestManager { + getActiveRequest$: (tabId: number) => Observable; + getActiveRequest: (tabId: number) => ActiveRequest | undefined; + newActiveRequest: ( + tabId: number, + credentials: Fido2CredentialView[], + abortController: AbortController, + ) => Promise; + removeActiveRequest: (tabId: number) => void; +} diff --git a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts index f3aa616cb35d..535248e7ecd1 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts @@ -1,3 +1,5 @@ +import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; + /** * This class represents an abstraction of the WebAuthn Authenticator model as described by W3C: * https://www.w3.org/TR/webauthn-3/#sctn-authenticator-model @@ -32,6 +34,14 @@ export abstract class Fido2AuthenticatorService { tab: chrome.tabs.Tab, abortController?: AbortController, ) => Promise; + + /** + * Discover credentials for a given Relying Party + * + * @param rpId The Relying Party's ID + * @returns A promise that resolves with an array of discoverable credentials + */ + silentCredentialDiscovery: (rpId: string) => Promise; } export enum Fido2AlgorithmIdentifier { @@ -132,6 +142,9 @@ export interface Fido2AuthenticatorGetAssertionParams { extensions: unknown; /** Forwarded to user interface */ fallbackSupported: boolean; + + // Bypass the UI and assume that the user has already interacted with the authenticator + assumeUserPresence?: boolean; } export interface Fido2AuthenticatorGetAssertionResult { diff --git a/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts index 8e2a15383082..2ba67a48be2b 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts @@ -1,3 +1,7 @@ +import { Observable } from "rxjs"; + +import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; + export const UserRequestedFallbackAbortReason = "UserRequestedFallback"; export type UserVerification = "discouraged" | "preferred" | "required"; @@ -16,6 +20,10 @@ export type UserVerification = "discouraged" | "preferred" | "required"; export abstract class Fido2ClientService { isFido2FeatureEnabled: (hostname: string, origin: string) => Promise; + availableAutofillCredentials$: (tabId: number) => Observable; + + autofillCredential: (tabId: number, credentialId: string) => Promise; + /** * Allows WebAuthn Relying Party scripts to request the creation of a new public key credential source. * For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-createCredential @@ -142,6 +150,7 @@ export interface AssertCredentialParams { userVerification?: UserVerification; timeout: number; sameOriginWithAncestors: boolean; + mediation?: "silent" | "optional" | "required" | "conditional"; fallbackSupported: boolean; } diff --git a/libs/common/src/platform/services/fido2/fido2-active-request-manager.spec.ts b/libs/common/src/platform/services/fido2/fido2-active-request-manager.spec.ts new file mode 100644 index 000000000000..77f9bd3f9cb2 --- /dev/null +++ b/libs/common/src/platform/services/fido2/fido2-active-request-manager.spec.ts @@ -0,0 +1,89 @@ +import { mock } from "jest-mock-extended"; +import { firstValueFrom, Observable } from "rxjs"; + +import { flushPromises } from "@bitwarden/browser/src/autofill/spec/testing-utils"; + +import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; + +import { Fido2ActiveRequestManager } from "./fido2-active-request-manager"; + +jest.mock("rxjs", () => { + const rxjs = jest.requireActual("rxjs"); + const { firstValueFrom } = rxjs; + return { + ...rxjs, + firstValueFrom: jest.fn(firstValueFrom), + }; +}); + +describe("Fido2ActiveRequestManager", () => { + const credentialId = "123"; + const tabId = 1; + let requestManager: Fido2ActiveRequestManager; + + beforeEach(() => { + requestManager = new Fido2ActiveRequestManager(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("creates a new active request", async () => { + const fido2CredentialView = mock({ + credentialId, + }); + const credentials = [fido2CredentialView]; + const abortController = new AbortController(); + (firstValueFrom as jest.Mock).mockResolvedValue(credentialId); + + const result = await requestManager.newActiveRequest(tabId, credentials, abortController); + await flushPromises(); + + expect(result).toBe(credentialId); + }); + + it("gets the observable stream of active requests", async () => { + (firstValueFrom as jest.Mock).mockResolvedValue(credentialId); + await requestManager.newActiveRequest(tabId, [], new AbortController()); + + const result = requestManager.getActiveRequest$(tabId); + + expect(result).toBeInstanceOf(Observable); + + result.subscribe((activeRequest) => { + expect(activeRequest).toBeDefined(); + }); + }); + + it("returns the active request associated with a given tab id", async () => { + const fido2CredentialView = mock({ + credentialId, + }); + const credentials = [fido2CredentialView]; + (firstValueFrom as jest.Mock).mockResolvedValue(credentialId); + await requestManager.newActiveRequest(tabId, credentials, new AbortController()); + + const result = requestManager.getActiveRequest(tabId); + + expect(result).toEqual({ + credentials: credentials, + subject: expect.any(Object), + }); + }); + + it("removes the active request associated with a given tab id", async () => { + const fido2CredentialView = mock({ + credentialId, + }); + const credentials = [fido2CredentialView]; + (firstValueFrom as jest.Mock).mockResolvedValue(credentialId); + await requestManager.newActiveRequest(tabId, credentials, new AbortController()); + + requestManager.removeActiveRequest(tabId); + + const result = requestManager.getActiveRequest(tabId); + + expect(result).toBeUndefined(); + }); +}); diff --git a/libs/common/src/platform/services/fido2/fido2-active-request-manager.ts b/libs/common/src/platform/services/fido2/fido2-active-request-manager.ts new file mode 100644 index 000000000000..0f82d8a09ce5 --- /dev/null +++ b/libs/common/src/platform/services/fido2/fido2-active-request-manager.ts @@ -0,0 +1,109 @@ +import { + BehaviorSubject, + distinctUntilChanged, + firstValueFrom, + map, + Observable, + shareReplay, + startWith, + Subject, +} from "rxjs"; + +import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; +import { + ActiveRequest, + RequestCollection, + Fido2ActiveRequestManager as Fido2ActiveRequestManagerAbstraction, +} from "../../abstractions/fido2/fido2-active-request-manager.abstraction"; + +export class Fido2ActiveRequestManager implements Fido2ActiveRequestManagerAbstraction { + private activeRequests$: BehaviorSubject = new BehaviorSubject({}); + + /** + * Gets the observable stream of all active requests associated with a given tab id. + * + * @param tabId - The tab id to get the active request for. + */ + getActiveRequest$(tabId: number): Observable { + return this.activeRequests$.pipe( + map((requests) => requests[tabId]), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + startWith(undefined), + ); + } + + /** + * Gets the active request associated with a given tab id. + * + * @param tabId - The tab id to get the active request for. + */ + getActiveRequest(tabId: number): ActiveRequest | undefined { + return this.activeRequests$.value[tabId]; + } + + /** + * Creates a new active fido2 request. + * + * @param tabId - The tab id to associate the request with. + * @param credentials - The credentials to use for the request. + * @param abortController - The abort controller to use for the request. + */ + async newActiveRequest( + tabId: number, + credentials: Fido2CredentialView[], + abortController: AbortController, + ): Promise { + const newRequest: ActiveRequest = { + credentials, + subject: new Subject(), + }; + this.updateRequests((existingRequests) => ({ + ...existingRequests, + [tabId]: newRequest, + })); + + const abortListener = () => this.abortActiveRequest(tabId); + abortController.signal.addEventListener("abort", abortListener); + const credentialId = firstValueFrom(newRequest.subject); + abortController.signal.removeEventListener("abort", abortListener); + + return credentialId; + } + + /** + * Removes and aborts the active request associated with a given tab id. + * + * @param tabId - The tab id to abort the active request for. + */ + removeActiveRequest(tabId: number) { + this.abortActiveRequest(tabId); + this.updateRequests((existingRequests) => { + const newRequests = { ...existingRequests }; + delete newRequests[tabId]; + return newRequests; + }); + } + + /** + * Aborts the active request associated with a given tab id. + * + * @param tabId - The tab id to abort the active request for. + */ + private abortActiveRequest(tabId: number): void { + this.activeRequests$.value[tabId]?.subject.error( + new DOMException("The operation either timed out or was not allowed.", "AbortError"), + ); + } + + /** + * Updates the active requests. + * + * @param updateFunction - The function to use to update the active requests. + */ + private updateRequests( + updateFunction: (existingRequests: RequestCollection) => RequestCollection, + ) { + this.activeRequests$.next(updateFunction(this.activeRequests$.value)); + } +} diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts index 202381c5ead4..806b6592737c 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts @@ -756,6 +756,22 @@ describe("FidoAuthenticatorService", () => { }); }); + describe("silentCredentialDiscovery", () => { + it("returns the fido2Credentials of a cipher found by its rpId", async () => { + const credentialId = Utils.newGuid(); + const cipher = await createCipherView( + { type: CipherType.Login }, + { credentialId, rpId: RpId, discoverable: true }, + ); + const ciphers = [cipher]; + cipherService.getAllDecrypted.mockResolvedValue(ciphers); + + const result = await authenticator.silentCredentialDiscovery(RpId); + + expect(result).toEqual([cipher.login.fido2Credentials[0]]); + }); + }); + async function createParams( params: Partial = {}, ): Promise { diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts index 3464154b9cc6..e82a2e32c971 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts @@ -234,10 +234,15 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } - const response = await userInterfaceSession.pickCredential({ - cipherIds: cipherOptions.map((cipher) => cipher.id), - userVerification: params.requireUserVerification, - }); + let response; + if (this.requiresUserVerificationPrompt(params, cipherOptions)) { + response = await userInterfaceSession.pickCredential({ + cipherIds: cipherOptions.map((cipher) => cipher.id), + userVerification: params.requireUserVerification, + }); + } else { + response = { cipherId: cipherOptions[0].id, userVerified: false }; + } const selectedCipherId = response.cipherId; const userVerified = response.userVerified; const selectedCipher = cipherOptions.find((c) => c.id === selectedCipherId); @@ -310,6 +315,24 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr } } + private requiresUserVerificationPrompt( + params: Fido2AuthenticatorGetAssertionParams, + cipherOptions: CipherView[], + ): boolean { + return ( + params.requireUserVerification || + !params.assumeUserPresence || + cipherOptions.length > 1 || + cipherOptions.length === 0 || + cipherOptions.some((cipher) => cipher.reprompt !== CipherRepromptType.None) + ); + } + + async silentCredentialDiscovery(rpId: string): Promise { + const credentials = await this.findCredentialsByRp(rpId); + return credentials.map((c) => c.login.fido2Credentials[0]); + } + /** Finds existing crendetials and returns the `cipherId` for each one */ private async findExcludedCredentials( credentials: PublicKeyCredentialDescriptor[], diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts index aac447e0337a..c0ae2cabfba0 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts @@ -1,12 +1,17 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { of } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; import { Utils } from "../../../platform/misc/utils"; import { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service"; +import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; import { ConfigService } from "../../abstractions/config/config.service"; +import { + ActiveRequest, + Fido2ActiveRequestManager, +} from "../../abstractions/fido2/fido2-active-request-manager.abstraction"; import { Fido2AuthenticatorError, Fido2AuthenticatorErrorCode, @@ -37,6 +42,8 @@ describe("FidoAuthenticatorService", () => { let vaultSettingsService: MockProxy; let domainSettingsService: MockProxy; let taskSchedulerService: MockProxy; + let activeRequest!: MockProxy; + let requestManager!: MockProxy; let client!: Fido2ClientService; let tab!: chrome.tabs.Tab; let isValidRpId!: jest.SpyInstance; @@ -48,6 +55,13 @@ describe("FidoAuthenticatorService", () => { vaultSettingsService = mock(); domainSettingsService = mock(); taskSchedulerService = mock(); + activeRequest = mock({ + subject: new BehaviorSubject(""), + }); + requestManager = mock({ + getActiveRequest$: (tabId: number) => new BehaviorSubject(activeRequest), + getActiveRequest: (tabId: number) => activeRequest, + }); isValidRpId = jest.spyOn(DomainUtils, "isValidRpId"); @@ -58,11 +72,12 @@ describe("FidoAuthenticatorService", () => { vaultSettingsService, domainSettingsService, taskSchedulerService, + requestManager, ); configService.serverConfig$ = of({ environment: { vault: VaultUrl } } as any); vaultSettingsService.enablePasskeys$ = of(true); domainSettingsService.neverDomains$ = of({}); - authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked); + authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); tab = { id: 123, windowId: 456 } as chrome.tabs.Tab; }); @@ -592,6 +607,50 @@ describe("FidoAuthenticatorService", () => { }); }); + describe("assert mediated conditional ui credential", () => { + const params = createParams({ + userVerification: "required", + mediation: "conditional", + allowedCredentialIds: [], + }); + + beforeEach(() => { + requestManager.newActiveRequest.mockResolvedValue(crypto.randomUUID()); + authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); + }); + + it("creates an active mediated conditional request", async () => { + await client.assertCredential(params, tab); + + expect(requestManager.newActiveRequest).toHaveBeenCalled(); + expect(authenticator.getAssertion).toHaveBeenCalledWith( + expect.objectContaining({ + assumeUserPresence: true, + rpId: RpId, + }), + tab, + ); + }); + + it("restarts the mediated conditional request if a user aborts the request", async () => { + authenticator.getAssertion.mockRejectedValueOnce(new Error()); + + await client.assertCredential(params, tab); + + expect(authenticator.getAssertion).toHaveBeenCalledTimes(2); + }); + + it("restarts the mediated conditional request if a the abort controller aborts the request", async () => { + const abortController = new AbortController(); + abortController.abort(); + authenticator.getAssertion.mockRejectedValueOnce(new DOMException("AbortError")); + + await client.assertCredential(params, tab); + + expect(authenticator.getAssertion).toHaveBeenCalledTimes(2); + }); + }); + function createParams(params: Partial = {}): AssertCredentialParams { return { allowedCredentialIds: params.allowedCredentialIds ?? [], @@ -602,6 +661,7 @@ describe("FidoAuthenticatorService", () => { userVerification: params.userVerification, sameOriginWithAncestors: true, fallbackSupported: params.fallbackSupported ?? false, + mediation: params.mediation, }; } @@ -616,6 +676,28 @@ describe("FidoAuthenticatorService", () => { }; } }); + + describe("autofill of credentials through the active request manager", () => { + it("returns an observable that updates with an array of the credentials for active Fido2 requests", async () => { + const activeRequestCredentials = mock(); + activeRequest.credentials = [activeRequestCredentials]; + + const observable = client.availableAutofillCredentials$(tab.id); + observable.subscribe((credentials) => { + expect(credentials).toEqual([activeRequestCredentials]); + }); + }); + + it("triggers the logic of the next behavior subject of an active request", async () => { + const activeRequestCredentials = mock(); + activeRequest.credentials = [activeRequestCredentials]; + jest.spyOn(activeRequest.subject, "next"); + + await client.autofillCredential(tab.id, activeRequestCredentials.credentialId); + + expect(activeRequest.subject.next).toHaveBeenCalled(); + }); + }); }); /** This is a fake function that always returns the same byte sequence */ diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.ts b/libs/common/src/platform/services/fido2/fido2-client.service.ts index b384fce1f12e..972d68891226 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.ts @@ -1,15 +1,18 @@ -import { firstValueFrom, Subscription } from "rxjs"; +import { firstValueFrom, map, Observable, Subscription } from "rxjs"; import { parse } from "tldts"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; import { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service"; +import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; import { ConfigService } from "../../abstractions/config/config.service"; +import { Fido2ActiveRequestManager } from "../../abstractions/fido2/fido2-active-request-manager.abstraction"; import { Fido2AuthenticatorError, Fido2AuthenticatorErrorCode, Fido2AuthenticatorGetAssertionParams, + Fido2AuthenticatorGetAssertionResult, Fido2AuthenticatorMakeCredentialsParams, Fido2AuthenticatorService, PublicKeyCredentialDescriptor, @@ -32,6 +35,7 @@ import { TaskSchedulerService } from "../../scheduling/task-scheduler.service"; import { isValidRpId } from "./domain-utils"; import { Fido2Utils } from "./fido2-utils"; +import { guidToRawFormat } from "./guid-utils"; /** * Bitwarden implementation of the Web Authentication API as described by W3C @@ -61,6 +65,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { private vaultSettingsService: VaultSettingsService, private domainSettingsService: DomainSettingsService, private taskSchedulerService: TaskSchedulerService, + private requestManager: Fido2ActiveRequestManager, private logService?: LogService, ) { this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.fido2ClientAbortTimeout, () => @@ -68,6 +73,17 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { ); } + availableAutofillCredentials$(tabId: number): Observable { + return this.requestManager + .getActiveRequest$(tabId) + .pipe(map((request) => request?.credentials ?? [])); + } + + async autofillCredential(tabId: number, credentialId: string) { + const request = this.requestManager.getActiveRequest(tabId); + request.subject.next(credentialId); + } + async isFido2FeatureEnabled(hostname: string, origin: string): Promise { const isUserLoggedIn = (await this.authService.getAuthStatus()) !== AuthenticationStatus.LoggedOut; @@ -287,6 +303,16 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { }; const clientDataJSON = JSON.stringify(collectedClientData); const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON); + + if (params.mediation === "conditional") { + return this.handleMediatedConditionalRequest( + params, + tab, + abortController, + clientDataJSONBytes, + ); + } + const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes); const getAssertionParams = mapToGetAssertionParams({ params, clientDataHash }); @@ -339,6 +365,59 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { timeoutSubscription?.unsubscribe(); + return this.generateAssertCredentialResult(getAssertionResult, clientDataJSONBytes); + } + + private async handleMediatedConditionalRequest( + params: AssertCredentialParams, + tab: chrome.tabs.Tab, + abortController: AbortController, + clientDataJSONBytes: Uint8Array, + ): Promise { + let getAssertionResult; + let assumeUserPresence = false; + while (!getAssertionResult) { + const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); + const availableCredentials = + authStatus === AuthenticationStatus.Unlocked + ? await this.authenticator.silentCredentialDiscovery(params.rpId) + : []; + this.logService?.info( + `[Fido2Client] started mediated request, available credentials: ${availableCredentials.length}`, + ); + const credentialId = await this.requestManager.newActiveRequest( + tab.id, + availableCredentials, + abortController, + ); + params.allowedCredentialIds = [Fido2Utils.bufferToString(guidToRawFormat(credentialId))]; + assumeUserPresence = true; + + const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes); + const getAssertionParams = mapToGetAssertionParams({ + params, + clientDataHash, + assumeUserPresence, + }); + + try { + getAssertionResult = await this.authenticator.getAssertion(getAssertionParams, tab); + } catch (e) { + this.logService?.info(`[Fido2Client] Aborted by user: ${e}`); + } + + if (abortController.signal.aborted) { + this.logService?.info(`[Fido2Client] Aborted with AbortController`); + } + } + + return this.generateAssertCredentialResult(getAssertionResult, clientDataJSONBytes); + } + + private generateAssertCredentialResult( + getAssertionResult: Fido2AuthenticatorGetAssertionResult, + clientDataJSONBytes: Uint8Array, + ): AssertCredentialResult { return { authenticatorData: Fido2Utils.bufferToString(getAssertionResult.authenticatorData), clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes), @@ -431,9 +510,11 @@ function mapToMakeCredentialParams({ function mapToGetAssertionParams({ params, clientDataHash, + assumeUserPresence, }: { params: AssertCredentialParams; clientDataHash: ArrayBuffer; + assumeUserPresence?: boolean; }): Fido2AuthenticatorGetAssertionParams { const allowCredentialDescriptorList: PublicKeyCredentialDescriptor[] = params.allowedCredentialIds.map((id) => ({ @@ -453,5 +534,6 @@ function mapToGetAssertionParams({ allowCredentialDescriptorList, extensions: {}, fallbackSupported: params.fallbackSupported, + assumeUserPresence, }; }