feat: Passkey (WebAuthn) login (#1111)

* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle

Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers.

* feat(auth): passkey enrolment, login button + admin settings UI

PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action.

* i18n(auth): passkey strings across all locales

Add login/settings/admin passkey keys to en and all 19 translated locales.
This commit is contained in:
Maurice
2026-06-05 18:54:13 +02:00
committed by GitHub
parent 247433fb2a
commit a876fb2634
83 changed files with 2421 additions and 8 deletions
+30
View File
@@ -65,6 +65,13 @@ export function useAdmin() {
const [oidcConfigured, setOidcConfigured] = useState<boolean>(false)
const [requireMfa, setRequireMfa] = useState<boolean>(false)
// Passkey (WebAuthn) login
const [passkeyLogin, setPasskeyLogin] = useState<boolean>(false)
const [passkeyConfigured, setPasskeyConfigured] = useState<boolean>(false)
const [webauthnRpId, setWebauthnRpId] = useState<string>('')
const [webauthnOrigins, setWebauthnOrigins] = useState<string>('')
const [savingWebauthn, setSavingWebauthn] = useState<boolean>(false)
// Invite links
const [invites, setInvites] = useState<any[]>([])
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(false)
@@ -80,6 +87,8 @@ export function useAdmin() {
useEffect(() => {
apiClient.get('/auth/app-settings').then(r => {
setSmtpValues(r.data || {})
if (r.data?.webauthn_rp_id) setWebauthnRpId(r.data.webauthn_rp_id)
if (r.data?.webauthn_origins) setWebauthnOrigins(r.data.webauthn_origins)
setSmtpLoaded(true)
}).catch(() => setSmtpLoaded(true))
}, [])
@@ -141,6 +150,8 @@ export function useAdmin() {
setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false)
setOidcConfigured(config.oidc_configured ?? false)
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
setPasskeyLogin(!!config.passkey_login)
setPasskeyConfigured(!!config.passkey_configured)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err: unknown) {
// ignore
@@ -179,6 +190,23 @@ export function useAdmin() {
}
}
const handleSaveWebauthn = async () => {
setSavingWebauthn(true)
try {
await authApi.updateAppSettings({
webauthn_rp_id: webauthnRpId.trim(),
webauthn_origins: webauthnOrigins.trim(),
})
// Re-read app-config so passkey_configured reflects the new RP ID.
await loadAppConfig()
toast.success(t('common.saved'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setSavingWebauthn(false)
}
}
const toggleKey = (key) => {
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
}
@@ -341,6 +369,8 @@ export function useAdmin() {
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured,
requireMfa, setRequireMfa,
passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
smtpValues, setSmtpValues, smtpLoaded,