Skip to content

Commit a03d62d

Browse files
webauthn autofill UI spike
1 parent 030b2cd commit a03d62d

File tree

2 files changed

+59
-3
lines changed

2 files changed

+59
-3
lines changed

src/v2/controllers/FormController.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,10 @@ export default Controller.extend({
187187
}
188188

189189
// Build options to invoke or throw error for invalid action
190-
if (FORMS.LAUNCH_AUTHENTICATOR === actionPath && actionParams) {
190+
//Passkey experience
191+
const userHandle = (actionParams as any)?.credentials?.userHandle;
192+
const isPasskey = FORMS.CHALLENGE_AUTHENTICATOR === actionPath && !_.isEmpty(userHandle);
193+
if ((FORMS.LAUNCH_AUTHENTICATOR === actionPath || isPasskey) && actionParams) {
191194
//https://oktainc.atlassian.net/browse/OKTA-562885 a temp solution to send rememberMe when click the launch OV buttion.
192195
//will redesign to handle FastPass silent probing case where no username and rememberMe opiton at all.
193196
invokeOptions = {

src/v2/view-builder/views/IdentifierView.js

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { loc, createCallout } from '@okta/courage';
1+
import { _, loc, createCallout } from '@okta/courage';
22
import { FORMS as RemediationForms } from '../../ion/RemediationConstants';
33
import { BaseForm, BaseView, createIdpButtons, createCustomButtons } from '../internals';
4+
import CryptoUtil from '../../../util/CryptoUtil';
45
import DeviceFingerprinting from '../utils/DeviceFingerprinting';
56
import IdentifierFooter from '../components/IdentifierFooter';
67
import Link from '../components/Link';
@@ -13,6 +14,7 @@ import { getForgotPasswordLink } from '../utils/LinksUtil';
1314
import CookieUtil from 'util/CookieUtil';
1415
import CustomAccessDeniedErrorMessage from './shared/CustomAccessDeniedErrorMessage';
1516
import Util from 'util/Util';
17+
import { getMessageFromBrowserError } from '../../ion/i18nTransformer';
1618

1719
const CUSTOM_ACCESS_DENIED_KEY = 'security.access_denied_custom_message';
1820

@@ -115,6 +117,7 @@ const Body = BaseForm.extend({
115117
// When a user enters invalid credentials, /introspect returns an error,
116118
// along with a user object containing the identifier entered by the user.
117119
this.$el.find('.identifier-container').remove();
120+
this.getWebauthnAutofillUICredentialsAndSave();
118121
},
119122

120123
/**
@@ -144,7 +147,7 @@ const Body = BaseForm.extend({
144147
// because we want to allow the user to choose from previously used identifiers.
145148
newSchema = {
146149
...newSchema,
147-
autoComplete: Util.getAutocompleteValue(this.options.settings, 'username')
150+
autoComplete: Util.getAutocompleteValue(this.options.settings, 'username') + this.options.appState.get('webauthnAutofillUIChallenge')?.challengeData ? ' webauthn' : ''
148151
};
149152
} else if (schema.name === 'credentials.passcode') {
150153
newSchema = {
@@ -245,6 +248,56 @@ const Body = BaseForm.extend({
245248
if (cookieUsername) {
246249
this.model.set('identifier', cookieUsername);
247250
}
251+
},
252+
253+
remove() {
254+
BaseForm.prototype.remove.apply(this, arguments);
255+
if (this.webauthnAbortController) {
256+
this.webauthnAbortController.abort();
257+
this.webauthnAbortController = null;
258+
}
259+
},
260+
261+
getWebauthnAutofillUICredentialsAndSave() {
262+
const challengeData = this.options.appState.get('webauthnAutofillUIChallenge')?.challengeData;
263+
if (!challengeData) return;
264+
const options = _.extend({}, challengeData, {
265+
challenge: CryptoUtil.strToBin(challengeData.challenge),
266+
});
267+
268+
if (typeof AbortController !== 'undefined') {
269+
this.webauthnAbortController = new AbortController();
270+
}
271+
272+
navigator.credentials.get({
273+
mediation: 'conditional',
274+
publicKey: options,
275+
signal: this.webauthnAbortController && this.webauthnAbortController.signal
276+
}).then((assertion) => {
277+
const userHandle = CryptoUtil.binToStr(assertion.response.userHandle ?? '');
278+
if (_.isEmpty(userHandle)) {
279+
this.model.trigger('error', this.model, {responseJSON: {errorSummary: 'Invalid Passkey'}});
280+
return;
281+
}
282+
const credentials = {
283+
clientData: CryptoUtil.binToStr(assertion.response.clientDataJSON),
284+
authenticatorData: CryptoUtil.binToStr(assertion.response.authenticatorData),
285+
signatureData: CryptoUtil.binToStr(assertion.response.signature),
286+
userHandle
287+
};
288+
289+
//TODO, is there a better way for this?
290+
this.options.appState.trigger('invokeAction', RemediationForms.CHALLENGE_AUTHENTICATOR, {'credentials': credentials});
291+
}, (error) => {
292+
// Do not display if it is abort error triggered by code when switching.
293+
// this.webauthnAbortController would be null if abort was triggered by code.
294+
if (this.webauthnAbortController) {
295+
this.model.trigger('error', this.model, {responseJSON: {errorSummary: getMessageFromBrowserError(error)}});
296+
}
297+
}).finally(() => {
298+
// unset webauthnAbortController on successful authentication or error
299+
this.webauthnAbortController = null;
300+
});
248301
}
249302
});
250303

0 commit comments

Comments
 (0)