From 69a37a884fa6e5b5799ef1c944a01856dee94ddf Mon Sep 17 00:00:00 2001
From: Bernd Schoolmann <mail@quexten.com>
Date: Tue, 16 Jul 2024 16:46:37 +0200
Subject: [PATCH] Add shared webauthn component (#9771)

---
 apps/browser/src/_locales/en/messages.json    |   3 +
 .../auth/popup/two-factor-auth.component.ts   |   2 +
 .../src/auth/two-factor-auth.component.ts     |   2 +
 apps/desktop/src/locales/en/messages.json     |   3 +
 .../src/app/auth/two-factor-auth.component.ts |   2 +
 apps/web/src/locales/en/messages.json         |   3 +
 .../two-factor-auth-webauthn.component.html   |  11 ++
 .../two-factor-auth-webauthn.component.ts     | 131 ++++++++++++++++++
 .../two-factor-auth.component.html            |   6 +-
 .../two-factor-auth.component.ts              |   2 +
 10 files changed, 164 insertions(+), 1 deletion(-)
 create mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.html
 create mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.ts

diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index a89ac05e4e..6cebe0e231 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -611,6 +611,9 @@
   "verificationCodeRequired": {
     "message": "Verification code is required."
   },
+  "webauthnCancelOrTimeout": {
+    "message": "The authentication was cancelled or took too long. Please try again."
+  },
   "invalidVerificationCode": {
     "message": "Invalid verification code"
   },
diff --git a/apps/browser/src/auth/popup/two-factor-auth.component.ts b/apps/browser/src/auth/popup/two-factor-auth.component.ts
index 23251e2d58..d2a1ba20bf 100644
--- a/apps/browser/src/auth/popup/two-factor-auth.component.ts
+++ b/apps/browser/src/auth/popup/two-factor-auth.component.ts
@@ -4,6 +4,7 @@ import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
 import { ActivatedRoute, Router, RouterLink } from "@angular/router";
 
 import { TwoFactorAuthAuthenticatorComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-authenticator.component";
+import { TwoFactorAuthWebAuthnComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-webauthn.component";
 import { TwoFactorAuthYubikeyComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-yubikey.component";
 import { TwoFactorAuthComponent as BaseTwoFactorAuthComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth.component";
 import { TwoFactorOptionsComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-options.component";
@@ -64,6 +65,7 @@ import { TwoFactorAuthEmailComponent } from "./two-factor-auth-email.component";
     TwoFactorAuthEmailComponent,
     TwoFactorAuthAuthenticatorComponent,
     TwoFactorAuthYubikeyComponent,
+    TwoFactorAuthWebAuthnComponent,
   ],
   providers: [I18nPipe],
 })
diff --git a/apps/desktop/src/auth/two-factor-auth.component.ts b/apps/desktop/src/auth/two-factor-auth.component.ts
index a07509527e..bb1ef60138 100644
--- a/apps/desktop/src/auth/two-factor-auth.component.ts
+++ b/apps/desktop/src/auth/two-factor-auth.component.ts
@@ -6,6 +6,7 @@ import { RouterLink } from "@angular/router";
 
 import { TwoFactorAuthAuthenticatorComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component";
 import { TwoFactorAuthEmailComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-email.component";
+import { TwoFactorAuthWebAuthnComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component";
 import { TwoFactorAuthYubikeyComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-yubikey.component";
 import { TwoFactorAuthComponent as BaseTwoFactorAuthComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component";
 import { TwoFactorOptionsComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-options.component";
@@ -39,6 +40,7 @@ import { TypographyModule } from "../../../../libs/components/src/typography";
     TwoFactorAuthEmailComponent,
     TwoFactorAuthAuthenticatorComponent,
     TwoFactorAuthYubikeyComponent,
+    TwoFactorAuthWebAuthnComponent,
   ],
   providers: [I18nPipe],
 })
diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json
index 72d17baa14..c0ce5c17ee 100644
--- a/apps/desktop/src/locales/en/messages.json
+++ b/apps/desktop/src/locales/en/messages.json
@@ -627,6 +627,9 @@
   "verificationCodeRequired": {
     "message": "Verification code is required."
   },
+  "webauthnCancelOrTimeout": {
+    "message": "The authentication was cancelled or took too long. Please try again."
+  },
   "invalidVerificationCode": {
     "message": "Invalid verification code"
   },
diff --git a/apps/web/src/app/auth/two-factor-auth.component.ts b/apps/web/src/app/auth/two-factor-auth.component.ts
index 9834529b52..352d935728 100644
--- a/apps/web/src/app/auth/two-factor-auth.component.ts
+++ b/apps/web/src/app/auth/two-factor-auth.component.ts
@@ -21,6 +21,7 @@ import { LinkModule, TypographyModule, CheckboxModule, DialogService } from "@bi
 
 import { TwoFactorAuthAuthenticatorComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component";
 import { TwoFactorAuthEmailComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-email.component";
+import { TwoFactorAuthWebAuthnComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component";
 import { TwoFactorAuthYubikeyComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-yubikey.component";
 import { TwoFactorAuthComponent as BaseTwoFactorAuthComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component";
 import { TwoFactorOptionsComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-options.component";
@@ -54,6 +55,7 @@ import { FormFieldModule } from "../../../../../libs/components/src/form-field";
     TwoFactorAuthEmailComponent,
     TwoFactorAuthAuthenticatorComponent,
     TwoFactorAuthYubikeyComponent,
+    TwoFactorAuthWebAuthnComponent,
   ],
   providers: [I18nPipe],
 })
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index b99f065746..8b8c265653 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -5519,6 +5519,9 @@
   "verificationCodeRequired": {
     "message": "Verification code is required."
   },
+  "webauthnCancelOrTimeout": {
+    "message": "The authentication was cancelled or took too long. Please try again."
+  },
   "invalidVerificationCode": {
     "message": "Invalid verification code"
   },
diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.html b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.html
new file mode 100644
index 0000000000..65a7ef9a50
--- /dev/null
+++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.html
@@ -0,0 +1,11 @@
+<div id="web-authn-frame" class="tw-mb-3" *ngIf="!webAuthnNewTab">
+  <iframe id="webauthn_iframe" sandbox="allow-scripts allow-same-origin"></iframe>
+</div>
+<ng-container *ngIf="webAuthnNewTab">
+  <div class="content text-center" *ngIf="webAuthnNewTab">
+    <p class="text-center">{{ "webAuthnNewTab" | i18n }}</p>
+    <button type="button" class="btn primary block" (click)="authWebAuthn()" appStopClick>
+      {{ "webAuthnNewTabOpen" | i18n }}
+    </button>
+  </div>
+</ng-container>
diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.ts b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.ts
new file mode 100644
index 0000000000..d6814fa9c0
--- /dev/null
+++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-webauthn.component.ts
@@ -0,0 +1,131 @@
+import { DialogModule } from "@angular/cdk/dialog";
+import { CommonModule } from "@angular/common";
+import { Component, EventEmitter, Inject, Output } from "@angular/core";
+import { ReactiveFormsModule, FormsModule } from "@angular/forms";
+import { ActivatedRoute } from "@angular/router";
+import { firstValueFrom } from "rxjs";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
+import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
+import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
+import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
+import { WebAuthnIFrame } from "@bitwarden/common/auth/webauthn-iframe";
+import { ClientType } from "@bitwarden/common/enums";
+import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import {
+  ButtonModule,
+  LinkModule,
+  TypographyModule,
+  FormFieldModule,
+  AsyncActionsModule,
+} from "@bitwarden/components";
+
+@Component({
+  standalone: true,
+  selector: "app-two-factor-auth-webauthn",
+  templateUrl: "two-factor-auth-webauthn.component.html",
+  imports: [
+    CommonModule,
+    JslibModule,
+    DialogModule,
+    ButtonModule,
+    LinkModule,
+    TypographyModule,
+    ReactiveFormsModule,
+    FormFieldModule,
+    AsyncActionsModule,
+    FormsModule,
+  ],
+  providers: [I18nPipe],
+})
+export class TwoFactorAuthWebAuthnComponent {
+  @Output() token = new EventEmitter<string>();
+
+  webAuthnReady = false;
+  webAuthnNewTab = false;
+  webAuthnSupported = false;
+  webAuthn: WebAuthnIFrame = null;
+
+  constructor(
+    protected i18nService: I18nService,
+    protected platformUtilsService: PlatformUtilsService,
+    @Inject(WINDOW) protected win: Window,
+    protected environmentService: EnvironmentService,
+    protected twoFactorService: TwoFactorService,
+    protected route: ActivatedRoute,
+  ) {
+    this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win);
+
+    if (this.platformUtilsService.getClientType() == ClientType.Browser) {
+      // FIXME: Chromium 110 has broken WebAuthn support in extensions via an iframe
+      this.webAuthnNewTab = true;
+    }
+  }
+
+  async ngOnInit(): Promise<void> {
+    if (this.route.snapshot.paramMap.has("webAuthnResponse")) {
+      this.token.emit(this.route.snapshot.paramMap.get("webAuthnResponse"));
+    }
+
+    this.cleanupWebAuthn();
+
+    if (this.win != null && this.webAuthnSupported) {
+      const env = await firstValueFrom(this.environmentService.environment$);
+      const webVaultUrl = env.getWebVaultUrl();
+      this.webAuthn = new WebAuthnIFrame(
+        this.win,
+        webVaultUrl,
+        this.webAuthnNewTab,
+        this.platformUtilsService,
+        this.i18nService,
+        (token: string) => {
+          this.token.emit(token);
+        },
+        (error: string) => {
+          this.platformUtilsService.showToast(
+            "error",
+            this.i18nService.t("errorOccurred"),
+            this.i18nService.t("webauthnCancelOrTimeout"),
+          );
+        },
+        (info: string) => {
+          if (info === "ready") {
+            this.webAuthnReady = true;
+          }
+        },
+      );
+
+      if (!this.webAuthnNewTab) {
+        setTimeout(async () => {
+          await this.authWebAuthn();
+        }, 500);
+      }
+    }
+  }
+
+  ngOnDestroy(): void {
+    this.cleanupWebAuthn();
+  }
+
+  async authWebAuthn() {
+    const providerData = (await this.twoFactorService.getProviders()).get(
+      TwoFactorProviderType.WebAuthn,
+    );
+
+    if (!this.webAuthnSupported || this.webAuthn == null) {
+      return;
+    }
+
+    this.webAuthn.init(providerData);
+  }
+
+  private cleanupWebAuthn() {
+    if (this.webAuthn != null) {
+      this.webAuthn.stop();
+      this.webAuthn.cleanup();
+    }
+  }
+}
diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html
index 1d29cc5a4f..33a5e291fa 100644
--- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html
+++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html
@@ -12,6 +12,10 @@
       (token)="token = $event"
       *ngIf="selectedProviderType === providerType.Yubikey"
     />
+    <app-two-factor-auth-webauthn
+      (token)="token = $event; submitForm()"
+      *ngIf="selectedProviderType === providerType.WebAuthn"
+    />
     <bit-form-control *ngIf="selectedProviderType != null">
       <bit-label>{{ "rememberMe" | i18n }}</bit-label>
       <input type="checkbox" bitCheckbox formControlName="remember" />
@@ -31,7 +35,7 @@
         buttonType="primary"
         bitButton
         bitFormButton
-        *ngIf="selectedProviderType != null"
+        *ngIf="selectedProviderType != null && selectedProviderType !== providerType.WebAuthn"
       >
         <span> <i class="bwi bwi-sign-in" aria-hidden="true"></i> {{ actionButtonText }} </span>
       </button>
diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts
index 9bd2c49006..16a95d6ba2 100644
--- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts
+++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts
@@ -40,6 +40,7 @@ import { CaptchaProtectedComponent } from "../captcha-protected.component";
 
 import { TwoFactorAuthAuthenticatorComponent } from "./two-factor-auth-authenticator.component";
 import { TwoFactorAuthEmailComponent } from "./two-factor-auth-email.component";
+import { TwoFactorAuthWebAuthnComponent } from "./two-factor-auth-webauthn.component";
 import { TwoFactorAuthYubikeyComponent } from "./two-factor-auth-yubikey.component";
 import {
   TwoFactorOptionsDialogResult,
@@ -63,6 +64,7 @@ import {
     TwoFactorAuthAuthenticatorComponent,
     TwoFactorAuthEmailComponent,
     TwoFactorAuthYubikeyComponent,
+    TwoFactorAuthWebAuthnComponent,
   ],
   providers: [I18nPipe],
 })