From cd70b17b9a9037e8c504d9b179077415bc33340b Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Wed, 5 Apr 2023 16:17:40 +0200 Subject: [PATCH] [EC-598] feat: fully refactored user interface Now uses sessions instead of single request-response style communcation --- .../browser-fido2-user-interface.service.ts | 410 ++++++++---------- .../webauthn/popup/fido2/fido2.component.html | 6 +- .../webauthn/popup/fido2/fido2.component.ts | 115 +++-- .../fido2-client.service.abstraction.ts | 2 + 4 files changed, 254 insertions(+), 279 deletions(-) diff --git a/apps/browser/src/services/fido2/browser-fido2-user-interface.service.ts b/apps/browser/src/services/fido2/browser-fido2-user-interface.service.ts index b570c4980a4..c79ab20de74 100644 --- a/apps/browser/src/services/fido2/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/services/fido2/browser-fido2-user-interface.service.ts @@ -1,6 +1,17 @@ -import { filter, first, lastValueFrom, Observable, Subject, takeUntil } from "rxjs"; +import { + BehaviorSubject, + EmptyError, + filter, + firstValueFrom, + fromEvent, + Observable, + Subject, + take, + takeUntil, +} from "rxjs"; import { Utils } from "@bitwarden/common/misc/utils"; +import { UserRequestedFallbackAbortReason } from "@bitwarden/common/webauthn/abstractions/fido2-client.service.abstraction"; import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction, Fido2UserInterfaceSession, @@ -12,19 +23,26 @@ import { PopupUtilsService } from "../../popup/services/popup-utils.service"; const BrowserFido2MessageName = "BrowserFido2UserInterfaceServiceMessage"; -export class Fido2Error extends Error { - constructor(message: string, readonly fallbackRequested = false) { - super(message); +export class SessionClosedError extends Error { + constructor() { + super("Fido2UserInterfaceSession was closed"); } } -export class RequestAbortedError extends Fido2Error { - constructor(fallbackRequested = false) { - super("Fido2 request was aborted", fallbackRequested); - } -} - -export type BrowserFido2Message = { requestId: string } & ( +export type BrowserFido2Message = { sessionId: string } & ( + | /** + * This message is used by popouts to announce that they are ready + * to recieve messages. + **/ { + type: "ConnectResponse"; + } + /** + * This message is used to announce the creation of a new session. + * It iss used by popouts to know when to close. + **/ + | { + type: "NewSessionCreatedRequest"; + } | { type: "PickCredentialRequest"; cipherIds: string[]; @@ -66,228 +84,77 @@ export type BrowserFido2Message = { requestId: string } & ( } ); -export interface BrowserFido2UserInterfaceRequestData { - requestId: string; -} - export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { - static sendMessage(msg: BrowserFido2Message) { - BrowserApi.sendMessage(BrowserFido2MessageName, msg); - } - - static onAbort$(requestId: string): Observable { - const messages$ = BrowserApi.messageListener$() as Observable; - return messages$.pipe( - filter((message) => message.type === "AbortRequest" && message.requestId === requestId), - first() - ); - } - - private messages$ = BrowserApi.messageListener$() as Observable; - private destroy$ = new Subject(); - constructor(private popupUtilsService: PopupUtilsService) {} async newSession(abortController?: AbortController): Promise { - return await BrowserFido2UserInterfaceSession.create(this, abortController); - } - - async confirmCredential( - cipherId: string, - abortController = new AbortController() - ): Promise { - const requestId = Utils.newGuid(); - const data: BrowserFido2Message = { type: "ConfirmCredentialRequest", cipherId, requestId }; - const queryParams = new URLSearchParams({ data: JSON.stringify(data) }).toString(); - - const abortHandler = () => - BrowserFido2UserInterfaceService.sendMessage({ type: "AbortRequest", requestId }); - abortController.signal.addEventListener("abort", abortHandler); - - this.popupUtilsService.popOut( - null, - `popup/index.html?uilocation=popout#/fido2?${queryParams}`, - { center: true } - ); - - const response = await lastValueFrom( - this.messages$.pipe( - filter((msg) => msg.requestId === requestId), - first(), - takeUntil(this.destroy$) - ) - ); - - if (response.type === "ConfirmCredentialResponse") { - return true; - } - - if (response.type === "AbortResponse") { - throw new RequestAbortedError(response.fallbackRequested); - } - - abortController.signal.removeEventListener("abort", abortHandler); - - return false; - } - - async pickCredential( - cipherIds: string[], - abortController = new AbortController() - ): Promise { - const requestId = Utils.newGuid(); - const data: BrowserFido2Message = { type: "PickCredentialRequest", cipherIds, requestId }; - const queryParams = new URLSearchParams({ data: JSON.stringify(data) }).toString(); - - const abortHandler = () => - BrowserFido2UserInterfaceService.sendMessage({ type: "AbortRequest", requestId }); - abortController.signal.addEventListener("abort", abortHandler); - - this.popupUtilsService.popOut( - null, - `popup/index.html?uilocation=popout#/fido2?${queryParams}`, - { center: true } - ); - - const response = await lastValueFrom( - this.messages$.pipe( - filter((msg) => msg.requestId === requestId), - first(), - takeUntil(this.destroy$) - ) - ); - - if (response.type === "AbortResponse") { - throw new RequestAbortedError(response.fallbackRequested); - } - - if (response.type !== "PickCredentialResponse") { - throw new RequestAbortedError(); - } - - abortController.signal.removeEventListener("abort", abortHandler); - - return response.cipherId; - } - - async confirmNewCredential( - { credentialName, userName }: NewCredentialParams, - abortController = new AbortController() - ): Promise { - const requestId = Utils.newGuid(); - const data: BrowserFido2Message = { - type: "ConfirmNewCredentialRequest", - requestId, - credentialName, - userName, - }; - const queryParams = new URLSearchParams({ data: JSON.stringify(data) }).toString(); - - const abortHandler = () => - BrowserFido2UserInterfaceService.sendMessage({ type: "AbortRequest", requestId }); - abortController.signal.addEventListener("abort", abortHandler); - - this.popupUtilsService.popOut( - null, - `popup/index.html?uilocation=popout#/fido2?${queryParams}`, - { center: true } - ); - - const response = await lastValueFrom( - this.messages$.pipe( - filter((msg) => msg.requestId === requestId), - first(), - takeUntil(this.destroy$) - ) - ); - - if (response.type === "ConfirmNewCredentialResponse") { - return true; - } - - if (response.type === "AbortResponse") { - throw new RequestAbortedError(response.fallbackRequested); - } - - abortController.signal.removeEventListener("abort", abortHandler); - - return false; - } - - async confirmNewNonDiscoverableCredential( - { credentialName, userName }: NewCredentialParams, - abortController?: AbortController - ): Promise { - const requestId = Utils.newGuid(); - const data: BrowserFido2Message = { - type: "ConfirmNewNonDiscoverableCredentialRequest", - requestId, - credentialName, - userName, - }; - const queryParams = new URLSearchParams({ data: JSON.stringify(data) }).toString(); - - const abortHandler = () => - BrowserFido2UserInterfaceService.sendMessage({ type: "AbortRequest", requestId }); - abortController.signal.addEventListener("abort", abortHandler); - - this.popupUtilsService.popOut( - null, - `popup/index.html?uilocation=popout#/fido2?${queryParams}`, - { center: true } - ); - - const response = await lastValueFrom( - this.messages$.pipe( - filter((msg) => msg.requestId === requestId), - first(), - takeUntil(this.destroy$) - ) - ); - - if (response.type === "ConfirmNewNonDiscoverableCredentialResponse") { - return response.cipherId; - } - - if (response.type === "AbortResponse") { - throw new RequestAbortedError(response.fallbackRequested); - } - - abortController.signal.removeEventListener("abort", abortHandler); - - return undefined; - } - - async informExcludedCredential( - existingCipherIds: string[], - newCredential: NewCredentialParams, - abortController?: AbortController - ): Promise { - // Not Implemented - } - - private setAbortTimeout(abortController: AbortController) { - return setTimeout(() => abortController.abort()); + return await BrowserFido2UserInterfaceSession.create(this.popupUtilsService, abortController); } } export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession { static async create( - parentService: BrowserFido2UserInterfaceService, + popupUtilsService: PopupUtilsService, abortController?: AbortController ): Promise { - return new BrowserFido2UserInterfaceSession(parentService, abortController); + return new BrowserFido2UserInterfaceSession(popupUtilsService, abortController); } - readonly abortListener: () => void; + static sendMessage(msg: BrowserFido2Message) { + BrowserApi.sendMessage(BrowserFido2MessageName, msg); + } + + private closed = false; + private messages$ = (BrowserApi.messageListener$() as Observable).pipe( + filter((msg) => msg.sessionId === this.sessionId) + ); + private connected$ = new BehaviorSubject(false); + private destroy$ = new Subject(); private constructor( - private readonly parentService: BrowserFido2UserInterfaceService, + private readonly popupUtilsService: PopupUtilsService, readonly abortController = new AbortController(), readonly sessionId = Utils.newGuid() ) { - this.abortListener = () => this.abort(); - abortController.signal.addEventListener("abort", this.abortListener); + this.messages$ + .pipe( + filter((msg) => msg.type === "ConnectResponse"), + take(1), + takeUntil(this.destroy$) + ) + .subscribe(() => { + this.connected$.next(true); + }); + + // Handle session aborted by RP + fromEvent(abortController.signal, "abort") + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.close(); + BrowserFido2UserInterfaceSession.sendMessage({ + type: "AbortRequest", + sessionId: this.sessionId, + }); + }); + + // Handle session aborted by user + this.messages$ + .pipe( + filter((msg) => msg.type === "AbortResponse"), + take(1), + takeUntil(this.destroy$) + ) + .subscribe((msg) => { + if (msg.type === "AbortResponse") { + this.close(); + this.abortController.abort(UserRequestedFallbackAbortReason); + } + }); + + BrowserFido2UserInterfaceSession.sendMessage({ + type: "NewSessionCreatedRequest", + sessionId, + }); } fallbackRequested = false; @@ -296,26 +163,61 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi return this.abortController.signal.aborted; } - confirmCredential(cipherId: string, abortController?: AbortController): Promise { - return this.parentService.confirmCredential(cipherId, this.abortController); + async confirmCredential(cipherId: string): Promise { + const data: BrowserFido2Message = { + type: "ConfirmCredentialRequest", + cipherId, + sessionId: this.sessionId, + }; + + await this.send(data); + await this.receive("ConfirmCredentialResponse"); + + return true; } - pickCredential(cipherIds: string[], abortController?: AbortController): Promise { - return this.parentService.pickCredential(cipherIds, this.abortController); + async pickCredential(cipherIds: string[]): Promise { + const data: BrowserFido2Message = { + type: "PickCredentialRequest", + cipherIds, + sessionId: this.sessionId, + }; + + await this.send(data); + const response = await this.receive("PickCredentialResponse"); + + return response.cipherId; } - confirmNewCredential( - params: NewCredentialParams, - abortController?: AbortController - ): Promise { - return this.parentService.confirmNewCredential(params, this.abortController); + async confirmNewCredential({ credentialName, userName }: NewCredentialParams): Promise { + const data: BrowserFido2Message = { + type: "ConfirmNewCredentialRequest", + sessionId: this.sessionId, + credentialName, + userName, + }; + + await this.send(data); + await this.receive("ConfirmNewCredentialResponse"); + + return true; } - confirmNewNonDiscoverableCredential( - params: NewCredentialParams, - abortController?: AbortController - ): Promise { - return this.parentService.confirmNewNonDiscoverableCredential(params, this.abortController); + async confirmNewNonDiscoverableCredential({ + credentialName, + userName, + }: NewCredentialParams): Promise { + const data: BrowserFido2Message = { + type: "ConfirmNewNonDiscoverableCredentialRequest", + sessionId: this.sessionId, + credentialName, + userName, + }; + + await this.send(data); + const response = await this.receive("ConfirmNewNonDiscoverableCredentialResponse"); + + return response.cipherId; } informExcludedCredential( @@ -323,18 +225,52 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi newCredential: NewCredentialParams, abortController?: AbortController ): Promise { - return this.parentService.informExcludedCredential( - existingCipherIds, - newCredential, - this.abortController - ); + return null; } - private abort() { - this.close(); + private async send(msg: BrowserFido2Message): Promise { + if (!this.connected$.value) { + await this.connect(); + } + BrowserFido2UserInterfaceSession.sendMessage(msg); + } + + private async receive( + type: T + ): Promise { + try { + const response = await firstValueFrom( + this.messages$.pipe( + filter((msg) => msg.sessionId === this.sessionId && msg.type === type), + takeUntil(this.destroy$) + ) + ); + return response as BrowserFido2Message & { type: T }; + } catch (error) { + if (error instanceof EmptyError) { + throw new SessionClosedError(); + } + throw error; + } + } + + private async connect(): Promise { + if (this.closed) { + throw new Error("Cannot re-open closed session"); + } + + const queryParams = new URLSearchParams({ sessionId: this.sessionId }).toString(); + this.popupUtilsService.popOut( + null, + `popup/index.html?uilocation=popout#/fido2?${queryParams}`, + { center: true } + ); + await firstValueFrom(this.connected$.pipe(filter((connected) => connected === true))); } private close() { - this.abortController.signal.removeEventListener("abort", this.abortListener); + this.closed = true; + this.destroy$.next(); + this.destroy$.complete(); } } diff --git a/apps/browser/src/webauthn/popup/fido2/fido2.component.html b/apps/browser/src/webauthn/popup/fido2/fido2.component.html index 7127e58de72..07670e71348 100644 --- a/apps/browser/src/webauthn/popup/fido2/fido2.component.html +++ b/apps/browser/src/webauthn/popup/fido2/fido2.component.html @@ -1,4 +1,4 @@ - +
A site is asking for authentication using the following credential: @@ -37,9 +37,9 @@
- - +
diff --git a/apps/browser/src/webauthn/popup/fido2/fido2.component.ts b/apps/browser/src/webauthn/popup/fido2/fido2.component.ts index d8dd452147b..191002c44a7 100644 --- a/apps/browser/src/webauthn/popup/fido2/fido2.component.ts +++ b/apps/browser/src/webauthn/popup/fido2/fido2.component.ts @@ -1,15 +1,25 @@ import { Component, HostListener, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { concatMap, Subject, switchMap, takeUntil } from "rxjs"; +import { + BehaviorSubject, + combineLatest, + concatMap, + map, + Observable, + Subject, + take, + takeUntil, +} from "rxjs"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { Fido2KeyView } from "@bitwarden/common/webauthn/models/view/fido2-key.view"; +import { BrowserApi } from "../../../browser/browserApi"; import { BrowserFido2Message, - BrowserFido2UserInterfaceService, + BrowserFido2UserInterfaceSession, } from "../../../services/fido2/browser-fido2-user-interface.service"; @Component({ @@ -20,35 +30,58 @@ import { export class Fido2Component implements OnInit, OnDestroy { private destroy$ = new Subject(); - protected data?: BrowserFido2Message; + protected data$ = new BehaviorSubject(null); + protected sessionId?: string; protected ciphers?: CipherView[] = []; constructor(private activatedRoute: ActivatedRoute, private cipherService: CipherService) {} ngOnInit(): void { - this.activatedRoute.queryParamMap - .pipe( - concatMap(async (queryParamMap) => { - this.data = JSON.parse(queryParamMap.get("data")); + const sessionId$ = this.activatedRoute.queryParamMap.pipe( + take(1), + map((queryParamMap) => queryParamMap.get("sessionId")) + ); - if (this.data?.type === "ConfirmNewCredentialRequest") { + combineLatest([sessionId$, BrowserApi.messageListener$() as Observable]) + .pipe(takeUntil(this.destroy$)) + .subscribe(([sessionId, message]) => { + this.sessionId = sessionId; + if (message.type === "NewSessionCreatedRequest" && message.sessionId !== sessionId) { + return this.abort(false); + } + + if (message.sessionId !== sessionId) { + return; + } + + if (message.type === "AbortRequest") { + return this.abort(false); + } + + this.data$.next(message); + }); + + this.data$ + .pipe( + concatMap(async (data) => { + if (data?.type === "ConfirmNewCredentialRequest") { const cipher = new CipherView(); - cipher.name = this.data.credentialName; + cipher.name = data.credentialName; cipher.type = CipherType.Fido2Key; cipher.fido2Key = new Fido2KeyView(); - cipher.fido2Key.userName = this.data.userName; + cipher.fido2Key.userName = data.userName; this.ciphers = [cipher]; - } else if (this.data?.type === "ConfirmCredentialRequest") { - const cipher = await this.cipherService.get(this.data.cipherId); + } else if (data?.type === "ConfirmCredentialRequest") { + const cipher = await this.cipherService.get(data.cipherId); this.ciphers = [await cipher.decrypt()]; - } else if (this.data?.type === "PickCredentialRequest") { + } else if (data?.type === "PickCredentialRequest") { this.ciphers = await Promise.all( - this.data.cipherIds.map(async (cipherId) => { + data.cipherIds.map(async (cipherId) => { const cipher = await this.cipherService.get(cipherId); return cipher.decrypt(); }) ); - } else if (this.data?.type === "ConfirmNewNonDiscoverableCredentialRequest") { + } else if (data?.type === "ConfirmNewNonDiscoverableCredentialRequest") { this.ciphers = (await this.cipherService.getAllDecrypted()).filter( (cipher) => cipher.type === CipherType.Login && !cipher.isDeleted ); @@ -58,27 +91,25 @@ export class Fido2Component implements OnInit, OnDestroy { ) .subscribe(); - this.activatedRoute.queryParamMap - .pipe( - switchMap((queryParamMap) => { - const data = JSON.parse(queryParamMap.get("data")); - return BrowserFido2UserInterfaceService.onAbort$(data.requestId); - }), - takeUntil(this.destroy$) - ) - .subscribe(() => this.cancel(false)); + sessionId$.pipe(takeUntil(this.destroy$)).subscribe((sessionId) => { + this.send({ + sessionId: sessionId, + type: "ConnectResponse", + }); + }); } async pick(cipher: CipherView) { - if (this.data?.type === "PickCredentialRequest") { - BrowserFido2UserInterfaceService.sendMessage({ - requestId: this.data.requestId, + const data = this.data$.value; + if (data?.type === "PickCredentialRequest") { + this.send({ + sessionId: this.sessionId, cipherId: cipher.id, type: "PickCredentialResponse", }); - } else if (this.data?.type === "ConfirmNewNonDiscoverableCredentialRequest") { - BrowserFido2UserInterfaceService.sendMessage({ - requestId: this.data.requestId, + } else if (data?.type === "ConfirmNewNonDiscoverableCredentialRequest") { + this.send({ + sessionId: this.sessionId, cipherId: cipher.id, type: "ConfirmNewNonDiscoverableCredentialResponse", }); @@ -88,31 +119,30 @@ export class Fido2Component implements OnInit, OnDestroy { } confirm() { - BrowserFido2UserInterfaceService.sendMessage({ - requestId: this.data.requestId, + this.send({ + sessionId: this.sessionId, type: "ConfirmCredentialResponse", }); window.close(); } confirmNew() { - BrowserFido2UserInterfaceService.sendMessage({ - requestId: this.data.requestId, + this.send({ + sessionId: this.sessionId, type: "ConfirmNewCredentialResponse", }); window.close(); } - cancel(fallback: boolean) { + abort(fallback: boolean) { this.unload(fallback); window.close(); } @HostListener("window:unload") - unload(fallback = true) { - const data = this.data; - BrowserFido2UserInterfaceService.sendMessage({ - requestId: data.requestId, + unload(fallback = false) { + this.send({ + sessionId: this.sessionId, type: "AbortResponse", fallbackRequested: fallback, }); @@ -122,4 +152,11 @@ export class Fido2Component implements OnInit, OnDestroy { this.destroy$.next(); this.destroy$.complete(); } + + private send(msg: BrowserFido2Message) { + BrowserFido2UserInterfaceSession.sendMessage({ + sessionId: this.sessionId, + ...msg, + }); + } } diff --git a/libs/common/src/webauthn/abstractions/fido2-client.service.abstraction.ts b/libs/common/src/webauthn/abstractions/fido2-client.service.abstraction.ts index c76f5439a14..8fb2197dc89 100644 --- a/libs/common/src/webauthn/abstractions/fido2-client.service.abstraction.ts +++ b/libs/common/src/webauthn/abstractions/fido2-client.service.abstraction.ts @@ -1,3 +1,5 @@ +export const UserRequestedFallbackAbortReason = "UserRequestedFallback"; + export type UserVerification = "discouraged" | "preferred" | "required"; export abstract class Fido2ClientService {