From 2652ff04a1d5c010e980af25c87370ea795480da Mon Sep 17 00:00:00 2001 From: DariusIII Date: Mon, 13 Jul 2026 13:13:59 +0200 Subject: [PATCH] Update passkeys support --- .../js/alpine/components/passkey-login.js | 140 ++++++++++++++---- .../js/alpine/components/passkey-manage.js | 22 ++- resources/js/bootstrap.js | 2 + .../partials/passkey-authenticate.blade.php | 17 ++- 4 files changed, 154 insertions(+), 27 deletions(-) diff --git a/resources/js/alpine/components/passkey-login.js b/resources/js/alpine/components/passkey-login.js index 4f5174624..10f121290 100644 --- a/resources/js/alpine/components/passkey-login.js +++ b/resources/js/alpine/components/passkey-login.js @@ -2,7 +2,10 @@ import Alpine from '@alpinejs/csp'; Alpine.data('passkeyLogin', () => ({ supported: false, + supportsAutofill: false, busy: false, + browserPasskeyPending: false, + browserPasskeyStarted: false, error: '', remember: false, showCreateHint: false, @@ -11,21 +14,35 @@ Alpine.data('passkeyLogin', () => ({ init() { this.supported = typeof window.browserSupportsWebAuthn === 'function' && window.browserSupportsWebAuthn(); - // If backend already reported an invalid passkey login attempt, // immediately show the "sign in first, then create passkey" guidance. this.showCreateHint = this.$el.dataset.serverPasskeyError === '1'; this.remember = this.$el.dataset.rememberDefault === '1'; const shouldAutoPrompt = this.$el.dataset.autoPrompt === '1'; + void this.detectAutofillSupport(shouldAutoPrompt); + }, + + async detectAutofillSupport(shouldAutoPrompt) { + this.supportsAutofill = typeof window.browserSupportsWebAuthnAutofill === 'function' + && this.supported + && await window.browserSupportsWebAuthnAutofill(); + if (this.supported && shouldAutoPrompt && !this.showCreateHint) { window.requestAnimationFrame(() => { - if (this.hasAutoPrompted || this.busy) { + if (this.hasAutoPrompted || this.busy || this.browserPasskeyStarted) { return; } this.hasAutoPrompted = true; - void this.authenticate(); + if (this.supportsAutofill) { + void this.startBrowserPasskeyFallback({ silent: true }); + return; + } + + if (shouldAutoPrompt) { + void this.authenticate(); + } }); } }, @@ -36,33 +53,22 @@ Alpine.data('passkeyLogin', () => ({ this.busy = true; try { - this.copyCaptchaResponse(); - - const rawOptionsUrl = this.$el.dataset.optionsUrl; - const optionsUrl = (!rawOptionsUrl || rawOptionsUrl === 'undefined') - ? '/passkeys/authentication-options' - : rawOptionsUrl; - const optionsResponse = await fetch(optionsUrl, { - credentials: 'same-origin', - headers: { - 'Accept': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - }); - - if (!optionsResponse.ok) { - throw new Error('Unable to load passkey authentication options.'); - } - - const optionsJson = await optionsResponse.json(); + const optionsJson = await this.loadAuthenticationOptions(); const startAuthenticationResponse = await window.startAuthentication({ optionsJSON: optionsJson, }); - this.$refs.remember.value = this.remember ? '1' : '0'; - this.$refs.response.value = JSON.stringify(startAuthenticationResponse); - document.getElementById('passkey-login-form')?.submit(); + this.submitAuthentication(startAuthenticationResponse); } catch (error) { + if (this.shouldOfferBrowserPasskeyFallback(error)) { + this.error = 'Select a saved browser or app passkey from the username field.'; + this.focusBrowserPasskeyInput(); + void this.startBrowserPasskeyFallback({ silent: true }); + this.busy = false; + + return; + } + this.error = error instanceof Error ? error.message : 'Passkey authentication failed.'; @@ -76,6 +82,90 @@ Alpine.data('passkeyLogin', () => ({ } }, + async startBrowserPasskeyFallback({ silent = false } = {}) { + if (!this.supported || !this.supportsAutofill || this.browserPasskeyStarted) { + return; + } + + this.browserPasskeyStarted = true; + this.browserPasskeyPending = true; + + if (!silent) { + this.error = ''; + } + + try { + const optionsJson = await this.loadAuthenticationOptions(); + const startAuthenticationResponse = await window.startAuthentication({ + optionsJSON: optionsJson, + useBrowserAutofill: true, + }); + + this.submitAuthentication(startAuthenticationResponse); + } catch (error) { + this.browserPasskeyStarted = false; + this.browserPasskeyPending = false; + + if (this.isCeremonyAbort(error)) { + return; + } + + if (!silent) { + this.error = error instanceof Error + ? error.message + : 'Browser passkey sign-in failed.'; + } + } + }, + + async loadAuthenticationOptions() { + this.copyCaptchaResponse(); + + const rawOptionsUrl = this.$el.dataset.optionsUrl; + const optionsUrl = (!rawOptionsUrl || rawOptionsUrl === 'undefined') + ? '/passkeys/authentication-options' + : rawOptionsUrl; + const optionsResponse = await fetch(optionsUrl, { + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (!optionsResponse.ok) { + throw new Error('Unable to load passkey authentication options.'); + } + + return optionsResponse.json(); + }, + + submitAuthentication(startAuthenticationResponse) { + this.$refs.remember.value = this.remember ? '1' : '0'; + this.$refs.response.value = JSON.stringify(startAuthenticationResponse); + document.getElementById('passkey-login-form')?.submit(); + }, + + shouldOfferBrowserPasskeyFallback(error) { + if (!this.supportsAutofill || this.browserPasskeyStarted) { + return false; + } + + return error instanceof Error + && ['NotAllowedError', 'InvalidStateError', 'SecurityError', 'UnknownError'].includes(error.name); + }, + + focusBrowserPasskeyInput() { + window.requestAnimationFrame(() => { + this.$refs.browserPasskeyInput?.focus(); + }); + }, + + isCeremonyAbort(error) { + return error instanceof Error + && (error.name === 'AbortError' || error.code === 'ERROR_CEREMONY_ABORTED'); + }, + copyCaptchaResponse() { const turnstileValue = this.getFieldValue('cf-turnstile-response'); const recaptchaValue = this.getFieldValue('g-recaptcha-response'); diff --git a/resources/js/alpine/components/passkey-manage.js b/resources/js/alpine/components/passkey-manage.js index bf1dc07bf..e180a3387 100644 --- a/resources/js/alpine/components/passkey-manage.js +++ b/resources/js/alpine/components/passkey-manage.js @@ -60,7 +60,7 @@ Alpine.data('passkeyManage', () => ({ }); const options = optionsResponse.data?.options; - const registration = await window.startRegistration({ optionsJSON: options }); + const registration = await this.startRegistrationWithFallback(options); const storeResponse = await window.axios.post(storeUrl, { name: this.name, @@ -118,6 +118,26 @@ Alpine.data('passkeyManage', () => ({ } }, + async startRegistrationWithFallback(options) { + try { + return await window.startRegistration({ optionsJSON: options }); + } catch (error) { + if (!this.shouldTryBrowserPasskeyRegistration(error)) { + throw error; + } + + return window.startRegistration({ + optionsJSON: options, + useAutoRegister: true, + }); + } + }, + + shouldTryBrowserPasskeyRegistration(error) { + return error instanceof Error + && ['NotAllowedError', 'ConstraintError', 'UnknownError'].includes(error.name); + }, + formatDate(value) { if (!value) { return 'Unknown'; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 3856182f6..6098c5437 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -1,6 +1,7 @@ import axios from 'axios'; import { browserSupportsWebAuthn, + browserSupportsWebAuthnAutofill, startAuthentication, startRegistration, } from '@simplewebauthn/browser'; @@ -9,5 +10,6 @@ window.axios = axios; window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; window.browserSupportsWebAuthn = browserSupportsWebAuthn; +window.browserSupportsWebAuthnAutofill = browserSupportsWebAuthnAutofill; window.startAuthentication = startAuthentication; window.startRegistration = startRegistration; diff --git a/resources/views/partials/passkey-authenticate.blade.php b/resources/views/partials/passkey-authenticate.blade.php index 19c3d1893..1baeb6bd4 100644 --- a/resources/views/partials/passkey-authenticate.blade.php +++ b/resources/views/partials/passkey-authenticate.blade.php @@ -10,7 +10,7 @@ class="mt-6" >

- On managed/company devices, platform passkeys may be unavailable due to policy. Use a FIDO2 security key if prompted. + Saved browser and password-manager passkeys can appear from the username field. Security keys are still supported.

@if($message = session('authenticatePasskey::message')) @@ -27,6 +27,21 @@ +
+ + +
+