1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-18 01:03:35 +00:00

PM-19095: Wire passkey autofill to UI (#13051)

* Passkey stuff

Co-authored-by: Anders Åberg <github@andersaberg.com>

* Ugly hacks

* Work On Modal State Management

* Applying modalStyles

* modal

* Improved hide/show

* fixed promise

* File name

* fix prettier

* Protecting against null API's and undefined data

* Only show fake popup to devs

* cleanup mock code

* rename minmimal-app to modal-app

* Added comment

* Added comment

* removed old comment

* Avoided changing minimum size

* Add small comment

* Rename component

* adress feedback

* Fixed uppercase file

* Fixed build

* Added codeowners

* added void

* commentary

* feat: reset setting on app start

* Moved reset to be in main / process launch

* Add comment to create window

* Added a little bit of styling

* Use Messaging service to loadUrl

* Enable passkeysautofill

* Add logging

* halfbaked

* Integration working

* And now it works without extra delay

* Clean up

* add note about messaging

* lb

* removed console.logs

* Cleanup and adress review feedback

* This hides the swift UI

* pick credential, draft

* Remove logger

* a whole lot of wiring

* not working

* Improved wiring

* Cancel after 90s

* Introduced observable

* Launching bitwarden if its not running

* Passing position from native to electron

* Rename inModalMode to modalMode

* remove tap

* revert spaces

* added back isDev

* cleaned up a bit

* Cleanup swift file

* tweaked logging

* clean up

* Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Update apps/desktop/src/platform/main/autofill/native-autofill.main.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Update apps/desktop/src/platform/services/desktop-settings.service.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* adress position feedback

* Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Removed extra logging

* Adjusted error logging

* Use .error to log errors

* remove dead code

* Update desktop-autofill.service.ts

* use parseCredentialId instead of guidToRawFormat

* Update apps/desktop/src/autofill/services/desktop-autofill.service.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Change windowXy to a Record instead of [number,number]

* Update apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Remove unsued dep and comment

* changed timeout to be spec recommended maxium, 10 minutes, for now.

* Correctly assume UP

* Removed extra cancelRequest in deinint

* Add timeout and UV to confirmChoseCipher

UV is performed by UI, not the service

* Improved docs regarding undefined cipherId

* cleanup: UP is no longer undefined

* Run completeError if ipc messages conversion failed

* don't throw, instead return undefined

* Disabled passkey provider

* Throw error if no activeUserId was found

* removed comment

* Fixed lint

* removed unsued service

* reset entitlement formatting

* Update entitlements.mas.plist

---------

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Co-authored-by: Colton Hurst <colton@coltonhurst.com>
Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
This commit is contained in:
Anders Åberg
2025-03-24 12:50:11 +01:00
committed by GitHub
parent a6e785d63c
commit 8e455007c0
22 changed files with 826 additions and 208 deletions

View File

@@ -182,6 +182,10 @@ const routes: Routes = [
path: "passkeys",
component: Fido2PlaceholderComponent,
},
{
path: "passkeys",
component: Fido2PlaceholderComponent,
},
{
path: "",
component: AnonLayoutWrapperComponent,

View File

@@ -1,16 +1,45 @@
import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
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],
template: `
<div
style="background:white; display:flex; justify-content: center; align-items: center; flex-direction: column"
>
<h1 style="color: black">Select your passkey</h1>
<div *ngFor="let item of cipherIds$ | async">
<button
style="color:black; padding: 10px 20px; border: 1px solid blue; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="chooseCipher(item)"
>
{{ item }}
</button>
</div>
<br />
<button
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="confirmPasskey()"
>
Confirm passkey
</button>
<button
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
@@ -23,14 +52,69 @@ import { DesktopSettingsService } from "../../platform/services/desktop-settings
</div>
`,
})
export class Fido2PlaceholderComponent {
export class Fido2PlaceholderComponent implements OnInit, OnDestroy {
session?: DesktopFido2UserInterfaceSession = null;
private cipherIdsSubject = new BehaviorSubject<string[]>([]);
cipherIds$: Observable<string[]>;
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly router: Router,
) {}
ngOnInit() {
this.session = this.fido2UserInterfaceService.getCurrentSession();
this.cipherIds$ = this.session?.availableCipherIds$;
}
async chooseCipher(cipherId: string) {
// For now: Set UV to true
this.session?.confirmChosenCipher(cipherId, true);
await this.router.navigate(["/"]);
await this.desktopSettingsService.setModalMode(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.notifyConfirmNewCredential(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.setModalMode(false);
} catch {
// TODO: Handle error appropriately
}
}
async closeModal() {
await this.router.navigate(["/"]);
await this.desktopSettingsService.setInModalMode(false);
await this.desktopSettingsService.setModalMode(false);
this.session.notifyConfirmNewCredential(false);
// little bit hacky:
this.session.confirmChosenCipher(null);
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { APP_INITIALIZER, NgModule } from "@angular/core";
import { Router } from "@angular/router";
import { Subject, merge } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
@@ -334,9 +335,21 @@ const safeProviders: SafeProvider[] = [
],
}),
safeProvider({
provide: Fido2UserInterfaceServiceAbstraction,
provide: DesktopFido2UserInterfaceService,
useClass: DesktopFido2UserInterfaceService,
deps: [AuthServiceAbstraction, CipherServiceAbstraction, AccountService, LogService],
deps: [
AuthServiceAbstraction,
CipherServiceAbstraction,
AccountService,
LogService,
MessagingServiceAbstraction,
Router,
DesktopSettingsService,
],
}),
safeProvider({
provide: Fido2UserInterfaceServiceAbstraction, // We utilize desktop specific methods when wiring OS API's
useExisting: DesktopFido2UserInterfaceService,
}),
safeProvider({
provide: Fido2AuthenticatorServiceAbstraction,

View File

@@ -80,6 +80,44 @@ export default {
return;
}
ipcRenderer.send("autofill.completePasskeyAssertion", {
clientId,
sequenceNumber,
response,
});
});
},
);
},
listenPasskeyAssertionWithoutUserInterface: (
fn: (
clientId: number,
sequenceNumber: number,
request: autofill.PasskeyAssertionWithoutUserInterfaceRequest,
completeCallback: (error: Error | null, response: autofill.PasskeyAssertionResponse) => void,
) => void,
) => {
ipcRenderer.on(
"autofill.passkeyAssertionWithoutUserInterface",
(
event,
data: {
clientId: number;
sequenceNumber: number;
request: autofill.PasskeyAssertionWithoutUserInterfaceRequest;
},
) => {
const { clientId, sequenceNumber, request } = data;
fn(clientId, sequenceNumber, request, (error, response) => {
if (error) {
ipcRenderer.send("autofill.completeError", {
clientId,
sequenceNumber,
error: error.message,
});
return;
}
ipcRenderer.send("autofill.completePasskeyAssertion", {
clientId,
sequenceNumber,

View File

@@ -1,7 +1,6 @@
import { Injectable, OnDestroy } from "@angular/core";
import { autofill } from "desktop_native/napi";
import {
EMPTY,
Subject,
distinctUntilChanged,
filter,
@@ -10,6 +9,7 @@ import {
mergeMap,
switchMap,
takeUntil,
EMPTY,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -26,9 +26,9 @@ import {
} from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { parseCredentialId } from "@bitwarden/common/platform/services/fido2/credential-id-utils";
import { getCredentialsForAutofill } from "@bitwarden/common/platform/services/fido2/fido2-autofill-utils";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import { guidToRawFormat } from "@bitwarden/common/platform/services/fido2/guid-utils";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -41,6 +41,8 @@ import {
NativeAutofillSyncCommand,
} from "../../platform/main/autofill/sync.command";
import type { NativeWindowObject } from "./desktop-fido2-user-interface.service";
@Injectable()
export class DesktopAutofillService implements OnDestroy {
private destroy$ = new Subject<void>();
@@ -49,7 +51,7 @@ export class DesktopAutofillService implements OnDestroy {
private logService: LogService,
private cipherService: CipherService,
private configService: ConfigService,
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<void>,
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>,
private accountService: AccountService,
) {}
@@ -155,7 +157,11 @@ export class DesktopAutofillService implements OnDestroy {
const controller = new AbortController();
void this.fido2AuthenticatorService
.makeCredential(this.convertRegistrationRequest(request), null, controller)
.makeCredential(
this.convertRegistrationRequest(request),
{ windowXy: request.windowXy },
controller,
)
.then((response) => {
callback(null, this.convertRegistrationResponse(request, response));
})
@@ -165,47 +171,77 @@ export class DesktopAutofillService implements OnDestroy {
});
});
ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
async (clientId, sequenceNumber, request, callback) => {
this.logService.warning(
"listenPasskeyAssertion without user interface",
clientId,
sequenceNumber,
request,
);
// 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.
if (request.recordIdentifier && request.credentialId.length === 0) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (!activeUserId) {
this.logService.error("listenPasskeyAssertion error", "Active user not found");
callback(new Error("Active user not found"), null);
return;
}
const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId);
if (!cipher) {
this.logService.error("listenPasskeyAssertion error", "Cipher not found");
callback(new Error("Cipher not found"), null);
return;
}
const decrypted = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
const fido2Credential = decrypted.login.fido2Credentials?.[0];
if (!fido2Credential) {
this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found");
callback(new Error("Fido2Credential not found"), null);
return;
}
request.credentialId = Array.from(
parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId),
);
}
const controller = new AbortController();
void this.fido2AuthenticatorService
.getAssertion(
this.convertAssertionRequest(request),
{ windowXy: request.windowXy },
controller,
)
.then((response) => {
callback(null, this.convertAssertionResponse(request, response));
})
.catch((error) => {
this.logService.error("listenPasskeyAssertion error", error);
callback(error, null);
});
},
);
ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => {
this.logService.warning("listenPasskeyAssertion", 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.
if (request.recordIdentifier && request.credentialId.length === 0) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (!activeUserId) {
this.logService.error("listenPasskeyAssertion error", "Active user not found");
callback(new Error("Active user not found"), null);
return;
}
const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId);
if (!cipher) {
this.logService.error("listenPasskeyAssertion error", "Cipher not found");
callback(new Error("Cipher not found"), null);
return;
}
const decrypted = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
const fido2Credential = decrypted.login.fido2Credentials?.[0];
if (!fido2Credential) {
this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found");
callback(new Error("Fido2Credential not found"), null);
return;
}
request.credentialId = Array.from(
guidToRawFormat(decrypted.login.fido2Credentials?.[0].credentialId),
);
}
const controller = new AbortController();
void this.fido2AuthenticatorService
.getAssertion(this.convertAssertionRequest(request), null, controller)
.getAssertion(
this.convertAssertionRequest(request),
{ windowXy: request.windowXy },
controller,
)
.then((response) => {
callback(null, this.convertAssertionResponse(request, response));
})
@@ -257,27 +293,48 @@ export class DesktopAutofillService implements OnDestroy {
};
}
/**
*
* @param request
* @param assumeUserPresence For WithoutUserInterface requests, we assume the user is present
* @returns
*/
private convertAssertionRequest(
request: autofill.PasskeyAssertionRequest,
request:
| autofill.PasskeyAssertionRequest
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
): Fido2AuthenticatorGetAssertionParams {
let allowedCredentials;
if ("credentialId" in request) {
allowedCredentials = [
{
id: new Uint8Array(request.credentialId),
type: "public-key" as const,
},
];
} else {
allowedCredentials = request.allowedCredentials.map((credentialId) => ({
id: new Uint8Array(credentialId),
type: "public-key" as const,
}));
}
return {
rpId: request.rpId,
hash: new Uint8Array(request.clientDataHash),
allowCredentialDescriptorList: [
{
id: new Uint8Array(request.credentialId),
type: "public-key",
},
],
allowCredentialDescriptorList: allowedCredentials,
extensions: {},
requireUserVerification:
request.userVerification === "required" || request.userVerification === "preferred",
fallbackSupported: false,
assumeUserPresence: true, // For desktop assertions, it's safe to assume UP has been checked by OS dialogues
};
}
private convertAssertionResponse(
request: autofill.PasskeyAssertionRequest,
request:
| autofill.PasskeyAssertionRequest
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
response: Fido2AuthenticatorGetAssertionResult,
): autofill.PasskeyAssertionResponse {
return {

View File

@@ -1,4 +1,14 @@
import { firstValueFrom, map } from "rxjs";
import { Router } from "@angular/router";
import {
lastValueFrom,
firstValueFrom,
map,
Subject,
filter,
take,
BehaviorSubject,
timeout,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@@ -10,8 +20,10 @@ import {
PickCredentialParams,
} from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
@@ -19,28 +31,54 @@ import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
/**
* This type is used to pass the window position from the native UI
*/
export type NativeWindowObject = {
/**
* The position of the window, first entry is the x position, second is the y position
*/
windowXy?: { x: number; y: number };
};
export class DesktopFido2UserInterfaceService
implements Fido2UserInterfaceServiceAbstraction<void>
implements Fido2UserInterfaceServiceAbstraction<NativeWindowObject>
{
constructor(
private authService: AuthService,
private cipherService: CipherService,
private accountService: AccountService,
private logService: LogService,
private messagingService: MessagingService,
private router: Router,
private desktopSettingsService: DesktopSettingsService,
) {}
private currentSession: any;
getCurrentSession(): DesktopFido2UserInterfaceSession | undefined {
return this.currentSession;
}
async newSession(
fallbackSupported: boolean,
_tab: void,
nativeWindowObject: NativeWindowObject,
abortController?: AbortController,
): Promise<DesktopFido2UserInterfaceSession> {
this.logService.warning("newSession", fallbackSupported, abortController);
return new DesktopFido2UserInterfaceSession(
this.logService.warning("newSession", fallbackSupported, abortController, nativeWindowObject);
const session = new DesktopFido2UserInterfaceSession(
this.authService,
this.cipherService,
this.accountService,
this.logService,
this.router,
this.desktopSettingsService,
nativeWindowObject,
);
this.currentSession = session;
return session;
}
}
@@ -50,17 +88,110 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
private cipherService: CipherService,
private accountService: AccountService,
private logService: LogService,
private router: Router,
private desktopSettingsService: DesktopSettingsService,
private windowObject: NativeWindowObject,
) {}
private confirmCredentialSubject = new Subject<boolean>();
private createdCipher: Cipher;
private availableCipherIdsSubject = new BehaviorSubject<string[]>(null);
/**
* Observable that emits available cipher IDs once they're confirmed by the UI
*/
availableCipherIds$ = this.availableCipherIdsSubject.pipe(
filter((ids) => ids != null),
take(1),
);
private chosenCipherSubject = new Subject<{ cipherId: string; userVerified: boolean }>();
// Method implementation
async pickCredential({
cipherIds,
userVerification,
assumeUserPresence,
masterPasswordRepromptRequired,
}: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
this.logService.warning("pickCredential", cipherIds, userVerification);
this.logService.warning("pickCredential desktop function", {
cipherIds,
userVerification,
assumeUserPresence,
masterPasswordRepromptRequired,
});
return { cipherId: cipherIds[0], userVerified: userVerification };
try {
// Check if we can return the credential without user interaction
if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired) {
this.logService.debug(
"shortcut - Assuming user presence and returning cipherId",
cipherIds[0],
);
return { cipherId: cipherIds[0], userVerified: userVerification };
}
this.logService.debug("Could not shortcut, showing UI");
// make the cipherIds available to the UI.
this.availableCipherIdsSubject.next(cipherIds);
await this.showUi("/passkeys", this.windowObject.windowXy);
const chosenCipherResponse = await this.waitForUiChosenCipher();
this.logService.debug("Received chosen cipher", chosenCipherResponse);
return {
cipherId: chosenCipherResponse.cipherId,
userVerified: chosenCipherResponse.userVerified,
};
} finally {
// Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setModalMode(false);
}
}
confirmChosenCipher(cipherId: string, userVerified: boolean = false): void {
this.chosenCipherSubject.next({ cipherId, userVerified });
this.chosenCipherSubject.complete();
}
private async waitForUiChosenCipher(
timeoutMs: number = 60000,
): Promise<{ cipherId: string; userVerified: boolean } | undefined> {
try {
return await lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs)));
} catch {
// If we hit a timeout, return undefined instead of throwing
this.logService.warning("Timeout: User did not select a cipher within the allowed time", {
timeoutMs,
});
return { cipherId: undefined, userVerified: false };
}
}
/**
* Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS.
*/
notifyConfirmNewCredential(confirmed: boolean): void {
this.confirmCredentialSubject.next(confirmed);
this.confirmCredentialSubject.complete();
}
/**
* Returns once the UI has confirmed and completed the operation
* @returns
*/
private async waitForUiNewCredentialConfirmation(): Promise<boolean> {
return lastValueFrom(this.confirmCredentialSubject);
}
/**
* This is called by the OS. It loads the UI and waits for the user to confirm the new credential. Once the UI has confirmed, it returns to the the OS.
* @param param0
* @returns
*/
async confirmNewCredential({
credentialName,
userName,
@@ -75,6 +206,48 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
rpId,
);
try {
await this.showUi("/passkeys", this.windowObject.windowXy);
// Wait for the UI to wrap up
const confirmation = await this.waitForUiNewCredentialConfirmation();
if (!confirmation) {
return { cipherId: undefined, userVerified: false };
}
// 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 };
} finally {
// Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setModalMode(false);
}
}
private async showUi(route: string, position?: { x: number; y: number }): Promise<void> {
// Load the UI:
await this.desktopSettingsService.setModalMode(true, position);
await this.router.navigate(["/passkeys"]);
}
/**
* Can be called by the UI to create a new credential with user input etc.
* @param param0
*/
async createCredential({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> {
// Store the passkey on a new cipher to avoid replacing something important
const cipher = new CipherView();
cipher.name = credentialName;
@@ -97,7 +270,9 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
const createdCipher = await this.cipherService.createWithServer(encCipher);
return { cipherId: createdCipher.id, userVerified: userVerification };
this.createdCipher = createdCipher;
return createdCipher;
}
async informExcludedCredential(existingCipherIds: string[]): Promise<void> {

View File

@@ -209,6 +209,14 @@ export class Main {
new ElectronMainMessagingService(this.windowMain),
);
this.trayMain = new TrayMain(
this.windowMain,
this.i18nService,
this.desktopSettingsService,
this.messagingService,
this.biometricsService,
);
messageSubject.asObservable().subscribe((message) => {
void this.messagingMain.onMessage(message).catch((err) => {
this.logService.error(
@@ -236,7 +244,7 @@ export class Main {
this.windowMain,
this.i18nService,
this.desktopSettingsService,
biometricStateService,
this.messagingService,
this.biometricsService,
);
@@ -285,7 +293,7 @@ export class Main {
async () => {
await this.toggleHardwareAcceleration();
// Reset modal mode to make sure main window is displayed correctly
await this.desktopSettingsService.resetInModalMode();
await this.desktopSettingsService.resetModalMode();
await this.windowMain.init();
await this.i18nService.init();
await this.messagingMain.init();

View File

@@ -37,6 +37,10 @@ export class MessagingMain {
async onMessage(message: any) {
switch (message.command) {
case "loadurl":
// TODO: Remove this once fakepopup is removed from tray (just used for dev)
await this.main.windowMain.loadUrl(message.url, message.modal);
break;
case "scheduleNextSync":
this.scheduleNextSync();
break;

View File

@@ -1,16 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as path from "path";
import * as url from "url";
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron";
import { firstValueFrom } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BiometricStateService, BiometricsService } from "@bitwarden/key-management";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { BiometricsService } from "@bitwarden/key-management";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { cleanUserAgent, isDev } from "../utils";
import { isDev } from "../utils";
import { WindowMain } from "./window.main";
@@ -26,7 +26,7 @@ export class TrayMain {
private windowMain: WindowMain,
private i18nService: I18nService,
private desktopSettingsService: DesktopSettingsService,
private biometricsStateService: BiometricStateService,
private messagingService: MessagingService,
private biometricService: BiometricsService,
) {
if (process.platform === "win32") {
@@ -216,32 +216,6 @@ export class TrayMain {
* @returns
*/
private async fakePopup() {
if (this.windowMain.win == null || this.windowMain.win.isDestroyed()) {
await this.windowMain.createWindow("modal-app");
return;
}
// Restyle existing
const existingWin = this.windowMain.win;
await this.desktopSettingsService.setInModalMode(true);
await existingWin.loadURL(
url.format({
protocol: "file:",
//pathname: `${__dirname}/index.html`,
pathname: path.join(__dirname, "/index.html"),
slashes: true,
hash: "/passkeys",
query: {
redirectUrl: "/passkeys",
},
}),
{
userAgent: cleanUserAgent(existingWin.webContents.userAgent),
},
);
existingWin.once("ready-to-show", () => {
existingWin.show();
});
await this.messagingService.send("loadurl", { url: "/passkeys", modal: true });
}
}

View File

@@ -78,18 +78,19 @@ export class WindowMain {
}
});
this.desktopSettingsService.inModalMode$
this.desktopSettingsService.modalMode$
.pipe(
pairwise(),
concatMap(async ([lastValue, newValue]) => {
if (lastValue && !newValue) {
if (lastValue.isModalModeActive && !newValue.isModalModeActive) {
// Reset the window state to the main window state
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
// Because modal is used in front of another app, UX wise it makes sense to hide the main window when leaving modal mode.
this.win.hide();
} else if (!lastValue && newValue) {
} else if (!lastValue.isModalModeActive && newValue.isModalModeActive) {
// Apply the popup modal styles
applyPopupModalStyles(this.win);
this.logService.info("Applying popup modal styles", newValue.modalPosition);
applyPopupModalStyles(this.win, newValue.modalPosition);
this.win.show();
}
}),
@@ -209,6 +210,35 @@ export class WindowMain {
}
}
// TODO: REMOVE ONCE WE CAN STOP USING FAKE POP UP BTN FROM TRAY
// Only used for development
async loadUrl(targetPath: string, modal: boolean = false) {
if (this.win == null || this.win.isDestroyed()) {
await this.createWindow("modal-app");
return;
}
await this.desktopSettingsService.setModalMode(modal);
await this.win.loadURL(
url.format({
protocol: "file:",
//pathname: `${__dirname}/index.html`,
pathname: path.join(__dirname, "/index.html"),
slashes: true,
hash: targetPath,
query: {
redirectUrl: targetPath,
},
}),
{
userAgent: cleanUserAgent(this.win.webContents.userAgent),
},
);
this.win.once("ready-to-show", () => {
this.win.show();
});
}
/**
* Creates the main window. The template argument is used to determine the styling of the window and what url will be loaded.
* When the template is "modal-app", the window will be styled as a modal and the passkeys page will be loaded.
@@ -394,9 +424,9 @@ export class WindowMain {
return;
}
const inModalMode = await firstValueFrom(this.desktopSettingsService.inModalMode$);
const modalMode = await firstValueFrom(this.desktopSettingsService.modalMode$);
if (inModalMode) {
if (modalMode.isModalModeActive) {
return;
}

View File

@@ -40,6 +40,7 @@ export class NativeAutofillMain {
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.registration", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return;
}
this.windowMain.win.webContents.send("autofill.passkeyRegistration", {
@@ -52,6 +53,7 @@ export class NativeAutofillMain {
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.assertion", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return;
}
this.windowMain.win.webContents.send("autofill.passkeyAssertion", {
@@ -60,6 +62,19 @@ export class NativeAutofillMain {
request,
});
},
// AssertionWithoutUserInterfaceCallback
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.assertion", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return;
}
this.windowMain.win.webContents.send("autofill.passkeyAssertionWithoutUserInterface", {
clientId,
sequenceNumber,
request,
});
},
);
ipcMain.on("autofill.completePasskeyRegistration", (event, data) => {
@@ -77,7 +92,7 @@ export class NativeAutofillMain {
ipcMain.on("autofill.completeError", (event, data) => {
this.logService.warning("autofill.completeError", data);
const { clientId, sequenceNumber, error } = data;
this.ipcServer.completeAssertion(clientId, sequenceNumber, error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
});
}

View File

@@ -11,3 +11,8 @@ export class WindowState {
y?: number;
zoomFactor?: number;
}
export class ModalModeState {
isModalModeActive: boolean;
modalPosition?: { x: number; y: number }; // Modal position is often passed from the native UI
}

View File

@@ -6,10 +6,11 @@ import { WindowState } from "./models/domain/window-state";
const popupWidth = 680;
const popupHeight = 500;
export function applyPopupModalStyles(window: BrowserWindow) {
type Position = { x: number; y: number };
export function applyPopupModalStyles(window: BrowserWindow, position?: Position) {
window.unmaximize();
window.setSize(popupWidth, popupHeight);
window.center();
window.setWindowButtonVisibility?.(false);
window.setMenuBarVisibility?.(false);
window.setResizable(false);
@@ -20,8 +21,21 @@ export function applyPopupModalStyles(window: BrowserWindow) {
window.setFullScreen(false);
window.once("leave-full-screen", () => {
window.setSize(popupWidth, popupHeight);
window.center();
positionWindow(window, position);
});
} else {
// If not in full screen
positionWindow(window, position);
}
}
function positionWindow(window: BrowserWindow, position?: Position) {
if (position) {
const centeredX = position.x - popupWidth / 2;
const centeredY = position.y - popupHeight / 2;
window.setPosition(centeredX, centeredY);
} else {
window.center();
}
}

View File

@@ -8,7 +8,7 @@ import {
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { WindowState } from "../models/domain/window-state";
import { ModalModeState, WindowState } from "../models/domain/window-state";
export const HARDWARE_ACCELERATION = new KeyDefinition<boolean>(
DESKTOP_SETTINGS_DISK,
@@ -75,7 +75,7 @@ const MINIMIZE_ON_COPY = new UserKeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "
clearOn: [], // User setting, no need to clear
});
const IN_MODAL_MODE = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "inModalMode", {
const MODAL_MODE = new KeyDefinition<ModalModeState>(DESKTOP_SETTINGS_DISK, "modalMode", {
deserializer: (b) => b,
});
@@ -174,9 +174,9 @@ export class DesktopSettingsService {
*/
minimizeOnCopy$ = this.minimizeOnCopyState.state$.pipe(map(Boolean));
private readonly inModalModeState = this.stateProvider.getGlobal(IN_MODAL_MODE);
private readonly modalModeState = this.stateProvider.getGlobal(MODAL_MODE);
inModalMode$ = this.inModalModeState.state$.pipe(map(Boolean));
modalMode$ = this.modalModeState.state$;
constructor(private stateProvider: StateProvider) {
this.window$ = this.windowState.state$.pipe(
@@ -190,8 +190,8 @@ export class DesktopSettingsService {
* This is used to clear the setting on application start to make sure we don't end up
* stuck in modal mode if the application is force-closed in modal mode.
*/
async resetInModalMode() {
await this.inModalModeState.update(() => false);
async resetModalMode() {
await this.modalModeState.update(() => ({ isModalModeActive: false }));
}
async setHardwareAcceleration(enabled: boolean) {
@@ -306,8 +306,11 @@ export class DesktopSettingsService {
* Sets the modal mode of the application. Setting this changes the windows-size and other properties.
* @param value `true` if the application is in modal mode, `false` if it is not.
*/
async setInModalMode(value: boolean) {
await this.inModalModeState.update(() => value);
async setModalMode(value: boolean, modalPosition?: { x: number; y: number }) {
await this.modalModeState.update(() => ({
isModalModeActive: value,
modalPosition,
}));
}
/**