1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-16 10:45:20 +01:00

[PM-3812][PM-3809] Unify Create and Login Passkeys UI (#6403)

* PM-1235 Added component to display passkey on auth flow

* PM-1235 Implement basic structure and behaviour of UI

* PM-1235 Added localised strings

* PM-1235 Improved button UI

* Implemented view passkey button

* Implemented multiple matching passkeys

* Refactored fido2 popup to use browser popout windows service

* [PM-3807] feat: remove non-discoverable from fido2 user interface class

* [PM-3807] feat: merge fido2 component ui

* [PM-3807] feat: return `cipherId` from user interface

* [PM-3807] feat: merge credential creation logic in authenticator

* [PM-3807] feat: merge credential assertion logic in authenticator

* updated test cases and services using the config service

* [PM-3807] feat: add `discoverable` property to fido2keys

* [PM-3807] feat: assign discoverable property during creation

* [PM-3807] feat: save discoverable field to server

* [PM-3807] feat: filter credentials by rpId AND discoverable

* [PM-3807] chore: remove discoverable tests which are no longer needed

* [PM-3807] chore: remove all logic for handling standalone Fido2Key

View and components will be cleaned up as part of UI tickets

* [PM-3807] fix: add missing discoverable property handling to tests

* updated locales with new text

* Updated popout windows service to use defined type for custom width and height

* Update on unifying auth flow ui to align with architecture changes

* Moved click event

* Throw dom exception error if tab is null

* updated fido2key object to array

* removed discoverable key in client inerface service for now

* Get senderTabId from the query params and send to the view cipher component to allow the pop out close when the close button is clicked on the view cipher component

* Refactored view item if passkeys exists and the cipher row views by having an extra ng-conatiner for each case

* Allow fido2 pop out close wehn cancle is clicked on add edit component

* Removed makshift run in angular zone

* created focus directive to target first element in ngFor for displayed ciphers in fido2

* Refactored to use switch statement and added condtional on search and add div

* Adjusted footer link and added more features to the login flow

* Added host listener to abort when window is closed

* remove custom focus directive. instead stuck focus logic into fido2-cipher-row component

* Fixed bug where close and cancel on view and add component does not abort the fido2 request

* show info dialog when user account does not have master password

* Removed PopupUtilsService

* show info dialog when user account does not have master password

* Added comments

* Added comments

* made row height consistent

* update logo to be dynamic with theme selection

* added new translation key

* Dis some styling to align cipher items

* Changed passkey icon fill color

* updated flow of focus and selected items in the passkey popup

* Fixed bug when picking a credential

* Added text to lock popout screen

* Added passkeys test to home view

* changed class name

* Added uilocation as a query paramter to know if the user is in the popout window

* update fido2 component for dynamic subtitleText as well as additional appA11yTitle attrs

* moved another method out of html

* Added window id return to single action popout and used the window id to close and abort the popout

* removed duplicate activatedroute

* added a doNotSaveUrl true to 2fa options, so the previousUrl can remain as the fido2 url

* Added a div to restrict the use browser link ot the buttom left

* reverted view change which is handled by the view pr

* Updated locales text and removed unused variable

* Fixed issue where new cipher is not created for non discoverable keys

* switched from using svg for the logo to CL

* removed svg files

* default to browser implmentation if user is logged out of the browser exetension

* removed passkeys knowledge from login, 2fa

* Added fido2 use browser link component and a state service to reduce passkeys knowledge on the lock component

* removed function and removed unnecessary comment

* reverted to former

* [PM-4148] Added descriptive error messages (#6475)

* Added descriptive error messages

* Added descriptive error messages

* replaced fido2 state service with higher order inject functions

* removed null check for tab

* refactor fido2 cipher row component

* added a static abort function to the browser interface service

* removed width from content

* uncommented code

* removed sessionId from query params and redudant styles

* Put back removed sessionId

* Added fallbackRequested parameter to abortPopout and added comments to the standalone function

* minor styling update to fix padding and color on selected ciphers

* update padding again to address vertical pushdown of cipher selection

---------

Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
Co-authored-by: jng <jng@bitwarden.com>
This commit is contained in:
SmithThe4th 2023-10-10 16:34:54 -04:00 committed by GitHub
parent 94e5117c32
commit 68da3d9efd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1105 additions and 311 deletions

View File

@ -2419,6 +2419,21 @@
"message": "Toggle collapse",
"description": "Toggling an expand/collapse state."
},
"aliasDomain": {
"message": "Alias domain"
},
"passwordRepromptDisabledAutofillOnPageLoad": {
"message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.",
"description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load."
},
"autofillOnPageLoadSetToDefault": {
"message": "Auto-fill on page load set to use default setting.",
"description": "Toast message for informing the user that auto-fill on page load has been set to the default setting."
},
"turnOffMasterPasswordPromptToEditField": {
"message": "Turn off master password re-prompt to edit this field",
"description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item."
},
"loginPasskey": {
"message": "This login uses a passkey"
},
@ -2434,19 +2449,58 @@
"passkeyNotCopiedAlert": {
"message": "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?"
},
"aliasDomain": {
"message": "Alias domain"
"passkeyFeatureIsNotImplementedForAccountsWithoutMasterPassword": {
"message": "Verification required by the initiating site. This feature is not yet implemented for accounts without master password."
},
"passwordRepromptDisabledAutofillOnPageLoad": {
"message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.",
"description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load."
"logInWithPasskey": {
"message": "Log in with passkey?"
},
"autofillOnPageLoadSetToDefault": {
"message": "Auto-fill on page load set to use default setting.",
"description": "Toast message for informing the user that auto-fill on page load has been set to the default setting."
"savePasskeyInBitwarden": {
"message": "Save passkey in Bitwarden?"
},
"turnOffMasterPasswordPromptToEditField": {
"message": "Turn off master password re-prompt to edit this field",
"description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item."
"passkeyAlreadyExists": {
"message": "A passkey already exists for this application."
},
"noPasskeysFoundForThisApplication": {
"message": "No passkeys found for this application."
},
"noMatchingPasskeyLogin": {
"message": "You do not have a matching login for this site."
},
"confirm": {
"message": "Confirm"
},
"savePasskey": {
"message": "Save passkey"
},
"savePasskeyNewLogin": {
"message": "Save passkey as new login"
},
"choosePasskey": {
"message": "Choose a login to save this passkey to"
},
"fido2Item": {
"message": "Fido2 Item"
},
"overwritePasskey": {
"message": "Overwrite passkey?"
},
"overwritePasskeyAlert": {
"message": "This item already contains a passkey. Are you sure you want to overwrite the current passkey?"
},
"featureNotSupported": {
"message": "Feature not yet supported"
},
"searchLogins": {
"message": "Search all logins"
},
"yourPasskeyIsLocked": {
"message": "Authentication required to use passkey. Verify your identity to continue."
},
"loginToSavePasskey": {
"message": "Log in to use passkeys in Bitwarden"
},
"useBrowserName": {
"message": "Use browser"
}
}

View File

@ -21,11 +21,6 @@ export const fido2AuthGuard: CanActivateFn = async (
const authStatus = await authService.getAuthStatus();
if (authStatus === AuthenticationStatus.LoggedOut) {
routerService.setPreviousUrl(state.url);
return router.createUrlTree(["/home"], { queryParams: route.queryParams });
}
if (authStatus === AuthenticationStatus.Locked) {
routerService.setPreviousUrl(state.url);
return router.createUrlTree(["/lock"], { queryParams: route.queryParams });

View File

@ -11,81 +11,91 @@
</div>
</header>
<main tabindex="-1">
<div class="box">
<div class="box-content">
<div
class="box-content-row box-content-row-flex"
appBoxRow
*ngIf="pinEnabled || masterPasswordEnabled"
>
<div class="row-main" *ngIf="pinEnabled">
<label for="pin">{{ "pin" | i18n }}</label>
<input
id="pin"
type="{{ showPassword ? 'text' : 'password' }}"
name="PIN"
class="monospaced"
[(ngModel)]="pin"
required
appInputVerbatim
/>
</div>
<div class="row-main" *ngIf="masterPasswordEnabled && !pinEnabled">
<label for="masterPassword">{{ "masterPass" | i18n }}</label>
<input
id="masterPassword"
type="{{ showPassword ? 'text' : 'password' }}"
name="MasterPassword"
aria-describedby="masterPasswordHelp"
class="monospaced"
[(ngModel)]="masterPassword"
required
appInputVerbatim
/>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
(click)="togglePassword()"
[attr.aria-pressed]="showPassword"
>
<i
class="bwi bwi-lg"
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
aria-hidden="true"
></i>
</button>
<ng-container *ngIf="fido2PopoutSessionData$ | async as fido2Data">
<div class="box">
<div class="box-content">
<div
class="box-content-row box-content-row-flex"
appBoxRow
*ngIf="pinEnabled || masterPasswordEnabled"
>
<div class="row-main" *ngIf="pinEnabled">
<label for="pin">{{ "pin" | i18n }}</label>
<input
id="pin"
type="{{ showPassword ? 'text' : 'password' }}"
name="PIN"
class="monospaced"
[(ngModel)]="pin"
required
appInputVerbatim
/>
</div>
<div class="row-main" *ngIf="masterPasswordEnabled && !pinEnabled">
<label for="masterPassword">{{ "masterPass" | i18n }}</label>
<input
id="masterPassword"
type="{{ showPassword ? 'text' : 'password' }}"
name="MasterPassword"
aria-describedby="masterPasswordHelp"
class="monospaced"
[(ngModel)]="masterPassword"
required
appInputVerbatim
/>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
(click)="togglePassword()"
[attr.aria-pressed]="showPassword"
>
<i
class="bwi bwi-lg"
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
aria-hidden="true"
></i>
</button>
</div>
</div>
</div>
<div id="masterPasswordHelp" class="box-footer">
<p>
{{
fido2Data.isFido2Session
? ("yourPasskeyIsLocked" | i18n)
: ("yourVaultIsLocked" | i18n)
}}
</p>
{{ "loggedInAsOn" | i18n : email : webVaultHostname }}
</div>
</div>
<div id="masterPasswordHelp" class="box-footer">
<p>{{ "yourVaultIsLocked" | i18n }}</p>
{{ "loggedInAsOn" | i18n : email : webVaultHostname }}
<div class="box" *ngIf="biometricLock">
<div class="box-footer no-pad">
<button
type="button"
class="btn primary block"
(click)="unlockBiometric()"
appStopClick
[disabled]="pendingBiometric"
>
{{ "unlockWithBiometrics" | i18n }}
</button>
</div>
</div>
</div>
<div class="box" *ngIf="biometricLock">
<div class="box-footer no-pad">
<button
type="button"
class="btn primary block"
(click)="unlockBiometric()"
appStopClick
[disabled]="pendingBiometric"
>
{{ "unlockWithBiometrics" | i18n }}
</button>
</div>
</div>
<p class="text-center">
<button type="button" appStopClick (click)="logOut()">{{ "logOut" | i18n }}</button>
</p>
<app-private-mode-warning></app-private-mode-warning>
<app-callout *ngIf="biometricError" type="error">{{ biometricError }}</app-callout>
<p class="text-center text-muted" *ngIf="pendingBiometric">
<i class="bwi bwi-spinner bwi-spin" aria-hidden="true"></i> {{ "awaitDesktop" | i18n }}
</p>
<p class="text-center" *ngIf="!fido2Data.isFido2Session">
<button type="button" appStopClick (click)="logOut()">{{ "logOut" | i18n }}</button>
</p>
<app-private-mode-warning></app-private-mode-warning>
<app-callout *ngIf="biometricError" type="error">{{ biometricError }}</app-callout>
<p class="text-center text-muted" *ngIf="pendingBiometric">
<i class="bwi bwi-spinner bwi-spin" aria-hidden="true"></i> {{ "awaitDesktop" | i18n }}
</p>
<app-fido2-use-browser-link></app-fido2-use-browser-link>
</ng-container>
</main>
</form>

View File

@ -23,6 +23,7 @@ import { DialogService } from "@bitwarden/components";
import { BiometricErrors, BiometricErrorTypes } from "../../models/biometricErrors";
import { BrowserRouterService } from "../../platform/popup/services/browser-router.service";
import { fido2PopoutSessionData$ } from "../../vault/fido2/browser-fido2-user-interface.service";
@Component({
selector: "app-lock",
@ -33,6 +34,7 @@ export class LockComponent extends BaseLockComponent {
biometricError: string;
pendingBiometric = false;
fido2PopoutSessionData$ = fido2PopoutSessionData$();
constructor(
router: Router,

View File

@ -560,8 +560,9 @@ export default class MainBackground {
this.browserPopoutWindowService = new BrowserPopoutWindowService();
this.popupUtilsService = new PopupUtilsService(this.isPrivateMode);
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.popupUtilsService);
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(
this.browserPopoutWindowService
);
this.fido2AuthenticatorService = new Fido2AuthenticatorService(
this.cipherService,
this.fido2UserInterfaceService,
@ -571,6 +572,7 @@ export default class MainBackground {
this.fido2ClientService = new Fido2ClientService(
this.fido2AuthenticatorService,
this.configService,
this.authService,
this.logService
);

View File

@ -258,11 +258,11 @@ export default class RuntimeBackground {
return await this.main.fido2ClientService.isFido2FeatureEnabled();
case "fido2RegisterCredentialRequest":
return await this.abortManager.runWithAbortController(msg.requestId, (abortController) =>
this.main.fido2ClientService.createCredential(msg.data, abortController)
this.main.fido2ClientService.createCredential(msg.data, sender.tab, abortController)
);
case "fido2GetCredentialRequest":
return await this.abortManager.runWithAbortController(msg.requestId, (abortController) =>
this.main.fido2ClientService.assertCredential(msg.data, abortController)
this.main.fido2ClientService.assertCredential(msg.data, sender.tab, abortController)
);
}
}

View File

@ -10,6 +10,15 @@ interface BrowserPopoutWindowService {
}
): Promise<void>;
closePasswordRepromptPrompt(): Promise<void>;
openFido2Popout(
senderWindowId: number,
promptData: {
sessionId: string;
senderTabId: number;
fallbackSupported: boolean;
}
): Promise<number>;
closeFido2Popout(): Promise<void>;
}
export { BrowserPopoutWindowService };

View File

@ -49,29 +49,65 @@ class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface {
await this.closeSingleActionPopout("passwordReprompt");
}
async openFido2Popout(
senderWindowId: number,
{
sessionId,
senderTabId,
fallbackSupported,
}: {
sessionId: string;
senderTabId: number;
fallbackSupported: boolean;
}
): Promise<number> {
await this.closeFido2Popout();
const promptWindowPath =
"popup/index.html#/fido2" +
"?uilocation=popout" +
`&sessionId=${sessionId}` +
`&fallbackSupported=${fallbackSupported}` +
`&senderTabId=${senderTabId}`;
return await this.openSingleActionPopout(senderWindowId, promptWindowPath, "fido2Popout", {
width: 200,
height: 500,
});
}
async closeFido2Popout(): Promise<void> {
await this.closeSingleActionPopout("fido2Popout");
}
private async openSingleActionPopout(
senderWindowId: number,
popupWindowURL: string,
singleActionPopoutKey: string
) {
singleActionPopoutKey: string,
options: chrome.windows.CreateData = {}
): Promise<number> {
const senderWindow = senderWindowId && (await BrowserApi.getWindow(senderWindowId));
const url = chrome.extension.getURL(popupWindowURL);
const offsetRight = 15;
const offsetTop = 90;
const popupWidth = this.defaultPopoutWindowOptions.width;
/// Use overrides in `options` if provided, otherwise use default
const popupWidth = options?.width || this.defaultPopoutWindowOptions.width;
const windowOptions = senderWindow
? {
...this.defaultPopoutWindowOptions,
url,
left: senderWindow.left + senderWindow.width - popupWidth - offsetRight,
top: senderWindow.top + offsetTop,
...options,
url,
}
: { ...this.defaultPopoutWindowOptions, url };
: { ...this.defaultPopoutWindowOptions, url, ...options };
const popupWindow = await BrowserApi.createWindow(windowOptions);
await this.closeSingleActionPopout(singleActionPopoutKey);
this.singleActionPopoutTabIds[singleActionPopoutKey] = popupWindow?.tabs[0].id;
return popupWindow.id;
}
private async closeSingleActionPopout(popoutKey: string) {

View File

@ -115,7 +115,7 @@ const routes: Routes = [
path: "2fa-options",
component: TwoFactorOptionsComponent,
canActivate: [UnauthGuard],
data: { state: "2fa-options" },
data: { state: "2fa-options", doNotSaveUrl: true },
},
{
path: "login-initiated",

View File

@ -39,6 +39,8 @@ import { SendTypeComponent } from "../tools/popup/send/send-type.component";
import { ExportComponent } from "../tools/popup/settings/export.component";
import { ActionButtonsComponent } from "../vault/popup/components/action-buttons.component";
import { CipherRowComponent } from "../vault/popup/components/cipher-row.component";
import { Fido2CipherRowComponent } from "../vault/popup/components/fido2/fido2-cipher-row.component";
import { Fido2UseBrowserLinkComponent } from "../vault/popup/components/fido2/fido2-use-browser-link.component";
import { Fido2Component } from "../vault/popup/components/fido2/fido2.component";
import { PasswordRepromptComponent } from "../vault/popup/components/password-reprompt.component";
import { AddEditCustomFieldsComponent } from "../vault/popup/components/vault/add-edit-custom-fields.component";
@ -113,6 +115,8 @@ import "../platform/popup/locales";
EnvironmentComponent,
ExcludedDomainsComponent,
ExportComponent,
Fido2CipherRowComponent,
Fido2UseBrowserLinkComponent,
FolderAddEditComponent,
FoldersComponent,
VaultFilterComponent,

View File

@ -621,29 +621,6 @@ main {
}
}
app-fido2 {
.auth-wrapper {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 0 25px;
.btn {
margin-top: 25px;
}
}
.box.list {
overflow-y: auto;
}
}
.login-with-device {
.fingerprint-phrase-header {
padding-top: 1rem;

View File

@ -153,6 +153,14 @@ body.body-full {
margin: 15px 0 15px 0;
}
.useBrowserlink {
padding: 0 10px 5px 10px;
position: fixed;
bottom: 10px;
left: 0;
right: 0;
}
app-options {
.box {
margin: 10px 0;
@ -175,3 +183,169 @@ app-vault-attachments {
}
}
}
app-fido2 {
.auth-wrapper {
display: flex;
flex-direction: column;
padding: 12px 24px 12px 24px;
.auth-header {
display: flex;
justify-content: space-between;
align-items: center;
.left {
padding-right: 10px;
.logo {
display: inline-flex;
align-items: center;
i.bwi {
font-size: 35px;
margin-right: 3px;
@include themify($themes) {
color: themed("primaryColor");
}
}
span {
font-size: 45px;
font-weight: 300;
margin-top: -3px;
@include themify($themes) {
color: themed("primaryColor");
}
}
}
}
.search {
padding: 7px 10px;
width: 100%;
text-align: left;
position: relative;
display: flex;
.bwi {
position: absolute;
top: 15px;
left: 20px;
@include themify($themes) {
color: themed("labelColor");
}
}
input {
width: 100%;
margin: 0;
border: none;
padding: 5px 10px 5px 30px;
border-radius: $border-radius;
&:focus {
border-radius: $border-radius;
outline: none;
}
&[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
background-repeat: no-repeat;
mask-image: none;
-webkit-mask-image: none;
}
}
}
}
.auth-flow {
display: flex;
align-items: flex-start;
flex-direction: column;
margin-top: 32px;
margin-bottom: 32px;
.subtitle {
font-family: Open Sans;
font-size: 24px;
font-style: normal;
font-weight: 600;
line-height: 32px;
}
.box.list {
overflow-y: auto;
}
.box-content {
max-height: 140px;
}
@media screen and (min-height: 501px) and (max-height: 600px) {
.box-content {
max-height: 200px;
}
}
@media screen and (min-height: 601px) {
.box-content {
max-height: 260px;
}
}
.box-content-row {
display: flex;
justify-content: center;
align-items: center;
margin: 0px;
padding: 0px;
margin-bottom: 12px;
button {
min-height: 44px;
}
.row-main {
border-radius: 6px;
padding: 5px 0px 5px 12px;
&:focus {
@include themify($themes) {
border: 2px solid themed("headerInputBackgroundFocusColor");
}
}
&.row-selected {
@include themify($themes) {
outline: none;
border-left: 5px solid themed("primaryColor");
padding: 3px 0px 3px 7px;
background-color: themed("headerBackgroundHoverColor");
color: themed("headerColor");
}
}
}
.row-main-content {
display: flex;
flex-direction: column;
justify-content: center;
.detail {
min-height: 15px;
display: block;
}
}
}
.btn {
width: 100%;
font-size: 16px;
font-weight: 600;
}
}
}
}

View File

@ -1,9 +1,13 @@
import { inject } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import {
BehaviorSubject,
EmptyError,
filter,
firstValueFrom,
fromEvent,
fromEventPattern,
map,
merge,
Observable,
Subject,
@ -11,7 +15,6 @@ import {
take,
takeUntil,
throwError,
fromEventPattern,
} from "rxjs";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@ -24,10 +27,26 @@ import {
} from "@bitwarden/common/vault/abstractions/fido2/fido2-user-interface.service.abstraction";
import { BrowserApi } from "../../platform/browser/browser-api";
import { Popout, PopupUtilsService } from "../../popup/services/popup-utils.service";
import { BrowserPopoutWindowService } from "../../platform/popup/abstractions/browser-popout-window.service";
const BrowserFido2MessageName = "BrowserFido2UserInterfaceServiceMessage";
/**
* Function to retrieve FIDO2 session data from query parameters.
* Expected to be used within components tied to routes with these query parameters.
*/
export function fido2PopoutSessionData$() {
const route = inject(ActivatedRoute);
return route.queryParams.pipe(
map((queryParams) => ({
isFido2Session: queryParams.sessionId != null,
sessionId: queryParams.sessionId as string,
fallbackSupported: queryParams.fallbackSupported as boolean,
}))
);
}
export class SessionClosedError extends Error {
constructor() {
super("Fido2UserInterfaceSession was closed");
@ -94,15 +113,17 @@ export type BrowserFido2Message = { sessionId: string } & (
* The user interface is implemented as a popout and the service uses the browser's messaging API to communicate with it.
*/
export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction {
constructor(private popupUtilsService: PopupUtilsService) {}
constructor(private browserPopoutWindowService: BrowserPopoutWindowService) {}
async newSession(
fallbackSupported: boolean,
tab: chrome.tabs.Tab,
abortController?: AbortController
): Promise<Fido2UserInterfaceSession> {
return await BrowserFido2UserInterfaceSession.create(
this.popupUtilsService,
this.browserPopoutWindowService,
fallbackSupported,
tab,
abortController
);
}
@ -110,13 +131,15 @@ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServi
export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession {
static async create(
popupUtilsService: PopupUtilsService,
browserPopoutWindowService: BrowserPopoutWindowService,
fallbackSupported: boolean,
tab: chrome.tabs.Tab,
abortController?: AbortController
): Promise<BrowserFido2UserInterfaceSession> {
return new BrowserFido2UserInterfaceSession(
popupUtilsService,
browserPopoutWindowService,
fallbackSupported,
tab,
abortController
);
}
@ -125,19 +148,26 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
BrowserApi.sendMessage(BrowserFido2MessageName, msg);
}
static abortPopout(sessionId: string, fallbackRequested = false) {
this.sendMessage({
sessionId: sessionId,
type: "AbortResponse",
fallbackRequested: fallbackRequested,
});
}
private closed = false;
private messages$ = (BrowserApi.messageListener$() as Observable<BrowserFido2Message>).pipe(
filter((msg) => msg.sessionId === this.sessionId)
);
private windowClosed$: Observable<number>;
private tabClosed$: Observable<number>;
private connected$ = new BehaviorSubject(false);
private windowClosed$: Observable<number>;
private destroy$ = new Subject<void>();
private popout?: Popout;
private constructor(
private readonly popupUtilsService: PopupUtilsService,
private readonly browserPopoutWindowService: BrowserPopoutWindowService,
private readonly fallbackSupported: boolean,
private readonly tab: chrome.tabs.Tab,
readonly abortController = new AbortController(),
readonly sessionId = Utils.newGuid()
) {
@ -181,11 +211,6 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
(handler: any) => chrome.windows.onRemoved.removeListener(handler)
);
this.tabClosed$ = fromEventPattern(
(handler: any) => chrome.tabs.onRemoved.addListener(handler),
(handler: any) => chrome.tabs.onRemoved.removeListener(handler)
);
BrowserFido2UserInterfaceSession.sendMessage({
type: "NewSessionCreatedRequest",
sessionId,
@ -258,7 +283,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
}
async close() {
this.popupUtilsService.closePopOut(this.popout);
await this.browserPopoutWindowService.closeFido2Popout();
this.closed = true;
this.destroy$.next();
this.destroy$.complete();
@ -299,7 +324,6 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
throw new Error("Cannot re-open closed session");
}
// create promise first to avoid race condition where the popout opens before we start listening
const connectPromise = firstValueFrom(
merge(
this.connected$.pipe(filter((connected) => connected === true)),
@ -309,41 +333,24 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
)
);
this.popout = await this.generatePopOut();
const popoutId = await this.browserPopoutWindowService.openFido2Popout(this.tab.windowId, {
sessionId: this.sessionId,
senderTabId: this.tab.id,
fallbackSupported: this.fallbackSupported,
});
if (this.popout.type === "window") {
const popoutWindow = this.popout;
this.windowClosed$
.pipe(
filter((windowId) => popoutWindow.window.id === windowId),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.close();
this.abort();
});
} else if (this.popout.type === "tab") {
const popoutTab = this.popout;
this.tabClosed$
.pipe(
filter((tabId) => popoutTab.tab.id === tabId),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.close();
this.abort();
});
}
this.windowClosed$
.pipe(
filter((windowId) => {
return popoutId === windowId;
}),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.close();
this.abort();
});
await connectPromise;
}
private async generatePopOut() {
const queryParams = new URLSearchParams({ sessionId: this.sessionId });
return this.popupUtilsService.popOut(
null,
`popup/index.html?uilocation=popout#/fido2?${queryParams.toString()}`,
{ center: true }
);
}
}

View File

@ -0,0 +1,27 @@
<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>
</span>
</span>
<span class="detail" *ngIf="cipher.subTitle">{{ cipher.subTitle }}</span>
</div>
</button>
</div>
</div>

View File

@ -0,0 +1,20 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@Component({
selector: "app-fido2-cipher-row",
templateUrl: "fido2-cipher-row.component.html",
})
export class Fido2CipherRowComponent {
@Output() onSelected = new EventEmitter<CipherView>();
@Input() cipher: CipherView;
@Input() last: boolean;
@Input() title: string;
@Input() isSearching: boolean;
@Input() isSelected: boolean;
selectCipher(c: CipherView) {
this.onSelected.emit(c);
}
}

View File

@ -0,0 +1,5 @@
<div class="useBrowserlink" *ngIf="(fido2PopoutSessionData$ | async).fallbackSupported">
<a [routerLink]="[]" (click)="abort()">
{{ "useBrowserName" | i18n }}
</a>
</div>

View File

@ -0,0 +1,21 @@
import { Component } from "@angular/core";
import { firstValueFrom } from "rxjs";
import {
BrowserFido2UserInterfaceSession,
fido2PopoutSessionData$,
} from "../../../fido2/browser-fido2-user-interface.service";
@Component({
selector: "app-fido2-use-browser-link",
templateUrl: "fido2-use-browser-link.component.html",
})
export class Fido2UseBrowserLinkComponent {
fido2PopoutSessionData$ = fido2PopoutSessionData$();
async abort() {
const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId, true);
return;
}
}

View File

@ -1,49 +1,139 @@
<!-- TODO: Rewrite and refactor this component when implementing the new design -->
<ng-container *ngIf="data$ | async as data">
<div class="auth-wrapper">
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
<ng-container *ngIf="data.showUnsupportedVerification">
Verification required by the initiating site. This feature is not yet implemented for accounts
without master password.
</ng-container>
<ng-container *ngIf="!data.showUnsupportedVerification">
<div class="auth-header">
<div class="left">
<ng-container *ngIf="data.message.type != 'PickCredentialRequest'">
<div class="logo">
<i class="bwi bwi-shield"></i>
</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>
<ng-container *ngIf="data.message.type == 'ConfirmNewCredentialRequest'">
<div class="search">
<input
type="{{ searchTypeSearch ? 'search' : 'text' }}"
placeholder="{{ 'searchVault' | i18n }}"
id="search"
[(ngModel)]="searchText"
(input)="search(200)"
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 == 'PickCredentialRequest' ||
data.message.type == 'ConfirmNewCredentialRequest'
data.message.type === 'PickCredentialRequest' ||
data.message.type === 'ConfirmNewCredentialRequest'
"
>
A site is asking for authentication, please choose one of the following credentials to use:
<div class="box list">
<div class="box-content">
<app-cipher-row
*ngFor="let cipher of ciphers"
[cipher]="cipher"
(onSelected)="pick(cipher)"
></app-cipher-row>
</div>
<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="{{ 'fido2Item' | 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'">
A passkey already exists in Bitwarden for this account
<div class="box list">
<div class="box-content">
<app-cipher-row *ngFor="let cipher of ciphers" [cipher]="cipher"></app-cipher-row>
<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="{{ 'fido2Item' | 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'">
You do not have a matching login for this site.
<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>
<button
*ngIf="data.fallbackSupported"
type="button"
class="btn btn-outline-secondary"
(click)="abort(true)"
>
Use browser built-in
</button>
<button type="button" class="btn btn-outline-secondary" (click)="abort(false)">Abort</button>
<div class="useBrowserlink">
<a [routerLink]="[]" *ngIf="data.fallbackSupported" (click)="abort(true)">
{{ "useBrowserName" | i18n }}
</a>
</div>
</div>
</ng-container>

View File

@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { ActivatedRoute, Router } from "@angular/router";
import {
BehaviorSubject,
combineLatest,
@ -12,10 +12,23 @@ import {
takeUntil,
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { SecureNoteType } from "@bitwarden/common/enums";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-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 { BrowserApi } from "../../../../platform/browser/browser-api";
import {
@ -25,7 +38,6 @@ import {
interface ViewData {
message: BrowserFido2Message;
showUnsupportedVerification: boolean;
fallbackSupported: boolean;
}
@ -36,89 +48,177 @@ interface ViewData {
})
export class Fido2Component implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private hasSearched = false;
private searchTimeout: any = null;
private hasLoadedAllCiphers = 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);
constructor(
private router: Router,
private activatedRoute: ActivatedRoute,
private cipherService: CipherService,
private passwordRepromptService: PasswordRepromptService
private passwordRepromptService: PasswordRepromptService,
private platformUtilsService: PlatformUtilsService,
private settingsService: SettingsService,
private searchService: SearchService,
private logService: LogService,
private dialogService: DialogService
) {}
ngOnInit(): void {
const sessionId$ = this.activatedRoute.queryParamMap.pipe(
ngOnInit() {
this.searchTypeSearch = !this.platformUtilsService.isSafari();
const queryParams$ = this.activatedRoute.queryParamMap.pipe(
take(1),
map((queryParamMap) => queryParamMap.get("sessionId"))
map((queryParamMap) => ({
sessionId: queryParamMap.get("sessionId"),
senderTabId: queryParamMap.get("senderTabId"),
}))
);
combineLatest([sessionId$, BrowserApi.messageListener$() as Observable<BrowserFido2Message>])
.pipe(takeUntil(this.destroy$))
.subscribe(([sessionId, message]) => {
this.sessionId = sessionId;
if (message.type === "NewSessionCreatedRequest" && message.sessionId !== sessionId) {
return this.abort(false);
}
combineLatest([queryParams$, BrowserApi.messageListener$() as Observable<BrowserFido2Message>])
.pipe(
concatMap(async ([queryParams, message]) => {
this.sessionId = queryParams.sessionId;
this.senderTabId = queryParams.senderTabId;
if (message.sessionId !== sessionId) {
return;
}
// For a 'NewSessionCreatedRequest', abort if it doesn't belong to the current session.
if (
message.type === "NewSessionCreatedRequest" &&
message.sessionId !== queryParams.sessionId
) {
this.abort(false);
return;
}
if (message.type === "AbortRequest") {
return this.abort(false);
}
// Ignore messages that don't belong to the current session.
if (message.sessionId !== queryParams.sessionId) {
return;
}
if (message.type === "AbortRequest") {
this.abort(false);
return;
}
// Show dialog if user account does not have master password
if (!(await this.passwordRepromptService.enabled())) {
await this.dialogService.openSimpleDialog({
title: { key: "featureNotSupported" },
content: { key: "passkeyFeatureIsNotImplementedForAccountsWithoutMasterPassword" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "info",
});
this.abort(true);
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) => {
if (message.type === "ConfirmNewCredentialRequest") {
this.ciphers = (await this.cipherService.getAllDecrypted()).filter(
(cipher) => cipher.type === CipherType.Login && !cipher.isDeleted
);
} else if (message.type === "PickCredentialRequest") {
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));
})
);
} else if (message.type === "InformExcludedCredentialRequest") {
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));
})
);
switch (message.type) {
case "ConfirmNewCredentialRequest": {
const activeTabs = await BrowserApi.getActiveTabs();
this.url = activeTabs[0].url;
const equivalentDomains = this.settingsService.getEquivalentDomains(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)
);
if (this.displayedCiphers.length > 0) {
this.selectedPasskey(this.displayedCiphers[0]);
}
break;
}
case "PickCredentialRequest": {
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)
);
})
);
this.displayedCiphers = [...this.ciphers];
if (this.displayedCiphers.length > 0) {
this.selectedPasskey(this.displayedCiphers[0]);
}
break;
}
case "InformExcludedCredentialRequest": {
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)
);
})
);
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,
showUnsupportedVerification:
"userVerification" in message &&
message.userVerification &&
!(await this.passwordRepromptService.enabled()),
fallbackSupported: "fallbackSupported" in message && message.fallbackSupported,
};
}),
takeUntil(this.destroy$)
);
sessionId$.pipe(takeUntil(this.destroy$)).subscribe((sessionId) => {
queryParams$.pipe(takeUntil(this.destroy$)).subscribe((queryParams) => {
this.send({
sessionId: sessionId,
sessionId: queryParams.sessionId,
type: "ConnectResponse",
});
});
}
async pick(cipher: CipherView) {
async submit() {
const data = this.message$.value;
if (data?.type === "PickCredentialRequest") {
let userVerified = false;
@ -128,19 +228,32 @@ export class Fido2Component implements OnInit, OnDestroy {
this.send({
sessionId: this.sessionId,
cipherId: cipher.id,
cipherId: this.cipher.id,
type: "PickCredentialResponse",
userVerified,
});
} else if (data?.type === "ConfirmNewCredentialRequest") {
let userVerified = false;
if (this.cipher.login.fido2Credentials.length > 0) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "overwritePasskey" },
content: { key: "overwritePasskeyAlert" },
type: "info",
});
if (!confirmed) {
return false;
}
}
if (data.userVerification) {
userVerified = await this.passwordRepromptService.showPasswordPrompt();
}
this.send({
sessionId: this.sessionId,
cipherId: cipher.id,
cipherId: this.cipher.id,
type: "ConfirmNewCredentialResponse",
userVerified,
});
@ -149,6 +262,131 @@ export class Fido2Component implements OnInit, OnDestroy {
this.loading = true;
}
async saveNewLogin() {
const data = this.message$.value;
if (data?.type === "ConfirmNewCredentialRequest") {
let userVerified = false;
if (data.userVerification) {
userVerified = await this.passwordRepromptService.showPasswordPrompt();
}
if (!data.userVerification || userVerified) {
await this.createNewCipher();
}
this.send({
sessionId: this.sessionId,
cipherId: this.cipher?.id,
type: "ConfirmNewCredentialResponse",
userVerified,
});
}
this.loading = true;
}
getCredentialSubTitleText(messageType: string): string {
return messageType == "ConfirmNewCredentialRequest" ? "choosePasskey" : "logInWithPasskey";
}
getCredentialButtonText(messageType: string): string {
return messageType == "ConfirmNewCredentialRequest" ? "savePasskey" : "confirm";
}
selectedPasskey(item: CipherView) {
this.cipher = item;
}
viewPasskey() {
this.router.navigate(["/view-cipher"], {
queryParams: {
cipherId: this.cipher.id,
uilocation: "popout",
senderTabId: this.senderTabId,
sessionId: this.sessionId,
},
});
}
addCipher() {
this.router.navigate(["/add-cipher"], {
queryParams: {
name: Utils.getHostname(this.url),
uri: this.url,
uilocation: "popout",
senderTabId: this.senderTabId,
sessionId: this.sessionId,
},
});
}
buildCipher() {
this.cipher = new CipherView();
this.cipher.name = Utils.getHostname(this.url);
this.cipher.type = CipherType.Login;
this.cipher.login = new LoginView();
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;
}
async createNewCipher() {
this.buildCipher();
const cipher = await this.cipherService.encrypt(this.cipher);
try {
await this.cipherService.createWithServer(cipher);
this.cipher.id = cipher.id;
} catch (e) {
this.logService.error(e);
}
}
async loadLoginCiphers() {
this.ciphers = (await this.cipherService.getAllDecrypted()).filter(
(cipher) => cipher.type === CipherType.Login && !cipher.isDeleted
);
if (!this.hasLoadedAllCiphers) {
this.hasLoadedAllCiphers = !this.searchService.isSearchable(this.searchText);
}
await this.search(null);
}
async search(timeout: number = null) {
this.searchPending = false;
if (this.searchTimeout != null) {
clearTimeout(this.searchTimeout);
}
if (timeout == null) {
this.hasSearched = this.searchService.isSearchable(this.searchText);
this.displayedCiphers = await this.searchService.searchCiphers(
this.searchText,
null,
this.ciphers
);
return;
}
this.searchPending = true;
this.searchTimeout = setTimeout(async () => {
this.hasSearched = this.searchService.isSearchable(this.searchText);
if (!this.hasLoadedAllCiphers && !this.hasSearched) {
await this.loadLoginCiphers();
} else {
this.displayedCiphers = await this.searchService.searchCiphers(
this.searchText,
null,
this.ciphers
);
}
this.searchPending = false;
this.selectedPasskey(this.displayedCiphers[0]);
}, timeout);
}
abort(fallback: boolean) {
this.unload(fallback);
window.close();

View File

@ -1,7 +1,8 @@
import { Location } from "@angular/common";
import { Component } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { first } from "rxjs/operators";
import { firstValueFrom } from "rxjs";
import { first, takeUntil } from "rxjs/operators";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
@ -24,6 +25,10 @@ import { DialogService } from "@bitwarden/components";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import { PopupUtilsService } from "../../../../popup/services/popup-utils.service";
import {
BrowserFido2UserInterfaceSession,
fido2PopoutSessionData$,
} from "../../../fido2/browser-fido2-user-interface.service";
@Component({
selector: "app-vault-add-edit",
@ -35,6 +40,11 @@ export class AddEditComponent extends BaseAddEditComponent {
showAttachments = true;
openAttachmentsInPopup: boolean;
showAutoFillOnPageLoadOptions: boolean;
inPopout = false;
senderTabId?: number;
uilocation?: "popout" | "popup" | "sidebar" | "tab";
private fido2PopoutSessionData$ = fido2PopoutSessionData$();
constructor(
cipherService: CipherService,
@ -79,6 +89,13 @@ export class AddEditComponent extends BaseAddEditComponent {
async ngOnInit() {
await super.ngOnInit();
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((value) => {
this.senderTabId = parseInt(value?.senderTabId, 10) || undefined;
this.uilocation = value?.uilocation;
});
this.inPopout = this.uilocation === "popout" || this.popupUtilsService.inPopout(window);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (params) => {
if (params.cipherId) {
@ -156,6 +173,12 @@ export class AddEditComponent extends BaseAddEditComponent {
return false;
}
// Would be refactored after rework is done on the windows popout service
const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
if (this.inPopout && sessionData.isFido2Session) {
return;
}
if (this.popupUtilsService.inTab(window)) {
this.popupUtilsService.disableCloseTabWarning();
this.messagingService.send("closeTab", { delay: 1000 });
@ -191,17 +214,37 @@ export class AddEditComponent extends BaseAddEditComponent {
}
}
cancel() {
async cancel() {
super.cancel();
// Would be refactored after rework is done on the windows popout service
const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
if (this.inPopout && sessionData.isFido2Session) {
BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId);
return;
}
if (this.popupUtilsService.inTab(window)) {
this.messagingService.send("closeTab");
return;
}
if (this.inPopout && this.senderTabId) {
this.close();
return;
}
this.location.back();
}
// Used for closing single-action views
close() {
BrowserApi.focusTab(this.senderTabId);
window.close();
return;
}
async generateUsername(): Promise<boolean> {
const confirmed = await super.generateUsername();
if (confirmed) {

View File

@ -1,7 +1,7 @@
import { Location } from "@angular/common";
import { ChangeDetectorRef, Component, NgZone } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { Subject, firstValueFrom, takeUntil } from "rxjs";
import { first } from "rxjs/operators";
import { ViewComponent as BaseViewComponent } from "@bitwarden/angular/vault/components/view.component";
@ -28,6 +28,10 @@ import { DialogService } from "@bitwarden/components";
import { AutofillService } from "../../../../autofill/services/abstractions/autofill.service";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import { PopupUtilsService } from "../../../../popup/services/popup-utils.service";
import {
BrowserFido2UserInterfaceSession,
fido2PopoutSessionData$,
} from "../../../fido2/browser-fido2-user-interface.service";
const BroadcasterSubscriptionId = "ChildViewComponent";
@ -55,6 +59,7 @@ export class ViewComponent extends BaseViewComponent {
uilocation?: "popout" | "popup" | "sidebar" | "tab";
loadPageDetailsTimeout: number;
inPopout = false;
private fido2PopoutSessionData$ = fido2PopoutSessionData$();
private destroy$ = new Subject<void>();
@ -299,7 +304,14 @@ export class ViewComponent extends BaseViewComponent {
return false;
}
close() {
async close() {
// Would be refactored after rework is done on the windows popout service
const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
if (this.inPopout && sessionData.isFido2Session) {
BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId);
return;
}
if (this.inPopout && this.senderTabId) {
BrowserApi.focusTab(this.senderTabId);
window.close();

View File

@ -15,6 +15,7 @@ export abstract class Fido2AuthenticatorService {
**/
makeCredential: (
params: Fido2AuthenticatorMakeCredentialsParams,
tab: chrome.tabs.Tab,
abortController?: AbortController
) => Promise<Fido2AuthenticatorMakeCredentialResult>;
@ -28,6 +29,7 @@ export abstract class Fido2AuthenticatorService {
*/
getAssertion: (
params: Fido2AuthenticatorGetAssertionParams,
tab: chrome.tabs.Tab,
abortController?: AbortController
) => Promise<Fido2AuthenticatorGetAssertionResult>;
}

View File

@ -24,6 +24,7 @@ export abstract class Fido2ClientService {
*/
createCredential: (
params: CreateCredentialParams,
tab: chrome.tabs.Tab,
abortController?: AbortController
) => Promise<CreateCredentialResult>;
@ -38,6 +39,7 @@ export abstract class Fido2ClientService {
*/
assertCredential: (
params: AssertCredentialParams,
tab: chrome.tabs.Tab,
abortController?: AbortController
) => Promise<AssertCredentialResult>;

View File

@ -50,6 +50,7 @@ export abstract class Fido2UserInterfaceService {
*/
newSession: (
fallbackSupported: boolean,
tab: chrome.tabs.Tab,
abortController?: AbortController
) => Promise<Fido2UserInterfaceSession>;
}

View File

@ -34,6 +34,7 @@ describe("FidoAuthenticatorService", () => {
let userInterfaceSession!: MockProxy<Fido2UserInterfaceSession>;
let syncService!: MockProxy<SyncService>;
let authenticator!: Fido2AuthenticatorService;
let tab!: chrome.tabs.Tab;
beforeEach(async () => {
cipherService = mock<CipherService>();
@ -42,6 +43,7 @@ describe("FidoAuthenticatorService", () => {
userInterface.newSession.mockResolvedValue(userInterfaceSession);
syncService = mock<SyncService>();
authenticator = new Fido2AuthenticatorService(cipherService, userInterface, syncService);
tab = { id: 123, windowId: 456 } as chrome.tabs.Tab;
});
describe("makeCredential", () => {
@ -55,19 +57,19 @@ describe("FidoAuthenticatorService", () => {
// Spec: Check if at least one of the specified combinations of PublicKeyCredentialType and cryptographic parameters in credTypesAndPubKeyAlgs is supported. If not, return an error code equivalent to "NotSupportedError" and terminate the operation.
it("should throw error when input does not contain any supported algorithms", async () => {
const result = async () =>
await authenticator.makeCredential(invalidParams.unsupportedAlgorithm);
await authenticator.makeCredential(invalidParams.unsupportedAlgorithm, tab);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotSupported);
});
it("should throw error when requireResidentKey has invalid value", async () => {
const result = async () => await authenticator.makeCredential(invalidParams.invalidRk);
const result = async () => await authenticator.makeCredential(invalidParams.invalidRk, tab);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown);
});
it("should throw error when requireUserVerification has invalid value", async () => {
const result = async () => await authenticator.makeCredential(invalidParams.invalidUv);
const result = async () => await authenticator.makeCredential(invalidParams.invalidUv, tab);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown);
});
@ -80,7 +82,7 @@ describe("FidoAuthenticatorService", () => {
it.skip("should throw error if requireUserVerification is set to true", async () => {
const params = await createParams({ requireUserVerification: true });
const result = async () => await authenticator.makeCredential(params);
const result = async () => await authenticator.makeCredential(params, tab);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Constraint);
});
@ -94,7 +96,7 @@ describe("FidoAuthenticatorService", () => {
for (const p of Object.values(invalidParams)) {
try {
await authenticator.makeCredential(p);
await authenticator.makeCredential(p, tab);
// eslint-disable-next-line no-empty
} catch {}
}
@ -135,7 +137,7 @@ describe("FidoAuthenticatorService", () => {
userInterfaceSession.informExcludedCredential.mockResolvedValue();
try {
await authenticator.makeCredential(params);
await authenticator.makeCredential(params, tab);
// eslint-disable-next-line no-empty
} catch {}
@ -146,7 +148,7 @@ describe("FidoAuthenticatorService", () => {
it("should throw error", async () => {
userInterfaceSession.informExcludedCredential.mockResolvedValue();
const result = async () => await authenticator.makeCredential(params);
const result = async () => await authenticator.makeCredential(params, tab);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed);
});
@ -157,7 +159,7 @@ describe("FidoAuthenticatorService", () => {
excludedCipher.organizationId = "someOrganizationId";
try {
await authenticator.makeCredential(params);
await authenticator.makeCredential(params, tab);
// eslint-disable-next-line no-empty
} catch {}
@ -170,7 +172,7 @@ describe("FidoAuthenticatorService", () => {
for (const p of Object.values(invalidParams)) {
try {
await authenticator.makeCredential(p);
await authenticator.makeCredential(p, tab);
// eslint-disable-next-line no-empty
} catch {}
}
@ -207,7 +209,7 @@ describe("FidoAuthenticatorService", () => {
userVerified: userVerification,
});
await authenticator.makeCredential(params);
await authenticator.makeCredential(params, tab);
expect(userInterfaceSession.confirmNewCredential).toHaveBeenCalledWith({
credentialName: params.rpEntity.name,
@ -225,7 +227,7 @@ describe("FidoAuthenticatorService", () => {
});
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
await authenticator.makeCredential(params);
await authenticator.makeCredential(params, tab);
const saved = cipherService.encrypt.mock.lastCall?.[0];
expect(saved).toEqual(
@ -262,7 +264,7 @@ describe("FidoAuthenticatorService", () => {
});
const params = await createParams();
const result = async () => await authenticator.makeCredential(params);
const result = async () => await authenticator.makeCredential(params, tab);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed);
});
@ -277,7 +279,7 @@ describe("FidoAuthenticatorService", () => {
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
cipherService.updateWithServer.mockRejectedValue(new Error("Internal error"));
const result = async () => await authenticator.makeCredential(params);
const result = async () => await authenticator.makeCredential(params, tab);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown);
});
@ -318,7 +320,7 @@ describe("FidoAuthenticatorService", () => {
});
it("should return attestation object", async () => {
const result = await authenticator.makeCredential(params);
const result = await authenticator.makeCredential(params, tab);
const attestationObject = CBOR.decode(
Fido2Utils.bufferSourceToUint8Array(result.attestationObject).buffer
@ -415,7 +417,7 @@ describe("FidoAuthenticatorService", () => {
describe("invalid input parameters", () => {
it("should throw error when requireUserVerification has invalid value", async () => {
const result = async () => await authenticator.getAssertion(invalidParams.invalidUv);
const result = async () => await authenticator.getAssertion(invalidParams.invalidUv, tab);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown);
});
@ -428,7 +430,7 @@ describe("FidoAuthenticatorService", () => {
it.skip("should throw error if requireUserVerification is set to true", async () => {
const params = await createParams({ requireUserVerification: true });
const result = async () => await authenticator.getAssertion(params);
const result = async () => await authenticator.getAssertion(params, tab);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Constraint);
});
@ -457,7 +459,7 @@ describe("FidoAuthenticatorService", () => {
userInterfaceSession.informCredentialNotFound.mockResolvedValue();
try {
await authenticator.getAssertion(params);
await authenticator.getAssertion(params, tab);
// eslint-disable-next-line no-empty
} catch {}
@ -472,7 +474,7 @@ describe("FidoAuthenticatorService", () => {
userInterfaceSession.informCredentialNotFound.mockResolvedValue();
try {
await authenticator.getAssertion(params);
await authenticator.getAssertion(params, tab);
// eslint-disable-next-line no-empty
} catch {}
@ -493,7 +495,7 @@ describe("FidoAuthenticatorService", () => {
/** Spec: If credentialOptions is now empty, return an error code equivalent to "NotAllowedError" and terminate the operation. */
it("should throw error", async () => {
const result = async () => await authenticator.getAssertion(params);
const result = async () => await authenticator.getAssertion(params, tab);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed);
});
@ -532,7 +534,7 @@ describe("FidoAuthenticatorService", () => {
userVerified: false,
});
await authenticator.getAssertion(params);
await authenticator.getAssertion(params, tab);
expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({
cipherIds: ciphers.map((c) => c.id),
@ -548,7 +550,7 @@ describe("FidoAuthenticatorService", () => {
userVerified: false,
});
await authenticator.getAssertion(params);
await authenticator.getAssertion(params, tab);
expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({
cipherIds: [discoverableCiphers[0].id],
@ -565,7 +567,7 @@ describe("FidoAuthenticatorService", () => {
userVerified: userVerification,
});
await authenticator.getAssertion(params);
await authenticator.getAssertion(params, tab);
expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({
cipherIds: ciphers.map((c) => c.id),
@ -581,7 +583,7 @@ describe("FidoAuthenticatorService", () => {
userVerified: false,
});
const result = async () => await authenticator.getAssertion(params);
const result = async () => await authenticator.getAssertion(params, tab);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed);
});
@ -629,7 +631,7 @@ describe("FidoAuthenticatorService", () => {
const encrypted = Symbol();
cipherService.encrypt.mockResolvedValue(encrypted as any);
await authenticator.getAssertion(params);
await authenticator.getAssertion(params, tab);
expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted);
@ -648,7 +650,7 @@ describe("FidoAuthenticatorService", () => {
});
it("should return an assertion result", async () => {
const result = await authenticator.getAssertion(params);
const result = await authenticator.getAssertion(params, tab);
const encAuthData = result.authenticatorData;
const rpIdHash = encAuthData.slice(0, 32);
@ -689,7 +691,7 @@ describe("FidoAuthenticatorService", () => {
for (let i = 0; i < 10; ++i) {
await init(); // Reset inputs
const result = await authenticator.getAssertion(params);
const result = await authenticator.getAssertion(params, tab);
const counter = result.authenticatorData.slice(33, 37);
expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // double check that the counter doesn't change
@ -706,7 +708,7 @@ describe("FidoAuthenticatorService", () => {
it("should throw unkown error if creation fails", async () => {
cipherService.updateWithServer.mockRejectedValue(new Error("Internal error"));
const result = async () => await authenticator.getAssertion(params);
const result = async () => await authenticator.getAssertion(params, tab);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown);
});

View File

@ -46,10 +46,12 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
async makeCredential(
params: Fido2AuthenticatorMakeCredentialsParams,
tab: chrome.tabs.Tab,
abortController?: AbortController
): Promise<Fido2AuthenticatorMakeCredentialResult> {
const userInterfaceSession = await this.userInterface.newSession(
params.fallbackSupported,
tab,
abortController
);
@ -175,10 +177,12 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
async getAssertion(
params: Fido2AuthenticatorGetAssertionParams,
tab: chrome.tabs.Tab,
abortController?: AbortController
): Promise<Fido2AuthenticatorGetAssertionResult> {
const userInterfaceSession = await this.userInterface.newSession(
params.fallbackSupported,
tab,
abortController
);
try {

View File

@ -1,5 +1,7 @@
import { mock, MockProxy } from "jest-mock-extended";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction";
import { Utils } from "../../../platform/misc/utils";
import {
@ -24,13 +26,18 @@ const RpId = "bitwarden.com";
describe("FidoAuthenticatorService", () => {
let authenticator!: MockProxy<Fido2AuthenticatorService>;
let configService!: MockProxy<ConfigServiceAbstraction>;
let authService!: MockProxy<AuthService>;
let client!: Fido2ClientService;
let tab!: chrome.tabs.Tab;
beforeEach(async () => {
authenticator = mock<Fido2AuthenticatorService>();
configService = mock<ConfigServiceAbstraction>();
client = new Fido2ClientService(authenticator, configService);
authService = mock<AuthService>();
client = new Fido2ClientService(authenticator, configService, authService);
configService.getFeatureFlag.mockResolvedValue(true);
tab = { id: 123, windowId: 456 } as chrome.tabs.Tab;
});
describe("createCredential", () => {
@ -39,7 +46,7 @@ describe("FidoAuthenticatorService", () => {
it("should throw error if sameOriginWithAncestors is false", async () => {
const params = createParams({ sameOriginWithAncestors: false });
const result = async () => await client.createCredential(params);
const result = async () => await client.createCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "NotAllowedError" });
@ -50,7 +57,7 @@ describe("FidoAuthenticatorService", () => {
it("should throw error if user.id is too small", async () => {
const params = createParams({ user: { id: "", displayName: "name" } });
const result = async () => await client.createCredential(params);
const result = async () => await client.createCredential(params, tab);
await expect(result).rejects.toBeInstanceOf(TypeError);
});
@ -64,7 +71,7 @@ describe("FidoAuthenticatorService", () => {
},
});
const result = async () => await client.createCredential(params);
const result = async () => await client.createCredential(params, tab);
await expect(result).rejects.toBeInstanceOf(TypeError);
});
@ -79,7 +86,7 @@ describe("FidoAuthenticatorService", () => {
origin: "invalid-domain-name",
});
const result = async () => await client.createCredential(params);
const result = async () => await client.createCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
@ -93,7 +100,7 @@ describe("FidoAuthenticatorService", () => {
rp: { id: "bitwarden.com", name: "Bitwraden" },
});
const result = async () => await client.createCredential(params);
const result = async () => await client.createCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
@ -106,7 +113,7 @@ describe("FidoAuthenticatorService", () => {
rp: { id: "bitwarden.com", name: "Bitwraden" },
});
const result = async () => await client.createCredential(params);
const result = async () => await client.createCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
@ -122,7 +129,7 @@ describe("FidoAuthenticatorService", () => {
],
});
const result = async () => await client.createCredential(params);
const result = async () => await client.createCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "NotSupportedError" });
@ -137,7 +144,7 @@ describe("FidoAuthenticatorService", () => {
const abortController = new AbortController();
abortController.abort();
const result = async () => await client.createCredential(params, abortController);
const result = async () => await client.createCredential(params, tab, abortController);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "AbortError" });
@ -152,7 +159,7 @@ describe("FidoAuthenticatorService", () => {
});
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
await client.createCredential(params);
await client.createCredential(params, tab);
expect(authenticator.makeCredential).toHaveBeenCalledWith(
expect.objectContaining({
@ -165,6 +172,7 @@ describe("FidoAuthenticatorService", () => {
displayName: params.user.displayName,
}),
}),
tab,
expect.anything()
);
});
@ -176,7 +184,7 @@ describe("FidoAuthenticatorService", () => {
new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState)
);
const result = async () => await client.createCredential(params);
const result = async () => await client.createCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "InvalidStateError" });
@ -188,7 +196,7 @@ describe("FidoAuthenticatorService", () => {
const params = createParams();
authenticator.makeCredential.mockRejectedValue(new Error("unknown error"));
const result = async () => await client.createCredential(params);
const result = async () => await client.createCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "NotAllowedError" });
@ -199,7 +207,17 @@ describe("FidoAuthenticatorService", () => {
const params = createParams();
configService.getFeatureFlag.mockResolvedValue(false);
const result = async () => await client.createCredential(params);
const result = async () => await client.createCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toThrow(FallbackRequestedError);
});
it("should throw FallbackRequestedError if user is logged out", async () => {
const params = createParams();
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);
const result = async () => await client.createCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toThrow(FallbackRequestedError);
@ -256,7 +274,7 @@ describe("FidoAuthenticatorService", () => {
origin: "invalid-domain-name",
});
const result = async () => await client.assertCredential(params);
const result = async () => await client.assertCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
@ -270,7 +288,7 @@ describe("FidoAuthenticatorService", () => {
rpId: "bitwarden.com",
});
const result = async () => await client.assertCredential(params);
const result = async () => await client.assertCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
@ -283,7 +301,7 @@ describe("FidoAuthenticatorService", () => {
rpId: "bitwarden.com",
});
const result = async () => await client.assertCredential(params);
const result = async () => await client.assertCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
@ -298,7 +316,7 @@ describe("FidoAuthenticatorService", () => {
const abortController = new AbortController();
abortController.abort();
const result = async () => await client.assertCredential(params, abortController);
const result = async () => await client.assertCredential(params, tab, abortController);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "AbortError" });
@ -314,7 +332,7 @@ describe("FidoAuthenticatorService", () => {
new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState)
);
const result = async () => await client.assertCredential(params);
const result = async () => await client.assertCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "InvalidStateError" });
@ -326,7 +344,7 @@ describe("FidoAuthenticatorService", () => {
const params = createParams();
authenticator.getAssertion.mockRejectedValue(new Error("unknown error"));
const result = async () => await client.assertCredential(params);
const result = async () => await client.assertCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "NotAllowedError" });
@ -337,7 +355,7 @@ describe("FidoAuthenticatorService", () => {
const params = createParams();
configService.getFeatureFlag.mockResolvedValue(false);
const result = async () => await client.assertCredential(params);
const result = async () => await client.assertCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toThrow(FallbackRequestedError);
@ -348,12 +366,22 @@ describe("FidoAuthenticatorService", () => {
const params = createParams();
params.sameOriginWithAncestors = false; // Simulating the falsey value
const result = async () => await client.assertCredential(params);
const result = async () => await client.assertCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "NotAllowedError" });
await rejects.toBeInstanceOf(DOMException);
});
it("should throw FallbackRequestedError if user is logged out", async () => {
const params = createParams();
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);
const result = async () => await client.assertCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toThrow(FallbackRequestedError);
});
});
describe("assert non-discoverable credential", () => {
@ -369,7 +397,7 @@ describe("FidoAuthenticatorService", () => {
});
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
await client.assertCredential(params);
await client.assertCredential(params, tab);
expect(authenticator.getAssertion).toHaveBeenCalledWith(
expect.objectContaining({
@ -387,6 +415,7 @@ describe("FidoAuthenticatorService", () => {
}),
],
}),
tab,
expect.anything()
);
});
@ -400,7 +429,7 @@ describe("FidoAuthenticatorService", () => {
});
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
await client.assertCredential(params);
await client.assertCredential(params, tab);
expect(authenticator.getAssertion).toHaveBeenCalledWith(
expect.objectContaining({
@ -408,6 +437,7 @@ describe("FidoAuthenticatorService", () => {
rpId: RpId,
allowCredentialDescriptorList: [],
}),
tab,
expect.anything()
);
});

View File

@ -1,5 +1,7 @@
import { parse } from "tldts";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction";
import { LogService } from "../../../platform/abstractions/log.service";
@ -37,6 +39,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
constructor(
private authenticator: Fido2AuthenticatorService,
private configService: ConfigServiceAbstraction,
private authService: AuthService,
private logService?: LogService
) {}
@ -46,6 +49,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
async createCredential(
params: CreateCredentialParams,
tab: chrome.tabs.Tab,
abortController = new AbortController()
): Promise<CreateCredentialResult> {
const enableFido2VaultCredentials = await this.isFido2FeatureEnabled();
@ -55,6 +59,13 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
throw new FallbackRequestedError();
}
const authStatus = await this.authService.getAuthStatus();
if (authStatus === AuthenticationStatus.LoggedOut) {
this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`);
throw new FallbackRequestedError();
}
if (!params.sameOriginWithAncestors) {
this.logService?.warning(
`[Fido2Client] Invalid 'sameOriginWithAncestors' value: ${params.sameOriginWithAncestors}`
@ -126,7 +137,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
// Set timeout before invoking authenticator
if (abortController.signal.aborted) {
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
throw new DOMException(undefined, "AbortError");
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
}
const timeout = setAbortTimeout(
abortController,
@ -138,6 +149,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
try {
makeCredentialResult = await this.authenticator.makeCredential(
makeCredentialParams,
tab,
abortController
);
} catch (error) {
@ -154,16 +166,19 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
error.errorCode === Fido2AutenticatorErrorCode.InvalidState
) {
this.logService?.warning(`[Fido2Client] Unknown error: ${error}`);
throw new DOMException(undefined, "InvalidStateError");
throw new DOMException("Unknown error occured.", "InvalidStateError");
}
this.logService?.info(`[Fido2Client] Aborted by user: ${error}`);
throw new DOMException(undefined, "NotAllowedError");
throw new DOMException(
"The operation either timed out or was not allowed.",
"NotAllowedError"
);
}
if (abortController.signal.aborted) {
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
throw new DOMException(undefined, "AbortError");
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
}
clearTimeout(timeout);
@ -179,6 +194,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
async assertCredential(
params: AssertCredentialParams,
tab: chrome.tabs.Tab,
abortController = new AbortController()
): Promise<AssertCredentialResult> {
const enableFido2VaultCredentials = await this.isFido2FeatureEnabled();
@ -188,6 +204,13 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
throw new FallbackRequestedError();
}
const authStatus = await this.authService.getAuthStatus();
if (authStatus === AuthenticationStatus.LoggedOut) {
this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`);
throw new FallbackRequestedError();
}
if (!params.sameOriginWithAncestors) {
this.logService?.warning(
`[Fido2Client] Invalid 'sameOriginWithAncestors' value: ${params.sameOriginWithAncestors}`
@ -230,7 +253,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
if (abortController.signal.aborted) {
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
throw new DOMException(undefined, "AbortError");
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
}
const timeout = setAbortTimeout(abortController, params.userVerification, params.timeout);
@ -239,6 +262,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
try {
getAssertionResult = await this.authenticator.getAssertion(
getAssertionParams,
tab,
abortController
);
} catch (error) {
@ -255,16 +279,19 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
error.errorCode === Fido2AutenticatorErrorCode.InvalidState
) {
this.logService?.warning(`[Fido2Client] Unknown error: ${error}`);
throw new DOMException(undefined, "InvalidStateError");
throw new DOMException("Unknown error occured.", "InvalidStateError");
}
this.logService?.info(`[Fido2Client] Aborted by user: ${error}`);
throw new DOMException(undefined, "NotAllowedError");
throw new DOMException(
"The operation either timed out or was not allowed.",
"NotAllowedError"
);
}
if (abortController.signal.aborted) {
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
throw new DOMException(undefined, "AbortError");
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
}
clearTimeout(timeout);