1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-23 03:22:50 +02:00

[PM-7956] Update passkey pop-out to new UI (#10796)

* rename existing fido2 components to use v1 designation

* use fido2 message type value constants in components

* add v2 fido2 components

* add search to login UX of fido2 v2 component

* add new item button in top nav of fido2 v2 component

* get and pass activeUserId to cipher key decription methods

* cleanup / PR suggestions
This commit is contained in:
Jonathan Prusik 2024-09-04 13:50:48 -04:00 committed by GitHub
parent 44f1fc156c
commit 095ce7ec30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1159 additions and 284 deletions

View File

@ -3477,7 +3477,7 @@
"passkeyFeatureIsNotImplementedForAccountsWithoutMasterPassword": {
"message": "Verification required by the initiating site. This feature is not yet implemented for accounts without master password."
},
"logInWithPasskey": {
"logInWithPasskeyQuestion": {
"message": "Log in with passkey?"
},
"passkeyAlreadyExists": {
@ -3489,6 +3489,9 @@
"noMatchingPasskeyLogin": {
"message": "You do not have a matching login for this site."
},
"noMatchingLoginsForSite": {
"message": "No matching logins for this site"
},
"confirm": {
"message": "Confirm"
},
@ -3498,9 +3501,12 @@
"savePasskeyNewLogin": {
"message": "Save passkey as new login"
},
"choosePasskey": {
"chooseCipherForPasskeySave": {
"message": "Choose a login to save this passkey to"
},
"chooseCipherForPasskeyAuth": {
"message": "Choose a passkey to log in with"
},
"passkeyItem": {
"message": "Passkey Item"
},

View File

@ -94,7 +94,7 @@
<i class="bwi bwi-spinner bwi-spin" aria-hidden="true"></i> {{ "awaitDesktop" | i18n }}
</p>
<app-fido2-use-browser-link></app-fido2-use-browser-link>
<app-fido2-use-browser-link-v1></app-fido2-use-browser-link-v1>
</ng-container>
</main>
</form>

View File

@ -18,7 +18,7 @@ export enum MessageType {
}
/**
* The params provided by the page-script are created in an insecure environemnt and
* The params provided by the page-script are created in an insecure environment and
* should not be trusted. This type is used to ensure that the content-script does not
* trust the `origin` or `sameOriginWithAncestors` params.
*/
@ -38,7 +38,7 @@ export type CredentialCreationResponse = {
};
/**
* The params provided by the page-script are created in an insecure environemnt and
* The params provided by the page-script are created in an insecure environment and
* should not be trusted. This type is used to ensure that the content-script does not
* trust the `origin` or `sameOriginWithAncestors` params.
*/

View File

@ -0,0 +1,36 @@
<div
role="group"
appA11yTitle="{{ cipher.name }} {{ cipher.subTitle }}"
class="virtual-scroll-item"
[ngClass]="{ 'override-last': !last }"
>
<div class="box-content-row box-content-row-flex">
<button
type="button"
(click)="selectCipher(cipher)"
tabindex="0"
appStopClick
title="{{ title }} - {{ cipher.name }}"
[ngClass]="{ 'row-main': true, 'row-selected': isSelected && !isSearching }"
>
<app-vault-icon [cipher]="cipher"></app-vault-icon>
<div class="row-main-content">
<span class="text">
<span class="truncate-box">
<span class="truncate">{{ cipher.name }}</span>
<ng-container *ngIf="cipher.organizationId">
<i
class="bwi bwi-collection text-muted"
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "shared" | i18n }}</span>
</ng-container>
</span>
</span>
<span class="detail" *ngIf="getSubName(cipher)">{{ getSubName(cipher) }}</span>
<span class="detail" *ngIf="cipher.subTitle">{{ cipher.subTitle }}</span>
</div>
</button>
</div>
</div>

View File

@ -0,0 +1,39 @@
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from "@angular/core";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@Component({
selector: "app-fido2-cipher-row-v1",
templateUrl: "fido2-cipher-row-v1.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Fido2CipherRowV1Component {
@Output() onSelected = new EventEmitter<CipherView>();
@Input() cipher: CipherView;
@Input() last: boolean;
@Input() title: string;
@Input() isSearching: boolean;
@Input() isSelected: boolean;
protected selectCipher(c: CipherView) {
this.onSelected.emit(c);
}
/**
* Returns a subname for the cipher.
* If this has a FIDO2 credential, and the cipher.name is different from the FIDO2 credential's rpId, return the rpId.
* @param c Cipher
* @returns
*/
protected getSubName(c: CipherView): string | null {
const fido2Credentials = c.login?.fido2Credentials;
if (!fido2Credentials || fido2Credentials.length === 0) {
return null;
}
const [fido2Credential] = fido2Credentials;
return c.name !== fido2Credential.rpId ? fido2Credential.rpId : null;
}
}

View File

@ -1,36 +1,21 @@
<div
role="group"
appA11yTitle="{{ cipher.name }} {{ cipher.subTitle }}"
class="virtual-scroll-item"
[ngClass]="{ 'override-last': !last }"
>
<div class="box-content-row box-content-row-flex">
<button
type="button"
(click)="selectCipher(cipher)"
tabindex="0"
appStopClick
title="{{ title }} - {{ cipher.name }}"
[ngClass]="{ 'row-main': true, 'row-selected': isSelected && !isSearching }"
>
<app-vault-icon [cipher]="cipher"></app-vault-icon>
<div class="row-main-content">
<span class="text">
<span class="truncate-box">
<span class="truncate">{{ cipher.name }}</span>
<ng-container *ngIf="cipher.organizationId">
<i
class="bwi bwi-collection text-muted"
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "shared" | i18n }}</span>
</ng-container>
</span>
</span>
<span class="detail" *ngIf="getSubName(cipher)">{{ getSubName(cipher) }}</span>
<span class="detail" *ngIf="cipher.subTitle">{{ cipher.subTitle }}</span>
</div>
</button>
</div>
</div>
<bit-item>
<button
(click)="selectCipher(cipher)"
appA11yTitle="{{ title }} - {{ cipher.name }}"
bit-item-content
tabindex="0"
type="button"
>
<app-vault-icon slot="start" [cipher]="cipher"></app-vault-icon>
<span data-testid="item-name">
{{ cipher.name }}
<i
*ngIf="cipher.organizationId"
[appA11yTitle]="'shared' | i18n"
class="bwi bwi-collection text-muted"
></i>
</span>
<span class="detail" *ngIf="getSubName(cipher)">{{ getSubName(cipher) }}</span>
<span *ngIf="cipher.subTitle" slot="secondary">{{ cipher.subTitle }}</span>
</button>
</bit-item>

View File

@ -1,19 +1,40 @@
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
BadgeModule,
ButtonModule,
IconButtonModule,
ItemModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
} from "@bitwarden/components";
@Component({
selector: "app-fido2-cipher-row",
templateUrl: "fido2-cipher-row.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
BadgeModule,
ButtonModule,
CommonModule,
IconButtonModule,
ItemModule,
JslibModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
],
})
export class Fido2CipherRowComponent {
@Output() onSelected = new EventEmitter<CipherView>();
@Input() cipher: CipherView;
@Input() last: boolean;
@Input() title: string;
@Input() isSearching: boolean;
@Input() isSelected: boolean;
protected selectCipher(c: CipherView) {
this.onSelected.emit(c);

View File

@ -0,0 +1,52 @@
<ng-container *ngIf="(fido2PopoutSessionData$ | async).fallbackSupported">
<div class="useBrowserlink">
<button
type="button"
(click)="toggle()"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
aria-haspopup="dialog"
aria-controls="cdk-overlay-container"
>
<span class="text-primary">
{{ "useDeviceOrHardwareKey" | i18n }}
</span>
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</button>
</div>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isOpen"
[cdkConnectedOverlayPositions]="overlayPosition"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
(backdropClick)="isOpen = false"
(detach)="close()"
>
<div class="box-content">
<div
class="fido2-browser-selector-dropdown"
[@transformPanel]="'open'"
cdkTrapFocus
cdkTrapFocusAutoCapture
role="dialog"
aria-modal="true"
>
<button type="button" class="fido2-browser-selector-dropdown-item" (click)="abort(false)">
<span>{{ "justOnce" | i18n }}</span>
</button>
<br />
<button type="button" class="fido2-browser-selector-dropdown-item" (click)="abort()">
<span>{{ "alwaysForThisSite" | i18n }}</span>
</button>
</div>
</div>
</ng-template>
<div
*ngIf="showOverlay"
class="tw-absolute tw-w-full tw-h-full tw-bg-background-alt tw-inset-0 tw-bg-opacity-80 tw-z-50"
></div>
</ng-container>

View File

@ -0,0 +1,113 @@
import { animate, state, style, transition, trigger } from "@angular/animations";
import { ConnectedPosition } from "@angular/cdk/overlay";
import { Component } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { fido2PopoutSessionData$ } from "../../../vault/popup/utils/fido2-popout-session-data";
import { BrowserFido2UserInterfaceSession } from "../../fido2/services/browser-fido2-user-interface.service";
@Component({
selector: "app-fido2-use-browser-link-v1",
templateUrl: "fido2-use-browser-link-v1.component.html",
animations: [
trigger("transformPanel", [
state(
"void",
style({
opacity: 0,
}),
),
transition(
"void => open",
animate(
"100ms linear",
style({
opacity: 1,
}),
),
),
transition("* => void", animate("100ms linear", style({ opacity: 0 }))),
]),
],
})
export class Fido2UseBrowserLinkV1Component {
showOverlay = false;
isOpen = false;
overlayPosition: ConnectedPosition[] = [
{
originX: "start",
originY: "bottom",
overlayX: "start",
overlayY: "top",
offsetY: 5,
},
];
protected fido2PopoutSessionData$ = fido2PopoutSessionData$();
constructor(
private domainSettingsService: DomainSettingsService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
) {}
toggle() {
this.isOpen = !this.isOpen;
}
close() {
this.isOpen = false;
}
/**
* Aborts the current FIDO2 session and fallsback to the browser.
* @param excludeDomain - Identifies if the domain should be excluded from future FIDO2 prompts.
*/
protected async abort(excludeDomain = true) {
this.close();
const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
if (!excludeDomain) {
this.abortSession(sessionData.sessionId);
return;
}
// Show overlay to prevent the user from interacting with the page.
this.showOverlay = true;
await this.handleDomainExclusion(sessionData.senderUrl);
// Give the user a chance to see the toast before closing the popout.
await Utils.delay(2000);
this.abortSession(sessionData.sessionId);
}
/**
* Excludes the domain from future FIDO2 prompts.
* @param uri - The domain uri to exclude from future FIDO2 prompts.
*/
private async handleDomainExclusion(uri: string) {
const existingDomains = await firstValueFrom(this.domainSettingsService.neverDomains$);
const validDomain = Utils.getHostname(uri);
const savedDomains: NeverDomains = {
...existingDomains,
};
savedDomains[validDomain] = null;
await this.domainSettingsService.setNeverDomains(savedDomains);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("domainAddedToExcludedDomains", validDomain),
);
}
private abortSession(sessionId: string) {
BrowserFido2UserInterfaceSession.abortPopout(sessionId, true);
}
}

View File

@ -1,8 +1,11 @@
import { animate, state, style, transition, trigger } from "@angular/animations";
import { ConnectedPosition } from "@angular/cdk/overlay";
import { A11yModule } from "@angular/cdk/a11y";
import { ConnectedPosition, CdkOverlayOrigin, CdkConnectedOverlay } from "@angular/cdk/overlay";
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -15,6 +18,8 @@ import { BrowserFido2UserInterfaceSession } from "../../fido2/services/browser-f
@Component({
selector: "app-fido2-use-browser-link",
templateUrl: "fido2-use-browser-link.component.html",
standalone: true,
imports: [A11yModule, CdkConnectedOverlay, CdkOverlayOrigin, CommonModule, JslibModule],
animations: [
trigger("transformPanel", [
state(
@ -90,11 +95,11 @@ export class Fido2UseBrowserLinkComponent {
* @param uri - The domain uri to exclude from future FIDO2 prompts.
*/
private async handleDomainExclusion(uri: string) {
const exisitingDomains = await firstValueFrom(this.domainSettingsService.neverDomains$);
const existingDomains = await firstValueFrom(this.domainSettingsService.neverDomains$);
const validDomain = Utils.getHostname(uri);
const savedDomains: NeverDomains = {
...exisitingDomains,
...existingDomains,
};
savedDomains[validDomain] = null;

View File

@ -0,0 +1,142 @@
<ng-container *ngIf="data$ | async as data">
<div class="auth-wrapper">
<div class="auth-header">
<div class="left">
<ng-container *ngIf="data.message.type != BrowserFido2MessageTypes.PickCredentialRequest">
<div class="logo">
<i class="bwi bwi-shield"></i>
</div>
</ng-container>
<ng-container *ngIf="data.message.type === BrowserFido2MessageTypes.PickCredentialRequest">
<div class="logo">
<i class="bwi bwi-shield"></i><span><strong>bit</strong>warden</span>
</div>
</ng-container>
</div>
<ng-container
*ngIf="data.message.type === BrowserFido2MessageTypes.ConfirmNewCredentialRequest"
>
<div class="search">
<input
type="{{ searchTypeSearch ? 'search' : 'text' }}"
placeholder="{{ 'searchVault' | i18n }}"
id="search"
[(ngModel)]="searchText"
(input)="search()"
autocomplete="off"
appAutofocus
/>
<i class="bwi bwi-search" aria-hidden="true"></i>
</div>
<div class="right">
<button type="button" (click)="addCipher()" appA11yTitle="{{ 'addItem' | i18n }}">
<i class="bwi bwi-plus bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
</div>
</ng-container>
</div>
<ng-container>
<ng-container
*ngIf="
data.message.type === BrowserFido2MessageTypes.PickCredentialRequest ||
data.message.type === BrowserFido2MessageTypes.ConfirmNewCredentialRequest
"
>
<div class="auth-flow">
<p class="subtitle" appA11yTitle="{{ subtitleText | i18n }}">
{{ subtitleText | i18n }}
</p>
<!-- Display when ciphers exist -->
<ng-container *ngIf="displayedCiphers.length > 0">
<div class="box list">
<div class="box-content">
<app-fido2-cipher-row-v1
*ngFor="let cipherItem of displayedCiphers"
[cipher]="cipherItem"
[isSearching]="searchPending"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="selectedPasskey($event)"
[isSelected]="cipher === cipherItem"
></app-fido2-cipher-row-v1>
</div>
</div>
<div class="box">
<button
type="submit"
(click)="submit()"
class="btn primary block"
appA11yTitle="{{ credentialText | i18n }}"
>
<span [hidden]="loading">
{{ credentialText | i18n }}
</span>
<i
class="bwi bwi-spinner bwi-lg bwi-spin"
[hidden]="!loading"
aria-hidden="true"
></i>
</button>
</div>
</ng-container>
<ng-container *ngIf="!displayedCiphers.length">
<div class="box">
<button
type="submit"
(click)="saveNewLogin()"
class="btn primary block"
appA11yTitle="{{ 'savePasskeyNewLogin' | i18n }}"
>
<span [hidden]="loading">
{{ "savePasskeyNewLogin" | i18n }}
</span>
<i
class="bwi bwi-spinner bwi-lg bwi-spin"
[hidden]="!loading"
aria-hidden="true"
></i>
</button>
</div>
</ng-container>
</div>
</ng-container>
<ng-container
*ngIf="data.message.type === BrowserFido2MessageTypes.InformExcludedCredentialRequest"
>
<div class="auth-flow">
<p class="subtitle">{{ "passkeyAlreadyExists" | i18n }}</p>
<div class="box list">
<div class="box-content">
<app-fido2-cipher-row-v1
*ngFor="let cipherItem of displayedCiphers"
[cipher]="cipherItem"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="selectedPasskey($event)"
[isSelected]="cipher === cipherItem"
></app-fido2-cipher-row-v1>
</div>
</div>
<button type="button" class="btn primary block" (click)="viewPasskey()">
<span [hidden]="loading">{{ "viewItem" | i18n }}</span>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
</button>
</div>
</ng-container>
<ng-container
*ngIf="data.message.type === BrowserFido2MessageTypes.InformCredentialNotFoundRequest"
>
<div class="auth-flow">
<p class="subtitle">{{ "noPasskeysFoundForThisApplication" | i18n }}</p>
</div>
<button type="button" class="btn primary block" (click)="abort(false)">
<span [hidden]="loading">{{ "close" | i18n }}</span>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
</button>
</ng-container>
</ng-container>
<app-fido2-use-browser-link-v1></app-fido2-use-browser-link-v1>
</div>
</ng-container>

View File

@ -0,0 +1,443 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import {
BehaviorSubject,
combineLatest,
concatMap,
filter,
firstValueFrom,
map,
Observable,
Subject,
take,
takeUntil,
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
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";
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 { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { ZonedMessageListenerService } from "../../../platform/browser/zoned-message-listener.service";
import { VaultPopoutType } from "../../../vault/popup/utils/vault-popout-window";
import { Fido2UserVerificationService } from "../../../vault/services/fido2-user-verification.service";
import {
BrowserFido2Message,
BrowserFido2UserInterfaceSession,
BrowserFido2MessageTypes,
} from "../../fido2/services/browser-fido2-user-interface.service";
interface ViewData {
message: BrowserFido2Message;
fallbackSupported: boolean;
}
@Component({
selector: "app-fido2-v1",
templateUrl: "fido2-v1.component.html",
styleUrls: [],
})
export class Fido2V1Component implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private hasSearched = false;
protected cipher: CipherView;
protected searchTypeSearch = false;
protected searchPending = false;
protected searchText: string;
protected url: string;
protected hostname: string;
protected data$: Observable<ViewData>;
protected sessionId?: string;
protected senderTabId?: string;
protected ciphers?: CipherView[] = [];
protected displayedCiphers?: CipherView[] = [];
protected loading = false;
protected subtitleText: string;
protected credentialText: string;
protected BrowserFido2MessageTypes = BrowserFido2MessageTypes;
private message$ = new BehaviorSubject<BrowserFido2Message>(null);
constructor(
private router: Router,
private activatedRoute: ActivatedRoute,
private cipherService: CipherService,
private platformUtilsService: PlatformUtilsService,
private domainSettingsService: DomainSettingsService,
private searchService: SearchService,
private logService: LogService,
private dialogService: DialogService,
private browserMessagingApi: ZonedMessageListenerService,
private passwordRepromptService: PasswordRepromptService,
private fido2UserVerificationService: Fido2UserVerificationService,
private accountService: AccountService,
) {}
ngOnInit() {
this.searchTypeSearch = !this.platformUtilsService.isSafari();
const queryParams$ = this.activatedRoute.queryParamMap.pipe(
take(1),
map((queryParamMap) => ({
sessionId: queryParamMap.get("sessionId"),
senderTabId: queryParamMap.get("senderTabId"),
senderUrl: queryParamMap.get("senderUrl"),
})),
);
combineLatest([
queryParams$,
this.browserMessagingApi.messageListener$() as Observable<BrowserFido2Message>,
])
.pipe(
concatMap(async ([queryParams, message]) => {
this.sessionId = queryParams.sessionId;
this.senderTabId = queryParams.senderTabId;
this.url = queryParams.senderUrl;
// For a 'NewSessionCreatedRequest', abort if it doesn't belong to the current session.
if (
message.type === BrowserFido2MessageTypes.NewSessionCreatedRequest &&
message.sessionId !== queryParams.sessionId
) {
this.abort(false);
return;
}
// Ignore messages that don't belong to the current session.
if (message.sessionId !== queryParams.sessionId) {
return;
}
if (message.type === BrowserFido2MessageTypes.AbortRequest) {
this.abort(false);
return;
}
return message;
}),
filter((message) => !!message),
takeUntil(this.destroy$),
)
.subscribe((message) => {
this.message$.next(message);
});
this.data$ = this.message$.pipe(
filter((message) => message != undefined),
concatMap(async (message) => {
switch (message.type) {
case BrowserFido2MessageTypes.ConfirmNewCredentialRequest: {
const equivalentDomains = await firstValueFrom(
this.domainSettingsService.getUrlEquivalentDomains(this.url),
);
this.ciphers = (await this.cipherService.getAllDecrypted()).filter(
(cipher) => cipher.type === CipherType.Login && !cipher.isDeleted,
);
this.displayedCiphers = this.ciphers.filter(
(cipher) =>
cipher.login.matchesUri(this.url, equivalentDomains) &&
this.hasNoOtherPasskeys(cipher, message.userHandle),
);
if (this.displayedCiphers.length > 0) {
this.selectedPasskey(this.displayedCiphers[0]);
}
break;
}
case BrowserFido2MessageTypes.PickCredentialRequest: {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
this.ciphers = await Promise.all(
message.cipherIds.map(async (cipherId) => {
const cipher = await this.cipherService.get(cipherId);
return cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
}),
);
this.displayedCiphers = [...this.ciphers];
if (this.displayedCiphers.length > 0) {
this.selectedPasskey(this.displayedCiphers[0]);
}
break;
}
case BrowserFido2MessageTypes.InformExcludedCredentialRequest: {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
this.ciphers = await Promise.all(
message.existingCipherIds.map(async (cipherId) => {
const cipher = await this.cipherService.get(cipherId);
return cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
}),
);
this.displayedCiphers = [...this.ciphers];
if (this.displayedCiphers.length > 0) {
this.selectedPasskey(this.displayedCiphers[0]);
}
break;
}
}
this.subtitleText =
this.displayedCiphers.length > 0
? this.getCredentialSubTitleText(message.type)
: "noMatchingPasskeyLogin";
this.credentialText = this.getCredentialButtonText(message.type);
return {
message,
fallbackSupported: "fallbackSupported" in message && message.fallbackSupported,
};
}),
takeUntil(this.destroy$),
);
queryParams$.pipe(takeUntil(this.destroy$)).subscribe((queryParams) => {
this.send({
sessionId: queryParams.sessionId,
type: BrowserFido2MessageTypes.ConnectResponse,
});
});
}
protected async submit() {
const data = this.message$.value;
if (data?.type === BrowserFido2MessageTypes.PickCredentialRequest) {
// TODO: Revert to use fido2 user verification service once user verification for passkeys is approved for production.
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
this.send({
sessionId: this.sessionId,
cipherId: this.cipher.id,
type: BrowserFido2MessageTypes.PickCredentialResponse,
userVerified,
});
} else if (data?.type === BrowserFido2MessageTypes.ConfirmNewCredentialRequest) {
if (this.cipher.login.hasFido2Credentials) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "overwritePasskey" },
content: { key: "overwritePasskeyAlert" },
type: "info",
});
if (!confirmed) {
return false;
}
}
// TODO: Revert to use fido2 user verification service once user verification for passkeys is approved for production.
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
this.send({
sessionId: this.sessionId,
cipherId: this.cipher.id,
type: BrowserFido2MessageTypes.ConfirmNewCredentialResponse,
userVerified,
});
}
this.loading = true;
}
protected async saveNewLogin() {
const data = this.message$.value;
if (data?.type === BrowserFido2MessageTypes.ConfirmNewCredentialRequest) {
const name = data.credentialName || data.rpId;
// TODO: Revert to check for user verification once user verification for passkeys is approved for production.
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
await this.createNewCipher(name, data.userName);
// We are bypassing user verification pending approval.
this.send({
sessionId: this.sessionId,
cipherId: this.cipher?.id,
type: BrowserFido2MessageTypes.ConfirmNewCredentialResponse,
userVerified: data.userVerification,
});
}
this.loading = true;
}
getCredentialSubTitleText(messageType: string): string {
return messageType == BrowserFido2MessageTypes.ConfirmNewCredentialRequest
? "chooseCipherForPasskeySave"
: "logInWithPasskeyQuestion";
}
getCredentialButtonText(messageType: string): string {
return messageType == BrowserFido2MessageTypes.ConfirmNewCredentialRequest
? "savePasskey"
: "confirm";
}
selectedPasskey(item: CipherView) {
this.cipher = item;
}
viewPasskey() {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/view-cipher"], {
queryParams: {
cipherId: this.cipher.id,
uilocation: "popout",
senderTabId: this.senderTabId,
sessionId: this.sessionId,
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
},
});
}
addCipher() {
const data = this.message$.value;
if (data?.type !== BrowserFido2MessageTypes.ConfirmNewCredentialRequest) {
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/add-cipher"], {
queryParams: {
name: data.credentialName || data.rpId,
uri: this.url,
type: CipherType.Login.toString(),
uilocation: "popout",
username: data.userName,
senderTabId: this.senderTabId,
sessionId: this.sessionId,
userVerification: data.userVerification,
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
},
});
}
protected async search() {
this.hasSearched = await this.searchService.isSearchable(this.searchText);
this.searchPending = true;
if (this.hasSearched) {
this.displayedCiphers = await this.searchService.searchCiphers(
this.searchText,
null,
this.ciphers,
);
} else {
const equivalentDomains = await firstValueFrom(
this.domainSettingsService.getUrlEquivalentDomains(this.url),
);
this.displayedCiphers = this.ciphers.filter((cipher) =>
cipher.login.matchesUri(this.url, equivalentDomains),
);
}
this.searchPending = false;
this.selectedPasskey(this.displayedCiphers[0]);
}
abort(fallback: boolean) {
this.unload(fallback);
window.close();
}
unload(fallback = false) {
this.send({
sessionId: this.sessionId,
type: BrowserFido2MessageTypes.AbortResponse,
fallbackRequested: fallback,
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private buildCipher(name: string, username: string) {
this.cipher = new CipherView();
this.cipher.name = name;
this.cipher.type = CipherType.Login;
this.cipher.login = new LoginView();
this.cipher.login.username = username;
this.cipher.login.uris = [new LoginUriView()];
this.cipher.login.uris[0].uri = this.url;
this.cipher.card = new CardView();
this.cipher.identity = new IdentityView();
this.cipher.secureNote = new SecureNoteView();
this.cipher.secureNote.type = SecureNoteType.Generic;
this.cipher.reprompt = CipherRepromptType.None;
}
private async createNewCipher(name: string, username: string) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
this.buildCipher(name, username);
const cipher = await this.cipherService.encrypt(this.cipher, activeUserId);
try {
await this.cipherService.createWithServer(cipher);
this.cipher.id = cipher.id;
} catch (e) {
this.logService.error(e);
}
}
// TODO: Remove and use fido2 user verification service once user verification for passkeys is approved for production.
private async handleUserVerification(
userVerificationRequested: boolean,
cipher: CipherView,
): Promise<boolean> {
const masterPasswordRepromptRequired = cipher && cipher.reprompt !== 0;
if (masterPasswordRepromptRequired) {
return await this.passwordRepromptService.showPasswordPrompt();
}
return userVerificationRequested;
}
private send(msg: BrowserFido2Message) {
BrowserFido2UserInterfaceSession.sendMessage({
sessionId: this.sessionId,
...msg,
});
}
/**
* This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle
* @param userHandle
*/
private hasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean {
if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) {
return true;
}
return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle);
}
}

View File

@ -1,136 +1,134 @@
<ng-container *ngIf="data$ | async as data">
<div class="auth-wrapper">
<div class="auth-header">
<div class="left">
<ng-container *ngIf="data.message.type != 'PickCredentialRequest'">
<div class="logo">
<i class="bwi bwi-shield"></i>
<popup-page *ngIf="data$ | async as data">
<popup-header
slot="header"
pageTitle="{{
(passkeyAction === PasskeyActions.Register ? 'savePasskey' : 'logInWithPasskeyQuestion')
| i18n
}}"
>
<button
*ngIf="showNewPasskeyButton"
bitButton
buttonType="primary"
type="button"
(click)="addCipher()"
slot="end"
>
<i class="bwi bwi-plus-f" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>
</popup-header>
<div class="tw-p-2">
<bit-section *ngIf="passkeyAction === PasskeyActions.Register">
<bit-search
appAutofocus
autocomplete="off"
id="search"
placeholder="{{ 'searchVault' | i18n }}"
(ngModelChange)="search()"
[(ngModel)]="searchText"
></bit-search>
</bit-section>
<!-- Display when adding a new passkey -->
<bit-section *ngIf="data.message.type === BrowserFido2MessageTypes.ConfirmNewCredentialRequest">
<!-- Display when matching ciphers (i.e. same domain, no passkeys) exist -->
<ng-container *ngIf="displayedCiphers.length > 0">
<bit-section-header>
<h2 bitTypography="h6">{{ "chooseCipherForPasskeySave" | i18n }}</h2>
</bit-section-header>
<app-fido2-cipher-row
*ngFor="let cipherItem of displayedCiphers"
[cipher]="cipherItem"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="handleCipherItemSelect($event)"
></app-fido2-cipher-row>
</ng-container>
<!-- Display when no matching ciphers exist -->
<ng-container *ngIf="!displayedCiphers.length">
<bit-no-items class="tw-text-main" [icon]="noResultsIcon">
<ng-container slot="title">{{ "noMatchingLoginsForSite" | i18n }}</ng-container>
<ng-container slot="description">Search or save passkey as new login</ng-container>
<button
bitButton
buttonType="primary"
slot="button"
type="button"
(click)="saveNewLogin()"
[loading]="loading"
>
{{ "savePasskeyNewLogin" | i18n }}
</button>
</bit-no-items>
</ng-container>
</bit-section>
<!-- Display when the passkey being saved already exists -->
<bit-section
*ngIf="data.message.type === BrowserFido2MessageTypes.InformExcludedCredentialRequest"
>
<div class="auth-flow">
<p class="subtitle">{{ "passkeyAlreadyExists" | i18n }}</p>
<div class="box list">
<div class="box-content">
<app-fido2-cipher-row
*ngFor="let cipherItem of displayedCiphers"
[cipher]="cipherItem"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="handleCipherItemSelect($event)"
></app-fido2-cipher-row>
</div>
</ng-container>
<ng-container *ngIf="data.message.type === 'PickCredentialRequest'">
<div class="logo">
<i class="bwi bwi-shield"></i><span><strong>bit</strong>warden</span>
</div>
</ng-container>
</div>
</div>
<ng-container *ngIf="data.message.type === 'ConfirmNewCredentialRequest'">
<div class="search">
<input
type="{{ searchTypeSearch ? 'search' : 'text' }}"
placeholder="{{ 'searchVault' | i18n }}"
id="search"
[(ngModel)]="searchText"
(input)="search()"
autocomplete="off"
appAutofocus
/>
<i class="bwi bwi-search" aria-hidden="true"></i>
</div>
<div class="right">
<button type="button" (click)="addCipher()" appA11yTitle="{{ 'addItem' | i18n }}">
<i class="bwi bwi-plus bwi-lg bwi-fw" aria-hidden="true"></i>
</bit-section>
<!-- Display when picking a passkey to login with -->
<bit-section *ngIf="data.message.type === BrowserFido2MessageTypes.PickCredentialRequest">
<!-- Display when matching ciphers exist -->
<ng-container *ngIf="displayedCiphers.length > 0">
<ng-container slot="title">{{ "chooseCipherForPasskeyAuth" | i18n }}</ng-container>
<app-fido2-cipher-row
*ngFor="let cipherItem of displayedCiphers"
[cipher]="cipherItem"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="handleCipherItemSelect($event)"
></app-fido2-cipher-row>
</ng-container>
<!-- Display when no matching ciphers exist -->
<ng-container *ngIf="!displayedCiphers.length">
<bit-no-items class="tw-text-main" [icon]="noResultsIcon">
<ng-container slot="title">No matching logins for this site</ng-container>
<ng-container slot="description">Search or save passkey as new login</ng-container>
<button
bitButton
buttonType="primary"
slot="button"
type="button"
(click)="saveNewLogin()"
[loading]="loading"
>
{{ "savePasskeyNewLogin" | i18n }}
</button>
</div>
</bit-no-items>
</ng-container>
</div>
</bit-section>
<ng-container>
<ng-container
*ngIf="
data.message.type === 'PickCredentialRequest' ||
data.message.type === 'ConfirmNewCredentialRequest'
"
>
<div class="auth-flow">
<p class="subtitle" appA11yTitle="{{ subtitleText | i18n }}">
{{ subtitleText | i18n }}
</p>
<!-- Display when ciphers exist -->
<ng-container *ngIf="displayedCiphers.length > 0">
<div class="box list">
<div class="box-content">
<app-fido2-cipher-row
*ngFor="let cipherItem of displayedCiphers"
[cipher]="cipherItem"
[isSearching]="searchPending"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="selectedPasskey($event)"
[isSelected]="cipher === cipherItem"
></app-fido2-cipher-row>
</div>
</div>
<div class="box">
<button
type="submit"
(click)="submit()"
class="btn primary block"
appA11yTitle="{{ credentialText | i18n }}"
>
<span [hidden]="loading">
{{ credentialText | i18n }}
</span>
<i
class="bwi bwi-spinner bwi-lg bwi-spin"
[hidden]="!loading"
aria-hidden="true"
></i>
</button>
</div>
</ng-container>
<ng-container *ngIf="!displayedCiphers.length">
<div class="box">
<button
type="submit"
(click)="saveNewLogin()"
class="btn primary block"
appA11yTitle="{{ 'savePasskeyNewLogin' | i18n }}"
>
<span [hidden]="loading">
{{ "savePasskeyNewLogin" | i18n }}
</span>
<i
class="bwi bwi-spinner bwi-lg bwi-spin"
[hidden]="!loading"
aria-hidden="true"
></i>
</button>
</div>
</ng-container>
</div>
</ng-container>
<ng-container *ngIf="data.message.type === 'InformExcludedCredentialRequest'">
<div class="auth-flow">
<p class="subtitle">{{ "passkeyAlreadyExists" | i18n }}</p>
<div class="box list">
<div class="box-content">
<app-fido2-cipher-row
*ngFor="let cipherItem of displayedCiphers"
[cipher]="cipherItem"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="selectedPasskey($event)"
[isSelected]="cipher === cipherItem"
></app-fido2-cipher-row>
</div>
</div>
<button type="button" class="btn primary block" (click)="viewPasskey()">
<span [hidden]="loading">{{ "viewItem" | i18n }}</span>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
</button>
</div>
</ng-container>
<ng-container *ngIf="data.message.type === 'InformCredentialNotFoundRequest'">
<div class="auth-flow">
<p class="subtitle">{{ "noPasskeysFoundForThisApplication" | i18n }}</p>
</div>
<button type="button" class="btn primary block" (click)="abort(false)">
<span [hidden]="loading">{{ "close" | i18n }}</span>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
</button>
</ng-container>
</ng-container>
<!-- Display when initiating passkey login, but no cooresponding cipher is found in the vault -->
<bit-section
*ngIf="data.message.type === BrowserFido2MessageTypes.InformCredentialNotFoundRequest"
>
<div class="auth-flow">
<p class="subtitle">{{ "noPasskeysFoundForThisApplication" | i18n }}</p>
</div>
<button type="button" class="btn primary block" (click)="abort(false)">
<span [hidden]="loading">{{ "close" | i18n }}</span>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
</button>
</bit-section>
<app-fido2-use-browser-link></app-fido2-use-browser-link>
</div>
</ng-container>
</popup-page>

View File

@ -1,4 +1,6 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import {
BehaviorSubject,
@ -13,13 +15,14 @@ import {
takeUntil,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { SecureNoteType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@ -27,17 +30,39 @@ 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";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { DialogService } from "@bitwarden/components";
import {
ButtonModule,
DialogService,
Icons,
ItemModule,
NoItemsModule,
SearchModule,
SectionComponent,
SectionHeaderComponent,
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { ZonedMessageListenerService } from "../../../platform/browser/zoned-message-listener.service";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { VaultPopoutType } from "../../../vault/popup/utils/vault-popout-window";
import { Fido2UserVerificationService } from "../../../vault/services/fido2-user-verification.service";
import {
BrowserFido2Message,
BrowserFido2UserInterfaceSession,
BrowserFido2MessageTypes,
} from "../../fido2/services/browser-fido2-user-interface.service";
import { Fido2CipherRowComponent } from "./fido2-cipher-row.component";
import { Fido2UseBrowserLinkComponent } from "./fido2-use-browser-link.component";
const PasskeyActions = {
Register: "register",
Authenticate: "authenticate",
} as const;
type PasskeyActionValue = (typeof PasskeyActions)[keyof typeof PasskeyActions];
interface ViewData {
message: BrowserFido2Message;
fallbackSupported: boolean;
@ -46,28 +71,45 @@ interface ViewData {
@Component({
selector: "app-fido2",
templateUrl: "fido2.component.html",
styleUrls: [],
standalone: true,
imports: [
ButtonModule,
CommonModule,
Fido2CipherRowComponent,
Fido2UseBrowserLinkComponent,
FormsModule,
ItemModule,
JslibModule,
NoItemsModule,
PopupHeaderComponent,
PopupPageComponent,
SearchModule,
SectionComponent,
SectionHeaderComponent,
],
})
export class Fido2Component implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private hasSearched = false;
protected cipher: CipherView;
protected searchTypeSearch = false;
protected searchPending = false;
protected searchText: string;
protected url: string;
protected hostname: string;
protected data$: Observable<ViewData>;
protected sessionId?: string;
protected senderTabId?: string;
protected ciphers?: CipherView[] = [];
protected displayedCiphers?: CipherView[] = [];
protected loading = false;
protected subtitleText: string;
protected credentialText: string;
private message$ = new BehaviorSubject<BrowserFido2Message>(null);
private hasSearched = false;
protected BrowserFido2MessageTypes = BrowserFido2MessageTypes;
protected cipher: CipherView;
protected ciphers?: CipherView[] = [];
protected data$: Observable<ViewData>;
protected displayedCiphers?: CipherView[] = [];
protected equivalentDomains: Set<string>;
protected equivalentDomainsURL: string;
protected hostname: string;
protected loading = false;
protected noResultsIcon = Icons.NoResults;
protected passkeyAction: PasskeyActionValue = PasskeyActions.Register;
protected PasskeyActions = PasskeyActions;
protected searchText: string;
protected searchTypeSearch = false;
protected senderTabId?: string;
protected sessionId?: string;
protected showNewPasskeyButton: boolean = false;
protected url: string;
constructor(
private router: Router,
@ -80,8 +122,8 @@ export class Fido2Component implements OnInit, OnDestroy {
private dialogService: DialogService,
private browserMessagingApi: ZonedMessageListenerService,
private passwordRepromptService: PasswordRepromptService,
private fido2UserVerificationService: Fido2UserVerificationService,
private accountService: AccountService,
private fido2UserVerificationService: Fido2UserVerificationService,
) {}
ngOnInit() {
@ -107,7 +149,7 @@ export class Fido2Component implements OnInit, OnDestroy {
this.url = queryParams.senderUrl;
// For a 'NewSessionCreatedRequest', abort if it doesn't belong to the current session.
if (
message.type === "NewSessionCreatedRequest" &&
message.type === BrowserFido2MessageTypes.NewSessionCreatedRequest &&
message.sessionId !== queryParams.sessionId
) {
this.abort(false);
@ -119,7 +161,7 @@ export class Fido2Component implements OnInit, OnDestroy {
return;
}
if (message.type === "AbortRequest") {
if (message.type === BrowserFido2MessageTypes.AbortRequest) {
this.abort(false);
return;
}
@ -137,7 +179,7 @@ export class Fido2Component implements OnInit, OnDestroy {
filter((message) => message != undefined),
concatMap(async (message) => {
switch (message.type) {
case "ConfirmNewCredentialRequest": {
case BrowserFido2MessageTypes.ConfirmNewCredentialRequest: {
const equivalentDomains = await firstValueFrom(
this.domainSettingsService.getUrlEquivalentDomains(this.url),
);
@ -145,19 +187,22 @@ export class Fido2Component implements OnInit, OnDestroy {
this.ciphers = (await this.cipherService.getAllDecrypted()).filter(
(cipher) => cipher.type === CipherType.Login && !cipher.isDeleted,
);
this.displayedCiphers = this.ciphers.filter(
(cipher) =>
cipher.login.matchesUri(this.url, equivalentDomains) &&
this.hasNoOtherPasskeys(cipher, message.userHandle),
this.cipherHasNoOtherPasskeys(cipher, message.userHandle),
);
if (this.displayedCiphers.length > 0) {
this.selectedPasskey(this.displayedCiphers[0]);
}
this.passkeyAction = PasskeyActions.Register;
// @TODO fix new cipher creation for other fido2 registration message types and remove `showNewPasskeyButton` from the template
this.showNewPasskeyButton = true;
break;
}
case "PickCredentialRequest": {
case BrowserFido2MessageTypes.PickCredentialRequest: {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
@ -170,14 +215,15 @@ export class Fido2Component implements OnInit, OnDestroy {
);
}),
);
this.displayedCiphers = [...this.ciphers];
if (this.displayedCiphers.length > 0) {
this.selectedPasskey(this.displayedCiphers[0]);
}
this.passkeyAction = PasskeyActions.Authenticate;
break;
}
case "InformExcludedCredentialRequest": {
case BrowserFido2MessageTypes.InformExcludedCredentialRequest: {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
@ -190,40 +236,42 @@ export class Fido2Component implements OnInit, OnDestroy {
);
}),
);
this.displayedCiphers = [...this.ciphers];
if (this.displayedCiphers.length > 0) {
this.selectedPasskey(this.displayedCiphers[0]);
}
this.passkeyAction = PasskeyActions.Register;
break;
}
case BrowserFido2MessageTypes.InformCredentialNotFoundRequest: {
this.passkeyAction = PasskeyActions.Authenticate;
break;
}
}
this.subtitleText =
this.displayedCiphers.length > 0
? this.getCredentialSubTitleText(message.type)
: "noMatchingPasskeyLogin";
this.credentialText = this.getCredentialButtonText(message.type);
return {
message,
fallbackSupported: "fallbackSupported" in message && message.fallbackSupported,
};
}),
takeUntil(this.destroy$),
);
queryParams$.pipe(takeUntil(this.destroy$)).subscribe((queryParams) => {
this.send({
sessionId: queryParams.sessionId,
type: "ConnectResponse",
type: BrowserFido2MessageTypes.ConnectResponse,
});
});
}
protected async submit() {
const data = this.message$.value;
if (data?.type === "PickCredentialRequest") {
if (data?.type === BrowserFido2MessageTypes.PickCredentialRequest) {
// TODO: Revert to use fido2 user verification service once user verification for passkeys is approved for production.
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
@ -231,10 +279,10 @@ export class Fido2Component implements OnInit, OnDestroy {
this.send({
sessionId: this.sessionId,
cipherId: this.cipher.id,
type: "PickCredentialResponse",
type: BrowserFido2MessageTypes.PickCredentialResponse,
userVerified,
});
} else if (data?.type === "ConfirmNewCredentialRequest") {
} else if (data?.type === BrowserFido2MessageTypes.ConfirmNewCredentialRequest) {
if (this.cipher.login.hasFido2Credentials) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "overwritePasskey" },
@ -254,7 +302,7 @@ export class Fido2Component implements OnInit, OnDestroy {
this.send({
sessionId: this.sessionId,
cipherId: this.cipher.id,
type: "ConfirmNewCredentialResponse",
type: BrowserFido2MessageTypes.ConfirmNewCredentialResponse,
userVerified,
});
}
@ -264,7 +312,8 @@ export class Fido2Component implements OnInit, OnDestroy {
protected async saveNewLogin() {
const data = this.message$.value;
if (data?.type === "ConfirmNewCredentialRequest") {
if (data?.type === BrowserFido2MessageTypes.ConfirmNewCredentialRequest) {
const name = data.credentialName || data.rpId;
// TODO: Revert to check for user verification once user verification for passkeys is approved for production.
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
@ -274,7 +323,7 @@ export class Fido2Component implements OnInit, OnDestroy {
this.send({
sessionId: this.sessionId,
cipherId: this.cipher?.id,
type: "ConfirmNewCredentialResponse",
type: BrowserFido2MessageTypes.ConfirmNewCredentialResponse,
userVerified: data.userVerification,
});
}
@ -282,59 +331,47 @@ export class Fido2Component implements OnInit, OnDestroy {
this.loading = true;
}
getCredentialSubTitleText(messageType: string): string {
return messageType == "ConfirmNewCredentialRequest" ? "choosePasskey" : "logInWithPasskey";
}
getCredentialButtonText(messageType: string): string {
return messageType == "ConfirmNewCredentialRequest" ? "savePasskey" : "confirm";
}
selectedPasskey(item: CipherView) {
async handleCipherItemSelect(item: CipherView) {
this.cipher = item;
await this.submit();
}
viewPasskey() {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/view-cipher"], {
queryParams: {
cipherId: this.cipher.id,
uilocation: "popout",
senderTabId: this.senderTabId,
sessionId: this.sessionId,
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
},
});
}
addCipher() {
async addCipher() {
const data = this.message$.value;
if (data?.type !== "ConfirmNewCredentialRequest") {
return;
if (data?.type === BrowserFido2MessageTypes.ConfirmNewCredentialRequest) {
await this.router.navigate(["/add-cipher"], {
queryParams: {
type: CipherType.Login.toString(),
name: data.credentialName || data.rpId,
uri: this.url,
uilocation: "popout",
username: data.userName,
senderTabId: this.senderTabId,
sessionId: this.sessionId,
userVerification: data.userVerification,
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
},
});
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/add-cipher"], {
queryParams: {
name: data.credentialName || data.rpId,
uri: this.url,
type: CipherType.Login.toString(),
uilocation: "popout",
username: data.userName,
senderTabId: this.senderTabId,
sessionId: this.sessionId,
userVerification: data.userVerification,
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
},
});
return;
}
async getEquivalentDomains() {
if (this.equivalentDomainsURL !== this.url) {
this.equivalentDomainsURL = this.url;
this.equivalentDomains = await firstValueFrom(
this.domainSettingsService.getUrlEquivalentDomains(this.url),
);
}
return this.equivalentDomains;
}
protected async search() {
this.hasSearched = await this.searchService.isSearchable(this.searchText);
this.searchPending = true;
if (this.hasSearched) {
this.displayedCiphers = await this.searchService.searchCiphers(
this.searchText,
@ -342,15 +379,11 @@ export class Fido2Component implements OnInit, OnDestroy {
this.ciphers,
);
} else {
const equivalentDomains = await firstValueFrom(
this.domainSettingsService.getUrlEquivalentDomains(this.url),
);
const equivalentDomains = await this.getEquivalentDomains();
this.displayedCiphers = this.ciphers.filter((cipher) =>
cipher.login.matchesUri(this.url, equivalentDomains),
);
}
this.searchPending = false;
this.selectedPasskey(this.displayedCiphers[0]);
}
abort(fallback: boolean) {
@ -361,7 +394,7 @@ export class Fido2Component implements OnInit, OnDestroy {
unload(fallback = false) {
this.send({
sessionId: this.sessionId,
type: "AbortResponse",
type: BrowserFido2MessageTypes.AbortResponse,
fallbackRequested: fallback,
});
}
@ -427,13 +460,11 @@ export class Fido2Component implements OnInit, OnDestroy {
* This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle
* @param userHandle
*/
private hasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean {
private cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean {
if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) {
return true;
}
return cipher.login.fido2Credentials.some((passkey) => {
passkey.userHandle === userHandle;
});
return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle);
}
}

View File

@ -41,6 +41,7 @@ import { TwoFactorAuthComponent } from "../auth/popup/two-factor-auth.component"
import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component";
import { TwoFactorComponent } from "../auth/popup/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { Fido2V1Component } from "../autofill/popup/fido2/fido2-v1.component";
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
import { AutofillV1Component } from "../autofill/popup/settings/autofill-v1.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
@ -127,12 +128,11 @@ const routes: Routes = [
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: { state: "home" },
},
{
...extensionRefreshSwap(Fido2V1Component, Fido2Component, {
path: "fido2",
component: Fido2Component,
canActivate: [fido2AuthGuard],
data: { state: "fido2" },
},
}),
{
path: "login",
component: LoginComponent,
@ -304,7 +304,6 @@ const routes: Routes = [
},
...extensionRefreshSwap(NotificationsSettingsV1Component, NotificationsSettingsComponent, {
path: "notifications",
component: NotificationsSettingsV1Component,
canActivate: [authGuard],
data: { state: "notifications" },
}),
@ -338,7 +337,6 @@ const routes: Routes = [
},
...extensionRefreshSwap(ExcludedDomainsV1Component, ExcludedDomainsComponent, {
path: "excluded-domains",
component: ExcludedDomainsV1Component,
canActivate: [authGuard],
data: { state: "excluded-domains" },
}),

View File

@ -35,8 +35,11 @@ import { SsoComponent } from "../auth/popup/sso.component";
import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component";
import { TwoFactorComponent } from "../auth/popup/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { Fido2CipherRowV1Component } from "../autofill/popup/fido2/fido2-cipher-row-v1.component";
import { Fido2CipherRowComponent } from "../autofill/popup/fido2/fido2-cipher-row.component";
import { Fido2UseBrowserLinkV1Component } from "../autofill/popup/fido2/fido2-use-browser-link-v1.component";
import { Fido2UseBrowserLinkComponent } from "../autofill/popup/fido2/fido2-use-browser-link.component";
import { Fido2V1Component } from "../autofill/popup/fido2/fido2-v1.component";
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
import { AutofillV1Component } from "../autofill/popup/settings/autofill-v1.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
@ -112,6 +115,9 @@ import "../platform/popup/locales";
ServicesModule,
DialogModule,
ExcludedDomainsComponent,
Fido2CipherRowComponent,
Fido2Component,
Fido2UseBrowserLinkComponent,
FilePopoutCalloutComponent,
AvatarModule,
AccountComponent,
@ -140,8 +146,8 @@ import "../platform/popup/locales";
CurrentTabComponent,
EnvironmentComponent,
ExcludedDomainsV1Component,
Fido2CipherRowComponent,
Fido2UseBrowserLinkComponent,
Fido2CipherRowV1Component,
Fido2UseBrowserLinkV1Component,
FolderAddEditComponent,
FoldersComponent,
VaultFilterComponent,
@ -180,7 +186,7 @@ import "../platform/popup/locales";
ViewCustomFieldsComponent,
RemovePasswordComponent,
VaultSelectComponent,
Fido2Component,
Fido2V1Component,
AutofillV1Component,
EnvironmentSelectorComponent,
],

View File

@ -217,7 +217,7 @@ app-vault-attachments {
}
}
app-fido2 {
app-fido2-v1 {
.auth-wrapper {
display: flex;
flex-direction: column;