feat: move step-ca into a plugin (C4)

This commit is contained in:
LukeGus committed 2026-09-24 19:55:28 -05:00
1 parent 98d367d7e2
commit b027717c46
128 files changed
+3805 -1781

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
- Step CA sign-in moved out of core. The callback is now
`/plugin-api/step-ca/callback`; the 2.8 URI `/host/step-ca-callback`
still works and upgraded installs keep sending it until an admin turns
off "Use the old redirect URI".
- The CA URL, fingerprint, provisioner and private endpoint list moved from
Admin Settings into this plugin's settings.
- Issued certificates are stored encrypted in the plugin's own table, so
they survive a restart. Certificates cached by 2.8 are not carried over;
the next connection asks for one sign-in.
- The terminal shows the sign-in dialog for Step CA hosts again.
+31
View File
@@ -0,0 +1,31 @@
# Step CA
Adds the `stepca` SSH auth type: Termix signs you in with the identity
provider behind a [smallstep step-ca](https://smallstep.com/docs/step-ca/)
OIDC provisioner, has the CA sign a fresh key, and keeps the short-lived SSH
certificate until it expires.
Termix never runs the `step` binary. It talks to the CA's HTTPS API
(`/root/<fingerprint>`, `/provisioners`, `/1.0/ssh/sign`) and to the identity
provider directly, through core's outbound request guard.
## Settings
Admin settings, under Settings > Plugins > Step CA:
- **CA URL**, **Root fingerprint** and **OIDC provisioner name**. Leave all
three empty to turn Step CA off.
- **Allowed private Step CA hosts**: the CA and, if it is internal, the
identity provider. Anything private that is not listed is refused.
- **Redirect URI**: register it with the identity provider:
`<base URL>/plugin-api/step-ca/callback`, or
`<base URL>/host/step-ca-callback` while "Use the old redirect URI" is on.
An install upgraded from 2.8 has its Step CA settings copied in and keeps
the old redirect URI turned on.
## Several instances
With `REDIS_URL` set, a callback that reaches another Termix instance is
handed to the one that started the sign-in. `TERMIX_STEP_CA_REDIS_PREFIX`
changes the key prefix (default `termix:step-ca`).
+48
View File
@@ -0,0 +1,48 @@
{
"hosts": {
"filterAuthStepca": "Step CA"
},
"editor": {
"label": "Step CA",
"description": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator in the Step CA plugin settings.",
"docs": "View docs"
},
"dialog": {
"title": "Step CA Authentication Required",
"description": "Complete authentication in your browser to continue. The certificate stays valid for as long as your CA allows.",
"openBrowser": "Open Browser to Authenticate",
"waiting": "Waiting for authentication in browser...",
"authenticating": "Processing authentication..."
},
"errors": {
"authFailed": "Authentication failed. Please check your credentials and try again.",
"timeout": "Authentication timed out. Please try again."
},
"settings": {
"caUrl": {
"label": "CA URL",
"description": "The https URL of your smallstep CA. Leave the CA URL, fingerprint and provisioner empty to turn Step CA off.",
"placeholder": "https://ca.internal:9000"
},
"fingerprint": {
"label": "Root fingerprint (SHA-256)",
"description": "The root certificate fingerprint, as shown by step ca bootstrap."
},
"provisioner": {
"label": "OIDC provisioner name",
"description": "The name of the CA's OIDC provisioner. Then choose \"Step CA\" as a host's authentication type."
},
"privateEndpoints": {
"label": "Allowed private Step CA hosts",
"description": "Private hosts the Step CA certificate flow may contact: the CA itself and, if internal, your identity provider. Separate them with commas or new lines."
},
"redirectUri": {
"label": "Redirect URI",
"description": "Register this URL as an allowed redirect URI with the identity provider behind the OIDC provisioner."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /host/step-ca-callback, the redirect URI used before 2.9. Turn this off once your identity provider allows the new one."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Stap CA"
},
"editor": {
"label": "Stap CA",
"description": "Meld aan deur jou identiteitsverskaffer en laat jou Step CA 'n kortstondige SSH-sertifikaat vir hierdie gasheer uitreik. Die CA-URL, wortelvingerafdruk en OIDC-voorsiening word deur 'n administrateur onder Admin-instellings ingestel.",
"docs": "Bekyk dokumente"
},
"dialog": {
"openBrowser": "Maak blaaier oop om te verifieer",
"waiting": "Wag vir verifikasie in blaaier...",
"authenticating": "Verwerk verifikasie..."
},
"errors": {
"authFailed": "Verifikasie het misluk. Kontroleer asseblief jou geloofsbriewe en probeer weer.",
"timeout": "Verifikasie het verstryk. Probeer asseblief weer."
},
"settings": {
"fingerprint": {
"label": "Wortelvingerafdruk (SHA-256)"
},
"provisioner": {
"label": "OIDC-voorsieningsnaam"
},
"privateEndpoints": {
"label": "Toegelate private Step CA-gashere",
"description": "Privaat gashere, die Step CA-sertifikaatvloei, mag kontak maak met: die CA self en, indien intern, jou identiteitsverskaffer. Skei hulle met kommas."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "الخطوة ج"
},
"editor": {
"label": "الخطوة ج",
"description": "سجّل الدخول عبر موفر الهوية الخاص بك، ودع جهة إصدار الشهادات المعتمدة (SA) التابعة لـ Step CA تُصدر شهادة SSH مؤقتة لهذا المضيف. يتم تحديد عنوان URL الخاص بجهة إصدار الشهادات المعتمدة، وبصمة الجذر، وموفر OIDC بواسطة مسؤول النظام ضمن إعدادات المسؤول.",
"docs": "عرض المستندات"
},
"dialog": {
"openBrowser": "افتح المتصفح للمصادقة",
"waiting": "في انتظار المصادقة في المتصفح...",
"authenticating": "جارٍ معالجة المصادقة..."
},
"errors": {
"authFailed": "فشلت المصادقة. يرجى التحقق من بيانات الاعتماد والمحاولة مرة أخرى.",
"timeout": "انتهت مهلة المصادقة. يرجى المحاولة مرة أخرى."
},
"settings": {
"fingerprint": {
"label": "بصمة الجذر (SHA-256)"
},
"provisioner": {
"label": "اسم موفر OIDC"
},
"privateEndpoints": {
"label": "مضيفو شهادات الخطوة الخاصة المسموح بها",
"description": "قد تتصل جهات الاستضافة الخاصة بعملية شهادة Step CA بما يلي: جهة إصدار الشهادات نفسها، وإذا كانت داخلية، مزود الهوية الخاص بك. افصل بينهما بفواصل."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Стъпка CA"
},
"editor": {
"label": "Стъпка CA",
"description": "Влезте чрез вашия доставчик на самоличност и позволете на вашия Step CA да издаде краткотраен SSH сертификат за този хост. URL адресът на CA, root пръстовият отпечатък и OIDC provisioner се задават от администратор в „Настройки на администратора“.",
"docs": "Преглед на документи"
},
"dialog": {
"openBrowser": "Отворете браузъра за удостоверяване",
"waiting": "Чака се удостоверяване в браузъра...",
"authenticating": "Обработва се удостоверяване..."
},
"errors": {
"authFailed": "Удостоверяването не бе успешно. Моля, проверете идентификационните си данни и опитайте отново.",
"timeout": "Времето за изчакване на удостоверяването изтече. Моля, опитайте отново."
},
"settings": {
"fingerprint": {
"label": "Коренен пръстов отпечатък (SHA-256)"
},
"provisioner": {
"label": "Име на доставчика на OIDC"
},
"privateEndpoints": {
"label": "Разрешени частни хостове на Step CA",
"description": "Частни хостове, с които потокът от сертификати на Step CA може да се свърже: самия CA и, ако е вътрешен, вашият доставчик на идентичност. Разделете ги със запетаи."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "স্টেপ সিএ"
},
"editor": {
"label": "স্টেপ সিএ",
"description": "আপনার আইডেন্টিটি প্রোভাইডারের মাধ্যমে সাইন ইন করুন এবং আপনার স্টেপ সিএ-কে এই হোস্টের জন্য একটি স্বল্পস্থায়ী এসএসএইচ সার্টিফিকেট ইস্যু করতে দিন। সিএ ইউআরএল, রুট ফিঙ্গারপ্রিন্ট এবং ওআইডিসি প্রোভিশনার একজন অ্যাডমিনিস্ট্রেটর দ্বারা অ্যাডমিন সেটিংস-এর অধীনে সেট করা হয়।",
"docs": "নথি দেখুন"
},
"dialog": {
"openBrowser": "প্রমাণীকরণের জন্য ব্রাউজার খুলুন",
"waiting": "ব্রাউজারে প্রমাণীকরণের জন্য অপেক্ষা করা হচ্ছে...",
"authenticating": "প্রমাণীকরণ প্রক্রিয়া চলছে..."
},
"errors": {
"authFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আপনার পরিচয়পত্র যাচাই করে আবার চেষ্টা করুন।",
"timeout": "প্রমাণীকরণের সময়সীমা শেষ হয়ে গেছে। অনুগ্রহ করে আবার চেষ্টা করুন।"
},
"settings": {
"fingerprint": {
"label": "রুট ফিঙ্গারপ্রিন্ট (SHA-256)"
},
"provisioner": {
"label": "OIDC সরবরাহকারীর নাম"
},
"privateEndpoints": {
"label": "ব্যক্তিগত স্টেপ সিএ হোস্টদের অনুমতি দেওয়া হয়েছে",
"description": "স্টেপ সিএ সার্টিফিকেট ফ্লো যেসব প্রাইভেট হোস্টের সাথে যোগাযোগ করতে পারে, সেগুলো হলো: স্বয়ং সিএ এবং, যদি অভ্যন্তরীণ হয়, আপনার আইডেন্টিটি প্রোভাইডার। এদেরকে কমা দিয়ে আলাদা করুন।"
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Pas CA"
},
"editor": {
"label": "Pas CA",
"description": "Inicieu la sessió a través del vostre proveïdor d'identitat i deixeu que la vostra Step CA emeti un certificat SSH de curta durada per a aquest amfitrió. L'URL de la CA, l'empremta digital arrel i el proveïdor OIDC els estableix un administrador a Configuració de l'administrador.",
"docs": "Veure documents"
},
"dialog": {
"openBrowser": "Obre el navegador per autenticar-te",
"waiting": "Esperant l'autenticació al navegador...",
"authenticating": "Processant l'autenticació..."
},
"errors": {
"authFailed": "L'autenticació ha fallat. Si us plau, comproveu les vostres credencials i torneu-ho a intentar.",
"timeout": "S'ha esgotat el temps d'autenticació. Torna-ho a provar."
},
"settings": {
"fingerprint": {
"label": "Empremta digital arrel (SHA-256)"
},
"provisioner": {
"label": "Nom del proveïdor OIDC"
},
"privateEndpoints": {
"label": "Amfitrions privats permesos de Step CA",
"description": "Amfitrions privats amb els quals el flux de certificats de la CA de Step pot contactar: la mateixa CA i, si és intern, el vostre proveïdor d'identitat. Separeu-los amb comes."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Krok CA"
},
"editor": {
"label": "Krok CA",
"description": "Přihlaste se prostřednictvím svého poskytovatele identity a nechte svou certifikační autoritu Step CA vydat krátkodobý SSH certifikát pro tohoto hostitele. URL certifikační autority, otisk kořenového serveru a zřizovatel OIDC nastavuje administrátor v Nastavení správce.",
"docs": "Zobrazit dokumenty"
},
"dialog": {
"openBrowser": "Otevřít prohlížeč pro ověření",
"waiting": "Čekání na ověření v prohlížeči...",
"authenticating": "Zpracování ověřování..."
},
"errors": {
"authFailed": "Ověření se nezdařilo. Zkontrolujte prosím své přihlašovací údaje a zkuste to znovu.",
"timeout": "Časový limit ověření vypršel. Zkuste to prosím znovu."
},
"settings": {
"fingerprint": {
"label": "Kořenový otisk (SHA-256)"
},
"provisioner": {
"label": "Název zřizovatele OIDC"
},
"privateEndpoints": {
"label": "Povolení soukromí hostitelé Step CA",
"description": "Soukromí hostitelé, které může tok certifikátů Step CA kontaktovat: samotná CA a v případě interní certifikace také váš poskytovatel identity. Oddělte je čárkami."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Trin CA"
},
"editor": {
"label": "Trin CA",
"description": "Log ind via din identitetsudbyder, og lad din Step CA udstede et kortvarigt SSH-certifikat til denne vært. CA-URL'en, root-fingeraftrykket og OIDC-provisioneren indstilles af en administrator under administratorindstillinger.",
"docs": "Se dokumenter"
},
"dialog": {
"openBrowser": "Åbn browseren for at godkende",
"waiting": "Venter på godkendelse i browseren...",
"authenticating": "Behandler godkendelse..."
},
"errors": {
"authFailed": "Godkendelse mislykkedes. Tjek dine loginoplysninger, og prøv igen.",
"timeout": "Godkendelsen udløb. Prøv igen."
},
"settings": {
"fingerprint": {
"label": "Rodfingeraftryk (SHA-256)"
},
"provisioner": {
"label": "OIDC-provisioneringsnavn"
},
"privateEndpoints": {
"label": "Tilladte private Step CA-værter",
"description": "Private værter, som Step CA-certifikatflowet kan kontakte: selve CA'en og, hvis den er intern, din identitetsudbyder. Adskil dem med kommaer."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Schritt CA"
},
"editor": {
"label": "Schritt CA",
"description": "Melden Sie sich über Ihren Identitätsanbieter an und lassen Sie Ihre Step-Zertifizierungsstelle ein kurzlebiges SSH-Zertifikat für diesen Host ausstellen. Die URL der Zertifizierungsstelle, der Root-Fingerabdruck und der OIDC-Provisioner werden von einem Administrator unter „Admin-Einstellungen“ festgelegt.",
"docs": "Dokumentation anzeigen"
},
"dialog": {
"openBrowser": "Browser zum Authentifizieren öffnen",
"waiting": "Warten auf Authentifizierung im Browser...",
"authenticating": "Authentifizierung wird verarbeitet..."
},
"errors": {
"authFailed": "Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldeinformationen und versuchen Sie es erneut.",
"timeout": "Authentifizierung abgelaufen. Bitte versuchen Sie es erneut."
},
"settings": {
"fingerprint": {
"label": "Root-Fingerabdruck (SHA-256)"
},
"provisioner": {
"label": "OIDC-Provisionername"
},
"privateEndpoints": {
"label": "Zulässige private Step CA-Hosts",
"description": "Private Hosts, mit denen der Step CA-Zertifikatsfluss in Kontakt treten kann, kontaktieren möglicherweise die Zertifizierungsstelle selbst und, falls intern, Ihren Identitätsanbieter. Trennen Sie diese durch Kommas."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Βήμα CA"
},
"editor": {
"label": "Βήμα CA",
"description": "Συνδεθείτε μέσω του παρόχου ταυτότητας και αφήστε την Step CA σας να εκδώσει ένα βραχύβιο πιστοποιητικό SSH για αυτόν τον κεντρικό υπολογιστή. Η διεύθυνση URL CA, το δακτυλικό αποτύπωμα root και ο πάροχος OIDC ορίζονται από έναν διαχειριστή στις Ρυθμίσεις διαχειριστή.",
"docs": "Προβολή εγγράφων"
},
"dialog": {
"openBrowser": "Άνοιγμα προγράμματος περιήγησης για έλεγχο ταυτότητας",
"waiting": "Αναμονή για έλεγχο ταυτότητας στο πρόγραμμα περιήγησης...",
"authenticating": "Επεξεργασία ελέγχου ταυτότητας..."
},
"errors": {
"authFailed": "Ο έλεγχος ταυτότητας απέτυχε. Ελέγξτε τα διαπιστευτήριά σας και προσπαθήστε ξανά.",
"timeout": "Το χρονικό όριο ελέγχου ταυτότητας έληξε. Δοκιμάστε ξανά."
},
"settings": {
"fingerprint": {
"label": "Δακτυλικό αποτύπωμα ρίζας (SHA-256)"
},
"provisioner": {
"label": "Όνομα παρόχου OIDC"
},
"privateEndpoints": {
"label": "Επιτρεπόμενοι ιδιωτικοί κεντρικοί υπολογιστές Step CA",
"description": "Οι ιδιωτικοί κεντρικοί υπολογιστές της ροής πιστοποιητικών Step CA ενδέχεται να επικοινωνήσουν με: την ίδια την CA και, εάν είναι εσωτερική, τον πάροχο ταυτότητάς σας. Διαχωρίστε τους με κόμματα."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Paso CA"
},
"editor": {
"label": "Paso CA",
"description": "Inicie sesión a través de su proveedor de identidad y permita que su CA de Step emita un certificado SSH de corta duración para este host. La URL de la CA, la huella digital raíz y el proveedor OIDC se configuran mediante un administrador en la sección de Configuración de administración.",
"docs": "Ver documentación"
},
"dialog": {
"openBrowser": "Abrir navegador para autenticarse",
"waiting": "Esperando autenticación en el navegador...",
"authenticating": "Procesando autenticación..."
},
"errors": {
"authFailed": "La autenticación falló. Revisa tus credenciales e inténtalo de nuevo.",
"timeout": "La autenticación expiró. Inténtalo de nuevo."
},
"settings": {
"fingerprint": {
"label": "Huella digital de la raíz (SHA-256)"
},
"provisioner": {
"label": "nombre del proveedor OIDC"
},
"privateEndpoints": {
"label": "Hosts de CA de paso privado permitidos",
"description": "Los hosts privados con los que puede contactar el flujo de certificados de Step CA son: la propia CA y, si es interna, su proveedor de identidad. Sepárelos con comas."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Vaihe CA"
},
"editor": {
"label": "Vaihe CA",
"description": "Kirjaudu sisään identiteetintarjoajasi kautta ja anna vaiheen varmentajan myöntää lyhytaikainen SSH-varmenne tälle isännälle. Varmentajan URL-osoitteen, pääkäyttäjän sormenjäljen ja OIDC-varmenteen määrittäjän määrittää järjestelmänvalvojan asetuksissa.",
"docs": "Näytä dokumentit"
},
"dialog": {
"openBrowser": "Avaa selain todennusta varten",
"waiting": "Odotetaan selaimen todennusta...",
"authenticating": "Todennusta käsitellään..."
},
"errors": {
"authFailed": "Todennus epäonnistui. Tarkista tunnistetietosi ja yritä uudelleen.",
"timeout": "Todennus aikakatkaistiin. Yritä uudelleen."
},
"settings": {
"fingerprint": {
"label": "Juurisormenjälki (SHA-256)"
},
"provisioner": {
"label": "OIDC-palveluntarjoajan nimi"
},
"privateEndpoints": {
"label": "Sallitut yksityiset Step CA -isännät",
"description": "Yksityiset isännöitsijät, joilla Step CA -varmennetyönkulku voi ottaa yhteyttä: itse CA:han ja, jos kyseessä on sisäinen varmenne, identiteetintarjoajaasi. Erota ne pilkuilla."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Étape CA"
},
"editor": {
"label": "Étape CA",
"description": "Connectez-vous via votre fournisseur d'identité et laissez votre autorité de certification Step CA émettre un certificat SSH temporaire pour cet hôte. L'URL de l'autorité de certification, l'empreinte racine et le provisionneur OIDC sont configurés par un administrateur dans les paramètres d'administration.",
"docs": "Voir la documentation"
},
"dialog": {
"openBrowser": "Ouvrir le navigateur pour s'authentifier",
"waiting": "En attente de l'authentification dans le navigateur...",
"authenticating": "Traitement de l'authentification..."
},
"errors": {
"authFailed": "Échec de l'authentification. Veuillez vérifier vos identifiants et réessayer.",
"timeout": "L'authentification a expiré. Veuillez réessayer."
},
"settings": {
"fingerprint": {
"label": "Empreinte racine (SHA-256)"
},
"provisioner": {
"label": "Nom du fournisseur OIDC"
},
"privateEndpoints": {
"label": "Hôtes d'autorité de certification privés autorisés",
"description": "Les hôtes privés pour lesquels le flux de certificats Step CA peut être sollicité peuvent contacter : l’autorité de certification elle-même et, si elle est interne, votre fournisseur d’identité. Séparez-les par des virgules."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "שלב CA"
},
"editor": {
"label": "שלב CA",
"description": "היכנס דרך ספק הזהויות שלך ותן ל-Step CA שלך להנפיק אישור SSH קצר מועד עבור מארח זה. כתובת ה-URL של CA, טביעת האצבע של הבסיס וספק ה-OIDC מוגדרים על ידי מנהל מערכת תחת הגדרות מנהל מערכת.",
"docs": "הצג מסמכים"
},
"dialog": {
"openBrowser": "פתח את הדפדפן כדי לאמת",
"waiting": "ממתין לאימות בדפדפן...",
"authenticating": "מעבד אימות..."
},
"errors": {
"authFailed": "האימות נכשל. אנא בדוק את פרטי הגישה שלך ונסה שוב.",
"timeout": "הזמן שהוקצב לאימות הסתיים. אנא נסה שוב."
},
"settings": {
"fingerprint": {
"label": "טביעת אצבע של שורש (SHA-256)"
},
"provisioner": {
"label": "שם ספק OIDC"
},
"privateEndpoints": {
"label": "מארחים פרטיים של Step CA מורשים",
"description": "מארחים פרטיים, זרימת האישורים של Step CA עשויה ליצור קשר עם: רשות האישורים עצמה, ואם היא פנימית, עם ספק הזהויות שלך. הפרידו ביניהם באמצעות פסיקים."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "स्टेप सीए"
},
"editor": {
"label": "स्टेप सीए",
"description": "अपने आइडेंटिटी प्रोवाइडर के माध्यम से साइन इन करें और अपने स्टेप सीए को इस होस्ट के लिए एक अल्पकालिक एसएसएच प्रमाणपत्र जारी करने दें। सीए यूआरएल, रूट फिंगरप्रिंट और ओआईडीसी प्रोविजनर को एडमिनिस्ट्रेटर द्वारा एडमिन सेटिंग्स के अंतर्गत सेट किया जाता है।",
"docs": "दस्तावेज़ देखें"
},
"dialog": {
"openBrowser": "प्रमाणीकरण के लिए ब्राउज़र खोलें",
"waiting": "ब्राउज़र में प्रमाणीकरण की प्रतीक्षा हो रही है...",
"authenticating": "प्रमाणीकरण प्रक्रिया में है..."
},
"errors": {
"authFailed": "प्रमाणीकरण विफल। कृपया अपने क्रेडेंशियल जाँचें और पुनः प्रयास करें।",
"timeout": "प्रमाणीकरण का समय समाप्त हो गया। कृपया पुनः प्रयास करें।"
},
"settings": {
"fingerprint": {
"label": "रूट फिंगरप्रिंट (SHA-256)"
},
"provisioner": {
"label": "OIDC प्रोविज़नर नाम"
},
"privateEndpoints": {
"label": "निजी स्टेप सीए होस्ट की अनुमति है",
"description": "निजी होस्ट, स्टेप सीए प्रमाणपत्र प्रवाह के दौरान निम्नलिखित से संपर्क कर सकते हैं: सीए स्वयं और, यदि आंतरिक है, तो आपका पहचान प्रदाता। इन्हें अल्पविराम से अलग करें।"
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "CA lépés"
},
"editor": {
"label": "CA lépés",
"description": "Jelentkezzen be az identitásszolgáltatóján keresztül, és hagyja, hogy a lépéses hitelesítésszolgáltatója rövid életű SSH-tanúsítványt állítson ki ehhez a gazdagéphez. A hitelesítésszolgáltató URL-címét, a root ujjlenyomatát és az OIDC-kiszolgálót a rendszergazda állítja be az Adminisztrátori beállítások alatt.",
"docs": "Dokumentumok megtekintése"
},
"dialog": {
"openBrowser": "Böngésző megnyitása a hitelesítéshez",
"waiting": "Várakozás a böngésző hitelesítésére...",
"authenticating": "Hitelesítés feldolgozása..."
},
"errors": {
"authFailed": "Sikertelen hitelesítés. Kérjük, ellenőrizze a hitelesítő adatait, és próbálja újra.",
"timeout": "A hitelesítés időtúllépést okozott. Kérjük, próbálja újra."
},
"settings": {
"fingerprint": {
"label": "Gyökér ujjlenyomat (SHA-256)"
},
"provisioner": {
"label": "OIDC-szolgáltató neve"
},
"privateEndpoints": {
"label": "Engedélyezett privát Step CA-hosztok",
"description": "A Step CA tanúsítványfolyamat által érintett privát hosztok a következőkkel vehetik fel a kapcsolatot: maga a CA, és ha belső, akkor az identitásszolgáltató. Vesszővel válassza el őket."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Langkah CA"
},
"editor": {
"label": "Langkah CA",
"description": "Masuk melalui penyedia identitas Anda dan biarkan Step CA Anda menerbitkan sertifikat SSH berumur pendek untuk host ini. URL CA, sidik jari root, dan penyedia OIDC diatur oleh administrator di bawah Pengaturan Admin.",
"docs": "Lihat dokumen"
},
"dialog": {
"openBrowser": "Buka Browser untuk Melakukan Otentikasi",
"waiting": "Menunggu otentikasi di browser...",
"authenticating": "Memproses otentikasi..."
},
"errors": {
"authFailed": "Autentikasi gagal. Harap periksa kredensial Anda dan coba lagi.",
"timeout": "Autentikasi habis waktu. Silakan coba lagi."
},
"settings": {
"fingerprint": {
"label": "Sidik jari akar (SHA-256)"
},
"provisioner": {
"label": "Nama penyedia OIDC"
},
"privateEndpoints": {
"label": "Host CA Langkah pribadi yang diizinkan",
"description": "Host privat yang dapat dihubungi oleh alur sertifikat Step CA adalah: CA itu sendiri dan, jika internal, penyedia identitas Anda. Pisahkan mereka dengan koma."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Passo CA"
},
"editor": {
"label": "Passo CA",
"description": "Accedi tramite il tuo provider di identità e lascia che la tua CA Step emetta un certificato SSH temporaneo per questo host. L'URL della CA, l'impronta digitale della radice e il provisioner OIDC vengono impostati da un amministratore nelle Impostazioni di amministrazione.",
"docs": "Visualizza documentazione"
},
"dialog": {
"openBrowser": "Apri il browser per autenticarti",
"waiting": "In attesa dell'autenticazione nel browser...",
"authenticating": "Autenticazione in corso..."
},
"errors": {
"authFailed": "Autenticazione fallita. Controlla le credenziali e riprova.",
"timeout": "Autenticazione scaduta. Riprova."
},
"settings": {
"fingerprint": {
"label": "Impronta digitale radice (SHA-256)"
},
"provisioner": {
"label": "Nome del fornitore OIDC"
},
"privateEndpoints": {
"label": "Host Step CA privati consentiti",
"description": "Gli host privati che utilizzano il flusso di certificati Step CA potrebbero contattare: la CA stessa e, se interna, il provider di identità. Separarli con delle virgole."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "ステップCA"
},
"editor": {
"label": "ステップCA",
"description": "IDプロバイダー経由でサインインし、Step CAにこのホスト用の有効期限の短いSSH証明書を発行させてください。CAのURL、ルートフィンガープリント、およびOIDCプロビジョナーは、管理者が「管理設定」で設定します。",
"docs": "ドキュメントを表示"
},
"dialog": {
"openBrowser": "ブラウザを開いて認証",
"waiting": "ブラウザでの認証を待機中...",
"authenticating": "認証を処理中..."
},
"errors": {
"authFailed": "認証に失敗しました。資格情報を確認して再試行してください。",
"timeout": "認証がタイムアウトしました。もう一度お試しください。"
},
"settings": {
"fingerprint": {
"label": "ルートフィンガープリント(SHA-256)"
},
"provisioner": {
"label": "OIDCプロビジョナー名"
},
"privateEndpoints": {
"label": "許可されたプライベートStep CAホスト",
"description": "プライベートホストの場合、Step CA 証明書フローは、認証局自体、および内部の場合は ID プロバイダーと連絡を取ることができます。これらはカンマで区切ってください。"
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "스텝 CA"
},
"editor": {
"label": "스텝 CA",
"description": "ID 공급자를 통해 로그인하고 Step CA에서 이 호스트에 대한 단기 SSH 인증서를 발급받으세요. CA URL, 루트 지문 및 OIDC 프로비저너는 관리자가 관리자 설정에서 설정합니다.",
"docs": "문서 보기"
},
"dialog": {
"openBrowser": "브라우저로 인증하기",
"waiting": "브라우저에서 인증 대기 중...",
"authenticating": "인증 처리 중..."
},
"errors": {
"authFailed": "인증에 실패했습니다. 자격 증명을 확인하고 다시 시도하세요.",
"timeout": "인증 시간이 초과되었습니다. 다시 시도하세요."
},
"settings": {
"fingerprint": {
"label": "루트 지문(SHA-256)"
},
"provisioner": {
"label": "OIDC 프로비저너 이름"
},
"privateEndpoints": {
"label": "허용된 개인 Step CA 호스트",
"description": "Step CA 인증서 흐름이 접촉할 수 있는 개인 호스트는 CA 자체와 (내부 호스트인 경우) ID 공급자입니다. 이들을 쉼표로 구분하세요."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Stap CA"
},
"editor": {
"label": "Stap CA",
"description": "Meld u aan via uw identiteitsprovider en laat uw Step CA een kortstondig SSH-certificaat voor deze host uitgeven. De CA-URL, root-fingerprint en OIDC-provisioner worden door een beheerder ingesteld onder Beheerinstellingen.",
"docs": "Documenten bekijken"
},
"dialog": {
"openBrowser": "Open de browser om te authenticeren.",
"waiting": "Wachten op authenticatie in de browser...",
"authenticating": "Authenticatie wordt verwerkt..."
},
"errors": {
"authFailed": "Authenticatie mislukt. Controleer uw inloggegevens en probeer het opnieuw.",
"timeout": "De authenticatie is mislukt vanwege een time-out. Probeer het opnieuw."
},
"settings": {
"fingerprint": {
"label": "Root-vingerafdruk (SHA-256)"
},
"provisioner": {
"label": "Naam van de OIDC-leverancier"
},
"privateEndpoints": {
"label": "Toegestane privé Step CA-hosts",
"description": "De private hosts waarmee de Step CA-certificaatstroom contact kan opnemen, zijn: de certificeringsinstantie zelf en, indien intern, uw identiteitsprovider. Scheid deze met komma's."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Trinn CA"
},
"editor": {
"label": "Trinn CA",
"description": "Logg på via identitetsleverandøren din, og la Step CA utstede et kortvarig SSH-sertifikat for denne verten. CA-URL-en, rotfingeravtrykket og OIDC-provisioneren angis av en administrator under Administratorinnstillinger.",
"docs": "Vis dokumenter"
},
"dialog": {
"openBrowser": "Åpne nettleseren for å autentisere",
"waiting": "Venter på autentisering i nettleseren...",
"authenticating": "Behandler autentisering ..."
},
"errors": {
"authFailed": "Autentiseringen mislyktes. Sjekk legitimasjonen din og prøv på nytt.",
"timeout": "Autentiseringen ble tidsavbrutt. Prøv på nytt."
},
"settings": {
"fingerprint": {
"label": "Rotfingeravtrykk (SHA-256)"
},
"provisioner": {
"label": "OIDC-provisionernavn"
},
"privateEndpoints": {
"label": "Tillatte private Step CA-verter",
"description": "Private verter som Step CA-sertifikatflyten kan kontakte: selve CA-en og, hvis intern, identitetsleverandøren din. Skill dem med komma."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Krok CA"
},
"editor": {
"label": "Krok CA",
"description": "Zaloguj się za pomocą swojego dostawcy tożsamości i pozwól, aby urząd certyfikacji Step CA wydał tymczasowy certyfikat SSH dla tego hosta. Adres URL urzędu certyfikacji, odcisk palca głównego i dostawca OIDC są ustawiane przez administratora w Ustawieniach administratora.",
"docs": "Zobacz dokumentację"
},
"dialog": {
"openBrowser": "Otwórz przeglądarkę, aby uwierzytelnić",
"waiting": "Oczekiwanie na uwierzytelnienie w przeglądarce...",
"authenticating": "Przetwarzanie uwierzytelniania..."
},
"errors": {
"authFailed": "Uwierzytelnienie nie powiodło się. Sprawdź poświadczenia i spróbuj ponownie.",
"timeout": "Upłynął limit czasu uwierzytelniania. Spróbuj ponownie."
},
"settings": {
"fingerprint": {
"label": "Odcisk palca głównego (SHA-256)"
},
"provisioner": {
"label": "Nazwa dostawcy OIDC"
},
"privateEndpoints": {
"label": "Dozwolone prywatne hosty Step CA",
"description": "Prywatni hosty, z których przepływ certyfikatów Step CA może się kontaktować: z samym CA oraz, jeśli jest wewnętrzny, z Twoim dostawcą tożsamości. Rozdziel je przecinkami."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Etapa CA"
},
"editor": {
"label": "Etapa CA",
"description": "Faça login através do seu provedor de identidade e permita que a sua Autoridade Certificadora (CA) Step emita um certificado SSH de curta duração para este host. O URL da CA, a impressão digital da raiz e o provisionador OIDC são definidos por um administrador em Configurações de Administrador.",
"docs": "Ver documentos"
},
"dialog": {
"openBrowser": "Abra o navegador para autenticar.",
"waiting": "Aguardando autenticação no navegador...",
"authenticating": "Processando autenticação..."
},
"errors": {
"authFailed": "A autenticação falhou. Verifique suas credenciais e tente novamente.",
"timeout": "A autenticação expirou. Tente novamente."
},
"settings": {
"fingerprint": {
"label": "Impressão digital raiz (SHA-256)"
},
"provisioner": {
"label": "nome do provisionador OIDC"
},
"privateEndpoints": {
"label": "Hosts CA privados permitidos",
"description": "Para hosts privados, o fluxo de certificados da Step CA pode contatar: a própria CA e, se interna, seu provedor de identidade. Separe-os com vírgulas."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Etapa CA"
},
"editor": {
"label": "Etapa CA",
"description": "Faça login através do seu fornecedor de identidade e permita que a sua Autoridade Certificadora (CA) Step emita um certificado SSH de curta duração para este host. O URL da AC, a impressão digital da raiz e o provisionador OIDC são definidos por um administrador em Definições de Administrador.",
"docs": "Ver documentação"
},
"dialog": {
"openBrowser": "Abrir navegador para autenticar",
"waiting": "A aguardar autenticação no navegador...",
"authenticating": "A processar autenticação..."
},
"errors": {
"authFailed": "A autenticação falhou. Verifique as suas credenciais e tente novamente.",
"timeout": "A autenticação expirou. Tente novamente."
},
"settings": {
"fingerprint": {
"label": "Impressão digital de raiz (SHA-256)"
},
"provisioner": {
"label": "nome do provisionador OIDC"
},
"privateEndpoints": {
"label": "Hosts CA privados permitidos",
"description": "Para hosts privados, o fluxo de certificados da Step CA pode contactar: a própria CA e, se for interna, o seu fornecedor de identidade. Separe-os com vírgulas."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Pasul CA"
},
"editor": {
"label": "Pasul CA",
"description": "Conectați-vă prin intermediul furnizorului de identitate și permiteți autorității de certificare Step CA să emită un certificat SSH de scurtă durată pentru această gazdă. Adresa URL a autorității de certificare, amprenta rădăcină și furnizorul OIDC sunt setate de un administrator în Setări administrator.",
"docs": "Vizualizați documentele"
},
"dialog": {
"openBrowser": "Deschideți browserul pentru autentificare",
"waiting": "Se așteaptă autentificarea în browser...",
"authenticating": "Se procesează autentificarea..."
},
"errors": {
"authFailed": "Autentificarea a eșuat. Verificați acreditările și încercați din nou.",
"timeout": "Autentificarea a expirat. Vă rugăm să încercați din nou."
},
"settings": {
"fingerprint": {
"label": "Amprentă digitală rădăcină (SHA-256)"
},
"provisioner": {
"label": "Numele furnizorului OIDC"
},
"privateEndpoints": {
"label": "Gazde private permise de Step CA",
"description": "Gazde private pe care fluxul de certificate Step CA le poate contacta: CA-ul în sine și, dacă este intern, furnizorul dvs. de identitate. Separați-le prin virgule."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Шаг CA"
},
"editor": {
"label": "Шаг CA",
"description": "Войдите в систему через своего поставщика идентификации и позвольте вашему центру сертификации Step CA выдать краткосрочный SSH-сертификат для этого хоста. URL-адрес центра сертификации, отпечаток корневого сертификата и средство подготовки OIDC задаются администратором в разделе «Настройки администратора».",
"docs": "Документация"
},
"dialog": {
"openBrowser": "Открыть браузер для аутентификации",
"waiting": "Ожидание аутентификации в браузере...",
"authenticating": "Обработка аутентификации..."
},
"errors": {
"authFailed": "Аутентификация не удалась. Проверьте учётные данные и попробуйте снова.",
"timeout": "Время аутентификации истекло. Попробуйте снова."
},
"settings": {
"fingerprint": {
"label": "Отпечаток корневого ключа (SHA-256)"
},
"provisioner": {
"label": "имя поставщика OIDC"
},
"privateEndpoints": {
"label": "Разрешены частные хосты Step CA",
"description": "Частные хосты, для которых выполняется процедура сертификации Step CA, могут связаться с: самим центром сертификации и, если он внутренний, с вашим поставщиком идентификации. Разделяйте их запятыми."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Корак CA"
},
"editor": {
"label": "Корак CA",
"description": "Пријавите се преко свог добављача идентитета и дозволите свом Step CA да изда краткотрајни SSH сертификат за овај хост. CA URL, root отисак прста и OIDC provisioner подешава администратор у подешавањима администратора.",
"docs": "Прикажи документе"
},
"dialog": {
"openBrowser": "Отворите прегледач за аутентификацију",
"waiting": "Чекање аутентификације у прегледачу...",
"authenticating": "Обрада аутентификације..."
},
"errors": {
"authFailed": "Аутентификација није успела. Проверите своје акредитиве и покушајте поново.",
"timeout": "Временско ограничење за аутентификацију је истекло. Молимо покушајте поново."
},
"settings": {
"fingerprint": {
"label": "Отисак коренског идентификатора (SHA-256)"
},
"provisioner": {
"label": "Име OIDC провизора"
},
"privateEndpoints": {
"label": "Дозвољени приватни хостови Step CA",
"description": "Приватни хостови које ток сертификата Step CA може контактирати: сам CA и, ако је интерно, ваш добављач идентитета. Раздвојите их зарезима."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Steg CA"
},
"editor": {
"label": "Steg CA",
"description": "Logga in via din identitetsleverantör och låt din Step CA utfärda ett kortlivat SSH-certifikat för den här värden. CA-URL:en, rotfingeravtrycket och OIDC-provisionern ställs in av en administratör under administratörsinställningar.",
"docs": "Visa dokument"
},
"dialog": {
"openBrowser": "Öppna webbläsaren för att autentisera",
"waiting": "Väntar på autentisering i webbläsaren...",
"authenticating": "Bearbetar autentisering..."
},
"errors": {
"authFailed": "Autentiseringen misslyckades. Kontrollera dina inloggningsuppgifter och försök igen.",
"timeout": "Autentiseringen har gått ut. Försök igen."
},
"settings": {
"fingerprint": {
"label": "Rotfingeravtryck (SHA-256)"
},
"provisioner": {
"label": "OIDC-provisioneringsnamn"
},
"privateEndpoints": {
"label": "Tillåtna privata Step CA-värdar",
"description": "Privata värdar som Step CA-certifikatflödet kan kontakta: själva CA:n och, om den är intern, din identitetsleverantör. Separera dem med kommatecken."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "ขั้นตอน CA"
},
"editor": {
"label": "ขั้นตอน CA",
"description": "ลงชื่อเข้าใช้ผ่านผู้ให้บริการยืนยันตัวตนของคุณ และให้ Step CA ออกใบรับรอง SSH ที่มีอายุสั้นสำหรับโฮสต์นี้ URL ของ CA, ลายนิ้วมือรูท และตัวจัดเตรียม OIDC จะถูกตั้งค่าโดยผู้ดูแลระบบภายใต้การตั้งค่าผู้ดูแลระบบ",
"docs": "ดูเอกสาร"
},
"dialog": {
"openBrowser": "เปิดเบราว์เซอร์เพื่อยืนยันตัวตน",
"waiting": "กำลังรอการยืนยันตัวตนในเบราว์เซอร์...",
"authenticating": "กำลังตรวจสอบสิทธิ์..."
},
"errors": {
"authFailed": "การตรวจสอบสิทธิ์ล้มเหลว โปรดตรวจสอบข้อมูลประจำตัวของคุณและลองอีกครั้ง",
"timeout": "การตรวจสอบสิทธิ์หมดเวลา โปรดลองอีกครั้ง"
},
"settings": {
"fingerprint": {
"label": "ลายนิ้วมือราก (SHA-256)"
},
"provisioner": {
"label": "ชื่อผู้จัดเตรียม OIDC"
},
"privateEndpoints": {
"label": "อนุญาตให้โฮสต์ Step CA ส่วนตัว",
"description": "สำหรับโฮสต์ส่วนตัว กระบวนการขอใบรับรอง Step CA อาจติดต่อกับ: CA เอง และหากเป็นโฮสต์ภายใน ก็อาจติดต่อกับผู้ให้บริการยืนยันตัวตนของคุณด้วย คั่นด้วยเครื่องหมายจุลภาค"
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Adım CA"
},
"editor": {
"label": "Adım CA",
"description": "Kimlik sağlayıcınız aracılığıyla oturum açın ve Step CA'nızın bu sunucu için kısa süreli bir SSH sertifikası düzenlemesine izin verin. CA URL'si, kök parmak izi ve OIDC sağlayıcısı, yönetici tarafından Yönetici Ayarları altında belirlenir.",
"docs": "Dokümanları görüntüle"
},
"dialog": {
"openBrowser": "Kimlik Doğrulaması için Tarayıcı Aç",
"waiting": "Tarayıcıda kimlik doğrulaması bekleniyor...",
"authenticating": "Kimlik doğrulaması işleniyor..."
},
"errors": {
"authFailed": "Kimlik doğrulaması başarısız oldu. Lütfen kimlik bilgilerinizi kontrol edip tekrar deneyin.",
"timeout": "Kimlik doğrulaması zaman aşımına uğradı. Lütfen tekrar deneyin."
},
"settings": {
"fingerprint": {
"label": "Kök parmak izi (SHA-256)"
},
"provisioner": {
"label": "OIDC tedarikçi adı"
},
"privateEndpoints": {
"label": "Özel Step CA sunucularına izin verildi.",
"description": "Özel sunucular, Step CA sertifika akışı kapsamında şu kuruluşlarla iletişime geçebilir: CA'nın kendisi ve (dahili ise) kimlik sağlayıcınız. Bunları virgülle ayırın."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Крок CA"
},
"editor": {
"label": "Крок CA",
"description": "Увійдіть через свого постачальника ідентифікаційних даних і дозвольте вашому центру сертифікації Step CA видати короткочасний SSH-сертифікат для цього хоста. URL-адресу центру сертифікації, кореневий відбиток і OIDC-провайдер встановлює адміністратор у розділі «Налаштування адміністратора».",
"docs": "Переглянути документи"
},
"dialog": {
"openBrowser": "Відкрийте браузер для автентифікації",
"waiting": "Очікування автентифікації у браузері...",
"authenticating": "Обробка автентифікації..."
},
"errors": {
"authFailed": "Помилка автентифікації. Перевірте свої облікові дані та спробуйте ще раз.",
"timeout": "Час очікування автентифікації минув. Спробуйте ще раз."
},
"settings": {
"fingerprint": {
"label": "Кореневий відбиток (SHA-256)"
},
"provisioner": {
"label": "Ім'я постачальника OIDC"
},
"privateEndpoints": {
"label": "Дозволені приватні хости Step CA",
"description": "Приватні хости, з якими може зв’язатися потік сертифікатів Step CA: сам CA та, якщо внутрішній, ваш постачальник ідентифікаційних даних. Розділяйте їх комами."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Bước CA"
},
"editor": {
"label": "Bước CA",
"description": "Đăng nhập thông qua nhà cung cấp danh tính của bạn và cho phép Step CA cấp chứng chỉ SSH có thời hạn ngắn cho máy chủ này. URL CA, dấu vân tay gốc và trình cung cấp OIDC được quản trị viên thiết lập trong Cài đặt quản trị.",
"docs": "Xem tài liệu"
},
"dialog": {
"openBrowser": "Mở trình duyệt để xác thực",
"waiting": "Đang chờ xác thực trên trình duyệt...",
"authenticating": "Đang xử lý xác thực..."
},
"errors": {
"authFailed": "Xác thực thất bại. Vui lòng kiểm tra thông tin đăng nhập và thử lại.",
"timeout": "Xác thực đã hết thời gian chờ. Vui lòng thử lại."
},
"settings": {
"fingerprint": {
"label": "Dấu vân tay gốc (SHA-256)"
},
"provisioner": {
"label": "Tên trình cung cấp OIDC"
},
"privateEndpoints": {
"label": "Cho phép máy chủ Step CA riêng tư",
"description": "Các máy chủ riêng mà quy trình chứng chỉ CA từng bước có thể liên hệ: chính CA và, nếu là máy chủ nội bộ, nhà cung cấp danh tính của bạn. Phân tách chúng bằng dấu phẩy."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "步骤 CA"
},
"editor": {
"label": "步骤 CA",
"description": "通过您的身份提供商登录,并让您的 Step CA 为此主机颁发一个短期 SSH 证书。CA URL、根指纹和 OIDC 配置器由管理员在“管理设置”中设置。",
"docs": "查看文档"
},
"dialog": {
"openBrowser": "打开浏览器进行认证",
"waiting": "等待浏览器认证...",
"authenticating": "正在处理认证..."
},
"errors": {
"authFailed": "认证失败,请检查凭据并重试。",
"timeout": "认证超时,请重试。"
},
"settings": {
"fingerprint": {
"label": "根指纹(SHA-256)"
},
"provisioner": {
"label": "OIDC配置器名称"
},
"privateEndpoints": {
"label": "允许的私有步骤 CA 主机",
"description": "私有主机在执行步骤 CA 证书流程时可能会联系:CA 本身,以及(如果是内部)您的身份提供商。请用逗号分隔它们。"
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "步驟 CA"
},
"editor": {
"label": "步驟 CA",
"description": "透過您的身分提供者登錄,並讓您的 Step CA 為此主機頒發短期 SSH 憑證。 CA URL、根指紋和 OIDC 配置器由管理員在「管理設定」中設定。",
"docs": "檢視文件"
},
"dialog": {
"openBrowser": "開啟瀏覽器以進行驗證",
"waiting": "正在等待瀏覽器中的驗證…",
"authenticating": "正在處理驗證…"
},
"errors": {
"authFailed": "驗證失敗。請檢查您的憑證並再試一次。",
"timeout": "驗證逾時。請再試一次。"
},
"settings": {
"fingerprint": {
"label": "根指紋(SHA-256)"
},
"provisioner": {
"label": "OIDC配置器名稱"
},
"privateEndpoints": {
"label": "允許的私有步驟 CA 主機",
"description": "私有主機在執行步驟 CA 憑證流程時可能會聯絡:CA 本身,以及(如果是內部)您的身分提供者。請用逗號分隔它們。"
}
}
}
+77
View File
@@ -0,0 +1,77 @@
{
"id": "step-ca",
"name": "Step CA",
"version": "1.0.0",
"description": "Sign in to hosts with short-lived SSH certificates from a smallstep step-ca server, using its OIDC provisioner in the browser. Termix calls the CA's HTTPS API directly and never runs the step binary.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Access & Security",
"icon": "ShieldCheck",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"db:own",
"secrets:own",
"network:outbound",
"network:serve",
"auth:provide",
"ui:surface"
],
"contributes": {
"auth": {
"sshAuthTypes": ["stepca"]
},
"settings": {
"admin": [
{
"key": "caUrl",
"type": "string",
"labelKey": "settings.caUrl.label",
"descriptionKey": "settings.caUrl.description",
"placeholderKey": "settings.caUrl.placeholder",
"default": ""
},
{
"key": "fingerprint",
"type": "string",
"labelKey": "settings.fingerprint.label",
"descriptionKey": "settings.fingerprint.description",
"default": ""
},
{
"key": "provisioner",
"type": "string",
"labelKey": "settings.provisioner.label",
"descriptionKey": "settings.provisioner.description",
"default": ""
},
{
"key": "privateEndpoints",
"type": "textarea",
"labelKey": "settings.privateEndpoints.label",
"descriptionKey": "settings.privateEndpoints.description",
"default": ""
},
{
"key": "redirectUri",
"type": "custom",
"component": "redirectUri"
},
{
"key": "legacyCallback",
"type": "boolean",
"labelKey": "settings.legacyCallback.label",
"descriptionKey": "settings.legacyCallback.description",
"default": false
}
]
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,16 @@
-- step-ca 0001: create_certs
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `p_step_ca_certs` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`host_id` int NOT NULL,
`ssh_cert` text NOT NULL,
`private_key` text NOT NULL,
`email` text,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`expires_at` varchar(255) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
CREATE UNIQUE INDEX `idx_step_ca_certs_user_host` ON `p_step_ca_certs` (`user_id`, `host_id`);
@@ -0,0 +1,16 @@
-- step-ca 0001: create_certs
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "p_step_ca_certs" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"host_id" integer NOT NULL,
"ssh_cert" text NOT NULL,
"private_key" text NOT NULL,
"email" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" varchar(255) NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_step_ca_certs_user_host" ON "p_step_ca_certs" ("user_id", "host_id");
+51
View File
@@ -0,0 +1,51 @@
{
"version": 1,
"tables": {
"certs": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"sshCert": {
"type": "text",
"notNull": true
},
"privateKey": {
"type": "text",
"notNull": true
},
"email": {
"type": "text"
},
"createdAt": {
"type": "text",
"notNull": true,
"defaultNow": true
},
"expiresAt": {
"type": "varchar",
"length": 255,
"notNull": true
}
},
"indexes": [
{
"name": "idx_step_ca_certs_user_host",
"columns": ["userId", "hostId"],
"unique": true
}
]
}
}
}
@@ -0,0 +1,16 @@
-- step-ca 0001: create_certs
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "p_step_ca_certs" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"host_id" integer NOT NULL,
"ssh_cert" text NOT NULL,
"private_key" text NOT NULL,
"email" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" text NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_step_ca_certs_user_host" ON "p_step_ca_certs" ("user_id", "host_id");
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@termix-plugin/step-ca",
"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": {
"redis": "^6.2.1"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
+378
View File
@@ -0,0 +1,378 @@
import { randomBytes } from "node:crypto";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { CertStore } from "./cert-store.js";
import type { CallbackQuery, CallbackResult, Runtime } from "./runtime.js";
import {
buildAuthorizationUrl,
createPkce,
decodeJwtClaims,
discoverOidcEndpoints,
exchangeCodeForIdToken,
fetchRootCertificate,
findOidcProvisioner,
generateSshKeyPair,
parseSshCertificate,
signSshCertificate,
type StepCaTarget,
} from "./client.js";
/** The redirect URI 2.8 registered with identity providers. */
export const LEGACY_CALLBACK_PATH = "/host/step-ca-callback";
export const CALLBACK_PATH = "/plugin-api/step-ca/callback";
const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
const REMOTE_CALLBACK_WAIT_MS = 30_000;
const REMOTE_POLL_MS = 250;
/** The terminal socket, as far as a sign-in needs it. */
export interface SignInSocket {
send: (data: string) => void;
on: (event: "close", listener: () => void) => void;
}
export interface StartRequest {
userId: string;
hostId: number;
username: string;
socket: SignInSocket;
requestOrigin: string;
}
interface Session {
state: string;
userId: string;
hostId: number;
username: string;
socket: SignInSocket;
target: StepCaTarget;
rootPem: string;
clientId: string;
clientSecret?: string;
tokenEndpoint: string;
redirectUri: string;
codeVerifier: string;
nonce: string;
keyPair: { publicKeyLine: string; privateKeyPem: string };
timeout: ReturnType<typeof setTimeout>;
commandPoll: ReturnType<typeof setInterval> | null;
processing: boolean;
completed: boolean;
}
export interface StepCaSettings {
caUrl: string;
fingerprint: string;
provisioner: string;
privateEndpoints: string[];
}
/** Comma or newline separated hosts, lower-cased. */
export function parseHostList(raw: unknown): string[] {
if (typeof raw !== "string") return [];
return raw
.split(/[\s,]+/)
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
}
function send(socket: SignInSocket, message: Record<string, unknown>): void {
try {
socket.send(JSON.stringify(message));
} catch {
// socket already gone
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export type AuthSessions = ReturnType<typeof createAuthSessions>;
/**
* Step CA sign-ins in flight: the OIDC authorization-code flow against the
* CA's provisioner, then the CA signs a fresh key. Created per activation.
*/
export function createAuthSessions(
ctx: PluginContext,
deps: { certs: CertStore; runtime: Runtime },
) {
const sessions = new Map<string, Session>();
async function readSettings(): Promise<StepCaSettings | null> {
const [caUrl, fingerprint, provisioner, privateEndpoints] =
await Promise.all([
ctx.settings.get<string>("caUrl"),
ctx.settings.get<string>("fingerprint"),
ctx.settings.get<string>("provisioner"),
ctx.settings.get<string>("privateEndpoints"),
]);
if (!caUrl?.trim() || !fingerprint?.trim() || !provisioner?.trim()) {
return null;
}
return {
caUrl: caUrl.trim(),
fingerprint: fingerprint.trim(),
provisioner: provisioner.trim(),
privateEndpoints: parseHostList(privateEndpoints),
};
}
async function callbackPath(): Promise<string> {
const legacy = await ctx.settings.get<boolean>("legacyCallback");
return legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH;
}
function end(session: Session, removeRuntime = true): void {
clearTimeout(session.timeout);
if (session.commandPoll) clearInterval(session.commandPoll);
sessions.delete(session.state);
if (removeRuntime) void deps.runtime.remove(session.state);
}
async function finish(
session: Session,
query: CallbackQuery,
removeRuntime = true,
): Promise<CallbackResult> {
if (query.error || !query.code) {
const message =
query.error_description || query.error || "Sign-in failed";
send(session.socket, {
type: "stepca_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
end(session, removeRuntime);
return { ok: false, message };
}
try {
send(session.socket, {
type: "stepca_status",
requestId: session.state,
stage: "authenticating",
});
const idToken = await exchangeCodeForIdToken({
fetch: ctx.fetch,
tokenEndpoint: session.tokenEndpoint,
clientId: session.clientId,
clientSecret: session.clientSecret,
code: query.code,
redirectUri: session.redirectUri,
codeVerifier: session.codeVerifier,
allowedPrivateHosts: session.target.allowedPrivateHosts,
});
const claims = decodeJwtClaims(idToken);
if (claims.nonce !== session.nonce) {
throw new Error("The identity token does not match this sign-in");
}
const email = typeof claims.email === "string" ? claims.email : undefined;
const certificate = await signSshCertificate(
session.target,
session.rootPem,
{
publicKeyLine: session.keyPair.publicKeyLine,
ott: idToken,
principals: [session.username],
keyId: email ?? session.username,
},
);
const info = parseSshCertificate(certificate);
if (info.publicKeyLine !== session.keyPair.publicKeyLine) {
throw new Error("The CA returned a certificate for a different key");
}
if (!info.principals.includes(session.username)) {
throw new Error(
"The CA certificate does not include the host username",
);
}
const now = Date.now();
if (
info.validBefore.getTime() <= now ||
info.validAfter.getTime() > now + 60_000
) {
throw new Error(
"The CA returned a certificate outside its validity window",
);
}
await deps.certs.save(
session.userId,
session.hostId,
{ sshCert: certificate, privateKey: session.keyPair.privateKeyPem },
info.validBefore,
email,
);
session.completed = true;
send(session.socket, {
type: "stepca_completed",
requestId: session.state,
expiresAt: info.validBefore.toISOString(),
});
end(session, removeRuntime);
return { ok: true, message: "Signed in. You can close this window." };
} catch (error) {
const message = errorMessage(error);
ctx.log.error(`Step CA certificate issuance failed: ${message}`);
send(session.socket, {
type: "stepca_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
end(session, removeRuntime);
return { ok: false, message };
}
}
return {
async start(request: StartRequest): Promise<void> {
const settings = await readSettings();
if (!settings) {
send(request.socket, {
type: "stepca_config_error",
requestId: "",
error:
"Step CA is not configured. An administrator must set the CA URL, root fingerprint and OIDC provisioner in the Step CA plugin settings.",
});
return;
}
const state = randomBytes(24).toString("base64url");
try {
const target: StepCaTarget = {
fetch: ctx.fetch,
caUrl: settings.caUrl,
fingerprint: settings.fingerprint,
allowedPrivateHosts: settings.privateEndpoints,
};
const rootPem = await fetchRootCertificate(target);
const provisioner = await findOidcProvisioner(
target,
rootPem,
settings.provisioner,
);
const endpoints = await discoverOidcEndpoints(
ctx.fetch,
provisioner.configurationEndpoint,
target.allowedPrivateHosts,
);
const pkce = createPkce();
const nonce = randomBytes(16).toString("base64url");
const redirectUri = `${request.requestOrigin}${await callbackPath()}`;
const session: Session = {
state,
userId: request.userId,
hostId: request.hostId,
username: request.username,
socket: request.socket,
target,
rootPem,
clientId: provisioner.clientID,
clientSecret: provisioner.clientSecret,
tokenEndpoint: endpoints.tokenEndpoint,
redirectUri,
codeVerifier: pkce.verifier,
nonce,
keyPair: generateSshKeyPair(),
processing: false,
completed: false,
commandPoll: null,
timeout: setTimeout(() => {
const current = sessions.get(state);
if (!current || current.completed || current.processing) return;
send(request.socket, { type: "stepca_timeout", requestId: state });
end(current);
}, AUTH_TIMEOUT_MS),
};
sessions.set(state, session);
await deps.runtime.register(state);
// Another instance may receive the callback and queue it for us.
session.commandPoll = setInterval(() => {
if (session.processing || session.completed) return;
void deps.runtime.takeCommand(state).then(async (query) => {
if (!query || session.processing || session.completed) return;
session.processing = true;
const result = await finish(session, query, false);
await deps.runtime.complete(state, result);
});
}, REMOTE_POLL_MS);
session.commandPoll.unref?.();
request.socket.on("close", () => {
const current = sessions.get(state);
if (current && !current.completed) end(current);
});
send(request.socket, {
type: "stepca_status",
requestId: state,
stage: "chooser",
url: buildAuthorizationUrl({
authorizationEndpoint: endpoints.authorizationEndpoint,
clientId: provisioner.clientID,
redirectUri,
state,
nonce,
codeChallenge: pkce.challenge,
}),
});
} catch (error) {
ctx.log.error(
`Failed to start Step CA authentication: ${errorMessage(error)}`,
);
send(request.socket, {
type: "stepca_error",
requestId: state,
error: `Step CA: ${errorMessage(error)}`,
});
}
},
cancel(requestId: string): boolean {
const session = sessions.get(requestId);
if (!session) return false;
end(session);
return true;
},
/**
* The identity provider's redirect: exchanges the code, has the CA sign
* the key and stores the certificate. A sign-in another instance started
* is handed to it through the runtime.
*/
async complete(query: CallbackQuery): Promise<CallbackResult> {
const session = query.state ? sessions.get(query.state) : undefined;
if (!session) {
if (!query.state || !(await deps.runtime.submit(query.state, query))) {
return {
ok: false,
message: "This sign-in request is no longer active.",
};
}
const deadline = Date.now() + REMOTE_CALLBACK_WAIT_MS;
while (Date.now() < deadline) {
const result = await deps.runtime.takeResult(query.state);
if (result) return result;
await new Promise((resolve) => setTimeout(resolve, REMOTE_POLL_MS));
}
return {
ok: false,
message: "The Termix instance handling this sign-in did not respond.",
};
}
if (session.processing || session.completed) {
return { ok: false, message: "This sign-in request was already used." };
}
session.processing = true;
return finish(session, query);
},
closeAll(): void {
for (const session of [...sessions.values()]) end(session);
},
};
}
+96
View File
@@ -0,0 +1,96 @@
import { and, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
/* 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 interface IssuedCertificate {
sshCert: string;
privateKey: string;
}
export type CertStore = ReturnType<typeof createCertStore>;
export function createCertStore(ctx: PluginContext, table: Table) {
const client = () => ctx.db.client<Drizzle>();
const byUserAndHost = (userId: string, hostId: number) =>
and(eq(table.userId, userId), eq(table.hostId, hostId));
async function find(userId: string, hostId: number) {
const rows = await (
await client()
)
.select()
.from(table)
.where(byUserAndHost(userId, hostId))
.limit(1);
return rows[0] ?? null;
}
async function remove(userId: string, hostId: number): Promise<boolean> {
if (!(await find(userId, hostId))) return false;
await (await client()).delete(table).where(byUserAndHost(userId, hostId));
await ctx.db.persist();
return true;
}
return {
async save(
userId: string,
hostId: number,
certificate: IssuedCertificate,
expiresAt: Date,
email?: string,
): Promise<void> {
const values = {
sshCert: await ctx.secrets.seal(certificate.sshCert),
privateKey: await ctx.secrets.seal(certificate.privateKey),
email: email ?? null,
createdAt: new Date().toISOString(),
expiresAt: expiresAt.toISOString(),
};
if (await find(userId, hostId)) {
await (
await client()
)
.update(table)
.set(values)
.where(byUserAndHost(userId, hostId));
} else {
await (
await client()
)
.insert(table)
.values({ userId, hostId, ...values });
}
await ctx.db.persist();
},
/** The usable certificate, or null. Expired or unreadable rows are deleted. */
async get(
userId: string,
hostId: number,
): Promise<IssuedCertificate | null> {
const row = await find(userId, hostId);
if (!row) return null;
const expiresAt = new Date(row.expiresAt).getTime();
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
await remove(userId, hostId);
return null;
}
const sshCert = await ctx.secrets.unseal(row.sshCert);
const privateKey = await ctx.secrets.unseal(row.privateKey);
if (!sshCert || !privateKey) {
await remove(userId, hostId);
return null;
}
return { sshCert, privateKey };
},
remove,
};
}
+398
View File
@@ -0,0 +1,398 @@
import crypto from "node:crypto";
import type { PluginFetch } from "@termix/plugin-sdk/backend";
/**
* A minimal client for smallstep's step-ca SSH user-certificate flow, done
* over its HTTP API rather than the `step` binary:
*
* 1. bootstrap the CA's root certificate by fingerprint (GET /root/{fp})
* 2. read the OIDC provisioner's client settings (GET /provisioners)
* 3. run the OIDC authorization-code flow against the provider
* 4. POST the id_token as the one-time token to /1.0/ssh/sign
*/
export interface StepCaTarget {
/** ctx.fetch: core's SSRF guard. */
fetch: PluginFetch;
caUrl: string;
fingerprint: string;
/** Hosts the SSRF guard may reach even when they resolve to private ranges. */
allowedPrivateHosts: readonly string[];
}
export interface StepCaOidcProvisioner {
name: string;
clientID: string;
clientSecret?: string;
configurationEndpoint: string;
}
export interface OidcEndpoints {
authorizationEndpoint: string;
tokenEndpoint: string;
}
const FETCH_TIMEOUT_MS = 15_000;
/** Display-only claims; the CA is the component that verifies the token. */
export function decodeJwtClaims(token: string): Record<string, unknown> {
const payload = token.split(".")[1];
if (!payload) return {};
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
} catch {
return {};
}
}
export function normalizeCaUrl(raw: string): string {
const url = new URL(raw.trim());
if (url.protocol !== "https:") {
throw new Error("Step CA URL must use https");
}
return url.toString().replace(/\/+$/, "");
}
export function normalizeFingerprint(raw: string): string {
const hex = raw.replace(/[^0-9a-fA-F]/g, "").toLowerCase();
if (hex.length !== 64) {
throw new Error("CA fingerprint must be a SHA-256 hex digest");
}
return hex;
}
export function pemToDer(pem: string): Buffer {
const body = pem
.replace(/-----BEGIN [^-]+-----/g, "")
.replace(/-----END [^-]+-----/g, "")
.replace(/\s+/g, "");
return Buffer.from(body, "base64");
}
export function certificateFingerprint(pem: string): string {
return crypto.createHash("sha256").update(pemToDer(pem)).digest("hex");
}
async function readJson<T>(response: Response, what: string): Promise<T> {
if (!response.ok) {
throw new Error(`${what} failed: HTTP ${response.status}`);
}
return (await response.json()) as T;
}
/**
* The root endpoint is served under the CA's own TLS certificate, which
* nothing trusts yet - so this one request skips verification and trusts
* the fingerprint instead, exactly like `step ca bootstrap`.
*/
export async function fetchRootCertificate(
target: StepCaTarget,
): Promise<string> {
const fingerprint = normalizeFingerprint(target.fingerprint);
const response = await target.fetch(
`${normalizeCaUrl(target.caUrl)}/root/${fingerprint}`,
{
timeoutMs: FETCH_TIMEOUT_MS,
allowPrivateHosts: target.allowedPrivateHosts,
tls: { rejectUnauthorized: false },
},
);
const { ca } = await readJson<{ ca?: string }>(
response,
"Fetching the CA root",
);
if (!ca || certificateFingerprint(ca) !== fingerprint) {
throw new Error("CA root certificate does not match the fingerprint");
}
return ca;
}
export async function findOidcProvisioner(
target: StepCaTarget,
rootPem: string,
name: string,
): Promise<StepCaOidcProvisioner> {
const base = normalizeCaUrl(target.caUrl);
let cursor = "";
for (let page = 0; page < 20; page++) {
const response = await target.fetch(
`${base}/provisioners?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
{
timeoutMs: FETCH_TIMEOUT_MS,
allowPrivateHosts: target.allowedPrivateHosts,
tls: { ca: rootPem },
},
);
const body = await readJson<{
provisioners?: Array<Record<string, unknown>>;
nextCursor?: string;
}>(response, "Listing CA provisioners");
const match = (body.provisioners ?? []).find(
(p) => p.name === name && p.type === "OIDC",
);
if (match) {
if (
typeof match.clientID !== "string" ||
typeof match.configurationEndpoint !== "string"
) {
throw new Error("The OIDC provisioner is missing its client settings");
}
return {
name,
clientID: match.clientID,
clientSecret:
typeof match.clientSecret === "string"
? match.clientSecret
: undefined,
configurationEndpoint: match.configurationEndpoint,
};
}
if (!body.nextCursor) break;
cursor = body.nextCursor;
}
throw new Error(`OIDC provisioner "${name}" not found on the CA`);
}
export async function discoverOidcEndpoints(
fetch: PluginFetch,
configurationEndpoint: string,
allowedPrivateHosts: readonly string[],
): Promise<OidcEndpoints> {
const response = await fetch(configurationEndpoint, {
timeoutMs: FETCH_TIMEOUT_MS,
allowPrivateHosts: allowedPrivateHosts,
});
const doc = await readJson<{
authorization_endpoint?: string;
token_endpoint?: string;
}>(response, "OIDC discovery");
if (!doc.authorization_endpoint || !doc.token_endpoint) {
throw new Error("OIDC discovery document is incomplete");
}
return {
authorizationEndpoint: doc.authorization_endpoint,
tokenEndpoint: doc.token_endpoint,
};
}
export function createPkce(): { verifier: string; challenge: string } {
const verifier = crypto.randomBytes(48).toString("base64url");
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
export function buildAuthorizationUrl(input: {
authorizationEndpoint: string;
clientId: string;
redirectUri: string;
state: string;
nonce: string;
codeChallenge: string;
}): string {
const url = new URL(input.authorizationEndpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", input.clientId);
url.searchParams.set("redirect_uri", input.redirectUri);
url.searchParams.set("scope", "openid email profile");
url.searchParams.set("state", input.state);
url.searchParams.set("nonce", input.nonce);
url.searchParams.set("code_challenge", input.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
export async function exchangeCodeForIdToken(input: {
fetch: PluginFetch;
tokenEndpoint: string;
clientId: string;
clientSecret?: string;
code: string;
redirectUri: string;
codeVerifier: string;
allowedPrivateHosts: readonly string[];
}): Promise<string> {
const form = new URLSearchParams({
grant_type: "authorization_code",
code: input.code,
redirect_uri: input.redirectUri,
client_id: input.clientId,
code_verifier: input.codeVerifier,
});
if (input.clientSecret) form.set("client_secret", input.clientSecret);
const response = await input.fetch(input.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(),
timeoutMs: FETCH_TIMEOUT_MS,
allowPrivateHosts: input.allowedPrivateHosts,
});
const body = await readJson<{ id_token?: string }>(
response,
"OIDC token exchange",
);
if (!body.id_token) {
throw new Error("The identity provider returned no id_token");
}
return body.id_token;
}
// --- SSH keys and certificates ---------------------------------------------
function sshString(value: Buffer | string): Buffer {
const data = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
return Buffer.concat([len, data]);
}
export function generateSshKeyPair(): {
publicKeyLine: string;
privateKeyPem: string;
} {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const jwk = publicKey.export({ format: "jwk" }) as { x: string };
const raw = Buffer.from(jwk.x, "base64url");
const blob = Buffer.concat([sshString("ssh-ed25519"), sshString(raw)]);
return {
publicKeyLine: `ssh-ed25519 ${blob.toString("base64")}`,
privateKeyPem: privateKey
.export({ format: "pem", type: "pkcs8" })
.toString(),
};
}
export async function signSshCertificate(
target: StepCaTarget,
rootPem: string,
input: {
publicKeyLine: string;
ott: string;
principals: string[];
keyId: string;
},
): Promise<string> {
const blob = input.publicKeyLine.trim().split(/\s+/)[1];
if (!blob) throw new Error("Invalid public key line");
const response = await target.fetch(
`${normalizeCaUrl(target.caUrl)}/1.0/ssh/sign`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
publicKey: blob,
ott: input.ott,
certType: "user",
principals: input.principals,
keyID: input.keyId,
}),
timeoutMs: FETCH_TIMEOUT_MS,
allowPrivateHosts: target.allowedPrivateHosts,
tls: { ca: rootPem },
},
);
if (!response.ok) {
let detail = "";
try {
const err = (await response.json()) as { message?: string };
detail = err.message ? `: ${err.message}` : "";
} catch {
/* no body */
}
throw new Error(
`The CA refused to sign the key (HTTP ${response.status})${detail}`,
);
}
const body = (await response.json()) as { crt?: string };
if (!body.crt) throw new Error("The CA returned no certificate");
return body.crt.trim();
}
export interface SshCertificateInfo {
keyType: string;
publicKeyLine: string;
keyId: string;
principals: string[];
validAfter: Date;
validBefore: Date;
}
/** Reads the identity and validity window out of an OpenSSH certificate line. */
export function parseSshCertificate(line: string): SshCertificateInfo {
const blob = Buffer.from(line.trim().split(/\s+/)[1] ?? "", "base64");
if (blob.length === 0) throw new Error("Invalid SSH certificate");
let offset = 0;
const readString = (): Buffer => {
if (offset + 4 > blob.length) throw new Error("Truncated SSH certificate");
const len = blob.readUInt32BE(offset);
offset += 4;
if (offset + len > blob.length) {
throw new Error("Truncated SSH certificate");
}
const value = blob.subarray(offset, offset + len);
offset += len;
return value;
};
const readUint64 = (): bigint => {
if (offset + 8 > blob.length) throw new Error("Truncated SSH certificate");
const value = blob.readBigUInt64BE(offset);
offset += 8;
return value;
};
const keyType = readString().toString();
if (!keyType.endsWith("-cert-v01@openssh.com")) {
throw new Error("The CA returned a public key instead of a certificate");
}
readString(); // nonce
// Public key fields differ by algorithm; consume them by shape.
const publicKeyParts: Buffer[] = [];
let plainKeyType: string;
if (keyType.startsWith("ssh-rsa")) {
plainKeyType = "ssh-rsa";
publicKeyParts.push(readString(), readString()); // e, n
} else if (keyType.startsWith("ecdsa-")) {
plainKeyType = keyType.replace(/-cert-v01@openssh\.com$/, "");
publicKeyParts.push(readString(), readString()); // curve, Q
} else {
plainKeyType = keyType.replace(/-cert-v01@openssh\.com$/, "");
publicKeyParts.push(readString()); // ed25519 pk
}
const publicKeyBlob = Buffer.concat([
sshString(plainKeyType),
...publicKeyParts.map(sshString),
]);
readUint64(); // serial
offset += 4; // type
const keyId = readString().toString();
const principalsBlob = readString();
const principals: string[] = [];
for (let p = 0; p < principalsBlob.length;) {
if (p + 4 > principalsBlob.length) {
throw new Error("Invalid SSH certificate principals");
}
const len = principalsBlob.readUInt32BE(p);
if (p + 4 + len > principalsBlob.length) {
throw new Error("Invalid SSH certificate principals");
}
principals.push(principalsBlob.subarray(p + 4, p + 4 + len).toString());
p += 4 + len;
}
const validAfter = readUint64();
const validBefore = readUint64();
const toDate = (seconds: bigint) =>
new Date(
Number(seconds > 8_640_000_000_000n ? 8_640_000_000_000n : seconds) *
1000,
);
return {
keyType,
publicKeyLine: `${plainKeyType} ${publicKeyBlob.toString("base64")}`,
keyId,
principals,
validAfter: toDate(validAfter),
validBefore: toDate(validBefore),
};
}
+28
View File
@@ -0,0 +1,28 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { certs as certsTable } from "./tables.js";
import { createCertStore } from "./cert-store.js";
import { createRuntime } from "./runtime.js";
import { createAuthSessions } from "./auth-session.js";
import { createStepCaProvider } from "./provider.js";
import { PUBLIC_PATHS, registerRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(certsTable);
const certs = createCertStore(ctx, table);
const runtime = createRuntime(ctx);
const sessions = createAuthSessions(ctx, { certs, runtime });
ctx.disposables.add(async () => {
sessions.closeAll();
await runtime.close();
});
ctx.auth.registerSshAuthProvider(createStepCaProvider(ctx, certs, sessions));
registerRoutes(ctx.http.router<Router>({ public: PUBLIC_PATHS }), sessions);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+88
View File
@@ -0,0 +1,88 @@
import type {
PluginContext,
PluginSshAuthProvider,
} from "@termix/plugin-sdk/backend";
import { applyCertificateAuth } from "@termix/plugin-sdk/ssh-certs";
import type { AuthSessions, SignInSocket } from "./auth-session.js";
import type { CertStore } from "./cert-store.js";
export const AUTH_TYPE = "stepca";
const REQUIRED_MESSAGE =
"Step CA authentication required. Please open a Terminal connection to this host first to complete browser-based authentication.";
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
/**
* Hosts with auth type "stepca" connect with the certificate the CA issued.
* Without one, the terminal is asked for a browser sign-in
* ("stepca_auth_required"), which startInteraction runs.
*/
export function createStepCaProvider(
ctx: PluginContext,
certs: CertStore,
sessions: AuthSessions,
): PluginSshAuthProvider {
return {
type: AUTH_TYPE,
labelKey: "hosts.filterAuthStepca",
needsUserInteraction: true,
supportsBackground: false,
interaction: AUTH_TYPE,
prepare: async (config, host, env) => {
const certificate = await certs.get(env.userId, env.hostId);
if (!certificate) {
env.log("info", "No valid certificate found, requesting sign-in");
return {
status: "interaction-required",
interaction: AUTH_TYPE,
message: REQUIRED_MESSAGE,
};
}
try {
await applyCertificateAuth(
config as never,
env.client as never,
{
privateKey: certificate.privateKey,
certificate: certificate.sshCert,
},
host.username,
);
} catch (error) {
return {
status: "error",
code: "failed",
message:
"Step CA authentication failed: " +
(error instanceof Error ? error.message : String(error)),
};
}
env.log("info", "Using cached SSH certificate");
return { status: "ready" };
},
onAuthFailed: (_host, env, context) => {
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return undefined;
ctx.log.warn("Step CA authentication failed, forgetting the certificate");
void certs.remove(env.userId, env.hostId).catch(() => undefined);
return {
status: "interaction-required",
interaction: AUTH_TYPE,
message:
"Step CA authentication failed or expired. Please authenticate again.",
};
},
startInteraction: async (request) => {
await sessions.start({
userId: request.userId,
hostId: request.hostId,
username: request.host.username,
socket: request.socket as SignInSocket,
requestOrigin: request.requestOrigin,
});
},
cancelInteraction: async (request) => {
if (request.requestId) sessions.cancel(request.requestId);
},
};
}
+65
View File
@@ -0,0 +1,65 @@
import type { Request, Response, Router } from "express";
import type { AuthSessions } from "./auth-session.js";
/**
* The identity provider redirects here without a Termix session: the sign-in
* may finish in another browser, so it is matched by the OAuth state.
*/
export const PUBLIC_PATHS = ["/callback"];
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function resultPage(ok: boolean, message: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
main{max-width:28rem;padding:2rem;border:1px solid #333;background:#181818}h1{font-size:1.1rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style></head>
<body><main><h1>${ok ? "Signed in" : "Sign-in failed"}</h1><p>${escapeHtml(message)}</p></main></body></html>`;
}
export function registerRoutes(router: Router, sessions: AuthSessions): void {
/**
* @openapi
* /plugin-api/step-ca/callback:
* get:
* summary: OIDC callback for a Step CA sign-in
* description: Public. The redirect URI registered with the identity provider behind the CA's OIDC provisioner. Exchanges the code, has the CA sign the key and tells the terminal to reconnect. The 2.8 URI /host/step-ca-callback redirects here.
* tags: [Step CA]
* parameters:
* - name: state
* in: query
* schema:
* type: string
* - name: code
* in: query
* schema:
* type: string
* responses:
* 200:
* description: Signed in; an HTML page the browser can close.
* 400:
* description: The sign-in failed or is no longer active.
*/
router.get("/callback", async (req: Request, res: Response) => {
const query = (name: string): string | undefined => {
const value = req.query[name];
return typeof value === "string" ? value : undefined;
};
const result = await sessions.complete({
state: query("state"),
code: query("code"),
error: query("error"),
error_description: query("error_description"),
});
res
.status(result.ok ? 200 : 400)
.type("html")
.send(resultPage(result.ok, result.message));
});
}
+164
View File
@@ -0,0 +1,164 @@
import { createClient } from "redis";
import type { PluginContext } from "@termix/plugin-sdk/backend";
export interface CallbackQuery {
state?: string;
code?: string;
error?: string;
error_description?: string;
}
export interface CallbackResult {
ok: boolean;
message: string;
}
const SESSION_TTL_SECONDS = 5 * 60;
const RESULT_TTL_SECONDS = 60;
const CONNECT_RETRY_MS = 15_000;
export type Runtime = ReturnType<typeof createRuntime>;
/**
* Hands an identity provider callback to the Termix instance that started
* the sign-in, through Redis when REDIS_URL is set. With one instance, or no
* Redis, every call is a no-op and the callback is handled locally.
*/
export function createRuntime(ctx: PluginContext) {
const prefix =
process.env.TERMIX_STEP_CA_REDIS_PREFIX?.trim() || "termix:step-ca";
let client: ReturnType<typeof createClient> | null = null;
let connecting: Promise<boolean> | null = null;
let nextConnectAttempt = 0;
const routeKey = (state: string) => `${prefix}:route:${state}`;
const commandKey = (state: string) => `${prefix}:command:${state}`;
const resultKey = (state: string) => `${prefix}:result:${state}`;
function logFailure(operation: string, error: unknown): void {
ctx.log.warn(
`Step CA Redis runtime unavailable (${operation}): ${error instanceof Error ? error.message : String(error)}`,
);
}
async function seal(value: unknown): Promise<string> {
return ctx.secrets.seal(JSON.stringify(value));
}
async function unseal<T>(raw: string): Promise<T | null> {
const plain = await ctx.secrets.unseal(raw);
if (!plain) return null;
try {
return JSON.parse(plain) as T;
} catch {
return null;
}
}
async function connect(url: string): Promise<boolean> {
try {
client = createClient({
url,
socket: { connectTimeout: 1500, reconnectStrategy: false },
});
client.on("error", (error) => logFailure("client", error));
await client.connect();
nextConnectAttempt = 0;
return true;
} catch (error) {
nextConnectAttempt = Date.now() + CONNECT_RETRY_MS;
logFailure("connect", error);
await client?.disconnect().catch(() => {});
client = null;
return false;
}
}
async function ready(): Promise<boolean> {
const url = process.env.REDIS_URL?.trim();
if (!url) return false;
if (client?.isReady) return true;
if (Date.now() < nextConnectAttempt) return false;
if (connecting) return connecting;
connecting = connect(url).finally(() => {
connecting = null;
});
return connecting;
}
return {
async register(state: string): Promise<void> {
if (!(await ready()) || !client) return;
await client
.set(routeKey(state), "active", { EX: SESSION_TTL_SECONDS })
.catch((error) => logFailure("register", error));
},
/** Queues a callback for the instance that owns the sign-in. */
async submit(state: string, query: CallbackQuery): Promise<boolean> {
if (!(await ready()) || !client) return false;
try {
if (!(await client.exists(routeKey(state)))) return false;
const stored = await client.set(commandKey(state), await seal(query), {
EX: SESSION_TTL_SECONDS,
NX: true,
});
return stored === "OK";
} catch (error) {
logFailure("submit", error);
return false;
}
},
async takeCommand(state: string): Promise<CallbackQuery | null> {
if (!(await ready()) || !client) return null;
try {
const raw = await client.getDel(commandKey(state));
return raw ? unseal<CallbackQuery>(raw.toString()) : null;
} catch (error) {
logFailure("take_command", error);
return null;
}
},
async complete(state: string, result: CallbackResult): Promise<void> {
if (!(await ready()) || !client) return;
try {
await client
.multi()
.set(resultKey(state), await seal(result), {
EX: RESULT_TTL_SECONDS,
})
.del(routeKey(state))
.del(commandKey(state))
.exec();
} catch (error) {
logFailure("complete", error);
}
},
async takeResult(state: string): Promise<CallbackResult | null> {
if (!(await ready()) || !client) return null;
try {
const raw = await client.getDel(resultKey(state));
return raw ? unseal<CallbackResult>(raw.toString()) : null;
} catch (error) {
logFailure("take_result", error);
return null;
}
},
async remove(state: string): Promise<void> {
if (!(await ready()) || !client) return;
await client
.del([routeKey(state), commandKey(state), resultKey(state)])
.catch((error) => logFailure("remove", error));
},
async close(): Promise<void> {
const current = client;
client = null;
if (current?.isOpen) await current.quit().catch(() => {});
},
};
}
+34
View File
@@ -0,0 +1,34 @@
import {
defineTable,
id,
refHost,
refUser,
text,
varchar,
} from "@termix/plugin-sdk/db";
/**
* One issued certificate per user and host. sshCert and privateKey are
* sealed with ctx.secrets.seal. 2.8 kept Step CA certificates in
* opkssh_tokens; those are left to expire.
*/
export const certs = defineTable(
"certs",
{
id: id(),
userId: refUser(),
hostId: refHost(),
sshCert: text().notNull(),
privateKey: text().notNull(),
email: text(),
createdAt: text().notNull().defaultNow(),
expiresAt: varchar().notNull(),
},
{
uniques: [
{ name: "idx_step_ca_certs_user_host", columns: ["userId", "hostId"] },
],
},
);
export const tables = [certs];
@@ -0,0 +1,54 @@
import { toast } from "sonner";
import {
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
import { Button, Input, copyToClipboard } from "@termix/plugin-sdk/ui";
const CALLBACK_PATH = "plugin-api/step-ca/callback";
const LEGACY_CALLBACK_PATH = "host/step-ca-callback";
/** The callback URI to register with the provisioner's identity provider. */
export function redirectUri(
legacy: boolean,
baseUri = document.baseURI,
): string {
return new URL(
legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH,
baseUri,
).toString();
}
export function RedirectUriSetting({ values }: SettingsComponentProps) {
const { t } = useTranslation();
const uri = redirectUri(values.legacyCallback === true);
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium">
{t("settings.redirectUri.label")}
</span>
<div className="flex gap-2">
<Input
value={uri}
readOnly
className="rounded-none font-mono text-xs"
/>
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={async () => {
if (await copyToClipboard(uri)) toast.success(t("common.copied"));
else toast.error(t("common.copyFailed"));
}}
>
{t("common.copy")}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
{t("settings.redirectUri.description")}
</p>
</div>
);
}
@@ -0,0 +1,29 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
const DOCS_URL =
"https://smallstep.com/docs/step-ca/provisioners/#oauthoidc-single-sign-on";
/** The host editor's "stepca" auth method: nothing to fill in, just a pointer. */
export function StepCaAuthEditor() {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("editor.label")}
</span>
<a
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("editor.docs")}
</a>
</div>
<p className="text-[10px] text-muted-foreground">
{t("editor.description")}
</p>
</div>
);
}
@@ -0,0 +1,102 @@
import { Shield, ExternalLink, Loader2, AlertCircle } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@termix/plugin-sdk/ui";
export type SignInStage = "chooser" | "waiting" | "authenticating" | "error";
interface StepCaDialogProps {
stage: SignInStage;
error?: string;
onCancel: () => void;
onOpenUrl: () => void;
backgroundColor?: string;
}
const actionButton =
"border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2";
const footerButton =
"rounded-none text-[10px] font-bold uppercase tracking-widest";
/** The browser sign-in the terminal shows while Step CA waits. */
export function StepCaDialog({
stage,
error,
onCancel,
onOpenUrl,
backgroundColor,
}: StepCaDialogProps) {
const { t } = useTranslation();
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-canvas"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-md mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<Shield className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest">
{t("dialog.title")}
</h3>
</div>
{stage === "chooser" && (
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
{t("dialog.description")}
</p>
)}
</div>
<div className="p-4 flex flex-col gap-4">
{stage === "chooser" && (
<Button
type="button"
variant="outline"
onClick={onOpenUrl}
className={actionButton}
>
<ExternalLink className="size-3.5" />
{t("dialog.openBrowser")}
</Button>
)}
{(stage === "waiting" || stage === "authenticating") && (
<div className="flex items-center gap-3 py-2">
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
<p className="text-xs text-muted-foreground">
{stage === "waiting"
? t("dialog.waiting")
: t("dialog.authenticating")}
</p>
</div>
)}
{stage === "error" && error && (
<div className="flex items-start gap-3 p-3 border border-destructive/20 bg-destructive/10">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-[10px] font-bold uppercase tracking-widest text-destructive">
{t("common.error")}
</p>
<p className="text-xs text-destructive/90 mt-1 whitespace-pre-wrap break-words">
{error}
</p>
</div>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
onClick={onCancel}
className={footerButton}
>
{stage === "error" ? t("common.close") : t("common.cancel")}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { StepCaDialog, type SignInStage } from "./StepCaDialog";
/** What the ssh-terminal plugin hands a "terminal.overlay" component. */
export interface TerminalOverlayProps {
host: { id: number | string; [key: string]: unknown } | undefined;
backgroundColor?: string;
subscribe: (
listener: (message: { type: string; [key: string]: unknown }) => void,
) => () => void;
holdConnectTimeout: (held: boolean) => void;
fail: (message: string) => void;
disconnect: () => void;
send: (type: string, data?: unknown) => void;
connectPayload: () => Record<string, unknown>;
}
interface DialogState {
authUrl: string;
requestId: string;
stage: SignInStage;
error?: string;
}
/** How long the sign-in dialog waits before giving up on the connection. */
const DIALOG_TIMEOUT_MS = 300_000;
/**
* Runs the Step CA browser sign-in for a terminal: asks the server to start
* it, opens the identity provider, and reconnects once the CA has issued the
* certificate.
*/
export function StepCaOverlay({
subscribe,
holdConnectTimeout,
fail,
disconnect,
send,
connectPayload,
backgroundColor,
}: TerminalOverlayProps) {
const { t } = useTranslation();
const [dialog, setDialog] = useState<DialogState | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const heldRef = useRef(false);
// A second "auth required" after a sign-in means the certificate did not work.
const failedRef = useRef(false);
useEffect(() => {
const clearTimer = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
};
const hold = (held: boolean) => {
if (heldRef.current === held) return;
heldRef.current = held;
holdConnectTimeout(held);
};
const showError = (requestId: unknown, error: string) => {
clearTimer();
setDialog((current) =>
current
? { ...current, stage: "error", error }
: {
authUrl: "",
requestId: typeof requestId === "string" ? requestId : "",
stage: "error",
error,
},
);
};
const unsubscribe = subscribe((message) => {
switch (message.type) {
case "stepca_auth_required":
hold(true);
if (failedRef.current) {
clearTimer();
setDialog(null);
hold(false);
fail(t("errors.authFailed"));
} else {
failedRef.current = true;
send("stepca_start_auth", { hostId: message.hostId });
}
break;
case "stepca_status":
if (message.stage === "chooser") {
setDialog({
authUrl: typeof message.url === "string" ? message.url : "",
requestId:
typeof message.requestId === "string" ? message.requestId : "",
stage: "chooser",
});
clearTimer();
timerRef.current = setTimeout(() => {
setDialog(null);
hold(false);
disconnect();
}, DIALOG_TIMEOUT_MS);
} else {
setDialog((current) =>
current
? { ...current, stage: message.stage as SignInStage }
: null,
);
}
break;
case "stepca_completed":
clearTimer();
setDialog(null);
send("stepca_auth_completed", connectPayload());
break;
case "stepca_error":
failedRef.current = true;
showError(message.requestId, String(message.error ?? ""));
break;
case "stepca_timeout":
failedRef.current = true;
showError(message.requestId, t("errors.timeout"));
break;
case "stepca_config_error":
showError(message.requestId, String(message.error ?? ""));
break;
case "connected":
case "sessionAttached":
failedRef.current = false;
hold(false);
break;
case "session_closed":
clearTimer();
setDialog(null);
hold(false);
break;
}
});
return () => {
unsubscribe();
clearTimer();
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
};
}, [
subscribe,
holdConnectTimeout,
fail,
disconnect,
send,
connectPayload,
t,
]);
if (!dialog) return null;
const close = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
if (dialog.requestId) {
send("stepca_cancel", { requestId: dialog.requestId });
}
setDialog(null);
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
};
return (
<StepCaDialog
stage={dialog.stage}
error={dialog.error}
onCancel={close}
onOpenUrl={() => {
if (!dialog.authUrl) return;
window.open(dialog.authUrl, "_blank");
setDialog((current) =>
current ? { ...current, stage: "waiting" } : null,
);
}}
backgroundColor={backgroundColor}
/>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ComponentType } from "react";
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { StepCaAuthEditor } from "./StepCaAuthEditor";
import { StepCaOverlay } from "./StepCaOverlay";
import { RedirectUriSetting } from "./RedirectUriSetting";
export function activate(app: TermixApp): void {
app.registerSshAuthEditor({
authType: "stepca",
titleKey: "hosts.filterAuthStepca",
component: StepCaAuthEditor,
});
// The browser sign-in while a terminal connects to a Step CA host.
app.registerSlotContribution("terminal.overlay", {
actionId: "step-ca.signIn",
titleKey: "dialog.title",
kind: "component",
component: StepCaOverlay as unknown as ComponentType<
Record<string, unknown>
>,
});
app.registerSettingsComponent("redirectUri", RedirectUriSetting);
}
export function deactivate(): void {
// Registrations through app are disposed by core.
}
@@ -0,0 +1,136 @@
import crypto from "node:crypto";
import { describe, expect, it } from "vitest";
import {
buildAuthorizationUrl,
certificateFingerprint,
createPkce,
decodeJwtClaims,
fetchRootCertificate,
findOidcProvisioner,
generateSshKeyPair,
normalizeCaUrl,
normalizeFingerprint,
parseSshCertificate,
} from "../../src/backend/client.js";
import { parseHostList } from "../../src/backend/auth-session.js";
import { CA_URL, createFakeCa, createSshCa, fakeRoot } from "./fake-ca.js";
describe("step-ca client helpers", () => {
it("normalizes the CA url and fingerprint the way step does", () => {
expect(normalizeCaUrl(" https://ca.internal:9000/ ")).toBe(
"https://ca.internal:9000",
);
expect(() => normalizeCaUrl("http://ca.internal")).toThrow(/https/);
const fp = "AB:cd".repeat(16).replace(/:/g, "");
expect(normalizeFingerprint("AB:cd".repeat(16))).toBe(fp.toLowerCase());
expect(() => normalizeFingerprint("abcd")).toThrow(/SHA-256/);
});
it("fingerprints a PEM certificate by the sha256 of its DER", () => {
const root = fakeRoot();
expect(certificateFingerprint(root.pem)).toBe(root.fingerprint);
});
it("builds a PKCE authorization request", () => {
const { verifier, challenge } = createPkce();
expect(challenge).toBe(
crypto.createHash("sha256").update(verifier).digest("base64url"),
);
const url = new URL(
buildAuthorizationUrl({
authorizationEndpoint: "https://idp.example/auth?tenant=x",
clientId: "cid",
redirectUri: "https://termix.example/callback",
state: "s",
nonce: "n",
codeChallenge: challenge,
}),
);
expect(url.searchParams.get("tenant")).toBe("x");
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("scope")).toContain("openid");
});
it("generates an ed25519 key a CA can certify, and reads the cert back", () => {
const { publicKeyLine, privateKeyPem } = generateSshKeyPair();
expect(publicKeyLine).toMatch(/^ssh-ed25519 [A-Za-z0-9+/=]+$/);
expect(privateKeyPem).toContain("BEGIN PRIVATE KEY");
const cert = createSshCa().sign({
publicKeyBlob: publicKeyLine.split(" ")[1],
keyId: "alice@example",
principals: ["alice", "ops"],
validAfter: 1_700_000_000,
validBefore: 1_700_057_600,
});
const info = parseSshCertificate(cert);
expect(info).toMatchObject({
keyType: "ssh-ed25519-cert-v01@openssh.com",
publicKeyLine,
keyId: "alice@example",
principals: ["alice", "ops"],
});
expect(info.validAfter.toISOString()).toBe("2023-11-14T22:13:20.000Z");
expect(info.validBefore.getTime() - info.validAfter.getTime()).toBe(
16 * 3600 * 1000,
);
expect(() => parseSshCertificate(publicKeyLine)).toThrow(/certificate/);
expect(() =>
parseSshCertificate("ssh-ed25519-cert-v01@openssh.com AAAA"),
).toThrow(/certificate/);
});
it("reads the private endpoint list with commas or new lines", () => {
expect(parseHostList("ca.internal, SSO.internal\nidp.lan")).toEqual([
"ca.internal",
"sso.internal",
"idp.lan",
]);
expect(parseHostList(undefined)).toEqual([]);
});
});
describe("talking to the CA", () => {
it("refuses a root whose fingerprint does not match", async () => {
const ca = createFakeCa({ nonce: () => "" });
await expect(
fetchRootCertificate({
fetch: ca.fetch,
caUrl: CA_URL,
fingerprint: "b".repeat(64),
allowedPrivateHosts: [],
}),
).rejects.toThrow();
});
it("finds the OIDC provisioner by name and passes the private hosts", async () => {
const ca = createFakeCa({ nonce: () => "" });
const target = {
fetch: ca.fetch,
caUrl: CA_URL,
fingerprint: ca.root.fingerprint,
allowedPrivateHosts: ["ca.test"],
};
const root = await fetchRootCertificate(target);
const provisioner = await findOidcProvisioner(target, root, "oidc");
expect(provisioner.clientID).toBe("termix");
expect(ca.calls.every((call) => call.init?.allowPrivateHosts)).toBe(true);
await expect(findOidcProvisioner(target, root, "jwk")).rejects.toThrow(
/not found/,
);
});
});
describe("decodeJwtClaims", () => {
it("reads the payload without verifying and tolerates junk", () => {
const payload = Buffer.from(
JSON.stringify({ email: "a@b.c", nonce: "n1" }),
).toString("base64url");
expect(decodeJwtClaims(`x.${payload}.y`)).toEqual({
email: "a@b.c",
nonce: "n1",
});
expect(decodeJwtClaims("not-a-jwt")).toEqual({});
});
});
+160
View File
@@ -0,0 +1,160 @@
import crypto from "node:crypto";
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com";
function sshString(value: Buffer | string): Buffer {
const data = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
return Buffer.concat([len, data]);
}
function uint64(value: bigint): Buffer {
const out = Buffer.alloc(8);
out.writeBigUInt64BE(value, 0);
return out;
}
function uint32(value: number): Buffer {
const out = Buffer.alloc(4);
out.writeUInt32BE(value, 0);
return out;
}
/** An ed25519 CA that signs OpenSSH user certificates, like step-ca does. */
export function createSshCa() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const raw = Buffer.from(
(publicKey.export({ format: "jwk" }) as { x: string }).x,
"base64url",
);
const caBlob = Buffer.concat([sshString("ssh-ed25519"), sshString(raw)]);
return {
sign(input: {
publicKeyBlob: string;
keyId: string;
principals: string[];
validAfter: number;
validBefore: number;
}): string {
const userBlob = Buffer.from(input.publicKeyBlob, "base64");
// ssh-ed25519 blob: string type, string pk
const pk = userBlob.subarray(4 + 11 + 4);
const body = Buffer.concat([
sshString(CERT_TYPE),
sshString(crypto.randomBytes(32)),
sshString(pk),
uint64(1n),
uint32(1),
sshString(input.keyId),
sshString(Buffer.concat(input.principals.map((p) => sshString(p)))),
uint64(BigInt(input.validAfter)),
uint64(BigInt(input.validBefore)),
sshString(Buffer.alloc(0)),
sshString(Buffer.alloc(0)),
sshString(Buffer.alloc(0)),
sshString(caBlob),
]);
const signature = Buffer.concat([
sshString("ssh-ed25519"),
sshString(crypto.sign(null, body, privateKey)),
]);
return `${CERT_TYPE} ${Buffer.concat([body, sshString(signature)]).toString("base64")}`;
},
};
}
/** A root "certificate" the client fingerprints by the sha256 of its DER. */
export function fakeRoot() {
const der = crypto.randomBytes(128);
return {
pem: `-----BEGIN CERTIFICATE-----\n${der.toString("base64")}\n-----END CERTIFICATE-----\n`,
fingerprint: crypto.createHash("sha256").update(der).digest("hex"),
};
}
export function jwt(claims: Record<string, unknown>): string {
const part = (value: object) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return `${part({ alg: "none" })}.${part(claims)}.sig`;
}
export const CA_URL = "https://ca.test";
export const DISCOVERY_URL =
"https://idp.test/.well-known/openid-configuration";
export interface FakeCaOptions {
/** Principals the CA puts on the certificate; defaults to what was asked. */
principals?: string[];
/** Nonce the identity provider puts in the id token. */
nonce: () => string;
/** Status for /1.0/ssh/sign. */
signStatus?: number;
}
/**
* Answers ctx.fetch as a step-ca server plus its identity provider would.
* Records every call so a test can check the TLS options.
*/
export function createFakeCa(options: FakeCaOptions) {
const root = fakeRoot();
const ca = createSshCa();
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
const fetch = async (url: string, init?: PluginFetchInit) => {
calls.push({ url, init });
if (url === `${CA_URL}/root/${root.fingerprint}`) {
return json({ ca: root.pem });
}
if (url.startsWith(`${CA_URL}/provisioners`)) {
return json({
provisioners: [
{ name: "jwk", type: "JWK" },
{
name: "oidc",
type: "OIDC",
clientID: "termix",
configurationEndpoint: DISCOVERY_URL,
},
],
});
}
if (url === DISCOVERY_URL) {
return json({
authorization_endpoint: "https://idp.test/authorize",
token_endpoint: "https://idp.test/token",
});
}
if (url === "https://idp.test/token") {
return json({
id_token: jwt({ email: "alice@example.com", nonce: options.nonce() }),
});
}
if (url === `${CA_URL}/1.0/ssh/sign`) {
if (options.signStatus && options.signStatus !== 200) {
return json({ message: "not allowed" }, options.signStatus);
}
const body = JSON.parse(init?.body ?? "{}");
const now = Math.floor(Date.now() / 1000);
return json({
crt: ca.sign({
publicKeyBlob: body.publicKey,
keyId: body.keyID,
principals: options.principals ?? body.principals,
validAfter: now - 60,
validBefore: now + 16 * 3600,
}),
});
}
return new Response("not found", { status: 404 });
};
return { fetch, calls, root, ca };
}
+105
View File
@@ -0,0 +1,105 @@
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 {
PluginFetchInit,
PluginSshAuthProvider,
} from "@termix/plugin-sdk/backend";
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;
/** A fake terminal socket that records what it was sent. */
export function fakeSocket() {
const sent: Array<Record<string, unknown>> = [];
const closers: Array<() => void> = [];
return {
sent,
socket: {
send: (data: string) => sent.push(JSON.parse(data)),
on: (_event: "close", listener: () => void) => closers.push(listener),
},
close: () => closers.forEach((listener) => listener()),
};
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
provider: () => PluginSshAuthProvider;
get: (path: string) => Promise<{ status: number; body: string }>;
close: () => Promise<void>;
}
export async function startServer(
options: {
settings?: Record<string, unknown>;
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
db.sqlite
.prepare("INSERT OR IGNORE INTO users (id) VALUES (?)")
.run("user-1");
db.sqlite.prepare("INSERT OR IGNORE INTO ssh_data (id) VALUES (?)").run(7);
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
settings: options.settings,
fetch: options.fetch,
baseUrl: "https://termix.test",
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
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,
provider: () =>
mock.auth.sshAuthProviders.find(
(provider) => provider.type === "stepca",
)!,
async get(path) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
redirect: "manual",
});
return { status: response.status, body: await response.text() };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
export const env = (hostId = 7) => ({
client: {},
userId: "user-1",
hostId,
purpose: "terminal",
interactive: true,
log: () => {},
});
export const HOST = { id: 7, ip: "10.0.0.7", port: 22, username: "root" };
@@ -0,0 +1,295 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
import { CA_URL, createFakeCa } from "./fake-ca.js";
import {
HOST,
env,
fakeSocket,
manifest,
pluginDir,
startServer,
type TestServer,
} from "./helpers.js";
const certs = vi.hoisted(() => ({ applyCertificateAuth: vi.fn() }));
vi.mock("@termix/plugin-sdk/ssh-certs", () => certs);
let server: TestServer | null = null;
let nonce = "";
beforeEach(() => {
certs.applyCertificateAuth.mockReset();
nonce = "";
});
afterEach(async () => {
await server?.close();
server = null;
});
async function startWithCa(
options: {
principals?: string[];
signStatus?: number;
wrongNonce?: boolean;
settings?: Record<string, unknown>;
} = {},
) {
const ca = createFakeCa({
principals: options.principals,
signStatus: options.signStatus,
nonce: () => (options.wrongNonce ? "other" : nonce),
});
server = await startServer({
fetch: ca.fetch,
settings: {
caUrl: CA_URL,
fingerprint: ca.root.fingerprint,
provisioner: "oidc",
...options.settings,
},
});
return ca;
}
/** Starts a sign-in and returns the socket and the authorization URL. */
async function startSignIn() {
const socket = fakeSocket();
await server!.provider().startInteraction!({
userId: "user-1",
hostId: 7,
host: { name: "box", ip: "10.0.0.7", username: "root" },
socket: socket.socket,
requestOrigin: "https://termix.test",
payload: {},
});
const chooser = socket.sent.find((m) => m.stage === "chooser");
const url = chooser ? new URL(String(chooser.url)) : null;
nonce = url?.searchParams.get("nonce") ?? "";
return { socket, url, state: url?.searchParams.get("state") ?? "" };
}
describe("the certs table", () => {
it("is created on a fresh install", async () => {
const db = await createTestDb(pluginDir);
expect(
db.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE name = 'p_step_ca_certs'",
)
.get(),
).toBeTruthy();
db.close();
});
});
describe("activate", () => {
it("registers the stepca auth type and its callback route", async () => {
server = await startServer();
expect(server.provider()).toMatchObject({
type: "stepca",
interaction: "stepca",
supportsBackground: false,
});
expect(server.mock.httpRouters).toHaveLength(1);
});
it.each(["db:own", "auth:provide", "network:serve"])(
"fails closed without %s",
async (capability) => {
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: (await createTestDb(pluginDir)).database,
});
const { activate } = await import("../../src/backend/index.js");
await expect(activate(mock.ctx)).rejects.toThrow(PluginCapabilityError);
},
);
});
describe("issuing a certificate", () => {
it("runs the OIDC flow against the CA and stores the certificate sealed", async () => {
const ca = await startWithCa();
const { socket, url, state } = await startSignIn();
expect(url?.origin + url?.pathname).toBe("https://idp.test/authorize");
expect(url?.searchParams.get("client_id")).toBe("termix");
expect(url?.searchParams.get("redirect_uri")).toBe(
"https://termix.test/plugin-api/step-ca/callback",
);
expect(url?.searchParams.get("code_challenge_method")).toBe("S256");
// The root is fetched unverified and checked by fingerprint, then pinned.
expect(ca.calls[0].init?.tls).toEqual({ rejectUnauthorized: false });
expect(ca.calls[1].init?.tls).toEqual({ ca: ca.root.pem });
const response = await server!.get(`/callback?state=${state}&code=abc`);
expect(response.status).toBe(200);
expect(response.body).toContain("Signed in");
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_completed",
requestId: state,
});
const sign = ca.calls.find((call) => call.url.endsWith("/1.0/ssh/sign"));
expect(JSON.parse(sign!.init!.body!)).toMatchObject({
certType: "user",
principals: ["root"],
keyID: "alice@example.com",
});
expect(sign!.init!.tls).toEqual({ ca: ca.root.pem });
const row = server!.db.sqlite
.prepare("SELECT * FROM p_step_ca_certs")
.get() as Record<string, string>;
expect(row.user_id).toBe("user-1");
expect(row.email).toBe("alice@example.com");
expect(row.ssh_cert).toMatch(/^sealed:/);
expect(row.private_key).toMatch(/^sealed:/);
});
it("answers a second use of the same callback with an error", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
const again = await server!.get(`/callback?state=${state}&code=abc`);
expect(again.status).toBe(400);
expect(again.body).toContain("no longer active");
});
it("refuses an id token issued for another sign-in", async () => {
await startWithCa({ wrongNonce: true });
const { socket, state } = await startSignIn();
const response = await server!.get(`/callback?state=${state}&code=abc`);
expect(response.status).toBe(400);
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_error",
error: expect.stringContaining("does not match"),
});
expect(
server!.db.sqlite.prepare("SELECT * FROM p_step_ca_certs").all(),
).toHaveLength(0);
});
it("reports the CA refusing to sign", async () => {
await startWithCa({ signStatus: 403 });
const { socket, state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_error",
error: expect.stringContaining("HTTP 403"),
});
});
it("rejects a certificate without the host username", async () => {
await startWithCa({ principals: ["someone-else"] });
const { socket, state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_error",
error: expect.stringContaining("host username"),
});
});
it("passes an identity provider error through", async () => {
await startWithCa();
const { socket, state } = await startSignIn();
const response = await server!.get(
`/callback?state=${state}&error=access_denied&error_description=Denied`,
);
expect(response.status).toBe(400);
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_error",
error: "Step CA: Denied",
});
});
it("sends the 2.8 redirect URI while legacyCallback is on", async () => {
await startWithCa({ settings: { legacyCallback: true } });
const { url } = await startSignIn();
expect(url?.searchParams.get("redirect_uri")).toBe(
"https://termix.test/host/step-ca-callback",
);
});
it("tells the terminal when Step CA is not configured", async () => {
server = await startServer();
const { socket } = await startSignIn();
expect(socket.sent).toEqual([
expect.objectContaining({ type: "stepca_config_error" }),
]);
});
it("lets a pending sign-in be cancelled", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.provider().cancelInteraction!({
userId: "user-1",
requestId: state,
});
const response = await server!.get(`/callback?state=${state}&code=abc`);
expect(response.status).toBe(400);
});
});
describe("the stepca provider", () => {
it("asks for a sign-in without a certificate", async () => {
server = await startServer();
const outcome = await server.provider().prepare({}, HOST, env());
expect(outcome).toMatchObject({
status: "interaction-required",
interaction: "stepca",
});
expect(certs.applyCertificateAuth).not.toHaveBeenCalled();
});
it("connects with the issued certificate", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
const config: Record<string, unknown> = {};
const outcome = await server!.provider().prepare(config, HOST, env());
expect(outcome).toEqual({ status: "ready" });
const [passedConfig, , credentials, username] =
certs.applyCertificateAuth.mock.calls[0];
expect(passedConfig).toBe(config);
expect(username).toBe("root");
expect(credentials.certificate).toMatch(
/^ssh-ed25519-cert-v01@openssh\.com /,
);
expect(credentials.privateKey).toContain("BEGIN PRIVATE KEY");
});
it("forgets an expired certificate", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
server!.db.sqlite
.prepare("UPDATE p_step_ca_certs SET expires_at = ?")
.run("2000-01-01T00:00:00.000Z");
const outcome = await server!.provider().prepare({}, HOST, env());
expect(outcome.status).toBe("interaction-required");
expect(
server!.db.sqlite.prepare("SELECT * FROM p_step_ca_certs").all(),
).toHaveLength(0);
});
it("drops the certificate when the host rejects it", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
const decision = server!.provider().onAuthFailed!(HOST, env(), {
error: new Error("All configured authentication methods failed"),
} as never);
expect(decision).toMatchObject({ status: "interaction-required" });
await vi.waitFor(() =>
expect(
server!.db.sqlite.prepare("SELECT * FROM p_step_ca_certs").all(),
).toHaveLength(0),
);
});
});
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import { StepCaOverlay } from "../../src/frontend/StepCaOverlay";
import { redirectUri } from "../../src/frontend/RedirectUriSetting";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
useTranslation: () => ({ t: (key: string) => key }),
}));
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe("step-ca activate", () => {
it("registers the auth editor, the terminal overlay and the settings field", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.settingsComponents()).toEqual(["redirectUri"]);
expect(rendered.registered.slot("terminal.overlay")).toEqual([
"step-ca.signIn",
]);
await rendered.deactivate();
expect(rendered.registered.settingsComponents()).toEqual([]);
expect(rendered.registered.slot("terminal.overlay")).toEqual([]);
rendered = null;
});
});
describe("redirectUri", () => {
it("names the plugin callback, or the 2.8 one", () => {
expect(redirectUri(false, "https://t.example/app/")).toBe(
"https://t.example/app/plugin-api/step-ca/callback",
);
expect(redirectUri(true, "https://t.example/")).toBe(
"https://t.example/host/step-ca-callback",
);
});
});
describe("StepCaOverlay", () => {
function setup() {
let listener: (message: {
type: string;
[key: string]: unknown;
}) => void = () => {};
const props = {
host: { id: 7 },
subscribe: vi.fn((next: typeof listener) => {
listener = next;
return () => {};
}),
holdConnectTimeout: vi.fn(),
fail: vi.fn(),
disconnect: vi.fn(),
send: vi.fn(),
connectPayload: vi.fn(() => ({ hostId: 7, cols: 80, rows: 24 })),
};
render(<StepCaOverlay {...props} />);
const emit = (message: { type: string; [key: string]: unknown }) =>
act(() => listener(message));
return { props, emit };
}
it("starts the sign-in once, then reports a second request as a failure", () => {
const { props, emit } = setup();
emit({ type: "stepca_auth_required", hostId: 7 });
expect(props.send).toHaveBeenCalledWith("stepca_start_auth", { hostId: 7 });
expect(props.holdConnectTimeout).toHaveBeenCalledWith(true);
emit({ type: "stepca_auth_required", hostId: 7 });
expect(props.fail).toHaveBeenCalledWith("errors.authFailed");
});
it("opens the identity provider, waits, and reconnects once signed in", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
const { props, emit } = setup();
emit({ type: "stepca_auth_required", hostId: 7 });
emit({
type: "stepca_status",
stage: "chooser",
requestId: "req-1",
url: "https://idp.test/authorize?state=req-1",
});
fireEvent.click(screen.getByText("dialog.openBrowser"));
expect(open).toHaveBeenCalledWith(
"https://idp.test/authorize?state=req-1",
"_blank",
);
expect(screen.getByText("dialog.waiting")).toBeTruthy();
emit({ type: "stepca_completed", requestId: "req-1" });
expect(props.send).toHaveBeenCalledWith("stepca_auth_completed", {
hostId: 7,
cols: 80,
rows: 24,
});
expect(screen.queryByText("dialog.title")).toBeNull();
open.mockRestore();
});
it("cancels a pending sign-in", () => {
const { props, emit } = setup();
emit({
type: "stepca_status",
stage: "chooser",
requestId: "req-2",
url: "https://idp.test/authorize",
});
fireEvent.click(screen.getByText("common.cancel"));
expect(props.send).toHaveBeenCalledWith("stepca_cancel", {
requestId: "req-2",
});
});
it("shows a config error", () => {
const { emit } = setup();
emit({
type: "stepca_config_error",
requestId: "",
error: "Step CA is not configured.",
});
expect(screen.getByText("Step CA is not configured.")).toBeTruthy();
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);