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
+22 -2
View File
@@ -21,6 +21,7 @@ import { verifyJwtAndLoadUser } from '../middleware/auth';
import { User } from '../types';
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
import { avatarUrl } from './avatarUrl';
import { isPasskeyConfigured } from './webauthnConfig';
export { avatarUrl };
@@ -51,6 +52,7 @@ const ADMIN_SETTINGS_KEYS = [
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
'notify_trip_reminder',
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
'passkey_login', 'webauthn_rp_id', 'webauthn_origins',
];
const avatarDir = path.join(__dirname, '../../uploads/avatars');
@@ -128,10 +130,17 @@ export function resolveAuthToggles(): {
password_registration: boolean;
oidc_login: boolean;
oidc_registration: boolean;
passkey_login: boolean;
} {
const get = (key: string) =>
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value ?? null;
// Passkey login is independent of the password/OIDC "new keys" probe, so it
// must be resolved OUTSIDE the branch below — otherwise on a fresh install
// that never touched the password/OIDC toggles it would silently read false
// even after an admin enabled it. Default OFF (opt-in).
const passkey_login = get('passkey_login') === 'true';
const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration']
.some(k => get(k) !== null);
@@ -141,6 +150,7 @@ export function resolveAuthToggles(): {
password_registration: get('password_registration') !== 'false',
oidc_login: get('oidc_login') !== 'false',
oidc_registration: get('oidc_registration') !== 'false',
passkey_login,
};
if (process.env.OIDC_ONLY?.toLowerCase() === 'true') {
result.password_login = false;
@@ -163,6 +173,7 @@ export function resolveAuthToggles(): {
password_registration: !oidcOnly && allowReg,
oidc_login: true,
oidc_registration: allowReg,
passkey_login,
};
}
@@ -299,6 +310,12 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
password_registration: isDemo ? false : toggles.password_registration,
oidc_login: toggles.oidc_login,
oidc_registration: isDemo ? false : toggles.oidc_registration,
// Passkey login: the instance toggle + whether a usable RP ID resolves for
// this deployment. The login page shows the passkey button only when both
// are true. `passkey_configured` stays a pure boolean — it never leaks the
// resolved RP ID / origin / APP_URL on this unauthenticated endpoint.
passkey_login: toggles.passkey_login,
passkey_configured: isPasskeyConfigured(),
env_override_oidc_only: process.env.OIDC_ONLY === 'true',
has_users: userCount > 0,
setup_complete: setupComplete,
@@ -812,9 +829,12 @@ export function updateAppSettings(
const { require_mfa } = body;
if (require_mfa === true || require_mfa === 'true') {
const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined;
if (!(adminMfa?.mfa_enabled === 1)) {
// A user-verified passkey satisfies the MFA policy, so an admin who secured
// their own account with a passkey may enable it too (not only TOTP).
const adminHasPasskey = !!db.prepare('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1').get(userId);
if (!(adminMfa?.mfa_enabled === 1) && !adminHasPasskey) {
return {
error: 'Enable two-factor authentication on your own account before requiring it for all users.',
error: 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.',
status: 400,
};
}