diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts
index 071706a1e32..935291be2ae 100644
--- a/apps/desktop/src/app/app-routing.module.ts
+++ b/apps/desktop/src/app/app-routing.module.ts
@@ -338,7 +338,7 @@ const routes: Routes = [
component: Fido2VaultComponent,
},
{
- path: "create-passkey",
+ path: "passkey-create",
component: Fido2CreateComponent,
},
{
diff --git a/apps/desktop/src/app/components/fido2placeholder.component.ts b/apps/desktop/src/app/components/fido2placeholder.component.ts
new file mode 100644
index 00000000000..fa6fbae86d8
--- /dev/null
+++ b/apps/desktop/src/app/components/fido2placeholder.component.ts
@@ -0,0 +1,125 @@
+import { CommonModule } from "@angular/common"; // Add this
+import { Component, OnDestroy, OnInit } from "@angular/core";
+import { Router } from "@angular/router";
+import { BehaviorSubject, Observable } from "rxjs";
+
+import {
+ DesktopFido2UserInterfaceService,
+ DesktopFido2UserInterfaceSession,
+} from "../../autofill/services/desktop-fido2-user-interface.service";
+import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
+
+@Component({
+ standalone: true,
+ imports: [CommonModule], // Add this
+
+ template: `
+
+
Select your passkey
+
+
+
+
+
+
+
+
+
+ `,
+})
+export class Fido2PlaceholderComponent implements OnInit, OnDestroy {
+ session?: DesktopFido2UserInterfaceSession = null;
+ private cipherIdsSubject = new BehaviorSubject([]);
+ cipherIds$: Observable = this.cipherIdsSubject.asObservable();
+
+ constructor(
+ private readonly desktopSettingsService: DesktopSettingsService,
+ private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
+ private readonly router: Router,
+ ) {}
+
+ async ngOnInit(): Promise {
+ this.session = this.fido2UserInterfaceService.getCurrentSession();
+
+ const cipherIds = await this.session?.getAvailableCipherIds();
+ this.cipherIdsSubject.next(cipherIds || []);
+
+ // eslint-disable-next-line no-console
+ console.log("Available cipher IDs", cipherIds);
+ }
+
+ async chooseCipher(cipherId: string) {
+ this.session?.confirmChosenCipher(cipherId);
+
+ await this.router.navigate(["/"]);
+ await this.desktopSettingsService.setInModalMode(false);
+ }
+
+ ngOnDestroy() {
+ this.cipherIdsSubject.complete(); // Clean up the BehaviorSubject
+ }
+
+ async confirmPasskey() {
+ try {
+ // Retrieve the current UI session to control the flow
+ if (!this.session) {
+ // todo: handle error
+ throw new Error("No session found");
+ }
+
+ // If we want to we could submit information to the session in order to create the credential
+ // const cipher = await session.createCredential({
+ // userHandle: "userHandle2",
+ // userName: "username2",
+ // credentialName: "zxsd2",
+ // rpId: "webauthn.io",
+ // userVerification: true,
+ // });
+
+ this.session.notifyConfirmCredential(true);
+
+ // Not sure this clean up should happen here or in session.
+ // The session currently toggles modal on and send us here
+ // But if this route is somehow opened outside of session we want to make sure we clean up?
+ await this.router.navigate(["/"]);
+ await this.desktopSettingsService.setInModalMode(false);
+ } catch (error) {
+ // TODO: Handle error appropriately
+ }
+ }
+
+ async closeModal() {
+ await this.router.navigate(["/"]);
+ await this.desktopSettingsService.setInModalMode(false);
+
+ this.session.notifyConfirmCredential(false);
+ // little bit hacky:
+ this.session.confirmChosenCipher(null);
+ }
+}
diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts
index 1a1c46c822a..3d19699c2eb 100644
--- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts
+++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts
@@ -159,7 +159,12 @@ export class DesktopAutofillService implements OnDestroy {
ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
async (clientId, sequenceNumber, request, callback) => {
- this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request);
+ this.logService.warning(
+ "listenPasskeyAssertion without user interface",
+ clientId,
+ sequenceNumber,
+ request,
+ );
// TODO: For some reason the credentialId is passed as an empty array in the request, so we need to
// get it from the cipher. For that we use the recordIdentifier, which is the cipherId.
@@ -193,7 +198,7 @@ export class DesktopAutofillService implements OnDestroy {
const controller = new AbortController();
void this.fido2AuthenticatorService
- .getAssertion(this.convertAssertionRequest(request), null, controller)
+ .getAssertion(this.convertAssertionRequest(request, true), null, controller)
.then((response) => {
callback(null, this.convertAssertionResponse(request, response));
})
@@ -261,10 +266,17 @@ export class DesktopAutofillService implements OnDestroy {
};
}
+ /**
+ *
+ * @param request
+ * @param assumeUserPresence For WithoutUserInterface requests, we assume the user is present
+ * @returns
+ */
private convertAssertionRequest(
request:
| autofill.PasskeyAssertionRequest
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
+ assumeUserPresence: boolean = false,
): Fido2AuthenticatorGetAssertionParams {
let allowedCredentials;
if ("credentialId" in request) {
@@ -289,6 +301,7 @@ export class DesktopAutofillService implements OnDestroy {
requireUserVerification:
request.userVerification === "required" || request.userVerification === "preferred",
fallbackSupported: false,
+ assumeUserPresence: assumeUserPresence,
};
}
diff --git a/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts
index 919a9e0365c..4e89af55535 100644
--- a/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts
+++ b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts
@@ -1,5 +1,14 @@
import { Router } from "@angular/router";
-import { lastValueFrom, firstValueFrom, map, Subject } from "rxjs";
+import {
+ lastValueFrom,
+ firstValueFrom,
+ map,
+ Subject,
+ filter,
+ take,
+ timeout,
+ BehaviorSubject,
+} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@@ -77,30 +86,105 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
) {}
private confirmCredentialSubject = new Subject();
+
private createdCipher: Cipher;
+ private updatedCipher: CipherView;
+
+ private availableCipherIds = new BehaviorSubject(null);
+ private rpId = new BehaviorSubject(null);
+
+ private chosenCipherSubject = new Subject();
// Method implementation
- async pickCredential(
- params: PickCredentialParams,
- ): Promise<{ cipherId: string; userVerified: boolean }> {
- this.logService.warning("pickCredential desktop function", params);
+ async pickCredential({
+ cipherIds,
+ userVerification,
+ assumeUserPresence,
+ masterPasswordRepromptRequired,
+ }: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
+ this.logService.warning("pickCredential desktop function", {
+ cipherIds,
+ userVerification,
+ assumeUserPresence,
+ masterPasswordRepromptRequired,
+ });
try {
- await this.showUi();
+ // Check if we can return the credential without user interaction
+ // TODO: Assume user presence is undefined
+ if (cipherIds.length === 1 && !masterPasswordRepromptRequired) {
+ this.logService.debug(
+ "shortcut - Assuming user presence and returning cipherId",
+ cipherIds[0],
+ );
+ return { cipherId: cipherIds[0], userVerified: userVerification };
+ }
- await this.waitForUiCredentialConfirmation();
+ this.logService.debug("Could not shortcut, showing UI");
- return { cipherId: params.cipherIds[0], userVerified: true };
+ // make the cipherIds available to the UI.
+ // Not sure if the UI also need to know about masterPasswordRepromptRequired -- probably not, otherwise we can send all of the params.
+ this.availableCipherIds.next(cipherIds);
+
+ await this.showUi("/passkeys");
+
+ const chosenCipherId = await this.waitForUiChosenCipher();
+
+ this.logService.debug("Received chosen cipher", chosenCipherId);
+ if (!chosenCipherId) {
+ throw new Error("User cancelled");
+ }
+
+ const resultCipherId = cipherIds.find((id) => id === chosenCipherId);
+
+ // TODO: perform userverification
+ return { cipherId: resultCipherId, userVerified: true };
} finally {
// Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setInModalMode(false);
}
}
+ /**
+ * Returns once the UI has confirmed and completed the operation
+ * @returns
+ */
+ async getAvailableCipherIds(): Promise {
+ return lastValueFrom(
+ this.availableCipherIds.pipe(
+ filter((ids) => ids != null),
+ take(1),
+ timeout(50000),
+ ),
+ );
+ }
+
+ async getRpId(): Promise {
+ return lastValueFrom(
+ this.rpId.pipe(
+ filter((id) => id != null),
+ take(1),
+ timeout(50000),
+ ),
+ );
+ }
+
+ confirmChosenCipher(cipherId: string): void {
+ this.chosenCipherSubject.next(cipherId);
+ this.chosenCipherSubject.complete();
+ }
+
+ private async waitForUiChosenCipher(): Promise {
+ return lastValueFrom(this.chosenCipherSubject);
+ }
+
/**
* Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS.
*/
- notifyConfirmCredential(confirmed: boolean): void {
+ notifyConfirmCredential(confirmed: boolean, updatedCipher?: CipherView): void {
+ if (updatedCipher) {
+ this.updatedCipher = updatedCipher;
+ }
this.confirmCredentialSubject.next(confirmed);
this.confirmCredentialSubject.complete();
}
@@ -109,7 +193,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
* Returns once the UI has confirmed and completed the operation
* @returns
*/
- private async waitForUiCredentialConfirmation(): Promise {
+ private async waitForUiNewCredentialConfirmation(): Promise {
return lastValueFrom(this.confirmCredentialSubject);
}
@@ -133,52 +217,53 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
);
try {
- await this.showUi(rpId);
+ await this.showUi("/passkey-create");
// Wait for the UI to wrap up
- const confirmation = await this.waitForUiCredentialConfirmation();
+ const confirmation = await this.waitForUiNewCredentialConfirmation();
if (!confirmation) {
throw new Error("User cancelled");
+ //if existing credential is selected, update credential
}
- // Create the credential
- await this.createCredential({
- credentialName,
- userName,
- rpId,
- userHandle: "",
- userVerification,
- });
- // wait for 10ms to help RXJS catch up(?)
- // We sometimes get a race condition from this.createCredential not updating cipherService in time
- //console.log("waiting 10ms..");
- //await new Promise((resolve) => setTimeout(resolve, 10));
- //console.log("Just waited 10ms");
-
- // Return the new cipher (this.createdCipher)
- return { cipherId: this.createdCipher.id, userVerified: userVerification };
+ if (this.updatedCipher) {
+ await this.updateCredential(this.updatedCipher);
+ return { cipherId: this.updatedCipher.id, userVerified: userVerification };
+ } else {
+ // Create the credential
+ await this.createCipher({
+ credentialName,
+ userName,
+ rpId,
+ userHandle: "",
+ userVerification,
+ });
+ return { cipherId: this.createdCipher.id, userVerified: userVerification };
+ }
} finally {
// Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setInModalMode(false);
}
}
- private async showUi(rpId?: string) {
+ private async showUi(route: string) {
// Load the UI:
// maybe toggling to modal mode shouldn't be done here?
await this.desktopSettingsService.setInModalMode(true);
//pass the rpid to the fido2placeholder component through routing parameter
// await this.router.navigate(["/passkeys"]);
- await this.router.navigate(["/passkeys"], { state: { rpid: rpId } });
+ await this.router.navigate([route]);
}
/**
* Can be called by the UI to create a new credential with user input etc.
* @param param0
*/
- async createCredential({ credentialName, userName, rpId }: NewCredentialParams): Promise {
+ async createCipher({ credentialName, userName, rpId }: NewCredentialParams): Promise {
// Store the passkey on a new cipher to avoid replacing something important
+ this.rpId.next(rpId);
+
const cipher = new CipherView();
cipher.name = credentialName;
@@ -205,6 +290,15 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
return createdCipher;
}
+ async updateCredential(cipher: CipherView): Promise {
+ this.logService.warning("updateCredential");
+ const activeUserId = await firstValueFrom(
+ this.accountService.activeAccount$.pipe(map((a) => a?.id)),
+ );
+ const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
+ await this.cipherService.updateWithServer(encCipher);
+ }
+
async informExcludedCredential(existingCipherIds: string[]): Promise {
this.logService.warning("informExcludedCredential", existingCipherIds);
}
diff --git a/apps/desktop/src/modal/passkeys/create/fido2-create.component.html b/apps/desktop/src/modal/passkeys/create/fido2-create.component.html
index e205ab52fd6..8dbf6639a67 100644
--- a/apps/desktop/src/modal/passkeys/create/fido2-create.component.html
+++ b/apps/desktop/src/modal/passkeys/create/fido2-create.component.html
@@ -23,15 +23,24 @@
-
-
diff --git a/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts b/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts
index d7e8b9b693e..34bb0b163da 100644
--- a/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts
+++ b/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts
@@ -1,10 +1,11 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { RouterModule, Router } from "@angular/router";
+import { BehaviorSubject, firstValueFrom, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
-import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
BadgeModule,
@@ -47,38 +48,44 @@ import { DesktopSettingsService } from "../../../platform/services/desktop-setti
templateUrl: "fido2-create.component.html",
})
export class Fido2CreateComponent implements OnInit {
- ciphers: CipherView[];
- rpId: string;
+ session?: DesktopFido2UserInterfaceSession = null;
+ private ciphersSubject = new BehaviorSubject([]);
+ ciphers$: Observable = this.ciphersSubject.asObservable();
readonly Icons = { BitwardenShield };
- session?: DesktopFido2UserInterfaceSession = null;
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly cipherService: CipherService,
+ private readonly domainSettingsService: DomainSettingsService,
private readonly router: Router,
) {}
async ngOnInit() {
- this.rpId = history.state.rpid;
this.session = this.fido2UserInterfaceService.getCurrentSession();
- if (!this.session) {
- await this.fido2UserInterfaceService.newSession(false, null);
- this.session = this.fido2UserInterfaceService.getCurrentSession();
- }
- let allCiphers = [];
+ const rpid = await this.session.getRpId();
+ const equivalentDomains = await firstValueFrom(
+ this.domainSettingsService.getUrlEquivalentDomains(rpid),
+ );
- if (this.rpId) {
- allCiphers = await this.cipherService.getAllDecryptedForUrl(this.rpId, [CipherType.Login]);
- } else {
- allCiphers = await this.cipherService.getAllDecrypted();
- }
+ this.cipherService
+ .getPasskeyCiphersForUrl(rpid)
+ .then((ciphers) => {
+ const relevantCiphers = ciphers.filter(
+ (cipher) =>
+ cipher.login.matchesUri(rpid, equivalentDomains) &&
+ (!cipher.login.fido2Credentials || cipher.login.fido2Credentials.length === 0),
+ );
+ this.ciphersSubject.next(relevantCiphers);
+ })
+ .catch(() => {
+ // console.error(err);
+ });
+ }
- //filter all ciphers to only return login ciphers without fido2Credentials
- this.ciphers = allCiphers.filter((cipher) => {
- return cipher.type === CipherType.Login && cipher.login.fido2Credentials.length === 0;
- });
+ async addPasskeyToCipher(cipher: CipherView) {
+ this.session.notifyConfirmCredential(true, cipher);
}
async confirmPasskey() {
diff --git a/apps/desktop/src/modal/passkeys/fido2-vault.component.html b/apps/desktop/src/modal/passkeys/fido2-vault.component.html
index c3960141709..9e1cf9433be 100644
--- a/apps/desktop/src/modal/passkeys/fido2-vault.component.html
+++ b/apps/desktop/src/modal/passkeys/fido2-vault.component.html
@@ -22,8 +22,8 @@
-
-
+
+
{{ c.name }}
diff --git a/apps/desktop/src/modal/passkeys/fido2-vault.component.ts b/apps/desktop/src/modal/passkeys/fido2-vault.component.ts
index 68203a05e72..f1b5cb5e41b 100644
--- a/apps/desktop/src/modal/passkeys/fido2-vault.component.ts
+++ b/apps/desktop/src/modal/passkeys/fido2-vault.component.ts
@@ -1,9 +1,9 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { RouterModule, Router } from "@angular/router";
+import { BehaviorSubject, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
-import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
@@ -24,7 +24,6 @@ import {
DesktopFido2UserInterfaceSession,
} from "../../autofill/services/desktop-fido2-user-interface.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
-// import { AnchorLinkDirective } from "../../../../../libs/components/src/link/link.directive";
@Component({
standalone: true,
@@ -45,68 +44,38 @@ import { DesktopSettingsService } from "../../platform/services/desktop-settings
templateUrl: "fido2-vault.component.html",
})
export class Fido2VaultComponent implements OnInit {
- ciphers: CipherView[];
- rpId: string;
+ session?: DesktopFido2UserInterfaceSession = null;
+ private ciphersSubject = new BehaviorSubject([]);
+ ciphers$: Observable = this.ciphersSubject.asObservable();
readonly Icons = { BitwardenShield };
- session?: DesktopFido2UserInterfaceSession = null;
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly cipherService: CipherService,
- private readonly authService: AuthService,
private readonly router: Router,
) {}
async ngOnInit() {
- this.rpId = history.state.rpid;
this.session = this.fido2UserInterfaceService.getCurrentSession();
- if (!this.session) {
- await this.fido2UserInterfaceService.newSession(false, null);
- this.session = this.fido2UserInterfaceService.getCurrentSession();
- }
+ const cipherIds = await this.session?.getAvailableCipherIds();
- if (this.rpId) {
- this.ciphers = await this.cipherService.getPasskeyCiphersForUrl(this.rpId);
- } else {
- this.ciphers = await this.cipherService.getAllDecrypted();
- }
+ this.cipherService
+ .getAllDecryptedForIds(cipherIds || [])
+ .then((ciphers) => {
+ this.ciphersSubject.next(ciphers);
+ })
+ .catch(() => {
+ // console.error(err);
+ });
}
- async confirmPasskey() {
- try {
- this.session = this.fido2UserInterfaceService.getCurrentSession();
+ async chooseCipher(cipherId: string) {
+ this.session?.confirmChosenCipher(cipherId);
- // this.session.pickCredential({
- // cipherIds: [],
- // userVerification: false,
- // });
- // Retrieve the current UI session to control the flow
- if (!this.session) {
- // todo: handle error
- throw new Error("No session found");
- }
-
- // If we want to we could submit information to the session in order to create the credential
- // const cipher = await session.createCredential({
- // userHandle: "userHandle2",
- // userName: "username2",
- // credentialName: "zxsd2",
- // rpId: "webauthn.io",
- // userVerification: true,
- // });
-
- this.session.notifyConfirmCredential(true);
-
- // Not sure this clean up should happen here or in session.
- // The session currently toggles modal on and send us here
- // But if this route is somehow opened outside of session we want to make sure we clean up?
- await this.router.navigate(["/"]);
- await this.desktopSettingsService.setInModalMode(false);
- } catch (error) {
- // TODO: Handle error appropriately
- }
+ await this.router.navigate(["/"]);
+ await this.desktopSettingsService.setInModalMode(false);
}
async closeModal() {
diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts
index a677bf8ccfd..d551684f129 100644
--- a/libs/common/src/vault/abstractions/cipher.service.ts
+++ b/libs/common/src/vault/abstractions/cipher.service.ts
@@ -45,6 +45,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider Promise;
+ getAllDecryptedForIds: (ids: string[]) => Promise;
getPasskeyCiphersForUrl: (url: string) => Promise;
getPasskeyCiphers: () => Promise;
filterCiphersForUrl: (
diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts
index d3d61b0bfd6..955bdd0c256 100644
--- a/libs/common/src/vault/services/cipher.service.ts
+++ b/libs/common/src/vault/services/cipher.service.ts
@@ -485,6 +485,11 @@ export class CipherService implements CipherServiceAbstraction {
return await this.filterCiphersForUrl(ciphers, url, includeOtherTypes, defaultMatch);
}
+ async getAllDecryptedForIds(ids: string[]): Promise {
+ const ciphers = await this.getAllDecrypted();
+ return ciphers.filter((cipher) => ids.includes(cipher.id));
+ }
+
async filterCiphersForUrl(
ciphers: CipherView[],
url: string,