From b6dc9291410ea5ce8869ccb5655d69172edd61b8 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Mon, 23 Oct 2023 11:21:05 -0400 Subject: [PATCH] UX: Add conditional UI for passkeys (#24041) This allows users to see their passkeys recommended by the browser as they type their username. There's a small refactor here, to make sure the same action is used by both the conditional UI and the passkey login button. The webauthn API only supports one auth attempt at a time, so in this PR we need to add a service singleton to manage the navigator.credentials.get promise so that it can be cancelled and reused as the user picks the conditional UI (i.e. the username login input) or the dedicated passkey login button. --- .../app/components/login-buttons.hbs | 2 +- .../discourse/app/components/modal/login.hbs | 7 +- .../discourse/app/components/modal/login.js | 34 ++++++++- .../modal/login/local-login-form.hbs | 4 +- .../modal/login/local-login-form.js | 13 ++++ .../app/components/passkey-login-button.gjs | 34 +-------- .../javascripts/discourse/app/lib/webauthn.js | 70 +++++++++++++------ .../acceptance/modal/login/login-test.js | 39 +++++++++++ 8 files changed, 145 insertions(+), 58 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/login-buttons.hbs b/app/assets/javascripts/discourse/app/components/login-buttons.hbs index 2b03fb31d76..779f2bbd6e9 100644 --- a/app/assets/javascripts/discourse/app/components/login-buttons.hbs +++ b/app/assets/javascripts/discourse/app/components/login-buttons.hbs @@ -17,7 +17,7 @@ {{/each}} {{#if this.canUsePasskeys}} - + {{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/modal/login.hbs b/app/assets/javascripts/discourse/app/components/modal/login.hbs index 0c1d1c9717b..acb9696aea8 100644 --- a/app/assets/javascripts/discourse/app/components/modal/login.hbs +++ b/app/assets/javascripts/discourse/app/components/modal/login.hbs @@ -31,6 +31,8 @@ @loginName={{this.loginName}} @loginNameChanged={{this.loginNameChanged}} @canLoginLocalWithEmail={{this.canLoginLocalWithEmail}} + @canUsePasskeys={{this.canUsePasskeys}} + @passkeyLogin={{this.passkeyLogin}} @loginPassword={{this.loginPassword}} @secondFactorMethod={{this.secondFactorMethod}} @secondFactorToken={{this.secondFactorToken}} @@ -67,7 +69,10 @@ {{/unless}} {{/if}} diff --git a/app/assets/javascripts/discourse/app/components/modal/login.js b/app/assets/javascripts/discourse/app/components/modal/login.js index 6f4fb54e290..a8685290cb5 100644 --- a/app/assets/javascripts/discourse/app/components/modal/login.js +++ b/app/assets/javascripts/discourse/app/components/modal/login.js @@ -5,10 +5,14 @@ import { schedule } from "@ember/runloop"; import { inject as service } from "@ember/service"; import { isEmpty } from "@ember/utils"; import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; import cookie, { removeCookie } from "discourse/lib/cookie"; import { areCookiesEnabled } from "discourse/lib/utilities"; import { wavingHandURL } from "discourse/lib/waving-hand-url"; -import { isWebauthnSupported } from "discourse/lib/webauthn"; +import { + getPasskeyCredential, + isWebauthnSupported, +} from "discourse/lib/webauthn"; import { findAll } from "discourse/models/login-method"; import { SECOND_FACTOR_METHODS } from "discourse/models/user"; import escape from "discourse-common/lib/escape"; @@ -109,6 +113,34 @@ export default class Login extends Component { ); } + @action + async passkeyLogin(mediation = "optional") { + try { + const response = await ajax("/session/passkey/challenge.json"); + + const publicKeyCredential = await getPasskeyCredential( + response.challenge, + (errorMessage) => this.dialog.alert(errorMessage), + mediation + ); + + if (publicKeyCredential) { + const authResult = await ajax("/session/passkey/auth.json", { + type: "POST", + data: { publicKeyCredential }, + }); + + if (authResult && !authResult.error) { + window.location.reload(); + } else { + this.dialog.alert(authResult.error); + } + } + } catch (e) { + popupAjaxError(e); + } + } + @action preloadLogin() { const prefillUsername = document.querySelector( diff --git a/app/assets/javascripts/discourse/app/components/modal/login/local-login-form.hbs b/app/assets/javascripts/discourse/app/components/modal/login/local-login-form.hbs index b0ba7ed72e6..6df03d904c9 100644 --- a/app/assets/javascripts/discourse/app/components/modal/login/local-login-form.hbs +++ b/app/assets/javascripts/discourse/app/components/modal/login/local-login-form.hbs @@ -1,12 +1,12 @@
-
+
this.dialog.alert(errorMessage) - ); - - const authResult = await ajax("/session/passkey/auth.json", { - type: "POST", - data: { publicKeyCredential }, - }); - - if (authResult && !authResult.error) { - window.location.reload(); - } else { - this.dialog.alert(authResult.error); - } - } catch (e) { - popupAjaxError(e); - } - } -