diff --git a/config/passkeys.php b/config/passkeys.php index 6fe55f327..74b4b6af4 100644 --- a/config/passkeys.php +++ b/config/passkeys.php @@ -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', ], /* diff --git a/resources/js/alpine/components/passkey-manage.js b/resources/js/alpine/components/passkey-manage.js index e180a3387..f08142aa6 100644 --- a/resources/js/alpine/components/passkey-manage.js +++ b/resources/js/alpine/components/passkey-manage.js @@ -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) { diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 6098c5437..867adab35 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -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; diff --git a/resources/views/partials/passkeys-manage.blade.php b/resources/views/partials/passkeys-manage.blade.php index 1926cec10..4284991a5 100644 --- a/resources/views/partials/passkeys-manage.blade.php +++ b/resources/views/partials/passkeys-manage.blade.php @@ -18,10 +18,10 @@ data-passkeys='@json($passkeyPayload)' >

- 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.

- 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.