mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: move totp and passkeys into plugins (C1)
This commit is contained in:
1 parent
f4f26a9b8b
commit
5c18435105
235 files changed
+22545
-5610
No files matched your search
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"title": "Passkeys",
|
||||
"signIn": "Sign in with passkey",
|
||||
"loginFailed": "Passkey login failed",
|
||||
"description": "Use WebAuthn/FIDO2 credentials to sign in without a password",
|
||||
"namePlaceholder": "Passkey name",
|
||||
"defaultName": "Passkey",
|
||||
"uv": {
|
||||
"preferred": "Preferred",
|
||||
"required": "Required",
|
||||
"discouraged": "Discouraged"
|
||||
},
|
||||
"add": "Add Passkey",
|
||||
"none": "No passkeys registered.",
|
||||
"added": "Passkey added",
|
||||
"addFailed": "Failed to add passkey",
|
||||
"delete": "Delete passkey",
|
||||
"deleted": "Passkey deleted",
|
||||
"deleteFailed": "Failed to delete passkey",
|
||||
"unknownDevice": "unknown",
|
||||
"synced": "synced"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Wagwoorde",
|
||||
"signIn": "Teken aan met wagwoord",
|
||||
"loginFailed": "Wagwoord-aanmelding het misluk",
|
||||
"description": "Gebruik WebAuthn/FIDO2-bewyse om aan te meld sonder 'n wagwoord",
|
||||
"namePlaceholder": "Wagwoordnaam",
|
||||
"uv": {
|
||||
"preferred": "Verkies",
|
||||
"required": "Vereis",
|
||||
"discouraged": "Ontmoedig"
|
||||
},
|
||||
"add": "Voeg wagwoord by",
|
||||
"none": "Geen wagwoorde geregistreer nie.",
|
||||
"added": "Wagwoord bygevoeg",
|
||||
"addFailed": "Kon nie wagwoord byvoeg nie",
|
||||
"deleted": "Wagwoord verwyder",
|
||||
"deleteFailed": "Kon nie wagwoord verwyder nie"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "مفاتيح المرور",
|
||||
"signIn": "تسجيل الدخول باستخدام مفتاح المرور",
|
||||
"loginFailed": "فشل تسجيل الدخول بمفتاح المرور",
|
||||
"description": "استخدم بيانات اعتماد WebAuthn/FIDO2 لتسجيل الدخول بدون كلمة مرور",
|
||||
"namePlaceholder": "اسم مفتاح المرور",
|
||||
"uv": {
|
||||
"preferred": "مفضل",
|
||||
"required": "مطلوب",
|
||||
"discouraged": "غير مشجع"
|
||||
},
|
||||
"add": "إضافة مفتاح مرور",
|
||||
"none": "لا توجد مفاتيح مرور مسجلة.",
|
||||
"added": "تمت إضافة مفتاح المرور",
|
||||
"addFailed": "فشل إضافة مفتاح المرور",
|
||||
"deleted": "تم حذف مفتاح المرور",
|
||||
"deleteFailed": "فشل حذف مفتاح المرور"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Пароли",
|
||||
"signIn": "Влезте с парола",
|
||||
"loginFailed": "Влизането с парола не бе успешно",
|
||||
"description": "Използвайте WebAuthn/FIDO2 идентификационни данни за влизане без парола",
|
||||
"namePlaceholder": "Име на паролата",
|
||||
"uv": {
|
||||
"preferred": "Предпочитан",
|
||||
"required": "Задължително",
|
||||
"discouraged": "Обезкуражен"
|
||||
},
|
||||
"add": "Добавяне на парола",
|
||||
"none": "Няма регистрирани пароли.",
|
||||
"added": "Добавен е ключ за достъп",
|
||||
"addFailed": "Добавянето на ключ за достъп не бе успешно",
|
||||
"deleted": "Ключът за достъп е изтрит",
|
||||
"deleteFailed": "Изтриването на ключа за достъп не бе успешно"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "পাসকি",
|
||||
"signIn": "পাসকি দিয়ে সাইন ইন করুন",
|
||||
"loginFailed": "পাসকি লগইন ব্যর্থ হয়েছে",
|
||||
"description": "পাসওয়ার্ড ছাড়া সাইন ইন করতে WebAuthn/FIDO2 ক্রেডেনশিয়াল ব্যবহার করুন।",
|
||||
"namePlaceholder": "পাসকি নাম",
|
||||
"uv": {
|
||||
"preferred": "পছন্দের",
|
||||
"required": "প্রয়োজনীয়",
|
||||
"discouraged": "হতাশ"
|
||||
},
|
||||
"add": "পাসকি যোগ করুন",
|
||||
"none": "কোনো পাসকি নিবন্ধিত নেই।",
|
||||
"added": "পাসকি যোগ করা হয়েছে",
|
||||
"addFailed": "পাসকি যোগ করতে ব্যর্থ হয়েছে",
|
||||
"deleted": "পাসকি মুছে ফেলা হয়েছে",
|
||||
"deleteFailed": "পাসকি মুছে ফেলতে ব্যর্থ হয়েছে"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Claus d'accés",
|
||||
"signIn": "Inicia la sessió amb la clau d'accés",
|
||||
"loginFailed": "Error d'inici de sessió amb contrasenya",
|
||||
"description": "Utilitzeu les credencials de WebAuthn/FIDO2 per iniciar la sessió sense contrasenya",
|
||||
"namePlaceholder": "Nom de la clau d'accés",
|
||||
"uv": {
|
||||
"preferred": "Preferit",
|
||||
"required": "Obligatori",
|
||||
"discouraged": "Desanimat"
|
||||
},
|
||||
"add": "Afegeix una contrasenya",
|
||||
"none": "No hi ha cap clau d'accés registrada.",
|
||||
"added": "Clau d'accés afegida",
|
||||
"addFailed": "No s'ha pogut afegir la contrasenya",
|
||||
"deleted": "Clau d'accés suprimida",
|
||||
"deleteFailed": "No s'ha pogut suprimir la clau d'accés"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Přístupové klíče",
|
||||
"signIn": "Přihlásit se pomocí hesla",
|
||||
"loginFailed": "Přihlášení k heslu se nezdařilo",
|
||||
"description": "Pro přihlášení bez hesla použijte přihlašovací údaje WebAuthn/FIDO2",
|
||||
"namePlaceholder": "Název přístupového klíče",
|
||||
"uv": {
|
||||
"preferred": "Preferované",
|
||||
"required": "Požadovaný",
|
||||
"discouraged": "Odradit"
|
||||
},
|
||||
"add": "Přidat přístupový klíč",
|
||||
"none": "Nejsou registrovány žádné přístupové klíče.",
|
||||
"added": "Přístupový klíč byl přidán",
|
||||
"addFailed": "Nepodařilo se přidat přístupový klíč",
|
||||
"deleted": "Přístupový klíč smazán",
|
||||
"deleteFailed": "Nepodařilo se smazat přístupový klíč"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Adgangsnøgler",
|
||||
"signIn": "Log ind med adgangsnøgle",
|
||||
"loginFailed": "Login med adgangsnøgle mislykkedes",
|
||||
"description": "Brug WebAuthn/FIDO2-legitimationsoplysninger til at logge ind uden en adgangskode",
|
||||
"namePlaceholder": "Adgangsnøglenavn",
|
||||
"uv": {
|
||||
"preferred": "Foretrukket",
|
||||
"required": "Påkrævet",
|
||||
"discouraged": "Modløs"
|
||||
},
|
||||
"add": "Tilføj adgangsnøgle",
|
||||
"none": "Ingen adgangsnøgler registreret.",
|
||||
"added": "Adgangsnøgle tilføjet",
|
||||
"addFailed": "Kunne ikke tilføje adgangsnøgle",
|
||||
"deleted": "Adgangsnøgle slettet",
|
||||
"deleteFailed": "Kunne ikke slette adgangsnøglen"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Passkeys",
|
||||
"signIn": "Mit Passkey anmelden",
|
||||
"loginFailed": "Passkey-Anmeldung fehlgeschlagen",
|
||||
"description": "WebAuthn/FIDO2-Anmeldedaten für eine Anmeldung ohne Passwort verwenden",
|
||||
"namePlaceholder": "Passkey-Name",
|
||||
"uv": {
|
||||
"preferred": "Bevorzugt",
|
||||
"required": "Erforderlich",
|
||||
"discouraged": "Nicht empfohlen"
|
||||
},
|
||||
"add": "Passkey hinzufügen",
|
||||
"none": "Keine Passkeys registriert.",
|
||||
"added": "Passkey hinzugefügt",
|
||||
"addFailed": "Fehler beim Hinzufügen des Passkeys",
|
||||
"deleted": "Passkey gelöscht",
|
||||
"deleteFailed": "Löschen des Passkeys fehlgeschlagen"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Κλειδιά πρόσβασης",
|
||||
"signIn": "Σύνδεση με κλειδί πρόσβασης",
|
||||
"loginFailed": "Η σύνδεση με κωδικό πρόσβασης απέτυχε",
|
||||
"description": "Χρησιμοποιήστε διαπιστευτήρια WebAuthn/FIDO2 για να συνδεθείτε χωρίς κωδικό πρόσβασης",
|
||||
"namePlaceholder": "Όνομα κλειδιού πρόσβασης",
|
||||
"uv": {
|
||||
"preferred": "Προνομιούχος",
|
||||
"required": "Υποχρεούμαι",
|
||||
"discouraged": "Αποθαρρυμένος"
|
||||
},
|
||||
"add": "Προσθήκη κλειδιού πρόσβασης",
|
||||
"none": "Δεν έχουν καταχωρηθεί κλειδιά πρόσβασης.",
|
||||
"added": "Προστέθηκε κλειδί πρόσβασης",
|
||||
"addFailed": "Αποτυχία προσθήκης κλειδιού πρόσβασης",
|
||||
"deleted": "Ο κωδικός πρόσβασης διαγράφηκε",
|
||||
"deleteFailed": "Η διαγραφή του κλειδιού πρόσβασης απέτυχε"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Passkeys",
|
||||
"signIn": "Iniciar sesión con clave de acceso",
|
||||
"loginFailed": "Error al iniciar sesión con clave de acceso",
|
||||
"description": "Usa credenciales WebAuthn/FIDO2 para iniciar sesión sin contraseña",
|
||||
"namePlaceholder": "Nombre de la passkey",
|
||||
"uv": {
|
||||
"preferred": "Preferida",
|
||||
"required": "Requerida",
|
||||
"discouraged": "Desaconsejada"
|
||||
},
|
||||
"add": "Añadir passkey",
|
||||
"none": "No hay passkeys registradas.",
|
||||
"added": "Passkey añadida",
|
||||
"addFailed": "No se pudo añadir la passkey",
|
||||
"deleted": "Passkey eliminada",
|
||||
"deleteFailed": "Error al eliminar la clave de acceso"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Salasanat",
|
||||
"signIn": "Kirjaudu sisään salasanalla",
|
||||
"loginFailed": "Salasanan kirjautuminen epäonnistui",
|
||||
"description": "Kirjaudu sisään ilman salasanaa WebAuthn/FIDO2-tunnuksilla",
|
||||
"namePlaceholder": "Salasanan nimi",
|
||||
"uv": {
|
||||
"preferred": "Ensisijainen",
|
||||
"required": "Pakollinen",
|
||||
"discouraged": "Lannistunut"
|
||||
},
|
||||
"add": "Lisää salasana",
|
||||
"none": "Ei rekisteröityjä salasanoja.",
|
||||
"added": "Salasana lisätty",
|
||||
"addFailed": "Salasanan lisääminen epäonnistui",
|
||||
"deleted": "Salasana poistettu",
|
||||
"deleteFailed": "Salasanan poistaminen epäonnistui"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Clés d’accès",
|
||||
"signIn": "Se connecter avec une clé d'accès",
|
||||
"loginFailed": "Échec de la connexion par clé d'accès",
|
||||
"description": "Utilisez des identifiants WebAuthn/FIDO2 pour vous connecter sans mot de passe",
|
||||
"namePlaceholder": "Nom de la clé d’accès",
|
||||
"uv": {
|
||||
"preferred": "Préféré",
|
||||
"required": "Requis",
|
||||
"discouraged": "Déconseillé"
|
||||
},
|
||||
"add": "Ajouter une clé d’accès",
|
||||
"none": "Aucune clé d’accès enregistrée.",
|
||||
"added": "Clé d’accès ajoutée",
|
||||
"addFailed": "Échec de l’ajout de la clé d’accès",
|
||||
"deleted": "Clé d’accès supprimée",
|
||||
"deleteFailed": "Échec de la suppression de la clé d'accès"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "מפתחות סיסמה",
|
||||
"signIn": "כניסה עם סיסמה",
|
||||
"loginFailed": "כניסה עם סיסמה נכשלה",
|
||||
"description": "השתמש בפרטי הכניסה של WebAuthn/FIDO2 ללא סיסמה",
|
||||
"namePlaceholder": "שם המפתח",
|
||||
"uv": {
|
||||
"preferred": "מועדף",
|
||||
"required": "דָרוּשׁ",
|
||||
"discouraged": "מיואש"
|
||||
},
|
||||
"add": "הוסף סיסמה",
|
||||
"none": "אין סיסמות רשומות.",
|
||||
"added": "סיסמה נוספת",
|
||||
"addFailed": "נכשלה הוספת סיסמה",
|
||||
"deleted": "סיסמת הגישה נמחקה",
|
||||
"deleteFailed": "מחיקת המפתח נכשלה"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "पासकीज़",
|
||||
"signIn": "पासकी से साइन इन करें",
|
||||
"loginFailed": "पासकी लॉगिन विफल",
|
||||
"description": "बिना पासवर्ड साइन इन करने के लिए WebAuthn/FIDO2 क्रेडेंशियल का उपयोग करें",
|
||||
"namePlaceholder": "पासकी नाम",
|
||||
"uv": {
|
||||
"preferred": "पसंदीदा",
|
||||
"required": "आवश्यक",
|
||||
"discouraged": "हतोत्साहित"
|
||||
},
|
||||
"add": "पासकी जोड़ें",
|
||||
"none": "कोई पासकी पंजीकृत नहीं है।",
|
||||
"added": "पासकी जोड़ी गई",
|
||||
"addFailed": "पासकी जोड़ने में विफल",
|
||||
"deleted": "पासकी हटाई गई",
|
||||
"deleteFailed": "पासकी हटाने में विफल"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Jelszavak",
|
||||
"signIn": "Bejelentkezés jelszóval",
|
||||
"loginFailed": "Jelszóval történő bejelentkezés sikertelen",
|
||||
"description": "Jelszó nélküli bejelentkezéshez használja a WebAuthn/FIDO2 hitelesítő adatokat",
|
||||
"namePlaceholder": "Jelszó neve",
|
||||
"uv": {
|
||||
"preferred": "Előnyben részesített",
|
||||
"required": "Kívánt",
|
||||
"discouraged": "Elbátortalanodva"
|
||||
},
|
||||
"add": "Jelszó hozzáadása",
|
||||
"none": "Nincsenek regisztrált jelszók.",
|
||||
"added": "Hozzáadott jelszó",
|
||||
"addFailed": "Nem sikerült hozzáadni a jelszót",
|
||||
"deleted": "Jelszó törölve",
|
||||
"deleteFailed": "Nem sikerült törölni a jelszót"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Kode akses",
|
||||
"signIn": "Masuk dengan kata sandi",
|
||||
"loginFailed": "Login dengan kata sandi gagal.",
|
||||
"description": "Gunakan kredensial WebAuthn/FIDO2 untuk masuk tanpa kata sandi.",
|
||||
"namePlaceholder": "Nama kata sandi",
|
||||
"uv": {
|
||||
"preferred": "Lebih disukai",
|
||||
"required": "Diperlukan",
|
||||
"discouraged": "Berkecil hati"
|
||||
},
|
||||
"add": "Tambahkan Kata Sandi",
|
||||
"none": "Tidak ada kata sandi yang terdaftar.",
|
||||
"added": "Kata sandi telah ditambahkan",
|
||||
"addFailed": "Gagal menambahkan kata sandi.",
|
||||
"deleted": "Kata sandi telah dihapus.",
|
||||
"deleteFailed": "Gagal menghapus kata sandi."
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Passkeys",
|
||||
"signIn": "Accedi con passkey",
|
||||
"loginFailed": "Accesso con passkey non riuscito",
|
||||
"description": "Utilizza credenziali WebAuthn/FIDO2 per accedere senza password",
|
||||
"namePlaceholder": "Nome passkey",
|
||||
"uv": {
|
||||
"preferred": "Preferita",
|
||||
"required": "Obbligatoria",
|
||||
"discouraged": "Sconsigliata"
|
||||
},
|
||||
"add": "Aggiungi passkey",
|
||||
"none": "Nessuna passkey registrata.",
|
||||
"added": "Passkey aggiunta",
|
||||
"addFailed": "Impossibile aggiungere la passkey",
|
||||
"deleted": "Passkey eliminata",
|
||||
"deleteFailed": "Impossibile eliminare la passkey"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "パスキー",
|
||||
"signIn": "パスキーでサインイン",
|
||||
"loginFailed": "パスキーログインに失敗しました",
|
||||
"description": "WebAuthn/FIDO2認証情報を使用してパスワードなしでサインインできます",
|
||||
"namePlaceholder": "パスキー名",
|
||||
"uv": {
|
||||
"preferred": "優先",
|
||||
"required": "必須",
|
||||
"discouraged": "非推奨"
|
||||
},
|
||||
"add": "パスキーを追加",
|
||||
"none": "登録されたパスキーはありません。",
|
||||
"added": "パスキーが追加されました",
|
||||
"addFailed": "パスキーの追加に失敗しました",
|
||||
"deleted": "パスキーが削除されました",
|
||||
"deleteFailed": "パスキーの削除に失敗しました"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "패스키",
|
||||
"signIn": "Passkey로 로그인",
|
||||
"loginFailed": "패스키 로그인 실패",
|
||||
"description": "WebAuthn/FIDO2 자격 증명을 사용하여 비밀번호 없이 로그인하세요.",
|
||||
"namePlaceholder": "패스키 이름",
|
||||
"uv": {
|
||||
"preferred": "선호",
|
||||
"required": "필수",
|
||||
"discouraged": "권장 안 함"
|
||||
},
|
||||
"add": "패스키 추가",
|
||||
"none": "등록된 패스키가 없습니다.",
|
||||
"added": "패스키가 추가되었습니다.",
|
||||
"addFailed": "패스키 추가 실패",
|
||||
"deleted": "패스키가 삭제되었습니다.",
|
||||
"deleteFailed": "패스키 삭제 실패"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Wachtwoorden",
|
||||
"signIn": "Aanmelden met wachtwoord",
|
||||
"loginFailed": "Aanmelden met wachtwoord mislukt",
|
||||
"description": "Gebruik WebAuthn/FIDO2-gegevens om zonder wachtwoord in te loggen.",
|
||||
"namePlaceholder": "Wachtwoordnaam",
|
||||
"uv": {
|
||||
"preferred": "Voorkeur",
|
||||
"required": "Vereist",
|
||||
"discouraged": "Ontmoedigd"
|
||||
},
|
||||
"add": "Voeg wachtwoord toe",
|
||||
"none": "Er zijn geen wachtwoorden geregistreerd.",
|
||||
"added": "Wachtwoord toegevoegd",
|
||||
"addFailed": "Wachtwoord toevoegen mislukt",
|
||||
"deleted": "Wachtwoord verwijderd",
|
||||
"deleteFailed": "Het verwijderen van de toegangscode is mislukt."
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Passord",
|
||||
"signIn": "Logg inn med passord",
|
||||
"loginFailed": "Innlogging med passord mislyktes",
|
||||
"description": "Bruk WebAuthn/FIDO2-legitimasjon for å logge på uten passord",
|
||||
"namePlaceholder": "Navn på passord",
|
||||
"uv": {
|
||||
"preferred": "Foretrukket",
|
||||
"required": "Obligatorisk",
|
||||
"discouraged": "Motløs"
|
||||
},
|
||||
"add": "Legg til tilgangsnøkkel",
|
||||
"none": "Ingen passord registrert.",
|
||||
"added": "Tilgangsnøkkel lagt til",
|
||||
"addFailed": "Kunne ikke legge til passord",
|
||||
"deleted": "Tilgangsnøkkel slettet",
|
||||
"deleteFailed": "Kunne ikke slette passordet"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Klucze dostępu",
|
||||
"signIn": "Zaloguj się za pomocą klucza dostępu",
|
||||
"loginFailed": "Nie udało się zalogować za pomocą klucza dostępu",
|
||||
"description": "Używaj poświadczeń WebAuthn/FIDO2, aby logować się bez hasła",
|
||||
"namePlaceholder": "Nazwa klucza dostępu",
|
||||
"uv": {
|
||||
"preferred": "Preferowane",
|
||||
"required": "Wymagane",
|
||||
"discouraged": "Odradzane"
|
||||
},
|
||||
"add": "Dodaj klucz dostępu",
|
||||
"none": "Nie zarejestrowano żadnych kluczy dostępu.",
|
||||
"added": "Dodano klucz dostępu",
|
||||
"addFailed": "Nie udało się dodać klucza dostępu",
|
||||
"deleted": "Usunięto klucz dostępu",
|
||||
"deleteFailed": "Nie udało się usunąć klucza dostępu"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Senhas",
|
||||
"signIn": "Faça login com a chave de acesso.",
|
||||
"loginFailed": "Falha no login com a chave de acesso",
|
||||
"description": "Use as credenciais WebAuthn/FIDO2 para fazer login sem senha.",
|
||||
"namePlaceholder": "Nome da chave de acesso",
|
||||
"uv": {
|
||||
"preferred": "Preferido",
|
||||
"required": "Obrigatório",
|
||||
"discouraged": "Desencorajado"
|
||||
},
|
||||
"add": "Adicionar chave de acesso",
|
||||
"none": "Nenhuma chave de acesso registrada.",
|
||||
"added": "Senha adicionada",
|
||||
"addFailed": "Falha ao adicionar a chave de acesso.",
|
||||
"deleted": "Senha excluída",
|
||||
"deleteFailed": "Falha ao excluir a chave de acesso."
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Passkeys",
|
||||
"signIn": "Iniciar sessão com passkey",
|
||||
"loginFailed": "Falha no início de sessão com passkey",
|
||||
"description": "Utilize credenciais WebAuthn/FIDO2 para iniciar sessão sem palavra-passe",
|
||||
"namePlaceholder": "Nome da passkey",
|
||||
"uv": {
|
||||
"preferred": "Preferencial",
|
||||
"required": "Obrigatório",
|
||||
"discouraged": "Desaconselhado"
|
||||
},
|
||||
"add": "Adicionar Passkey",
|
||||
"none": "Nenhuma passkey registada.",
|
||||
"added": "Passkey adicionada",
|
||||
"addFailed": "Falha ao adicionar passkey",
|
||||
"deleted": "Passkey eliminada",
|
||||
"deleteFailed": "Falha ao eliminar chave de acesso"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Chei de acces",
|
||||
"signIn": "Conectați-vă cu cheia de acces",
|
||||
"loginFailed": "Conectarea cu parola a eșuat",
|
||||
"description": "Folosește acreditările WebAuthn/FIDO2 pentru a te conecta fără parolă",
|
||||
"namePlaceholder": "Numele cheii de acces",
|
||||
"uv": {
|
||||
"preferred": "Preferat",
|
||||
"required": "Necesar",
|
||||
"discouraged": "Descurajat"
|
||||
},
|
||||
"add": "Adăugați o cheie de acces",
|
||||
"none": "Nicio cheie de acces înregistrată.",
|
||||
"added": "Cheie de acces adăugată",
|
||||
"addFailed": "Nu s-a putut adăuga parola",
|
||||
"deleted": "Cheia de acces a fost ștearsă",
|
||||
"deleteFailed": "Nu s-a putut șterge parola"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Ключи доступа",
|
||||
"signIn": "Войти с ключом доступа",
|
||||
"loginFailed": "Вход по ключу не удался",
|
||||
"description": "Используйте учётные данные WebAuthn/FIDO2 для входа без пароля",
|
||||
"namePlaceholder": "Название ключа доступа",
|
||||
"uv": {
|
||||
"preferred": "Предпочтительно",
|
||||
"required": "Обязательно",
|
||||
"discouraged": "Не рекомендуется"
|
||||
},
|
||||
"add": "Добавить ключ доступа",
|
||||
"none": "Нет зарегистрированных ключей доступа.",
|
||||
"added": "Ключ доступа добавлен",
|
||||
"addFailed": "Не удалось добавить ключ доступа",
|
||||
"deleted": "Ключ доступа удалён",
|
||||
"deleteFailed": "Ошибка удаления ключа доступа"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Лозинке",
|
||||
"signIn": "Пријавите се помоћу лозинке",
|
||||
"loginFailed": "Пријава лозинком није успела",
|
||||
"description": "Користите WebAuthn/FIDO2 акредитиве за пријаву без лозинке",
|
||||
"namePlaceholder": "Назив лозинке",
|
||||
"uv": {
|
||||
"preferred": "Преферирано",
|
||||
"required": "Обавезно",
|
||||
"discouraged": "Обесхрабрено"
|
||||
},
|
||||
"add": "Додај лозинку",
|
||||
"none": "Није регистрована ниједна лозинка.",
|
||||
"added": "Лозинка је додата",
|
||||
"addFailed": "Додавање лозинке није успело",
|
||||
"deleted": "Лозинка је обрисана",
|
||||
"deleteFailed": "Брисање лозинке није успело"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Lösenord",
|
||||
"signIn": "Logga in med lösenord",
|
||||
"loginFailed": "Inloggning med lösenordsnyckel misslyckades",
|
||||
"description": "Använd WebAuthn/FIDO2-inloggningsuppgifter för att logga in utan lösenord",
|
||||
"namePlaceholder": "Lösenordsnamn",
|
||||
"uv": {
|
||||
"preferred": "Föredragen",
|
||||
"required": "Nödvändig",
|
||||
"discouraged": "Avskräckt"
|
||||
},
|
||||
"add": "Lägg till lösenord",
|
||||
"none": "Inga lösennycklar registrerade.",
|
||||
"added": "Lösenord tillagt",
|
||||
"addFailed": "Misslyckades med att lägga till lösenord",
|
||||
"deleted": "Lösenordet raderat",
|
||||
"deleteFailed": "Misslyckades med att ta bort lösenordet"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "รหัสผ่าน",
|
||||
"signIn": "ลงชื่อเข้าใช้ด้วยรหัสผ่าน",
|
||||
"loginFailed": "การเข้าสู่ระบบด้วยรหัสผ่านล้มเหลว",
|
||||
"description": "ใช้ข้อมูลรับรอง WebAuthn/FIDO2 เพื่อเข้าสู่ระบบโดยไม่ต้องใช้รหัสผ่าน",
|
||||
"namePlaceholder": "ชื่อรหัสผ่าน",
|
||||
"uv": {
|
||||
"preferred": "ที่ต้องการ",
|
||||
"required": "ที่จำเป็น",
|
||||
"discouraged": "ท้อใจ"
|
||||
},
|
||||
"add": "เพิ่มรหัสผ่าน",
|
||||
"none": "ไม่มีการลงทะเบียนรหัสผ่านใดๆ",
|
||||
"added": "เพิ่มรหัสผ่านแล้ว",
|
||||
"addFailed": "ไม่สามารถเพิ่มรหัสผ่านได้",
|
||||
"deleted": "รหัสผ่านถูกลบแล้ว",
|
||||
"deleteFailed": "ไม่สามารถลบรหัสผ่านได้"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Geçiş Anahtarları",
|
||||
"signIn": "passkey ile oturum aç",
|
||||
"loginFailed": "Passkey girişi başarısız oldu",
|
||||
"description": "Parola olmadan oturum açmak için WebAuthn/FIDO2 kimlik bilgilerini kullanın",
|
||||
"namePlaceholder": "Geçiş anahtarı adı",
|
||||
"uv": {
|
||||
"preferred": "Tercih edilen",
|
||||
"required": "Gerekli",
|
||||
"discouraged": "Önerilmeyen"
|
||||
},
|
||||
"add": "Geçiş Anahtarı Ekle",
|
||||
"none": "Kayıtlı geçiş anahtarı yok.",
|
||||
"added": "Geçiş anahtarı eklendi",
|
||||
"addFailed": "Geçiş anahtarı eklenemedi",
|
||||
"deleted": "Geçiş anahtarı silindi",
|
||||
"deleteFailed": "Geçiş anahtarı silinemedi"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Ключі доступу",
|
||||
"signIn": "Увійти за допомогою ключа доступу",
|
||||
"loginFailed": "Не вдалося ввійти з ключем доступу",
|
||||
"description": "Використовуйте облікові дані WebAuthn/FIDO2 для входу без пароля",
|
||||
"namePlaceholder": "Ім'я ключа доступу",
|
||||
"uv": {
|
||||
"preferred": "Бажаний",
|
||||
"required": "Обов'язково",
|
||||
"discouraged": "Знеохочений"
|
||||
},
|
||||
"add": "Додати пароль",
|
||||
"none": "Не зареєстровано ключів доступу.",
|
||||
"added": "Ключ доступу додано",
|
||||
"addFailed": "Не вдалося додати ключ доступу",
|
||||
"deleted": "Ключ доступу видалено",
|
||||
"deleteFailed": "Не вдалося видалити ключ доступу"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Passkeys",
|
||||
"signIn": "Đăng nhập bằng khóa truy cập",
|
||||
"loginFailed": "Đăng nhập bằng passkey thất bại",
|
||||
"description": "Sử dụng thông tin đăng nhập WebAuthn/FIDO2 để đăng nhập không cần mật khẩu",
|
||||
"namePlaceholder": "Tên passkey",
|
||||
"uv": {
|
||||
"preferred": "Ưu tiên",
|
||||
"required": "Bắt buộc",
|
||||
"discouraged": "Không khuyến khích"
|
||||
},
|
||||
"add": "Thêm Passkey",
|
||||
"none": "Chưa đăng ký passkey nào.",
|
||||
"added": "Đã thêm Passkey",
|
||||
"addFailed": "Không thêm được Passkey",
|
||||
"deleted": "Đã xóa Passkey",
|
||||
"deleteFailed": "Xóa passkey thất bại"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "通行密钥",
|
||||
"signIn": "使用通行密钥登录",
|
||||
"loginFailed": "通行密钥登录失败",
|
||||
"description": "使用 WebAuthn/FIDO2 凭据进行无密码登录",
|
||||
"namePlaceholder": "通行密钥名称",
|
||||
"uv": {
|
||||
"preferred": "首选",
|
||||
"required": "必需",
|
||||
"discouraged": "不鼓励"
|
||||
},
|
||||
"add": "添加通行密钥",
|
||||
"none": "未注册通行密钥。",
|
||||
"added": "通行密钥已添加",
|
||||
"addFailed": "添加通行密钥失败",
|
||||
"deleted": "通行密钥已删除",
|
||||
"deleteFailed": "删除通行密钥失败"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "通行金鑰",
|
||||
"signIn": "使用通行密鑰登入",
|
||||
"loginFailed": "Passkey 登入失敗",
|
||||
"description": "使用 WebAuthn/FIDO2 憑證無需密碼即可登入",
|
||||
"namePlaceholder": "通行金鑰名稱",
|
||||
"uv": {
|
||||
"preferred": "偏好",
|
||||
"required": "需要",
|
||||
"discouraged": "不鼓勵"
|
||||
},
|
||||
"add": "新增通行金鑰",
|
||||
"none": "尚未註冊通行金鑰。",
|
||||
"added": "通行金鑰已新增",
|
||||
"addFailed": "無法新增通行金鑰",
|
||||
"deleted": "通行金鑰已刪除",
|
||||
"deleteFailed": "刪除通行密鑰失敗"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "webauthn",
|
||||
"name": "Passkeys",
|
||||
"version": "1.0.0",
|
||||
"description": "Sign in with a passkey or security key instead of a password. A passkey that checks your PIN or fingerprint also counts as the second factor.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Access & Security",
|
||||
"icon": "Fingerprint",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"auth:provide",
|
||||
"settings:read-core",
|
||||
"ui:surface"
|
||||
],
|
||||
"contributes": {
|
||||
"auth": {
|
||||
"loginMethods": ["passkey"]
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- webauthn 0001: adopt_webauthn_credentials
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `webauthn_credentials` (
|
||||
`id` varchar(255) PRIMARY KEY NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`credential_id` varchar(255) NOT NULL UNIQUE,
|
||||
`public_key` text NOT NULL,
|
||||
`counter` int NOT NULL DEFAULT 0,
|
||||
`device_type` text,
|
||||
`backed_up` boolean NOT NULL DEFAULT false,
|
||||
`transports` text,
|
||||
`user_verification` text NOT NULL DEFAULT ('preferred'),
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`last_used_at` text,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `webauthn_credentials` TO `p_webauthn_credentials`;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- webauthn 0001: adopt_webauthn_credentials
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "webauthn_credentials" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"credential_id" varchar(255) NOT NULL UNIQUE,
|
||||
"public_key" text NOT NULL,
|
||||
"counter" integer NOT NULL DEFAULT 0,
|
||||
"device_type" text,
|
||||
"backed_up" boolean NOT NULL DEFAULT false,
|
||||
"transports" text,
|
||||
"user_verification" text NOT NULL DEFAULT 'preferred',
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_used_at" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
ALTER TABLE "webauthn_credentials" RENAME TO "p_webauthn_credentials";
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"credentials": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"credentialId": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"notNull": true,
|
||||
"unique": true
|
||||
},
|
||||
"publicKey": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"counter": {
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": 0
|
||||
},
|
||||
"deviceType": {
|
||||
"type": "text"
|
||||
},
|
||||
"backedUp": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
},
|
||||
"transports": {
|
||||
"type": "text"
|
||||
},
|
||||
"userVerification": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "preferred"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"lastUsedAt": {
|
||||
"type": "timestamp"
|
||||
}
|
||||
},
|
||||
"indexes": [],
|
||||
"adopts": "webauthn_credentials"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- webauthn 0001: adopt_webauthn_credentials
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "webauthn_credentials" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"credential_id" text NOT NULL UNIQUE,
|
||||
"public_key" text NOT NULL,
|
||||
"counter" integer NOT NULL DEFAULT 0,
|
||||
"device_type" text,
|
||||
"backed_up" integer NOT NULL DEFAULT 0,
|
||||
"transports" text,
|
||||
"user_verification" text NOT NULL DEFAULT 'preferred',
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_used_at" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
ALTER TABLE "webauthn_credentials" RENAME TO "p_webauthn_credentials";
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@termix-plugin/webauthn",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { credentials } from "./tables.js";
|
||||
import { createCredentialRepository } from "./repository.js";
|
||||
import { createPasskeyService } from "./passkeys.js";
|
||||
import { PUBLIC_PATHS, registerPasskeyRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(credentials);
|
||||
const repository = createCredentialRepository(ctx.db, table);
|
||||
const passkeys = createPasskeyService(ctx, repository);
|
||||
|
||||
// No describe: a passkey only helps a user who already registered one, so
|
||||
// it never counts for the password lockout guard.
|
||||
ctx.auth.registerLoginMethod({
|
||||
id: "passkey",
|
||||
labelKey: "signIn",
|
||||
icon: "fingerprint",
|
||||
kind: "form",
|
||||
verify: (request) => passkeys.verifyLogin(request.body ?? {}),
|
||||
});
|
||||
|
||||
registerPasskeyRoutes(
|
||||
ctx.http.router<Router>({ public: PUBLIC_PATHS }),
|
||||
ctx,
|
||||
repository,
|
||||
passkeys,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
Base64URLString,
|
||||
RegistrationResponseJSON,
|
||||
WebAuthnCredential,
|
||||
} from "@simplewebauthn/server";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import {
|
||||
LoginMethodError,
|
||||
type PluginContext,
|
||||
type PluginVerifiedIdentity,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { CredentialRecord, CredentialRepository } from "./repository.js";
|
||||
|
||||
export type UserVerification = "discouraged" | "preferred" | "required";
|
||||
|
||||
interface ChallengeRecord {
|
||||
challenge: string;
|
||||
userId?: string;
|
||||
rpID: string;
|
||||
origin: string;
|
||||
userVerification: UserVerification;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export function normalizeUserVerification(value: unknown): UserVerification {
|
||||
return value === "discouraged" || value === "required" ? value : "preferred";
|
||||
}
|
||||
|
||||
function header(
|
||||
headers: Record<string, unknown>,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const value = headers[name];
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/** The browser's origin, or the one a proxy reports. */
|
||||
export function requestOrigin(headers: Record<string, unknown>): string {
|
||||
const origin = header(headers, "origin");
|
||||
if (origin) return origin;
|
||||
const proto = header(headers, "x-forwarded-proto") || "http";
|
||||
const host =
|
||||
header(headers, "x-forwarded-host") ||
|
||||
header(headers, "host") ||
|
||||
"localhost";
|
||||
return `${proto.split(",")[0]}://${host.split(",")[0]}`;
|
||||
}
|
||||
|
||||
export function parseTransports(
|
||||
value: string | null,
|
||||
): AuthenticatorTransportFuture[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toCredential(record: CredentialRecord): WebAuthnCredential {
|
||||
return {
|
||||
id: record.credentialId as Base64URLString,
|
||||
publicKey: Uint8Array.from(
|
||||
Buffer.from(record.publicKey, "base64url"),
|
||||
) as WebAuthnCredential["publicKey"],
|
||||
counter: record.counter,
|
||||
transports: parseTransports(record.transports),
|
||||
};
|
||||
}
|
||||
|
||||
export type PasskeyService = ReturnType<typeof createPasskeyService>;
|
||||
|
||||
/**
|
||||
* Registration and sign-in ceremonies. Challenges live in memory for five
|
||||
* minutes and are made in activate, so a disabled plugin forgets them.
|
||||
*/
|
||||
export function createPasskeyService(
|
||||
ctx: PluginContext,
|
||||
repository: CredentialRepository,
|
||||
) {
|
||||
const registrations = new Map<string, ChallengeRecord>();
|
||||
const authentications = new Map<string, ChallengeRecord>();
|
||||
|
||||
const prune = (map: Map<string, ChallengeRecord>) => {
|
||||
const now = Date.now();
|
||||
for (const [id, record] of map) {
|
||||
if (now - record.createdAt > CHALLENGE_TTL_MS) map.delete(id);
|
||||
}
|
||||
};
|
||||
const put = (
|
||||
map: Map<string, ChallengeRecord>,
|
||||
record: Omit<ChallengeRecord, "createdAt">,
|
||||
) => {
|
||||
prune(map);
|
||||
const id = randomUUID();
|
||||
map.set(id, { ...record, createdAt: Date.now() });
|
||||
return id;
|
||||
};
|
||||
const take = (map: Map<string, ChallengeRecord>, id: unknown) => {
|
||||
if (typeof id !== "string") return null;
|
||||
prune(map);
|
||||
const record = map.get(id) ?? null;
|
||||
map.delete(id);
|
||||
return record;
|
||||
};
|
||||
|
||||
async function rpName(): Promise<string> {
|
||||
try {
|
||||
return (await ctx.settings.readCore("app_name")) || "Termix";
|
||||
} catch {
|
||||
return "Termix";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async registrationOptions(input: {
|
||||
userId: string;
|
||||
username: string;
|
||||
origin: string;
|
||||
userVerification: UserVerification;
|
||||
}) {
|
||||
const rpID = new URL(input.origin).hostname;
|
||||
const existing = await repository.listByUserId(input.userId);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: await rpName(),
|
||||
rpID,
|
||||
userID: Buffer.from(input.userId, "utf8"),
|
||||
userName: input.username,
|
||||
userDisplayName: input.username,
|
||||
attestationType: "none",
|
||||
excludeCredentials: existing.map((credential) => ({
|
||||
id: credential.credentialId as Base64URLString,
|
||||
transports: parseTransports(credential.transports),
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: "required",
|
||||
userVerification: input.userVerification,
|
||||
},
|
||||
});
|
||||
const challengeId = put(registrations, {
|
||||
challenge: options.challenge,
|
||||
userId: input.userId,
|
||||
rpID,
|
||||
origin: input.origin,
|
||||
userVerification: input.userVerification,
|
||||
});
|
||||
return { options, challengeId };
|
||||
},
|
||||
|
||||
/** Stores the passkey. False when the challenge or response is bad. */
|
||||
async register(input: {
|
||||
userId: string;
|
||||
challengeId: unknown;
|
||||
response: RegistrationResponseJSON | undefined;
|
||||
name: unknown;
|
||||
}): Promise<boolean | "expired"> {
|
||||
const challenge = take(registrations, input.challengeId);
|
||||
if (!challenge || challenge.userId !== input.userId) return "expired";
|
||||
if (!input.response) return false;
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: input.response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: challenge.origin,
|
||||
expectedRPID: challenge.rpID,
|
||||
requireUserVerification: challenge.userVerification === "required",
|
||||
});
|
||||
if (!verification.verified) return false;
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||
verification.registrationInfo;
|
||||
const name =
|
||||
typeof input.name === "string" && input.name.trim()
|
||||
? input.name.trim().slice(0, 80)
|
||||
: "Passkey";
|
||||
await repository.create({
|
||||
id: randomUUID(),
|
||||
userId: input.userId,
|
||||
name,
|
||||
credentialId: credential.id,
|
||||
publicKey: Buffer.from(credential.publicKey).toString("base64url"),
|
||||
counter: credential.counter,
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
transports: JSON.stringify(input.response.response?.transports ?? []),
|
||||
userVerification: challenge.userVerification,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
async authenticationOptions(input: {
|
||||
userId?: string;
|
||||
origin: string;
|
||||
userVerification: UserVerification;
|
||||
}) {
|
||||
const rpID = new URL(input.origin).hostname;
|
||||
let allowCredentials:
|
||||
| { id: Base64URLString; transports?: AuthenticatorTransportFuture[] }[]
|
||||
| undefined;
|
||||
if (input.userId) {
|
||||
const credentials = await repository.listByUserId(input.userId);
|
||||
if (credentials.length === 0) return null;
|
||||
allowCredentials = credentials.map((credential) => ({
|
||||
id: credential.credentialId as Base64URLString,
|
||||
transports: parseTransports(credential.transports),
|
||||
}));
|
||||
}
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
allowCredentials,
|
||||
userVerification: input.userVerification,
|
||||
});
|
||||
const challengeId = put(authentications, {
|
||||
challenge: options.challenge,
|
||||
userId: input.userId,
|
||||
rpID,
|
||||
origin: input.origin,
|
||||
userVerification: input.userVerification,
|
||||
});
|
||||
return { options, challengeId };
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks an assertion and says who signed in. A passkey that verified
|
||||
* the user (PIN or biometric) counts as the second factor too.
|
||||
*/
|
||||
async verifyLogin(
|
||||
body: Record<string, unknown>,
|
||||
): Promise<PluginVerifiedIdentity> {
|
||||
const challenge = take(authentications, body.challengeId);
|
||||
if (!challenge) {
|
||||
throw new LoginMethodError("Authentication challenge expired", 400);
|
||||
}
|
||||
const response = body.response as AuthenticationResponseJSON | undefined;
|
||||
if (!response?.id) {
|
||||
throw new LoginMethodError("Invalid passkey response", 400);
|
||||
}
|
||||
const credential = await repository.findByCredentialId(response.id);
|
||||
if (
|
||||
!credential ||
|
||||
(challenge.userId && challenge.userId !== credential.userId)
|
||||
) {
|
||||
throw new LoginMethodError("Passkey not recognized", 401);
|
||||
}
|
||||
|
||||
let verification: Awaited<
|
||||
ReturnType<typeof verifyAuthenticationResponse>
|
||||
>;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: challenge.origin,
|
||||
expectedRPID: challenge.rpID,
|
||||
credential: toCredential(credential),
|
||||
requireUserVerification: challenge.userVerification === "required",
|
||||
advancedFIDOConfig: { userVerification: challenge.userVerification },
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.warn(
|
||||
`Passkey authentication failed for ${credential.userId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw new LoginMethodError("Passkey authentication failed", 401);
|
||||
}
|
||||
if (!verification.verified) {
|
||||
throw new LoginMethodError("Passkey authentication failed", 401);
|
||||
}
|
||||
|
||||
await repository.updateAuthState(credential.id, {
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
backedUp: verification.authenticationInfo.credentialBackedUp,
|
||||
deviceType: verification.authenticationInfo.credentialDeviceType,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "user",
|
||||
userId: credential.userId,
|
||||
mfaSatisfied: verification.authenticationInfo.userVerified === true,
|
||||
rememberMe: !!body.rememberMe,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface CredentialRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
credentialId: string;
|
||||
publicKey: string;
|
||||
counter: number;
|
||||
deviceType: string | null;
|
||||
backedUp: boolean;
|
||||
transports: string | null;
|
||||
userVerification: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands the table back untyped and the drizzle handle is the
|
||||
// server's own, so both are typed at this module's edge.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type CredentialRepository = ReturnType<
|
||||
typeof createCredentialRepository
|
||||
>;
|
||||
|
||||
export function createCredentialRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
return {
|
||||
async listByUserId(userId: string): Promise<CredentialRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle.select().from(table).where(eq(table.userId, userId));
|
||||
},
|
||||
|
||||
async findByCredentialId(
|
||||
credentialId: string,
|
||||
): Promise<CredentialRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.credentialId, credentialId))
|
||||
.limit(1);
|
||||
return (rows[0] as CredentialRecord) ?? null;
|
||||
},
|
||||
|
||||
async create(
|
||||
input: Omit<CredentialRecord, "lastUsedAt" | "createdAt">,
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.insert(table)
|
||||
.values({ ...input, createdAt: new Date().toISOString() });
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async updateAuthState(
|
||||
id: string,
|
||||
state: {
|
||||
counter: number;
|
||||
backedUp: boolean;
|
||||
deviceType: string;
|
||||
lastUsedAt: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle.update(table).set(state).where(eq(table.id, id));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async deleteForUser(userId: string, id: string): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
normalizeUserVerification,
|
||||
parseTransports,
|
||||
requestOrigin,
|
||||
type PasskeyService,
|
||||
} from "./passkeys.js";
|
||||
import type { CredentialRepository } from "./repository.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export const PUBLIC_PATHS = ["/authenticate/options"];
|
||||
|
||||
export function registerPasskeyRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
repository: CredentialRepository,
|
||||
passkeys: PasskeyService,
|
||||
): void {
|
||||
async function findUser(
|
||||
column: "id" | "username",
|
||||
value: string,
|
||||
): Promise<{ id: string; username: string } | null> {
|
||||
const { users } = await ctx.db.refs<{ users: Table }>();
|
||||
const drizzle = await ctx.db.client<Drizzle>();
|
||||
const rows = await drizzle
|
||||
.select({ id: users.id, username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users[column], value))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/webauthn/credentials:
|
||||
* get:
|
||||
* summary: List passkeys
|
||||
* description: Lists the caller's registered passkeys.
|
||||
* tags:
|
||||
* - Passkeys
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of passkeys.
|
||||
*/
|
||||
router.get("/credentials", async (_req: Request, res: Response) => {
|
||||
const credentials = await repository.listByUserId(ctx.currentActor()!);
|
||||
res.json({
|
||||
credentials: credentials.map((credential) => ({
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
deviceType: credential.deviceType,
|
||||
backedUp: !!credential.backedUp,
|
||||
transports: parseTransports(credential.transports),
|
||||
userVerification: credential.userVerification,
|
||||
createdAt: credential.createdAt,
|
||||
lastUsedAt: credential.lastUsedAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/webauthn/register/options:
|
||||
* post:
|
||||
* summary: Start passkey registration
|
||||
* tags:
|
||||
* - Passkeys
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Registration options and challenge id.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
*/
|
||||
router.post("/register/options", async (req: Request, res: Response) => {
|
||||
const user = await findUser("id", ctx.currentActor()!);
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
res.json(
|
||||
await passkeys.registrationOptions({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
origin: requestOrigin(req.headers),
|
||||
userVerification: normalizeUserVerification(req.body?.userVerification),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/webauthn/register/verify:
|
||||
* post:
|
||||
* summary: Finish passkey registration
|
||||
* tags:
|
||||
* - Passkeys
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Passkey registered.
|
||||
* 400:
|
||||
* description: Registration failed or challenge expired.
|
||||
*/
|
||||
router.post("/register/verify", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor()!;
|
||||
try {
|
||||
const result = await passkeys.register({
|
||||
userId,
|
||||
challengeId: req.body?.challengeId,
|
||||
response: req.body?.response as RegistrationResponseJSON | undefined,
|
||||
name: req.body?.name,
|
||||
});
|
||||
if (result === "expired") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Registration challenge expired" });
|
||||
}
|
||||
if (!result) {
|
||||
return res.status(400).json({ error: "Passkey registration failed" });
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
ctx.log.warn(
|
||||
`Passkey registration failed for ${userId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
res.status(400).json({ error: "Passkey registration failed" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/webauthn/authenticate/options:
|
||||
* post:
|
||||
* summary: Start passkey login
|
||||
* description: Public. Authentication options, optionally scoped to a username. The assertion is then sent to /users/auth/passkey/verify.
|
||||
* tags:
|
||||
* - Passkeys
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Authentication options and challenge id.
|
||||
* 404:
|
||||
* description: No passkeys found for the user.
|
||||
*/
|
||||
router.post("/authenticate/options", async (req: Request, res: Response) => {
|
||||
const username =
|
||||
typeof req.body?.username === "string" ? req.body.username.trim() : "";
|
||||
let userId: string | undefined;
|
||||
if (username) {
|
||||
const user = await findUser("username", username);
|
||||
if (!user) return res.status(404).json({ error: "No passkeys found" });
|
||||
userId = user.id;
|
||||
}
|
||||
const result = await passkeys.authenticationOptions({
|
||||
userId,
|
||||
origin: requestOrigin(req.headers),
|
||||
userVerification: normalizeUserVerification(req.body?.userVerification),
|
||||
});
|
||||
if (!result) return res.status(404).json({ error: "No passkeys found" });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/webauthn/credentials/{credentialId}:
|
||||
* delete:
|
||||
* summary: Delete a passkey
|
||||
* tags:
|
||||
* - Passkeys
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: credentialId
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Passkey deleted.
|
||||
*/
|
||||
router.delete(
|
||||
"/credentials/:credentialId",
|
||||
async (req: Request, res: Response) => {
|
||||
await repository.deleteForUser(
|
||||
ctx.currentActor()!,
|
||||
String(req.params.credentialId),
|
||||
);
|
||||
res.json({ success: true });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
boolean,
|
||||
defineTable,
|
||||
integer,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* Adopted from core's webauthn_credentials, so the column names are the
|
||||
* legacy ones and the rename carries every passkey across.
|
||||
*/
|
||||
export const credentials = adoptLegacyTable(
|
||||
"webauthn_credentials",
|
||||
defineTable("credentials", {
|
||||
id: varchar().primaryKey(),
|
||||
userId: refUser(),
|
||||
name: text().notNull(),
|
||||
credentialId: varchar().notNull().unique(),
|
||||
publicKey: text().notNull(),
|
||||
counter: integer().notNull().default(0),
|
||||
deviceType: text(),
|
||||
backedUp: boolean().notNull().default(false),
|
||||
transports: text(),
|
||||
userVerification: text().notNull().default("preferred"),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
lastUsedAt: timestamp(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const tables = [credentials];
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { KeyRound, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input, Select2 } from "@termix/plugin-sdk/ui";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
createWebAuthnApi,
|
||||
type WebAuthnCredentialSummary,
|
||||
type WebAuthnUserVerification,
|
||||
} from "./webauthn-api";
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string): string {
|
||||
const data = (error as { response?: { data?: { error?: unknown } } })
|
||||
?.response?.data;
|
||||
return typeof data?.error === "string" && data.error ? data.error : fallback;
|
||||
}
|
||||
|
||||
/** Settings > Security: register and remove passkeys. */
|
||||
export function PasskeyEnrollment() {
|
||||
const { t } = useTranslation();
|
||||
const pluginApi = usePluginApi();
|
||||
const api = useMemo(() => createWebAuthnApi(pluginApi), [pluginApi]);
|
||||
const [passkeys, setPasskeys] = useState<WebAuthnCredentialSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [userVerification, setUserVerification] =
|
||||
useState<WebAuthnUserVerification>("preferred");
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.list()
|
||||
.then(setPasskeys)
|
||||
.catch(() => {});
|
||||
}, [api]);
|
||||
|
||||
async function add() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.register(name || t("defaultName"), userVerification);
|
||||
setPasskeys(await api.list());
|
||||
setName("");
|
||||
toast.success(t("added"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("addFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.remove(id);
|
||||
setPasskeys((prev) => prev.filter((item) => item.id !== id));
|
||||
toast.success(t("deleted"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("deleteFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("description")}
|
||||
</span>
|
||||
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<Input
|
||||
placeholder={t("namePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Select2
|
||||
value={userVerification}
|
||||
onChange={(e) =>
|
||||
setUserVerification(e.target.value as WebAuthnUserVerification)
|
||||
}
|
||||
className="h-8 w-28 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="preferred">{t("uv.preferred")}</option>
|
||||
<option value="required">{t("uv.required")}</option>
|
||||
<option value="discouraged">{t("uv.discouraged")}</option>
|
||||
</Select2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={add}
|
||||
disabled={loading}
|
||||
>
|
||||
<KeyRound className="size-3" />
|
||||
{t("add")}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col divide-y divide-border border border-border">
|
||||
{passkeys.length === 0 ? (
|
||||
<div className="py-4 text-center text-[10px] text-muted-foreground">
|
||||
{t("none")}
|
||||
</div>
|
||||
) : (
|
||||
passkeys.map((passkey) => (
|
||||
<div
|
||||
key={passkey.id}
|
||||
className="flex items-center justify-between gap-2 px-2 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex flex-col">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{passkey.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{passkey.deviceType || t("unknownDevice")}
|
||||
{passkey.backedUp ? ` / ${t("synced")}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => remove(passkey.id)}
|
||||
disabled={loading}
|
||||
aria-label={t("delete")}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Fingerprint } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTranslation,
|
||||
type LoginMethodUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { createWebAuthnApi, isPasskeySupported } from "./webauthn-api";
|
||||
|
||||
export function PasskeyLoginButton({
|
||||
disabled,
|
||||
submit,
|
||||
username,
|
||||
}: LoginMethodUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const pluginApi = usePluginApi();
|
||||
const api = useMemo(() => createWebAuthnApi(pluginApi), [pluginApi]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [supported] = useState(isPasskeySupported);
|
||||
if (!supported) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await submit(
|
||||
await api.passkeyAssertion(username?.trim() || undefined),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
name?: string;
|
||||
message?: string;
|
||||
response?: { data?: { error?: string } };
|
||||
};
|
||||
// Closing the browser prompt is not a failure worth a toast.
|
||||
if (error?.name === "NotAllowedError" || error?.name === "AbortError")
|
||||
return;
|
||||
toast.error(
|
||||
error?.response?.data?.error || error?.message || t("loginFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
className="w-full h-10 font-bold"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Fingerprint className="size-4" />
|
||||
{t("signIn")}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { PasskeyLoginButton } from "./PasskeyLoginButton";
|
||||
import { PasskeyEnrollment } from "./PasskeyEnrollment";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerLoginMethod({
|
||||
id: "passkey",
|
||||
titleKey: "title",
|
||||
placement: "inline",
|
||||
component: PasskeyLoginButton,
|
||||
enrollment: PasskeyEnrollment,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/browser";
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
startAuthentication,
|
||||
startRegistration,
|
||||
} from "@simplewebauthn/browser";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export type WebAuthnUserVerification = "discouraged" | "preferred" | "required";
|
||||
|
||||
export type WebAuthnCredentialSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
deviceType?: string | null;
|
||||
backedUp: boolean;
|
||||
transports: string[];
|
||||
userVerification: WebAuthnUserVerification;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string | null;
|
||||
};
|
||||
|
||||
type OptionsResponse<T> = { options: T; challengeId: string };
|
||||
|
||||
export function isPasskeySupported(): boolean {
|
||||
try {
|
||||
return browserSupportsWebAuthn();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebAuthnApi(api: PluginApiClient) {
|
||||
return {
|
||||
/**
|
||||
* Runs the browser ceremony and returns the body for the login method's
|
||||
* verify step. Without a username, discoverable passkeys still work.
|
||||
*/
|
||||
async passkeyAssertion(username?: string) {
|
||||
const { data } = await api.post<
|
||||
OptionsResponse<PublicKeyCredentialRequestOptionsJSON>
|
||||
>("authenticate/options", username ? { username } : {});
|
||||
const response = await startAuthentication({
|
||||
optionsJSON: data.options,
|
||||
});
|
||||
return {
|
||||
challengeId: data.challengeId,
|
||||
response: response as AuthenticationResponseJSON,
|
||||
};
|
||||
},
|
||||
|
||||
async list(): Promise<WebAuthnCredentialSummary[]> {
|
||||
const { data } = await api.get<{
|
||||
credentials?: WebAuthnCredentialSummary[];
|
||||
}>("credentials");
|
||||
return data.credentials ?? [];
|
||||
},
|
||||
|
||||
async register(
|
||||
name: string,
|
||||
userVerification: WebAuthnUserVerification,
|
||||
): Promise<void> {
|
||||
const { data } = await api.post<
|
||||
OptionsResponse<PublicKeyCredentialCreationOptionsJSON>
|
||||
>("register/options", { userVerification });
|
||||
const response = await startRegistration({ optionsJSON: data.options });
|
||||
await api.post("register/verify", {
|
||||
challengeId: data.challengeId,
|
||||
name,
|
||||
response: response as RegistrationResponseJSON,
|
||||
});
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await api.delete(`credentials/${encodeURIComponent(id)}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* on an express app the way core mounts it: JSON parsed, the acting user set
|
||||
* from the request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
users?: string[];
|
||||
capabilities?: string[];
|
||||
before?: (sqlite: TestDb["sqlite"]) => void;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir, { before: options.before });
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: options.capabilities ?? manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
coreSettings: { app_name: "Termix Test" },
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") || undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
origin: "https://termix.test",
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const webauthn = vi.hoisted(() => ({
|
||||
verifyRegistration: vi.fn(),
|
||||
verifyAuthentication: vi.fn(),
|
||||
registrationOptions: vi.fn(),
|
||||
authenticationOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@simplewebauthn/server", () => ({
|
||||
generateRegistrationOptions: webauthn.registrationOptions,
|
||||
generateAuthenticationOptions: webauthn.authenticationOptions,
|
||||
verifyRegistrationResponse: webauthn.verifyRegistration,
|
||||
verifyAuthenticationResponse: webauthn.verifyAuthentication,
|
||||
}));
|
||||
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
import { requestOrigin } from "../../src/backend/passkeys.js";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
webauthn.registrationOptions.mockImplementation(async (input) => ({
|
||||
challenge: "reg-challenge",
|
||||
rp: { name: input.rpName, id: input.rpID },
|
||||
}));
|
||||
webauthn.authenticationOptions.mockImplementation(async (input) => ({
|
||||
challenge: "auth-challenge",
|
||||
allowCredentials: input.allowCredentials,
|
||||
}));
|
||||
webauthn.verifyRegistration.mockResolvedValue({
|
||||
verified: true,
|
||||
registrationInfo: {
|
||||
credential: {
|
||||
id: "cred-1",
|
||||
publicKey: new Uint8Array([1, 2, 3]),
|
||||
counter: 0,
|
||||
},
|
||||
credentialDeviceType: "multiDevice",
|
||||
credentialBackedUp: true,
|
||||
},
|
||||
});
|
||||
webauthn.verifyAuthentication.mockResolvedValue({
|
||||
verified: true,
|
||||
authenticationInfo: {
|
||||
newCounter: 5,
|
||||
userVerified: true,
|
||||
credentialBackedUp: true,
|
||||
credentialDeviceType: "multiDevice",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
function passkeyMethod(target: TestServer) {
|
||||
const method = target.mock.auth.loginMethods.find(
|
||||
(entry) => entry.id === "passkey",
|
||||
);
|
||||
if (!method?.verify) throw new Error("passkey method not registered");
|
||||
return method;
|
||||
}
|
||||
|
||||
async function registerPasskey(target: TestServer, user = "user-1") {
|
||||
const options = await target.request("POST", "/register/options", {
|
||||
user,
|
||||
body: { userVerification: "required" },
|
||||
});
|
||||
expect(options.status).toBe(200);
|
||||
const verify = await target.request("POST", "/register/verify", {
|
||||
user,
|
||||
body: {
|
||||
challengeId: options.body.challengeId,
|
||||
name: "Laptop",
|
||||
response: { id: "cred-1", response: { transports: ["internal"] } },
|
||||
},
|
||||
});
|
||||
expect(verify.status).toBe(200);
|
||||
}
|
||||
|
||||
async function loginWith(
|
||||
target: TestServer,
|
||||
body: Record<string, unknown> = {},
|
||||
) {
|
||||
const options = await target.request("POST", "/authenticate/options", {
|
||||
user: "",
|
||||
body,
|
||||
});
|
||||
expect(options.status).toBe(200);
|
||||
return passkeyMethod(target).verify!(
|
||||
{
|
||||
body: {
|
||||
challengeId: options.body.challengeId,
|
||||
response: { id: "cred-1" },
|
||||
},
|
||||
query: {},
|
||||
headers: {},
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
describe("registration", () => {
|
||||
it("registers a passkey for the caller and lists it", async () => {
|
||||
server = await startServer();
|
||||
await registerPasskey(server);
|
||||
|
||||
expect(webauthn.registrationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rpName: "Termix Test",
|
||||
rpID: "termix.test",
|
||||
userName: "user-1",
|
||||
}),
|
||||
);
|
||||
const list = await server.request("GET", "/credentials");
|
||||
expect(list.body.credentials).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Laptop",
|
||||
deviceType: "multiDevice",
|
||||
backedUp: true,
|
||||
transports: ["internal"],
|
||||
userVerification: "required",
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
(await server.request("GET", "/credentials", { user: "user-2" })).body
|
||||
.credentials,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a challenge issued to someone else", async () => {
|
||||
server = await startServer();
|
||||
const options = await server.request("POST", "/register/options", {
|
||||
body: {},
|
||||
});
|
||||
const verify = await server.request("POST", "/register/verify", {
|
||||
user: "user-2",
|
||||
body: { challengeId: options.body.challengeId, response: { id: "x" } },
|
||||
});
|
||||
expect(verify.status).toBe(400);
|
||||
});
|
||||
|
||||
it("refuses a failed attestation", async () => {
|
||||
server = await startServer();
|
||||
webauthn.verifyRegistration.mockResolvedValue({ verified: false });
|
||||
const options = await server.request("POST", "/register/options", {
|
||||
body: {},
|
||||
});
|
||||
const verify = await server.request("POST", "/register/verify", {
|
||||
body: { challengeId: options.body.challengeId, response: { id: "x" } },
|
||||
});
|
||||
expect(verify.status).toBe(400);
|
||||
});
|
||||
|
||||
it("deletes only the caller's own passkey", async () => {
|
||||
server = await startServer();
|
||||
await registerPasskey(server);
|
||||
const [{ id }] = (await server.request("GET", "/credentials")).body
|
||||
.credentials;
|
||||
await server.request("DELETE", `/credentials/${id}`, { user: "user-2" });
|
||||
expect(
|
||||
(await server.request("GET", "/credentials")).body.credentials,
|
||||
).toHaveLength(1);
|
||||
await server.request("DELETE", `/credentials/${id}`);
|
||||
expect(
|
||||
(await server.request("GET", "/credentials")).body.credentials,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("says who signed in and counts a verified user as the second factor", async () => {
|
||||
server = await startServer();
|
||||
await registerPasskey(server);
|
||||
const identity = await loginWith(server);
|
||||
expect(identity).toMatchObject({
|
||||
kind: "user",
|
||||
userId: "user-1",
|
||||
mfaSatisfied: true,
|
||||
});
|
||||
const row = server.db.sqlite
|
||||
.prepare("SELECT counter, last_used_at FROM p_webauthn_credentials")
|
||||
.get() as { counter: number; last_used_at: string | null };
|
||||
expect(row.counter).toBe(5);
|
||||
expect(row.last_used_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves the second factor to core when the user was not verified", async () => {
|
||||
server = await startServer();
|
||||
await registerPasskey(server);
|
||||
webauthn.verifyAuthentication.mockResolvedValue({
|
||||
verified: true,
|
||||
authenticationInfo: {
|
||||
newCounter: 1,
|
||||
userVerified: false,
|
||||
credentialBackedUp: false,
|
||||
credentialDeviceType: "singleDevice",
|
||||
},
|
||||
});
|
||||
expect(await loginWith(server)).toMatchObject({ mfaSatisfied: false });
|
||||
});
|
||||
|
||||
it("scopes the options to a username and 404s without passkeys", async () => {
|
||||
server = await startServer();
|
||||
await registerPasskey(server);
|
||||
const options = await server.request("POST", "/authenticate/options", {
|
||||
user: "",
|
||||
body: { username: "user-1" },
|
||||
});
|
||||
expect(options.body.options.allowCredentials).toEqual([
|
||||
{ id: "cred-1", transports: ["internal"] },
|
||||
]);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/authenticate/options", {
|
||||
user: "",
|
||||
body: { username: "user-2" },
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses a passkey that belongs to another user than asked for", async () => {
|
||||
server = await startServer();
|
||||
await registerPasskey(server);
|
||||
const options = await server.request("POST", "/authenticate/options", {
|
||||
user: "",
|
||||
body: { username: "user-1" },
|
||||
});
|
||||
server.db.sqlite.exec(
|
||||
"UPDATE p_webauthn_credentials SET user_id = 'user-2'",
|
||||
);
|
||||
await expect(
|
||||
passkeyMethod(server).verify!(
|
||||
{
|
||||
body: {
|
||||
challengeId: options.body.challengeId,
|
||||
response: { id: "cred-1" },
|
||||
},
|
||||
query: {},
|
||||
headers: {},
|
||||
},
|
||||
null,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it("refuses an unknown passkey and a failed assertion", async () => {
|
||||
server = await startServer();
|
||||
await expect(loginWith(server)).rejects.toMatchObject({ status: 401 });
|
||||
await registerPasskey(server);
|
||||
webauthn.verifyAuthentication.mockRejectedValue(new Error("bad sig"));
|
||||
await expect(loginWith(server)).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it("uses a challenge only once", async () => {
|
||||
server = await startServer();
|
||||
await registerPasskey(server);
|
||||
const options = await server.request("POST", "/authenticate/options", {
|
||||
user: "",
|
||||
body: {},
|
||||
});
|
||||
const request = {
|
||||
body: {
|
||||
challengeId: options.body.challengeId,
|
||||
response: { id: "cred-1" },
|
||||
},
|
||||
query: {},
|
||||
headers: {},
|
||||
};
|
||||
await passkeyMethod(server).verify!(request, null);
|
||||
await expect(
|
||||
passkeyMethod(server).verify!(request, null),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("adoption", () => {
|
||||
const LEGACY = `
|
||||
CREATE TABLE webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
device_type TEXT,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
user_verification TEXT NOT NULL DEFAULT 'preferred',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);`;
|
||||
|
||||
it("keeps existing passkeys, which still sign in", async () => {
|
||||
server = await startServer({
|
||||
before: (sqlite) => {
|
||||
sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run("old-user", "old-user");
|
||||
sqlite.exec(LEGACY);
|
||||
sqlite.exec(`INSERT INTO webauthn_credentials
|
||||
(id, user_id, name, credential_id, public_key, counter, transports)
|
||||
VALUES ('p1', 'old-user', 'Old key', 'cred-1', 'AQID', 3, '[]')`);
|
||||
},
|
||||
});
|
||||
const tables = server.db.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.all()
|
||||
.map((row) => (row as { name: string }).name);
|
||||
expect(tables).toContain("p_webauthn_credentials");
|
||||
expect(tables).not.toContain("webauthn_credentials");
|
||||
expect(await loginWith(server)).toMatchObject({ userId: "old-user" });
|
||||
});
|
||||
|
||||
it("creates the table on a fresh install", async () => {
|
||||
server = await startServer();
|
||||
expect(
|
||||
server.db.sqlite
|
||||
.prepare("SELECT COUNT(*) AS n FROM p_webauthn_credentials")
|
||||
.get(),
|
||||
).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities", () => {
|
||||
it("fails closed without auth:provide", async () => {
|
||||
await expect(
|
||||
startServer({
|
||||
capabilities: [
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"settings:read-core",
|
||||
"ui:surface",
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/auth:provide/);
|
||||
});
|
||||
|
||||
it("fails closed without network:serve", async () => {
|
||||
await expect(
|
||||
startServer({
|
||||
capabilities: [
|
||||
"db:own",
|
||||
"auth:provide",
|
||||
"settings:read-core",
|
||||
"ui:surface",
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/network:serve/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestOrigin", () => {
|
||||
it("prefers Origin, then forwarded host and proto", () => {
|
||||
expect(requestOrigin({ origin: "https://a.test" })).toBe("https://a.test");
|
||||
expect(
|
||||
requestOrigin({
|
||||
"x-forwarded-proto": "https, http",
|
||||
"x-forwarded-host": "b.test",
|
||||
host: "internal",
|
||||
}),
|
||||
).toBe("https://b.test");
|
||||
expect(requestOrigin({ host: "c.test:8080" })).toBe("http://c.test:8080");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
const browser = vi.hoisted(() => ({
|
||||
startAuthentication: vi.fn(async () => ({ id: "cred-1" })),
|
||||
startRegistration: vi.fn(async () => ({ id: "cred-1" })),
|
||||
browserSupportsWebAuthn: vi.fn(() => true),
|
||||
}));
|
||||
vi.mock("@simplewebauthn/browser", () => browser);
|
||||
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import { createWebAuthnApi } from "../../src/frontend/webauthn-api";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
browser.browserSupportsWebAuthn.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("registers the passkey login method", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.loginMethods()).toEqual(["passkey"]);
|
||||
});
|
||||
|
||||
it("runs the ceremony and submits the assertion", async () => {
|
||||
const post = vi.fn(async () => ({
|
||||
data: { options: { challenge: "abc" }, challengeId: "chal-1" },
|
||||
}));
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: { post } as never,
|
||||
});
|
||||
const submit = vi.fn(async () => {});
|
||||
rendered.renderLoginMethod("passkey", { submit, username: " luke " });
|
||||
fireEvent.click(screen.getByText(locales.signIn));
|
||||
await waitFor(() =>
|
||||
expect(submit).toHaveBeenCalledWith({
|
||||
challengeId: "chal-1",
|
||||
response: { id: "cred-1" },
|
||||
}),
|
||||
);
|
||||
expect(post).toHaveBeenCalledWith("authenticate/options", {
|
||||
username: "luke",
|
||||
});
|
||||
});
|
||||
|
||||
it("draws nothing where the browser has no WebAuthn", async () => {
|
||||
browser.browserSupportsWebAuthn.mockReturnValue(false);
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
rendered.renderLoginMethod("passkey");
|
||||
expect(screen.queryByText(locales.signIn)).toBeNull();
|
||||
});
|
||||
|
||||
it("lists passkeys in the enrolment section", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: {
|
||||
get: vi.fn(async () => ({
|
||||
data: {
|
||||
credentials: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Laptop",
|
||||
backedUp: true,
|
||||
transports: [],
|
||||
userVerification: "preferred",
|
||||
createdAt: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
} as never,
|
||||
});
|
||||
rendered.renderEnrollment("passkey");
|
||||
expect(await screen.findByText("Laptop")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("removes the method on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, { manifest, locales });
|
||||
await app.deactivate();
|
||||
expect(app.registered.loginMethods()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webauthn api", () => {
|
||||
it("omits the username so discoverable passkeys work", async () => {
|
||||
const post = vi.fn(async () => ({
|
||||
data: { options: { challenge: "abc" }, challengeId: "chal-2" },
|
||||
}));
|
||||
await createWebAuthnApi({ post } as never).passkeyAssertion();
|
||||
expect(post).toHaveBeenCalledWith("authenticate/options", {});
|
||||
});
|
||||
|
||||
it("registers through options then verify", async () => {
|
||||
const post = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: { options: { challenge: "r" }, challengeId: "reg-1" },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { success: true } });
|
||||
await createWebAuthnApi({ post } as never).register("Key", "required");
|
||||
expect(post).toHaveBeenNthCalledWith(1, "register/options", {
|
||||
userVerification: "required",
|
||||
});
|
||||
expect(post).toHaveBeenNthCalledWith(2, "register/verify", {
|
||||
challengeId: "reg-1",
|
||||
name: "Key",
|
||||
response: { id: "cred-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user