1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 00:33:44 +00:00

[EC-598] feat: fully refactored user interface

Now uses sessions instead of single request-response style communcation
This commit is contained in:
Andreas Coroiu
2023-04-05 16:17:40 +02:00
parent 11d340bc97
commit cd70b17b9a
4 changed files with 254 additions and 279 deletions

View File

@@ -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 { Utils } from "@bitwarden/common/misc/utils";
import { UserRequestedFallbackAbortReason } from "@bitwarden/common/webauthn/abstractions/fido2-client.service.abstraction";
import { import {
Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction, Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction,
Fido2UserInterfaceSession, Fido2UserInterfaceSession,
@@ -12,19 +23,26 @@ import { PopupUtilsService } from "../../popup/services/popup-utils.service";
const BrowserFido2MessageName = "BrowserFido2UserInterfaceServiceMessage"; const BrowserFido2MessageName = "BrowserFido2UserInterfaceServiceMessage";
export class Fido2Error extends Error { export class SessionClosedError extends Error {
constructor(message: string, readonly fallbackRequested = false) { constructor() {
super(message); super("Fido2UserInterfaceSession was closed");
} }
} }
export class RequestAbortedError extends Fido2Error { export type BrowserFido2Message = { sessionId: string } & (
constructor(fallbackRequested = false) { | /**
super("Fido2 request was aborted", fallbackRequested); * 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";
} }
}
export type BrowserFido2Message = { requestId: string } & (
| { | {
type: "PickCredentialRequest"; type: "PickCredentialRequest";
cipherIds: string[]; cipherIds: string[];
@@ -66,228 +84,77 @@ export type BrowserFido2Message = { requestId: string } & (
} }
); );
export interface BrowserFido2UserInterfaceRequestData {
requestId: string;
}
export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction {
static sendMessage(msg: BrowserFido2Message) {
BrowserApi.sendMessage(BrowserFido2MessageName, msg);
}
static onAbort$(requestId: string): Observable<BrowserFido2Message> {
const messages$ = BrowserApi.messageListener$() as Observable<BrowserFido2Message>;
return messages$.pipe(
filter((message) => message.type === "AbortRequest" && message.requestId === requestId),
first()
);
}
private messages$ = BrowserApi.messageListener$() as Observable<BrowserFido2Message>;
private destroy$ = new Subject<void>();
constructor(private popupUtilsService: PopupUtilsService) {} constructor(private popupUtilsService: PopupUtilsService) {}
async newSession(abortController?: AbortController): Promise<Fido2UserInterfaceSession> { async newSession(abortController?: AbortController): Promise<Fido2UserInterfaceSession> {
return await BrowserFido2UserInterfaceSession.create(this, abortController); return await BrowserFido2UserInterfaceSession.create(this.popupUtilsService, abortController);
}
async confirmCredential(
cipherId: string,
abortController = new AbortController()
): Promise<boolean> {
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<string> {
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<boolean> {
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<string> {
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<void> {
// Not Implemented
}
private setAbortTimeout(abortController: AbortController) {
return setTimeout(() => abortController.abort());
} }
} }
export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession { export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession {
static async create( static async create(
parentService: BrowserFido2UserInterfaceService, popupUtilsService: PopupUtilsService,
abortController?: AbortController abortController?: AbortController
): Promise<BrowserFido2UserInterfaceSession> { ): Promise<BrowserFido2UserInterfaceSession> {
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<BrowserFido2Message>).pipe(
filter((msg) => msg.sessionId === this.sessionId)
);
private connected$ = new BehaviorSubject(false);
private destroy$ = new Subject<void>();
private constructor( private constructor(
private readonly parentService: BrowserFido2UserInterfaceService, private readonly popupUtilsService: PopupUtilsService,
readonly abortController = new AbortController(), readonly abortController = new AbortController(),
readonly sessionId = Utils.newGuid() readonly sessionId = Utils.newGuid()
) { ) {
this.abortListener = () => this.abort(); this.messages$
abortController.signal.addEventListener("abort", this.abortListener); .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; fallbackRequested = false;
@@ -296,26 +163,61 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
return this.abortController.signal.aborted; return this.abortController.signal.aborted;
} }
confirmCredential(cipherId: string, abortController?: AbortController): Promise<boolean> { async confirmCredential(cipherId: string): Promise<boolean> {
return this.parentService.confirmCredential(cipherId, this.abortController); 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<string> { async pickCredential(cipherIds: string[]): Promise<string> {
return this.parentService.pickCredential(cipherIds, this.abortController); const data: BrowserFido2Message = {
type: "PickCredentialRequest",
cipherIds,
sessionId: this.sessionId,
};
await this.send(data);
const response = await this.receive("PickCredentialResponse");
return response.cipherId;
} }
confirmNewCredential( async confirmNewCredential({ credentialName, userName }: NewCredentialParams): Promise<boolean> {
params: NewCredentialParams, const data: BrowserFido2Message = {
abortController?: AbortController type: "ConfirmNewCredentialRequest",
): Promise<boolean> { sessionId: this.sessionId,
return this.parentService.confirmNewCredential(params, this.abortController); credentialName,
userName,
};
await this.send(data);
await this.receive("ConfirmNewCredentialResponse");
return true;
} }
confirmNewNonDiscoverableCredential( async confirmNewNonDiscoverableCredential({
params: NewCredentialParams, credentialName,
abortController?: AbortController userName,
): Promise<string> { }: NewCredentialParams): Promise<string> {
return this.parentService.confirmNewNonDiscoverableCredential(params, this.abortController); const data: BrowserFido2Message = {
type: "ConfirmNewNonDiscoverableCredentialRequest",
sessionId: this.sessionId,
credentialName,
userName,
};
await this.send(data);
const response = await this.receive("ConfirmNewNonDiscoverableCredentialResponse");
return response.cipherId;
} }
informExcludedCredential( informExcludedCredential(
@@ -323,18 +225,52 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
newCredential: NewCredentialParams, newCredential: NewCredentialParams,
abortController?: AbortController abortController?: AbortController
): Promise<void> { ): Promise<void> {
return this.parentService.informExcludedCredential( return null;
existingCipherIds,
newCredential,
this.abortController
);
} }
private abort() { private async send(msg: BrowserFido2Message): Promise<void> {
this.close(); if (!this.connected$.value) {
await this.connect();
}
BrowserFido2UserInterfaceSession.sendMessage(msg);
}
private async receive<T extends BrowserFido2Message["type"]>(
type: T
): Promise<BrowserFido2Message & { type: T }> {
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<void> {
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() { private close() {
this.abortController.signal.removeEventListener("abort", this.abortListener); this.closed = true;
this.destroy$.next();
this.destroy$.complete();
} }
} }

View File

@@ -1,4 +1,4 @@
<ng-container *ngIf="data"> <ng-container *ngIf="data$ | async as data">
<div class="auth-wrapper"> <div class="auth-wrapper">
<ng-container *ngIf="data.type == 'ConfirmCredentialRequest'"> <ng-container *ngIf="data.type == 'ConfirmCredentialRequest'">
A site is asking for authentication using the following credential: A site is asking for authentication using the following credential:
@@ -37,9 +37,9 @@
</div> </div>
<button type="button" class="btn btn-outline-secondary" (click)="confirmNew()">Create</button> <button type="button" class="btn btn-outline-secondary" (click)="confirmNew()">Create</button>
</ng-container> </ng-container>
<button type="button" class="btn btn-outline-secondary" (click)="cancel(true)"> <button type="button" class="btn btn-outline-secondary" (click)="abort(true)">
Use browser built-in Use browser built-in
</button> </button>
<button type="button" class="btn btn-outline-secondary" (click)="cancel(false)">Abort</button> <button type="button" class="btn btn-outline-secondary" (click)="abort(false)">Abort</button>
</div> </div>
</ng-container> </ng-container>

View File

@@ -1,15 +1,25 @@
import { Component, HostListener, OnDestroy, OnInit } from "@angular/core"; import { Component, HostListener, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; 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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { Fido2KeyView } from "@bitwarden/common/webauthn/models/view/fido2-key.view"; import { Fido2KeyView } from "@bitwarden/common/webauthn/models/view/fido2-key.view";
import { BrowserApi } from "../../../browser/browserApi";
import { import {
BrowserFido2Message, BrowserFido2Message,
BrowserFido2UserInterfaceService, BrowserFido2UserInterfaceSession,
} from "../../../services/fido2/browser-fido2-user-interface.service"; } from "../../../services/fido2/browser-fido2-user-interface.service";
@Component({ @Component({
@@ -20,35 +30,58 @@ import {
export class Fido2Component implements OnInit, OnDestroy { export class Fido2Component implements OnInit, OnDestroy {
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
protected data?: BrowserFido2Message; protected data$ = new BehaviorSubject<BrowserFido2Message>(null);
protected sessionId?: string;
protected ciphers?: CipherView[] = []; protected ciphers?: CipherView[] = [];
constructor(private activatedRoute: ActivatedRoute, private cipherService: CipherService) {} constructor(private activatedRoute: ActivatedRoute, private cipherService: CipherService) {}
ngOnInit(): void { ngOnInit(): void {
this.activatedRoute.queryParamMap const sessionId$ = this.activatedRoute.queryParamMap.pipe(
.pipe( take(1),
concatMap(async (queryParamMap) => { map((queryParamMap) => queryParamMap.get("sessionId"))
this.data = JSON.parse(queryParamMap.get("data")); );
if (this.data?.type === "ConfirmNewCredentialRequest") { 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);
}
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(); const cipher = new CipherView();
cipher.name = this.data.credentialName; cipher.name = data.credentialName;
cipher.type = CipherType.Fido2Key; cipher.type = CipherType.Fido2Key;
cipher.fido2Key = new Fido2KeyView(); cipher.fido2Key = new Fido2KeyView();
cipher.fido2Key.userName = this.data.userName; cipher.fido2Key.userName = data.userName;
this.ciphers = [cipher]; this.ciphers = [cipher];
} else if (this.data?.type === "ConfirmCredentialRequest") { } else if (data?.type === "ConfirmCredentialRequest") {
const cipher = await this.cipherService.get(this.data.cipherId); const cipher = await this.cipherService.get(data.cipherId);
this.ciphers = [await cipher.decrypt()]; this.ciphers = [await cipher.decrypt()];
} else if (this.data?.type === "PickCredentialRequest") { } else if (data?.type === "PickCredentialRequest") {
this.ciphers = await Promise.all( this.ciphers = await Promise.all(
this.data.cipherIds.map(async (cipherId) => { data.cipherIds.map(async (cipherId) => {
const cipher = await this.cipherService.get(cipherId); const cipher = await this.cipherService.get(cipherId);
return cipher.decrypt(); return cipher.decrypt();
}) })
); );
} else if (this.data?.type === "ConfirmNewNonDiscoverableCredentialRequest") { } else if (data?.type === "ConfirmNewNonDiscoverableCredentialRequest") {
this.ciphers = (await this.cipherService.getAllDecrypted()).filter( this.ciphers = (await this.cipherService.getAllDecrypted()).filter(
(cipher) => cipher.type === CipherType.Login && !cipher.isDeleted (cipher) => cipher.type === CipherType.Login && !cipher.isDeleted
); );
@@ -58,27 +91,25 @@ export class Fido2Component implements OnInit, OnDestroy {
) )
.subscribe(); .subscribe();
this.activatedRoute.queryParamMap sessionId$.pipe(takeUntil(this.destroy$)).subscribe((sessionId) => {
.pipe( this.send({
switchMap((queryParamMap) => { sessionId: sessionId,
const data = JSON.parse(queryParamMap.get("data")); type: "ConnectResponse",
return BrowserFido2UserInterfaceService.onAbort$(data.requestId); });
}), });
takeUntil(this.destroy$)
)
.subscribe(() => this.cancel(false));
} }
async pick(cipher: CipherView) { async pick(cipher: CipherView) {
if (this.data?.type === "PickCredentialRequest") { const data = this.data$.value;
BrowserFido2UserInterfaceService.sendMessage({ if (data?.type === "PickCredentialRequest") {
requestId: this.data.requestId, this.send({
sessionId: this.sessionId,
cipherId: cipher.id, cipherId: cipher.id,
type: "PickCredentialResponse", type: "PickCredentialResponse",
}); });
} else if (this.data?.type === "ConfirmNewNonDiscoverableCredentialRequest") { } else if (data?.type === "ConfirmNewNonDiscoverableCredentialRequest") {
BrowserFido2UserInterfaceService.sendMessage({ this.send({
requestId: this.data.requestId, sessionId: this.sessionId,
cipherId: cipher.id, cipherId: cipher.id,
type: "ConfirmNewNonDiscoverableCredentialResponse", type: "ConfirmNewNonDiscoverableCredentialResponse",
}); });
@@ -88,31 +119,30 @@ export class Fido2Component implements OnInit, OnDestroy {
} }
confirm() { confirm() {
BrowserFido2UserInterfaceService.sendMessage({ this.send({
requestId: this.data.requestId, sessionId: this.sessionId,
type: "ConfirmCredentialResponse", type: "ConfirmCredentialResponse",
}); });
window.close(); window.close();
} }
confirmNew() { confirmNew() {
BrowserFido2UserInterfaceService.sendMessage({ this.send({
requestId: this.data.requestId, sessionId: this.sessionId,
type: "ConfirmNewCredentialResponse", type: "ConfirmNewCredentialResponse",
}); });
window.close(); window.close();
} }
cancel(fallback: boolean) { abort(fallback: boolean) {
this.unload(fallback); this.unload(fallback);
window.close(); window.close();
} }
@HostListener("window:unload") @HostListener("window:unload")
unload(fallback = true) { unload(fallback = false) {
const data = this.data; this.send({
BrowserFido2UserInterfaceService.sendMessage({ sessionId: this.sessionId,
requestId: data.requestId,
type: "AbortResponse", type: "AbortResponse",
fallbackRequested: fallback, fallbackRequested: fallback,
}); });
@@ -122,4 +152,11 @@ export class Fido2Component implements OnInit, OnDestroy {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();
} }
private send(msg: BrowserFido2Message) {
BrowserFido2UserInterfaceSession.sendMessage({
sessionId: this.sessionId,
...msg,
});
}
} }

View File

@@ -1,3 +1,5 @@
export const UserRequestedFallbackAbortReason = "UserRequestedFallback";
export type UserVerification = "discouraged" | "preferred" | "required"; export type UserVerification = "discouraged" | "preferred" | "required";
export abstract class Fido2ClientService { export abstract class Fido2ClientService {