Update passkeys support

This commit is contained in:
DariusIII
2026-07-13 13:34:50 +02:00
parent 2652ff04a1
commit 1a9a976fa5
4 changed files with 121 additions and 24 deletions
+4 -2
View File
@@ -69,14 +69,16 @@ return [
/*
* WebAuthn Level 3 client hints. Modern Chromium based browsers (including
* Edge on Windows) use these to render a richer credential chooser that
* lists Windows Hello, mobile (hybrid/QR) and security keys side by side.
* lists local browser/app passkeys and mobile (hybrid/QR) options first.
* Security keys are still allowed by WebAuthn, but are not the default hint
* because managed Windows devices can otherwise jump straight to the USB
* security-key prompt.
*
* Allowed values: "client-device", "hybrid", "security-key".
*/
'hints' => [
'client-device',
'hybrid',
'security-key',
],
/*
@@ -2,6 +2,8 @@ import Alpine from '@alpinejs/csp';
Alpine.data('passkeyManage', () => ({
supported: false,
platformAvailable: false,
platformChecked: false,
busy: false,
name: '',
error: '',
@@ -14,6 +16,7 @@ Alpine.data('passkeyManage', () => ({
init() {
this.supported = typeof window.browserSupportsWebAuthn === 'function'
&& window.browserSupportsWebAuthn();
void this.detectPlatformAuthenticator();
// Capture URLs from the root element's data attributes once, so we don't
// depend on $el inside event handlers (which may resolve to the triggering
@@ -41,7 +44,54 @@ Alpine.data('passkeyManage', () => ({
}
},
async detectPlatformAuthenticator() {
this.platformAvailable = typeof window.platformAuthenticatorIsAvailable === 'function'
&& await window.platformAuthenticatorIsAvailable();
this.platformChecked = true;
},
isOperaOnWindows() {
const userAgent = window.navigator?.userAgent || '';
const platform = window.navigator?.platform || '';
return (userAgent.includes('OPR/') || userAgent.includes('Opera'))
&& (userAgent.includes('Windows') || platform.startsWith('Win'));
},
async createPasskey() {
return this.createBrowserPasskey();
},
async createBrowserPasskey() {
if (this.browserPasskeyBlocked()) {
this.error = 'Opera on Windows is routing passkey creation through Windows Security and cannot create a browser/app passkey here. Use Edge/Chrome with Windows Hello or a password-manager passkey provider, or use the USB security key button.';
return;
}
return this.createPasskeyWithMode('browser');
},
browserPasskeyBlocked() {
return this.isOperaOnWindows();
},
browserPasskeyNotice() {
if (this.browserPasskeyBlocked()) {
return 'Opera on Windows routes browser/app passkey creation through Windows Security on this setup. Use Edge/Chrome with Windows Hello or a password-manager passkey provider, or use the USB security key button.';
}
if (this.platformChecked && !this.platformAvailable) {
return 'This browser did not report a built-in platform passkey provider. You can still try browser/app passkey creation; if it fails, enable Windows Hello or a password-manager passkey provider, or use the USB security key button.';
}
return '';
},
async createSecurityKeyPasskey() {
return this.createPasskeyWithMode('security-key');
},
async createPasskeyWithMode(mode) {
if (!this.supported) {
this.error = 'Your browser does not support passkeys.';
return;
@@ -60,7 +110,9 @@ Alpine.data('passkeyManage', () => ({
});
const options = optionsResponse.data?.options;
const registration = await this.startRegistrationWithFallback(options);
const registration = mode === 'security-key'
? await window.startRegistration({ optionsJSON: this.withSecurityKeyHint(options) })
: await this.startBrowserPasskeyRegistration(options);
const storeResponse = await window.axios.post(storeUrl, {
name: this.name,
@@ -118,24 +170,54 @@ 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,
});
}
async startBrowserPasskeyRegistration(options) {
return window.startRegistration({ optionsJSON: this.withBrowserPasskeyHints(options) });
},
shouldTryBrowserPasskeyRegistration(error) {
return error instanceof Error
&& ['NotAllowedError', 'ConstraintError', 'UnknownError'].includes(error.name);
withBrowserPasskeyHints(options) {
const normalized = this.cloneOptions(options);
normalized.hints = ['client-device'];
normalized.authenticatorSelection = {
...normalized.authenticatorSelection,
authenticatorAttachment: 'platform',
residentKey: normalized.authenticatorSelection?.residentKey || 'preferred',
userVerification: normalized.authenticatorSelection?.userVerification || 'preferred',
};
if (normalized.publicKey) {
normalized.publicKey.hints = ['client-device'];
normalized.publicKey.authenticatorSelection = {
...normalized.publicKey.authenticatorSelection,
authenticatorAttachment: 'platform',
residentKey: normalized.publicKey.authenticatorSelection?.residentKey || 'preferred',
userVerification: normalized.publicKey.authenticatorSelection?.userVerification || 'preferred',
};
}
return normalized;
},
withSecurityKeyHint(options) {
const normalized = this.cloneOptions(options);
normalized.hints = ['security-key', 'client-device', 'hybrid'];
normalized.authenticatorSelection = {
...normalized.authenticatorSelection,
authenticatorAttachment: 'cross-platform',
};
if (normalized.publicKey) {
normalized.publicKey.hints = ['security-key', 'client-device', 'hybrid'];
normalized.publicKey.authenticatorSelection = {
...normalized.publicKey.authenticatorSelection,
authenticatorAttachment: 'cross-platform',
};
}
return normalized;
},
cloneOptions(options) {
return JSON.parse(JSON.stringify(options));
},
formatDate(value) {
+2
View File
@@ -2,6 +2,7 @@ import axios from 'axios';
import {
browserSupportsWebAuthn,
browserSupportsWebAuthnAutofill,
platformAuthenticatorIsAvailable,
startAuthentication,
startRegistration,
} from '@simplewebauthn/browser';
@@ -11,5 +12,6 @@ window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.browserSupportsWebAuthn = browserSupportsWebAuthn;
window.browserSupportsWebAuthnAutofill = browserSupportsWebAuthnAutofill;
window.platformAuthenticatorIsAvailable = platformAuthenticatorIsAvailable;
window.startAuthentication = startAuthentication;
window.startRegistration = startRegistration;
@@ -18,10 +18,10 @@
data-passkeys='@json($passkeyPayload)'
>
<p class="text-sm text-gray-600 dark:text-gray-300">
Register a passkey using Windows Hello, Touch ID / Face ID, your phone, a password manager, or a FIDO2 security key.
Register a passkey using your browser, password manager, Windows Hello, Touch ID / Face ID, or your phone.
</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
On a Windows domain / managed device the browser will show all available options. If Windows Hello is still disabled by group policy, you can fall back to a hardware security key or scan a QR code with your phone.
Hardware security keys are available separately when you want to use a USB/FIDO2 key.
</p>
<template x-if="!supported">
@@ -32,7 +32,7 @@
<template x-if="supported">
<div class="mt-4 space-y-4">
<form @submit.prevent="createPasskey()" class="space-y-3">
<form @submit.prevent="createBrowserPasskey()" class="space-y-3">
<label for="passkey_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Passkey name
</label>
@@ -48,13 +48,24 @@
>
<button
type="submit"
:disabled="busy"
:disabled="busy || browserPasskeyBlocked()"
class="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-primary-700 dark:hover:bg-primary-800"
>
<i class="fas fa-plus mr-2"></i>
<span x-text="busy ? 'Creating...' : 'Create passkey'"></span>
<span x-text="busy ? 'Creating...' : 'Create browser/app passkey'"></span>
</button>
</div>
<p x-show="browserPasskeyNotice()" x-text="browserPasskeyNotice()" x-cloak class="text-xs text-yellow-700 dark:text-yellow-300">
</p>
<button
type="button"
@click="createSecurityKeyPasskey()"
:disabled="busy || !name"
class="inline-flex items-center rounded-lg border border-gray-300 px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
>
<i class="fas fa-key mr-2"></i>
Use USB security key
</button>
</form>
<p x-show="error" x-text="error" class="text-sm text-red-600 dark:text-red-400"></p>