Update passkeys support

This commit is contained in:
DariusIII
2026-07-13 13:13:59 +02:00
parent 0eae1a7ca9
commit 2652ff04a1
4 changed files with 154 additions and 27 deletions
+115 -25
View File
@@ -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');
@@ -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';
+2
View File
@@ -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;
@@ -10,7 +10,7 @@
class="mt-6"
>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
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.
</p>
@if($message = session('authenticatePasskey::message'))
@@ -27,6 +27,21 @@
<input type="hidden" name="g-recaptcha-response" x-ref="recaptchaResponse" value="">
</form>
<div x-show="supportsAutofill" x-cloak class="mt-4">
<label for="passkey-autofill" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Username or email
</label>
<input
id="passkey-autofill"
x-ref="browserPasskeyInput"
type="text"
autocomplete="username webauthn"
inputmode="email"
class="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-3 text-sm text-gray-900 shadow-sm transition focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
placeholder="Choose a saved passkey"
>
</div>
<div class="mt-4 flex items-center">
<input
id="passkey-remember"