From a03d62d6862659f61a54c2eabe3425eb48cf0358 Mon Sep 17 00:00:00 2001 From: Xifeng Fang Date: Wed, 10 Apr 2024 16:07:00 -0400 Subject: [PATCH] webauthn autofill UI spike --- src/v2/controllers/FormController.ts | 5 +- src/v2/view-builder/views/IdentifierView.js | 57 ++++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/v2/controllers/FormController.ts b/src/v2/controllers/FormController.ts index fb50c08f16..44c6edf082 100644 --- a/src/v2/controllers/FormController.ts +++ b/src/v2/controllers/FormController.ts @@ -187,7 +187,10 @@ export default Controller.extend({ } // Build options to invoke or throw error for invalid action - if (FORMS.LAUNCH_AUTHENTICATOR === actionPath && actionParams) { + //Passkey experience + const userHandle = (actionParams as any)?.credentials?.userHandle; + const isPasskey = FORMS.CHALLENGE_AUTHENTICATOR === actionPath && !_.isEmpty(userHandle); + if ((FORMS.LAUNCH_AUTHENTICATOR === actionPath || isPasskey) && actionParams) { //https://oktainc.atlassian.net/browse/OKTA-562885 a temp solution to send rememberMe when click the launch OV buttion. //will redesign to handle FastPass silent probing case where no username and rememberMe opiton at all. invokeOptions = { diff --git a/src/v2/view-builder/views/IdentifierView.js b/src/v2/view-builder/views/IdentifierView.js index a04baa8ab6..e9cd6916b1 100644 --- a/src/v2/view-builder/views/IdentifierView.js +++ b/src/v2/view-builder/views/IdentifierView.js @@ -1,6 +1,7 @@ -import { loc, createCallout } from '@okta/courage'; +import { _, loc, createCallout } from '@okta/courage'; import { FORMS as RemediationForms } from '../../ion/RemediationConstants'; import { BaseForm, BaseView, createIdpButtons, createCustomButtons } from '../internals'; +import CryptoUtil from '../../../util/CryptoUtil'; import DeviceFingerprinting from '../utils/DeviceFingerprinting'; import IdentifierFooter from '../components/IdentifierFooter'; import Link from '../components/Link'; @@ -13,6 +14,7 @@ import { getForgotPasswordLink } from '../utils/LinksUtil'; import CookieUtil from 'util/CookieUtil'; import CustomAccessDeniedErrorMessage from './shared/CustomAccessDeniedErrorMessage'; import Util from 'util/Util'; +import { getMessageFromBrowserError } from '../../ion/i18nTransformer'; const CUSTOM_ACCESS_DENIED_KEY = 'security.access_denied_custom_message'; @@ -115,6 +117,7 @@ const Body = BaseForm.extend({ // When a user enters invalid credentials, /introspect returns an error, // along with a user object containing the identifier entered by the user. this.$el.find('.identifier-container').remove(); + this.getWebauthnAutofillUICredentialsAndSave(); }, /** @@ -144,7 +147,7 @@ const Body = BaseForm.extend({ // because we want to allow the user to choose from previously used identifiers. newSchema = { ...newSchema, - autoComplete: Util.getAutocompleteValue(this.options.settings, 'username') + autoComplete: Util.getAutocompleteValue(this.options.settings, 'username') + this.options.appState.get('webauthnAutofillUIChallenge')?.challengeData ? ' webauthn' : '' }; } else if (schema.name === 'credentials.passcode') { newSchema = { @@ -245,6 +248,56 @@ const Body = BaseForm.extend({ if (cookieUsername) { this.model.set('identifier', cookieUsername); } + }, + + remove() { + BaseForm.prototype.remove.apply(this, arguments); + if (this.webauthnAbortController) { + this.webauthnAbortController.abort(); + this.webauthnAbortController = null; + } + }, + + getWebauthnAutofillUICredentialsAndSave() { + const challengeData = this.options.appState.get('webauthnAutofillUIChallenge')?.challengeData; + if (!challengeData) return; + const options = _.extend({}, challengeData, { + challenge: CryptoUtil.strToBin(challengeData.challenge), + }); + + if (typeof AbortController !== 'undefined') { + this.webauthnAbortController = new AbortController(); + } + + navigator.credentials.get({ + mediation: 'conditional', + publicKey: options, + signal: this.webauthnAbortController && this.webauthnAbortController.signal + }).then((assertion) => { + const userHandle = CryptoUtil.binToStr(assertion.response.userHandle ?? ''); + if (_.isEmpty(userHandle)) { + this.model.trigger('error', this.model, {responseJSON: {errorSummary: 'Invalid Passkey'}}); + return; + } + const credentials = { + clientData: CryptoUtil.binToStr(assertion.response.clientDataJSON), + authenticatorData: CryptoUtil.binToStr(assertion.response.authenticatorData), + signatureData: CryptoUtil.binToStr(assertion.response.signature), + userHandle + }; + + //TODO, is there a better way for this? + this.options.appState.trigger('invokeAction', RemediationForms.CHALLENGE_AUTHENTICATOR, {'credentials': credentials}); + }, (error) => { + // Do not display if it is abort error triggered by code when switching. + // this.webauthnAbortController would be null if abort was triggered by code. + if (this.webauthnAbortController) { + this.model.trigger('error', this.model, {responseJSON: {errorSummary: getMessageFromBrowserError(error)}}); + } + }).finally(() => { + // unset webauthnAbortController on successful authentication or error + this.webauthnAbortController = null; + }); } });