mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: move opkssh and warpgate into plugins (C3)
This commit is contained in:
1 parent
925da4a661
commit
98d367d7e2
336 files changed
+22380
-4746
No files matched your search
@@ -0,0 +1,34 @@
|
||||
# OPKSSH
|
||||
|
||||
Adds the `opkssh` SSH auth type: Termix signs you in with your identity
|
||||
provider in the browser, gets a short-lived SSH certificate from
|
||||
[OpenPubkey SSH](https://github.com/openpubkey/opkssh), and caches it for 24
|
||||
hours.
|
||||
|
||||
## Config
|
||||
|
||||
`<DATA_DIR>/plugins/opkssh/config.yml`, OPKSSH's own config format. A
|
||||
template is written there the first time someone signs in without one. An
|
||||
install upgraded from 2.8 has its old `<DATA_DIR>/.opk/config.yml` copied
|
||||
here.
|
||||
|
||||
`redirect_uris` in that file are OPKSSH's local listener on the Termix
|
||||
server and must be localhost (or left out). The public callback is passed
|
||||
to OPKSSH as `--remote-redirect-uri`; register it with your identity
|
||||
provider. The plugin's admin settings page shows it:
|
||||
`<base URL>/plugin-api/opkssh/callback`, or `<base URL>/host/opkssh-callback`
|
||||
while "Use the old redirect URI" is on.
|
||||
|
||||
## Binary
|
||||
|
||||
The plugin runs `opkssh` pinned to one release and its SHA-256. It looks, in
|
||||
order, at:
|
||||
|
||||
1. `OPKSSH_BUNDLED_DIR/<asset>` (default `<cwd>/opkssh-bundled`). The Docker
|
||||
image bakes the binary into `/app/opkssh-bundled`, so offline installs
|
||||
never download it.
|
||||
2. `<DATA_DIR>/plugins/opkssh/bin/<asset>`, a copy downloaded earlier.
|
||||
3. A download from the GitHub release.
|
||||
|
||||
Any copy whose checksum does not match is ignored. `OPKSSH_VERSION` picks
|
||||
another release; `OPKSSH_SHA256` must then give its checksum.
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Sign in to this host using your identity provider instead of a password or key. Requires OPKSSH set up on the server.",
|
||||
"docs": "View docs"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH Authentication Required",
|
||||
"description": "Complete authentication in your browser to continue. This session will remain valid for 24 hours.",
|
||||
"openBrowser": "Open Browser to Authenticate",
|
||||
"waiting": "Waiting for authentication in browser...",
|
||||
"authenticating": "Processing authentication...",
|
||||
"signInWith": "Sign in with {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Authentication failed. Please check your credentials and try again.",
|
||||
"timeout": "Authentication timed out. Please try again."
|
||||
},
|
||||
"settings": {
|
||||
"redirectUri": {
|
||||
"label": "Redirect URI",
|
||||
"description": "Register this URL as an allowed redirect URI with every identity provider in your OPKSSH config."
|
||||
},
|
||||
"legacyCallback": {
|
||||
"label": "Use the old redirect URI",
|
||||
"description": "Keep sending /host/opkssh-callback, the redirect URI used before 2.9. Turn this off once your identity providers allow the new one."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Meld aan by hierdie gasheer met jou identiteitsverskaffer in plaas van 'n wagwoord of sleutel. Vereis dat OPKSSH op die bediener opgestel is.",
|
||||
"docs": "Bekyk dokumente"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH-verifikasie vereis",
|
||||
"description": "Voltooi verifikasie in jou blaaier om voort te gaan. Hierdie sessie sal vir 24 uur geldig bly.",
|
||||
"openBrowser": "Maak blaaier oop om te verifieer",
|
||||
"waiting": "Wag vir verifikasie in blaaier...",
|
||||
"authenticating": "Verwerk verifikasie...",
|
||||
"signInWith": "Teken aan met {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Verifikasie het misluk. Kontroleer asseblief jou geloofsbriewe en probeer weer.",
|
||||
"timeout": "Verifikasie het verstryk. Probeer asseblief weer."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "تسجيل الدخول إلى هذا المضيف باستخدام مزود الهوية بدلاً من كلمة المرور أو المفتاح. يتطلب إعداد OPKSSH على الخادم.",
|
||||
"docs": "عرض المستندات"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "مطلوب مصادقة OPKSSH",
|
||||
"description": "أكمل المصادقة في المتصفح للمتابعة. تبقى هذه الجلسة صالحة لمدة 24 ساعة.",
|
||||
"openBrowser": "افتح المتصفح للمصادقة",
|
||||
"waiting": "في انتظار المصادقة في المتصفح...",
|
||||
"authenticating": "جارٍ معالجة المصادقة...",
|
||||
"signInWith": "تسجيل الدخول باستخدام {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "فشلت المصادقة. يرجى التحقق من بيانات الاعتماد والمحاولة مرة أخرى.",
|
||||
"timeout": "انتهت مهلة المصادقة. يرجى المحاولة مرة أخرى."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "ОПКСШ",
|
||||
"description": "Влезте в този хост, използвайки вашия доставчик на самоличност вместо парола или ключ. Изисква се OPKSSH, настроен на сървъра.",
|
||||
"docs": "Преглед на документи"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Изисква се удостоверяване на OPKSSH",
|
||||
"description": "За да продължите, завършете удостоверяването в браузъра си. Тази сесия ще остане валидна 24 часа.",
|
||||
"openBrowser": "Отворете браузъра за удостоверяване",
|
||||
"waiting": "Чака се удостоверяване в браузъра...",
|
||||
"authenticating": "Обработва се удостоверяване...",
|
||||
"signInWith": "Влезте с {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Удостоверяването не бе успешно. Моля, проверете идентификационните си данни и опитайте отново.",
|
||||
"timeout": "Времето за изчакване на удостоверяването изтече. Моля, опитайте отново."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "পাসওয়ার্ড বা কী-এর পরিবর্তে আপনার আইডেন্টিটি প্রোভাইডার ব্যবহার করে এই হোস্টে সাইন ইন করুন। এর জন্য সার্ভারে OPKSSH সেট আপ করা আবশ্যক।",
|
||||
"docs": "নথি দেখুন"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH প্রমাণীকরণ আবশ্যক",
|
||||
"description": "চালিয়ে যাওয়ার জন্য আপনার ব্রাউজারে প্রমাণীকরণ সম্পন্ন করুন। এই সেশনটি ২৪ ঘণ্টার জন্য বৈধ থাকবে।",
|
||||
"openBrowser": "প্রমাণীকরণের জন্য ব্রাউজার খুলুন",
|
||||
"waiting": "ব্রাউজারে প্রমাণীকরণের জন্য অপেক্ষা করা হচ্ছে...",
|
||||
"authenticating": "প্রমাণীকরণ প্রক্রিয়া চলছে...",
|
||||
"signInWith": "{{provider}} দিয়ে সাইন ইন করুন"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আপনার পরিচয়পত্র যাচাই করে আবার চেষ্টা করুন।",
|
||||
"timeout": "প্রমাণীকরণের সময়সীমা শেষ হয়ে গেছে। অনুগ্রহ করে আবার চেষ্টা করুন।"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Inicieu la sessió en aquest amfitrió amb el vostre proveïdor d'identitat en comptes d'una contrasenya o clau. Requereix que OPKSSH estigui configurat al servidor.",
|
||||
"docs": "Veure documents"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Cal autenticació OPKSSH",
|
||||
"description": "Completeu l'autenticació al navegador per continuar. Aquesta sessió serà vàlida durant 24 hores.",
|
||||
"openBrowser": "Obre el navegador per autenticar-te",
|
||||
"waiting": "Esperant l'autenticació al navegador...",
|
||||
"authenticating": "Processant l'autenticació...",
|
||||
"signInWith": "Inicia la sessió amb {{provider}}"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Přihlaste se k tomuto hostiteli pomocí svého poskytovatele identity namísto hesla nebo klíče. Vyžaduje nastavení OPKSSH na serveru.",
|
||||
"docs": "Zobrazit dokumenty"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Vyžadováno ověřování OPKSSH",
|
||||
"description": "Pro pokračování dokončete ověření ve vašem prohlížeči. Tato relace bude platná 24 hodin.",
|
||||
"openBrowser": "Otevřít prohlížeč pro ověření",
|
||||
"waiting": "Čekání na ověření v prohlížeči...",
|
||||
"authenticating": "Zpracování ověřování...",
|
||||
"signInWith": "Přihlásit se pomocí {{provider}}"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Log ind på denne vært med din identitetsudbyder i stedet for en adgangskode eller nøgle. Kræver OPKSSH-opsætning på serveren.",
|
||||
"docs": "Se dokumenter"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH-godkendelse påkrævet",
|
||||
"description": "Fuldfør godkendelsen i din browser for at fortsætte. Denne session forbliver gyldig i 24 timer.",
|
||||
"openBrowser": "Åbn browseren for at godkende",
|
||||
"waiting": "Venter på godkendelse i browseren...",
|
||||
"authenticating": "Behandler godkendelse...",
|
||||
"signInWith": "Log ind med {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Godkendelse mislykkedes. Tjek dine loginoplysninger, og prøv igen.",
|
||||
"timeout": "Godkendelsen udløb. Prøv igen."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Melden Sie sich mit Ihrem Identitätsanbieter an diesem Host an, anstatt mit einem Passwort oder Schlüssel. Erfordert, dass OPKSSH auf dem Server eingerichtet ist.",
|
||||
"docs": "Dokumentation anzeigen"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH-Authentifizierung erforderlich",
|
||||
"description": "Schließen Sie die Authentifizierung in Ihrem Browser ab, um fortzufahren. Diese Sitzung bleibt 24 Stunden lang gültig.",
|
||||
"openBrowser": "Browser zum Authentifizieren öffnen",
|
||||
"waiting": "Warten auf Authentifizierung im Browser...",
|
||||
"authenticating": "Authentifizierung wird verarbeitet...",
|
||||
"signInWith": "Anmelden mit {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldeinformationen und versuchen Sie es erneut.",
|
||||
"timeout": "Authentifizierung abgelaufen. Bitte versuchen Sie es erneut."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Συνδεθείτε σε αυτόν τον κεντρικό υπολογιστή χρησιμοποιώντας την υπηρεσία παροχής ταυτότητας αντί για κωδικό πρόσβασης ή κλειδί. Απαιτείται η ρύθμιση του OPKSSH στον διακομιστή.",
|
||||
"docs": "Προβολή εγγράφων"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Απαιτείται έλεγχος ταυτότητας OPKSSH",
|
||||
"description": "Ολοκληρώστε τον έλεγχο ταυτότητας στο πρόγραμμα περιήγησής σας για να συνεχίσετε. Αυτή η περίοδος σύνδεσης θα παραμείνει έγκυρη για 24 ώρες.",
|
||||
"openBrowser": "Άνοιγμα προγράμματος περιήγησης για έλεγχο ταυτότητας",
|
||||
"waiting": "Αναμονή για έλεγχο ταυτότητας στο πρόγραμμα περιήγησης...",
|
||||
"authenticating": "Επεξεργασία ελέγχου ταυτότητας...",
|
||||
"signInWith": "Συνδεθείτε με {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Ο έλεγχος ταυτότητας απέτυχε. Ελέγξτε τα διαπιστευτήριά σας και προσπαθήστε ξανά.",
|
||||
"timeout": "Το χρονικό όριο ελέγχου ταυτότητας έληξε. Δοκιμάστε ξανά."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Inicie sesión en este host usando su proveedor de identidad en lugar de una contraseña o clave. Requiere OPKSSH configurado en el servidor.",
|
||||
"docs": "Ver documentación"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Autenticación OPKSSH requerida",
|
||||
"description": "Completa la autenticación en tu navegador para continuar. Esta sesión seguirá siendo válida durante 24 horas.",
|
||||
"openBrowser": "Abrir navegador para autenticarse",
|
||||
"waiting": "Esperando autenticación en el navegador...",
|
||||
"authenticating": "Procesando autenticación...",
|
||||
"signInWith": "Iniciar sesión con {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "La autenticación falló. Revisa tus credenciales e inténtalo de nuevo.",
|
||||
"timeout": "La autenticación expiró. Inténtalo de nuevo."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Kirjaudu tähän isäntään käyttämällä identiteetintarjoajaasi salasanan tai avaimen sijaan. Edellyttää OPKSSH:n määrittämistä palvelimelle.",
|
||||
"docs": "Näytä dokumentit"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH-todennus vaaditaan",
|
||||
"description": "Suorita todennus selaimessasi jatkaaksesi. Tämä istunto on voimassa 24 tuntia.",
|
||||
"openBrowser": "Avaa selain todennusta varten",
|
||||
"waiting": "Odotetaan selaimen todennusta...",
|
||||
"authenticating": "Todennusta käsitellään...",
|
||||
"signInWith": "Kirjaudu sisään tunnuksella {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Todennus epäonnistui. Tarkista tunnistetietosi ja yritä uudelleen.",
|
||||
"timeout": "Todennus aikakatkaistiin. Yritä uudelleen."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Connectez-vous à cet hôte en utilisant votre fournisseur d'identité au lieu d'un mot de passe ou d'une clé. Nécessite OPKSSH configuré sur le serveur.",
|
||||
"docs": "Voir la documentation"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Authentification OPKSSH requise",
|
||||
"description": "Terminez l'authentification dans votre navigateur pour continuer. Cette session restera valide pendant 24 heures.",
|
||||
"openBrowser": "Ouvrir le navigateur pour s'authentifier",
|
||||
"waiting": "En attente de l'authentification dans le navigateur...",
|
||||
"authenticating": "Traitement de l'authentification...",
|
||||
"signInWith": "Se connecter avec {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Échec de l'authentification. Veuillez vérifier vos identifiants et réessayer.",
|
||||
"timeout": "L'authentification a expiré. Veuillez réessayer."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "אופקש",
|
||||
"description": "היכנס למארח זה באמצעות ספק הזהות שלך במקום סיסמה או מפתח. נדרשת הגדרת OPKSSH בשרת.",
|
||||
"docs": "הצג מסמכים"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "נדרש אימות OPKSSH",
|
||||
"description": "השלם את האימות בדפדפן שלך כדי להמשיך. סשן זה יישאר תקף למשך 24 שעות.",
|
||||
"openBrowser": "פתח את הדפדפן כדי לאמת",
|
||||
"waiting": "ממתין לאימות בדפדפן...",
|
||||
"authenticating": "מעבד אימות...",
|
||||
"signInWith": "התחבר באמצעות {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "האימות נכשל. אנא בדוק את פרטי הגישה שלך ונסה שוב.",
|
||||
"timeout": "הזמן שהוקצב לאימות הסתיים. אנא נסה שוב."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "इस होस्ट में पासवर्ड या कुंजी के बजाय अपने पहचान प्रदाता का उपयोग करके साइन इन करें। सर्वर पर OPKSSH सेटअप आवश्यक है।",
|
||||
"docs": "दस्तावेज़ देखें"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH प्रमाणीकरण आवश्यक है",
|
||||
"description": "जारी रखने के लिए अपने ब्राउज़र में प्रमाणीकरण पूरा करें। यह सत्र 24 घंटे के लिए मान्य रहेगा।",
|
||||
"openBrowser": "प्रमाणीकरण के लिए ब्राउज़र खोलें",
|
||||
"waiting": "ब्राउज़र में प्रमाणीकरण की प्रतीक्षा हो रही है...",
|
||||
"authenticating": "प्रमाणीकरण प्रक्रिया में है...",
|
||||
"signInWith": "{{provider}} से साइन इन करें"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "प्रमाणीकरण विफल। कृपया अपने क्रेडेंशियल जाँचें और पुनः प्रयास करें।",
|
||||
"timeout": "प्रमाणीकरण का समय समाप्त हो गया। कृपया पुनः प्रयास करें।"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Jelentkezzen be erre a gazdagépre az identitásszolgáltatójával jelszó vagy kulcs helyett. OPKSSH beállítását igényli a szerveren.",
|
||||
"docs": "Dokumentumok megtekintése"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH hitelesítés szükséges",
|
||||
"description": "A folytatáshoz végezze el a hitelesítést a böngészőjében. Ez a munkamenet 24 órán át érvényes marad.",
|
||||
"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...",
|
||||
"signInWith": "Bejelentkezés {{provider}} felhasználónévvel"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Masuk ke host ini menggunakan penyedia identitas Anda, bukan kata sandi atau kunci. Membutuhkan OPKSSH yang terpasang di server.",
|
||||
"docs": "Lihat dokumen"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Otentikasi OPKSSH Diperlukan",
|
||||
"description": "Lakukan otentikasi di browser Anda untuk melanjutkan. Sesi ini akan tetap berlaku selama 24 jam.",
|
||||
"openBrowser": "Buka Browser untuk Melakukan Otentikasi",
|
||||
"waiting": "Menunggu otentikasi di browser...",
|
||||
"authenticating": "Memproses otentikasi...",
|
||||
"signInWith": "Masuk dengan {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Autentikasi gagal. Harap periksa kredensial Anda dan coba lagi.",
|
||||
"timeout": "Autentikasi habis waktu. Silakan coba lagi."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Accedi a questo host usando il tuo provider di identità invece di una password o chiave. Richiede OPKSSH configurato sul server.",
|
||||
"docs": "Visualizza documentazione"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Autenticazione OPKSSH richiesta",
|
||||
"description": "Completa l'autenticazione nel browser per continuare. Questa sessione rimarrà valida per 24 ore.",
|
||||
"openBrowser": "Apri il browser per autenticarti",
|
||||
"waiting": "In attesa dell'autenticazione nel browser...",
|
||||
"authenticating": "Autenticazione in corso...",
|
||||
"signInWith": "Accedi con {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Autenticazione fallita. Controlla le credenziali e riprova.",
|
||||
"timeout": "Autenticazione scaduta. Riprova."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "パスワードや鍵の代わりに、IDプロバイダーを使用してこのホストにサインインします。サーバーにOPKSSHのセットアップが必要です。",
|
||||
"docs": "ドキュメントを表示"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH認証が必要です",
|
||||
"description": "ブラウザで認証を完了して続行してください。このセッションは24時間有効です。",
|
||||
"openBrowser": "ブラウザを開いて認証",
|
||||
"waiting": "ブラウザでの認証を待機中...",
|
||||
"authenticating": "認証を処理中...",
|
||||
"signInWith": "{{provider}} でサインイン"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "認証に失敗しました。資格情報を確認して再試行してください。",
|
||||
"timeout": "認証がタイムアウトしました。もう一度お試しください。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "비밀번호나 키 대신 ID 공급자로 이 호스트에 로그인합니다. 서버에 OPKSSH가 설정되어 있어야 합니다.",
|
||||
"docs": "문서 보기"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH 인증 필요",
|
||||
"description": "브라우저에서 인증을 완료하여 계속 진행하세요. 이 세션은 24시간 동안 유지됩니다.",
|
||||
"openBrowser": "브라우저로 인증하기",
|
||||
"waiting": "브라우저에서 인증 대기 중...",
|
||||
"authenticating": "인증 처리 중...",
|
||||
"signInWith": "{{provider}}로 로그인"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "인증에 실패했습니다. 자격 증명을 확인하고 다시 시도하세요.",
|
||||
"timeout": "인증 시간이 초과되었습니다. 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Meld u aan bij deze host met uw identiteitsprovider in plaats van een wachtwoord of sleutel. Hiervoor moet OPKSSH op de server geconfigureerd zijn.",
|
||||
"docs": "Documenten bekijken"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH-authenticatie vereist",
|
||||
"description": "Voltooi de authenticatie in uw browser om verder te gaan. Deze sessie blijft 24 uur geldig.",
|
||||
"openBrowser": "Open de browser om te authenticeren.",
|
||||
"waiting": "Wachten op authenticatie in de browser...",
|
||||
"authenticating": "Authenticatie wordt verwerkt...",
|
||||
"signInWith": "Aanmelden met {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Authenticatie mislukt. Controleer uw inloggegevens en probeer het opnieuw.",
|
||||
"timeout": "De authenticatie is mislukt vanwege een time-out. Probeer het opnieuw."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Logg på denne verten med identitetsleverandøren din i stedet for et passord eller en nøkkel. Krever at OPKSSH er konfigurert på serveren.",
|
||||
"docs": "Vis dokumenter"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH-autentisering kreves",
|
||||
"description": "Fullfør autentiseringen i nettleseren din for å fortsette. Denne økten vil være gyldig i 24 timer.",
|
||||
"openBrowser": "Åpne nettleseren for å autentisere",
|
||||
"waiting": "Venter på autentisering i nettleseren...",
|
||||
"authenticating": "Behandler autentisering ...",
|
||||
"signInWith": "Logg inn med {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Autentiseringen mislyktes. Sjekk legitimasjonen din og prøv på nytt.",
|
||||
"timeout": "Autentiseringen ble tidsavbrutt. Prøv på nytt."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Zaloguj się do tego hosta, używając swojego dostawcy tożsamości, a nie hasła lub klucza. Wymagane jest skonfigurowanie OPKSSH na serwerze.",
|
||||
"docs": "Zobacz dokumentację"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Wymagane uwierzytelnienie OPKSSH",
|
||||
"description": "Aby kontynuować, dokończ uwierzytelnianie w przeglądarce. Ta sesja będzie ważna przez 24 godziny.",
|
||||
"openBrowser": "Otwórz przeglądarkę, aby uwierzytelnić",
|
||||
"waiting": "Oczekiwanie na uwierzytelnienie w przeglądarce...",
|
||||
"authenticating": "Przetwarzanie uwierzytelniania...",
|
||||
"signInWith": "Zaloguj się za pomocą {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Uwierzytelnienie nie powiodło się. Sprawdź poświadczenia i spróbuj ponownie.",
|
||||
"timeout": "Upłynął limit czasu uwierzytelniania. Spróbuj ponownie."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Faça login neste host usando seu provedor de identidade em vez de uma senha ou chave. Requer a configuração do OPKSSH no servidor.",
|
||||
"docs": "Ver documentos"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Autenticação OPKSSH necessária",
|
||||
"description": "Para continuar, complete a autenticação no seu navegador. Esta sessão permanecerá válida por 24 horas.",
|
||||
"openBrowser": "Abra o navegador para autenticar.",
|
||||
"waiting": "Aguardando autenticação no navegador...",
|
||||
"authenticating": "Processando autenticação...",
|
||||
"signInWith": "Faça login com {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "A autenticação falhou. Verifique suas credenciais e tente novamente.",
|
||||
"timeout": "A autenticação expirou. Tente novamente."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Inicie sessão neste anfitrião usando o seu fornecedor de identidade em vez de uma palavra-passe ou chave. Requer que o OPKSSH esteja configurado no servidor.",
|
||||
"docs": "Ver documentação"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Autenticação OPKSSH necessária",
|
||||
"description": "Conclua a autenticação no seu navegador para continuar. Esta sessão permanecerá válida por 24 horas.",
|
||||
"openBrowser": "Abrir navegador para autenticar",
|
||||
"waiting": "A aguardar autenticação no navegador...",
|
||||
"authenticating": "A processar autenticação...",
|
||||
"signInWith": "Iniciar sessão com {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "A autenticação falhou. Verifique as suas credenciais e tente novamente.",
|
||||
"timeout": "A autenticação expirou. Tente novamente."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Conectați-vă la această gazdă folosind furnizorul de identitate în loc de o parolă sau o cheie. Necesită configurarea OPKSSH pe server.",
|
||||
"docs": "Vizualizați documentele"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Autentificare OPKSSH necesară",
|
||||
"description": "Finalizați autentificarea în browser pentru a continua. Această sesiune va rămâne valabilă timp de 24 de ore.",
|
||||
"openBrowser": "Deschideți browserul pentru autentificare",
|
||||
"waiting": "Se așteaptă autentificarea în browser...",
|
||||
"authenticating": "Se procesează autentificarea...",
|
||||
"signInWith": "Conectați-vă cu {{provider}}"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Входите на этот хост через своего провайдера идентификации вместо пароля или ключа. Требуется настроенный OPKSSH на сервере.",
|
||||
"docs": "Документация"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Требуется аутентификация OPKSSH",
|
||||
"description": "Завершите аутентификацию в браузере, чтобы продолжить. Сеанс будет действителен в течение 24 часов.",
|
||||
"openBrowser": "Открыть браузер для аутентификации",
|
||||
"waiting": "Ожидание аутентификации в браузере...",
|
||||
"authenticating": "Обработка аутентификации...",
|
||||
"signInWith": "Войти через {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Аутентификация не удалась. Проверьте учётные данные и попробуйте снова.",
|
||||
"timeout": "Время аутентификации истекло. Попробуйте снова."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "ОПКШ",
|
||||
"description": "Пријавите се на овај хост користећи свог добављача идентитета уместо лозинке или кључа. Потребно је подешавање OPKSSH-а на серверу.",
|
||||
"docs": "Прикажи документе"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Потребна је аутентификација OPKSSH",
|
||||
"description": "Завршите аутентификацију у прегледачу да бисте наставили. Ова сесија ће остати важећа 24 сата.",
|
||||
"openBrowser": "Отворите прегледач за аутентификацију",
|
||||
"waiting": "Чекање аутентификације у прегледачу...",
|
||||
"authenticating": "Обрада аутентификације...",
|
||||
"signInWith": "Пријавите се са {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Аутентификација није успела. Проверите своје акредитиве и покушајте поново.",
|
||||
"timeout": "Временско ограничење за аутентификацију је истекло. Молимо покушајте поново."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Logga in på den här värden med din identitetsleverantör istället för ett lösenord eller en nyckel. Kräver att OPKSSH är konfigurerat på servern.",
|
||||
"docs": "Visa dokument"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH-autentisering krävs",
|
||||
"description": "Slutför autentiseringen i din webbläsare för att fortsätta. Denna session kommer att vara giltig i 24 timmar.",
|
||||
"openBrowser": "Öppna webbläsaren för att autentisera",
|
||||
"waiting": "Väntar på autentisering i webbläsaren...",
|
||||
"authenticating": "Bearbetar autentisering...",
|
||||
"signInWith": "Logga in med {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Autentiseringen misslyckades. Kontrollera dina inloggningsuppgifter och försök igen.",
|
||||
"timeout": "Autentiseringen har gått ut. Försök igen."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "โอพีเคเอสเอช",
|
||||
"description": "เข้าสู่ระบบโฮสต์นี้โดยใช้ผู้ให้บริการยืนยันตัวตนของคุณแทนรหัสผ่านหรือคีย์ จำเป็นต้องตั้งค่า OPKSSH บนเซิร์ฟเวอร์ก่อน",
|
||||
"docs": "ดูเอกสาร"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "ต้องใช้การตรวจสอบสิทธิ์ OPKSSH",
|
||||
"description": "โปรดยืนยันตัวตนในเบราว์เซอร์ของคุณเพื่อดำเนินการต่อ เซสชันนี้จะมีอายุใช้งาน 24 ชั่วโมง",
|
||||
"openBrowser": "เปิดเบราว์เซอร์เพื่อยืนยันตัวตน",
|
||||
"waiting": "กำลังรอการยืนยันตัวตนในเบราว์เซอร์...",
|
||||
"authenticating": "กำลังตรวจสอบสิทธิ์...",
|
||||
"signInWith": "ลงชื่อเข้าใช้ด้วย {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "การตรวจสอบสิทธิ์ล้มเหลว โปรดตรวจสอบข้อมูลประจำตัวของคุณและลองอีกครั้ง",
|
||||
"timeout": "การตรวจสอบสิทธิ์หมดเวลา โปรดลองอีกครั้ง"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Parola veya anahtar yerine kimlik sağlayıcınızı kullanarak bu sunucuda oturum açın. Sunucuda OPKSSH kurulumu gerektirir.",
|
||||
"docs": "Dokümanları görüntüle"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "OPKSSH Kimlik Doğrulaması Gerekli",
|
||||
"description": "Devam etmek için tarayıcıda kimlik doğrulamasını tamamlayın. Bu oturum 24 saat geçerli olacak.",
|
||||
"openBrowser": "Kimlik Doğrulaması için Tarayıcı Aç",
|
||||
"waiting": "Tarayıcıda kimlik doğrulaması bekleniyor...",
|
||||
"authenticating": "Kimlik doğrulaması işleniyor...",
|
||||
"signInWith": "{{provider}} ile oturum aç"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "ОПКШ",
|
||||
"description": "Увійдіть на цей хост, використовуючи свого постачальника ідентифікаційних даних замість пароля чи ключа. Потрібно налаштувати OPKSSH на сервері.",
|
||||
"docs": "Переглянути документи"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Потрібна автентифікація OPKSSH",
|
||||
"description": "Щоб продовжити, завершіть автентифікацію у вашому браузері. Цей сеанс залишатиметься дійсним протягом 24 годин.",
|
||||
"openBrowser": "Відкрийте браузер для автентифікації",
|
||||
"waiting": "Очікування автентифікації у браузері...",
|
||||
"authenticating": "Обробка автентифікації...",
|
||||
"signInWith": "Увійти за допомогою {{provider}}"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "Помилка автентифікації. Перевірте свої облікові дані та спробуйте ще раз.",
|
||||
"timeout": "Час очікування автентифікації минув. Спробуйте ще раз."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "Đăng nhập vào máy chủ này bằng nhà cung cấp danh tính thay vì mật khẩu hoặc khóa. Yêu cầu OPKSSH được thiết lập trên máy chủ.",
|
||||
"docs": "Xem tài liệu"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Yêu cầu xác thực OPKSSH",
|
||||
"description": "Hoàn tất xác thực trên trình duyệt để tiếp tục. Phiên làm việc này sẽ có hiệu lực trong 24 giờ.",
|
||||
"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...",
|
||||
"signInWith": "Đăng nhập bằng {{provider}}"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "使用身份提供商登录此主机,而非密码或密钥。需要在服务器上设置 OPKSSH。",
|
||||
"docs": "查看文档"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "需要 OPKSSH 认证",
|
||||
"description": "在浏览器中完成认证以继续。此会话将在 24 小时内有效。",
|
||||
"openBrowser": "打开浏览器进行认证",
|
||||
"waiting": "等待浏览器认证...",
|
||||
"authenticating": "正在处理认证...",
|
||||
"signInWith": "使用 {{provider}} 登录"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "认证失败,请检查凭据并重试。",
|
||||
"timeout": "认证超时,请重试。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"editor": {
|
||||
"label": "OPKSSH",
|
||||
"description": "使用您的識別提供者而非密碼或金鑰來登入此主機。需要在伺服器上設定 OPKSSH。",
|
||||
"docs": "檢視文件"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "需要 OPKSSH 驗證",
|
||||
"description": "請在瀏覽器中完成驗證以繼續。此工作階段將持續有效 24 小時。",
|
||||
"openBrowser": "開啟瀏覽器以進行驗證",
|
||||
"waiting": "正在等待瀏覽器中的驗證…",
|
||||
"authenticating": "正在處理驗證…",
|
||||
"signInWith": "使用 {{provider}} 登入"
|
||||
},
|
||||
"errors": {
|
||||
"authFailed": "驗證失敗。請檢查您的憑證並再試一次。",
|
||||
"timeout": "驗證逾時。請再試一次。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"id": "opkssh",
|
||||
"name": "OPKSSH",
|
||||
"version": "1.0.0",
|
||||
"description": "Sign in to hosts with short-lived SSH certificates from OpenPubkey SSH, using your identity provider in the browser.",
|
||||
"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",
|
||||
"files:own",
|
||||
"secrets:own",
|
||||
"hosts:read",
|
||||
"process:spawn",
|
||||
"network:outbound",
|
||||
"network:serve",
|
||||
"auth:provide",
|
||||
"ui:surface"
|
||||
],
|
||||
"contributes": {
|
||||
"auth": {
|
||||
"sshAuthTypes": ["opkssh"]
|
||||
},
|
||||
"settings": {
|
||||
"admin": [
|
||||
{
|
||||
"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,20 @@
|
||||
-- opkssh 0001: adopt_opkssh_tokens
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `opkssh_tokens` (
|
||||
`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,
|
||||
`sub` text,
|
||||
`issuer` text,
|
||||
`audience` text,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`expires_at` varchar(255) NOT NULL,
|
||||
`last_used` text,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `opkssh_tokens` TO `p_opkssh_tokens`;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- opkssh 0001: adopt_opkssh_tokens
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "opkssh_tokens" (
|
||||
"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,
|
||||
"sub" text,
|
||||
"issuer" text,
|
||||
"audience" text,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" varchar(255) NOT NULL,
|
||||
"last_used" text,
|
||||
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_opkssh_tokens_user_host" ON "opkssh_tokens" ("user_id", "host_id");
|
||||
ALTER TABLE "opkssh_tokens" RENAME TO "p_opkssh_tokens";
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"tokens": {
|
||||
"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"
|
||||
},
|
||||
"sub": {
|
||||
"type": "text"
|
||||
},
|
||||
"issuer": {
|
||||
"type": "text"
|
||||
},
|
||||
"audience": {
|
||||
"type": "text"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"lastUsed": {
|
||||
"type": "text"
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_opkssh_tokens_user_host",
|
||||
"columns": ["userId", "hostId"],
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"adopts": "opkssh_tokens"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- opkssh 0001: adopt_opkssh_tokens
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "opkssh_tokens" (
|
||||
"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,
|
||||
"sub" text,
|
||||
"issuer" text,
|
||||
"audience" text,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" text NOT NULL,
|
||||
"last_used" text,
|
||||
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_opkssh_tokens_user_host" ON "opkssh_tokens" ("user_id", "host_id");
|
||||
ALTER TABLE "opkssh_tokens" RENAME TO "p_opkssh_tokens";
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@termix-plugin/opkssh",
|
||||
"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": {
|
||||
"js-yaml": "^5.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*",
|
||||
"@types/js-yaml": "^4.0.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginProcessHandle,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
CONFIG_FILE,
|
||||
checkConfig,
|
||||
DOCS_URL,
|
||||
OPKSSH_DOCS_URL,
|
||||
validateRedirectUris,
|
||||
} from "./config.js";
|
||||
import type { CertificateIdentity, TokenStore } from "./token-store.js";
|
||||
|
||||
/** How long OPKSSH may wait for the browser sign-in. */
|
||||
export const AUTH_TIMEOUT_MS = 60 * 1000;
|
||||
|
||||
/** Where the identity provider sends the browser back, before 2.9. */
|
||||
export const LEGACY_CALLBACK_PATH = "/host/opkssh-callback";
|
||||
/** The plugin's own routes, relative to the install's base URL. */
|
||||
export const PLUGIN_PATH = "/plugin-api/opkssh";
|
||||
export const CALLBACK_PATH = `${PLUGIN_PATH}/callback`;
|
||||
|
||||
/** 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 AuthSession {
|
||||
requestId: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
/** OPKSSH's chooser listener on this server. */
|
||||
localPort: number;
|
||||
/** OPKSSH's login-callback listener on this server. */
|
||||
callbackPort: number;
|
||||
remoteRedirectUri: string;
|
||||
providers: Array<{ alias: string; issuer: string }>;
|
||||
status: "starting" | "waiting_for_auth" | "authenticating" | "completed";
|
||||
socket: SignInSocket;
|
||||
output: string;
|
||||
privateKey: string;
|
||||
sshCert: string;
|
||||
identity: CertificateIdentity;
|
||||
process: PluginProcessHandle | null;
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export interface StartRequest {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
socket: SignInSocket;
|
||||
requestOrigin: string;
|
||||
}
|
||||
|
||||
export type AuthSessions = ReturnType<typeof createAuthSessions>;
|
||||
|
||||
function send(socket: SignInSocket, message: Record<string, unknown>): void {
|
||||
try {
|
||||
socket.send(JSON.stringify(message));
|
||||
} catch {
|
||||
// socket already gone
|
||||
}
|
||||
}
|
||||
|
||||
const CHOOSER_PATTERN =
|
||||
/(?:Opening browser to|Open your browser to:)\s*http:\/\/(?:localhost|127\.0\.0\.1):(\d+)\/chooser/;
|
||||
const CALLBACK_PORT_PATTERN =
|
||||
/listening on http:\/\/(?:127\.0\.0\.1|localhost):(\d+)\//;
|
||||
const PRIVATE_KEY_PATTERN =
|
||||
/(-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----)/;
|
||||
const CERT_PATTERN =
|
||||
/((?:ecdsa-sha2-nistp256|ssh-rsa|ssh-ed25519)-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+)/;
|
||||
const IDENTITY_PATTERN =
|
||||
/Email, sub, issuer, audience:\s*\n?\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/;
|
||||
const GENERIC_REDIRECT_ERRORS = [
|
||||
"redirect_uri",
|
||||
"redirect uri",
|
||||
"invalid redirect",
|
||||
"no matching redirect",
|
||||
"allowed redirect",
|
||||
"mismatching redirection",
|
||||
];
|
||||
|
||||
/**
|
||||
* OPKSSH sign-ins in flight: one `opkssh login` process per session, whose
|
||||
* chooser and callback listeners the routes proxy. Created per activation.
|
||||
*/
|
||||
export function createAuthSessions(
|
||||
ctx: PluginContext,
|
||||
deps: {
|
||||
tokens: TokenStore;
|
||||
binaryPath: () => Promise<string>;
|
||||
},
|
||||
) {
|
||||
const sessions = new Map<string, AuthSession>();
|
||||
const oauthStates = new Map<string, string>();
|
||||
|
||||
async function configPath(): Promise<string> {
|
||||
return path.join(await ctx.files.dataDir(), CONFIG_FILE);
|
||||
}
|
||||
|
||||
async function callbackPath(): Promise<string> {
|
||||
const legacy = await ctx.settings.get<boolean>("legacyCallback");
|
||||
return legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH;
|
||||
}
|
||||
|
||||
async function end(requestId: string): Promise<void> {
|
||||
const session = sessions.get(requestId);
|
||||
if (!session || session.closed) return;
|
||||
session.closed = true;
|
||||
if (session.timeout) clearTimeout(session.timeout);
|
||||
for (const [state, id] of oauthStates) {
|
||||
if (id === requestId) oauthStates.delete(state);
|
||||
}
|
||||
const child = session.process;
|
||||
if (child) {
|
||||
child.kill("SIGTERM");
|
||||
const forced = setTimeout(() => child.kill("SIGKILL"), 3000);
|
||||
await child.exited.catch(() => undefined);
|
||||
clearTimeout(forced);
|
||||
}
|
||||
sessions.delete(requestId);
|
||||
}
|
||||
|
||||
async function complete(session: AuthSession): Promise<void> {
|
||||
if (!session.privateKey.includes("BEGIN OPENSSH PRIVATE KEY")) {
|
||||
send(session.socket, {
|
||||
type: "opkssh_error",
|
||||
requestId: session.requestId,
|
||||
error: "Failed to extract valid private key from OPKSSH output",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!/-cert-v01@openssh\.com/.test(session.sshCert)) {
|
||||
send(session.socket, {
|
||||
type: "opkssh_error",
|
||||
requestId: session.requestId,
|
||||
error: "Failed to extract valid SSH certificate from OPKSSH output",
|
||||
});
|
||||
return;
|
||||
}
|
||||
session.status = "completed";
|
||||
try {
|
||||
const expiresAt = await deps.tokens.save(
|
||||
session.userId,
|
||||
session.hostId,
|
||||
{ sshCert: session.sshCert, privateKey: session.privateKey },
|
||||
session.identity,
|
||||
);
|
||||
send(session.socket, {
|
||||
type: "opkssh_completed",
|
||||
requestId: session.requestId,
|
||||
expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
`Failed to store OPKSSH certificate: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
send(session.socket, {
|
||||
type: "opkssh_error",
|
||||
requestId: session.requestId,
|
||||
error: "Failed to store authentication token",
|
||||
});
|
||||
}
|
||||
await end(session.requestId);
|
||||
}
|
||||
|
||||
function handleOutput(session: AuthSession, chunk: string): void {
|
||||
if (session.closed) return;
|
||||
session.output += chunk;
|
||||
|
||||
const chooser = session.output.match(CHOOSER_PATTERN);
|
||||
if (chooser && session.status === "starting") {
|
||||
session.localPort = parseInt(chooser[1], 10);
|
||||
session.status = "waiting_for_auth";
|
||||
const origin = session.remoteRedirectUri.slice(
|
||||
0,
|
||||
session.remoteRedirectUri.length -
|
||||
(session.remoteRedirectUri.endsWith(LEGACY_CALLBACK_PATH)
|
||||
? LEGACY_CALLBACK_PATH.length
|
||||
: CALLBACK_PATH.length),
|
||||
);
|
||||
send(session.socket, {
|
||||
type: "opkssh_status",
|
||||
requestId: session.requestId,
|
||||
stage: "chooser",
|
||||
url: `${origin}${PLUGIN_PATH}/chooser/${session.requestId}`,
|
||||
providers: session.providers,
|
||||
message: "Please authenticate in your browser",
|
||||
});
|
||||
}
|
||||
|
||||
const callbackPort = session.output.match(CALLBACK_PORT_PATTERN);
|
||||
if (callbackPort && !session.callbackPort) {
|
||||
session.callbackPort = parseInt(callbackPort[1], 10);
|
||||
}
|
||||
|
||||
if (chunk.includes("BEGIN OPENSSH PRIVATE KEY")) {
|
||||
session.status = "authenticating";
|
||||
send(session.socket, {
|
||||
type: "opkssh_status",
|
||||
requestId: session.requestId,
|
||||
stage: "authenticating",
|
||||
message: "Processing authentication...",
|
||||
});
|
||||
}
|
||||
|
||||
const key = session.output.match(PRIVATE_KEY_PATTERN);
|
||||
if (key) session.privateKey = key[1].trim();
|
||||
const cert = session.output.match(CERT_PATTERN);
|
||||
if (cert) session.sshCert = cert[1].trim();
|
||||
const identity = session.output.match(IDENTITY_PATTERN);
|
||||
if (identity) {
|
||||
session.identity = {
|
||||
email: identity[1],
|
||||
sub: identity[2],
|
||||
issuer: identity[3],
|
||||
audience: identity[4],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
session.privateKey &&
|
||||
session.sshCert &&
|
||||
session.status !== "completed"
|
||||
) {
|
||||
void complete(session);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStderr(session: AuthSession, stderr: string): void {
|
||||
if (
|
||||
stderr.includes("Opening browser to") ||
|
||||
stderr.includes("Open your browser to:") ||
|
||||
stderr.includes("listening on")
|
||||
) {
|
||||
handleOutput(session, stderr);
|
||||
}
|
||||
|
||||
const lower = stderr.toLowerCase();
|
||||
const configError = (error: string, instructions: string) => {
|
||||
send(session.socket, {
|
||||
type: "opkssh_config_error",
|
||||
requestId: session.requestId,
|
||||
error,
|
||||
instructions,
|
||||
});
|
||||
void end(session.requestId);
|
||||
};
|
||||
|
||||
if (lower.includes("redirecturi must be localhost")) {
|
||||
ctx.log.warn("OPKSSH rejected a non-localhost entry in redirect_uris");
|
||||
configError(
|
||||
`OPKSSH rejected the local callback URI: every entry in 'redirect_uris' must be localhost.\n\n` +
|
||||
`OPKSSH output:\n${stderr.trim()}\n\n` +
|
||||
`The 'redirect_uris' config field is OPKSSH's LOCAL listener, not the public Termix callback. ` +
|
||||
`Remove any non-localhost entries from redirect_uris (or delete the whole block to use OPKSSH's ` +
|
||||
`defaults of :3000, :10001, :11110). Register the public Termix callback URL with your OAuth ` +
|
||||
`provider instead, Termix passes it to OPKSSH automatically via --remote-redirect-uri.`,
|
||||
`See documentation: ${DOCS_URL}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (GENERIC_REDIRECT_ERRORS.some((needle) => lower.includes(needle))) {
|
||||
ctx.log.warn("OPKSSH reported a redirect_uri error");
|
||||
configError(
|
||||
`OPKSSH or the OAuth provider rejected the redirect URI.\n\n` +
|
||||
`Computed Termix callback URI (sent to provider): ${session.remoteRedirectUri}\n\n` +
|
||||
`OPKSSH output:\n${stderr.trim()}\n\n` +
|
||||
`Register '${session.remoteRedirectUri}' as an authorized redirect URI with your OAuth provider. ` +
|
||||
`Also confirm any 'redirect_uris' in your OPKSSH config contain ONLY localhost URLs.`,
|
||||
`See documentation: ${DOCS_URL}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
stderr.includes("provider not found") ||
|
||||
stderr.includes("config error") ||
|
||||
stderr.includes("invalid config") ||
|
||||
stderr.includes("config not found")
|
||||
) {
|
||||
configError(
|
||||
"OPKSSH configuration error. Please verify your config file contains valid OIDC provider settings.",
|
||||
`See documentation: ${OPKSSH_DOCS_URL}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!stderr.includes('exec: "xdg-open"') &&
|
||||
(stderr.includes("bind: address already in use") ||
|
||||
stderr.includes("error logging in") ||
|
||||
stderr.includes("failed to start"))
|
||||
) {
|
||||
void end(session.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: (requestId: string) => sessions.get(requestId),
|
||||
|
||||
registerOAuthState(state: string, requestId: string): void {
|
||||
oauthStates.set(state, requestId);
|
||||
},
|
||||
|
||||
/** Finds the session an identity provider callback belongs to. */
|
||||
takeByOAuthState(state: string): AuthSession | undefined {
|
||||
const requestId = oauthStates.get(state);
|
||||
if (!requestId) return undefined;
|
||||
oauthStates.delete(state);
|
||||
return sessions.get(requestId);
|
||||
},
|
||||
|
||||
callbackPath,
|
||||
|
||||
async start(request: StartRequest): Promise<string> {
|
||||
const { socket } = request;
|
||||
let config;
|
||||
try {
|
||||
config = await checkConfig(await configPath());
|
||||
} catch (error) {
|
||||
send(socket, {
|
||||
type: "opkssh_error",
|
||||
error: `OPKSSH directory initialization failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
if (!config.ok) {
|
||||
send(socket, {
|
||||
type: "opkssh_config_error",
|
||||
requestId: "",
|
||||
error: config.error,
|
||||
instructions: config.error,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
const remoteRedirectUri = `${request.requestOrigin}${await callbackPath()}`;
|
||||
const redirects = validateRedirectUris(
|
||||
config.providers,
|
||||
remoteRedirectUri,
|
||||
);
|
||||
if (redirects.ok === false) {
|
||||
send(socket, {
|
||||
type: "opkssh_config_error",
|
||||
requestId: "",
|
||||
error: redirects.message,
|
||||
instructions: redirects.message,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const session: AuthSession = {
|
||||
requestId,
|
||||
userId: request.userId,
|
||||
hostId: request.hostId,
|
||||
localPort: 0,
|
||||
callbackPort: 0,
|
||||
remoteRedirectUri,
|
||||
providers: config.providers.map(({ alias, issuer }) => ({
|
||||
alias,
|
||||
issuer,
|
||||
})),
|
||||
status: "starting",
|
||||
socket,
|
||||
output: "",
|
||||
privateKey: "",
|
||||
sshCert: "",
|
||||
identity: {},
|
||||
process: null,
|
||||
timeout: null,
|
||||
closed: false,
|
||||
};
|
||||
sessions.set(requestId, session);
|
||||
|
||||
try {
|
||||
const binary = await deps.binaryPath();
|
||||
const child = await ctx.process.run(binary, [
|
||||
"login",
|
||||
"--print-key",
|
||||
"--disable-browser-open",
|
||||
`--config-path=${config.configPath}`,
|
||||
`--remote-redirect-uri=${remoteRedirectUri}`,
|
||||
]);
|
||||
session.process = child;
|
||||
session.timeout = setTimeout(() => {
|
||||
send(socket, { type: "opkssh_timeout", requestId });
|
||||
void end(requestId);
|
||||
}, AUTH_TIMEOUT_MS);
|
||||
socket.on("close", () => void end(requestId));
|
||||
|
||||
child.onStdout((chunk) => handleOutput(session, chunk));
|
||||
child.onStderr((chunk) => handleStderr(session, chunk));
|
||||
child.exited.then(
|
||||
({ code }) => {
|
||||
if (
|
||||
code !== 0 &&
|
||||
session.status !== "completed" &&
|
||||
!session.closed
|
||||
) {
|
||||
send(socket, {
|
||||
type: "opkssh_error",
|
||||
requestId,
|
||||
error: `OPKSSH process exited with code ${code}`,
|
||||
});
|
||||
}
|
||||
void end(requestId);
|
||||
},
|
||||
(error: unknown) => {
|
||||
send(socket, {
|
||||
type: "opkssh_error",
|
||||
requestId,
|
||||
error: `OPKSSH process error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
void end(requestId);
|
||||
},
|
||||
);
|
||||
return requestId;
|
||||
} catch (error) {
|
||||
sessions.delete(requestId);
|
||||
ctx.log.error(
|
||||
`Failed to start OPKSSH sign-in: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
send(socket, {
|
||||
type: "opkssh_error",
|
||||
requestId,
|
||||
error: `Failed to start OPKSSH authentication: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
cancel: (requestId: string) => end(requestId),
|
||||
|
||||
/** Ends every session, for deactivate. */
|
||||
async closeAll(): Promise<void> {
|
||||
await Promise.all([...sessions.keys()].map((id) => end(id)));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import path from "node:path";
|
||||
import type { PluginBinarySpec } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export const DEFAULT_VERSION = "v0.16.0";
|
||||
|
||||
const DEFAULT_CHECKSUMS: Record<string, string> = {
|
||||
"opkssh-linux-amd64":
|
||||
"c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf",
|
||||
"opkssh-linux-arm64":
|
||||
"9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44",
|
||||
"opkssh-osx-amd64":
|
||||
"e1ccddb4a73c7dd24e0677e9c933462b954dde9a151fcd96c8ee7ba83bc3f146",
|
||||
"opkssh-osx-arm64":
|
||||
"be279812cc4d44a28f8cb6eef4b13515fae16b6d00ae61e21630e0c061b02cbd",
|
||||
"opkssh-windows-amd64.exe":
|
||||
"db8991ceaac7ac224b704510ca6fba2114998291d4283de4bc2d3b8efa66ad07",
|
||||
"opkssh-windows-arm64.exe":
|
||||
"c35352dc2d12ef3775b280aa85dc1e97ff90cd8ad4beb62c3e2357fdf80ba5af",
|
||||
};
|
||||
|
||||
const OS_NAMES: Record<string, string> = {
|
||||
linux: "linux",
|
||||
darwin: "osx",
|
||||
win32: "windows",
|
||||
};
|
||||
|
||||
const ARCH_NAMES: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
|
||||
/** The release asset name for this server, e.g. "opkssh-linux-amd64". */
|
||||
export function binaryName(
|
||||
platform: string = process.platform,
|
||||
arch: string = process.arch,
|
||||
): string {
|
||||
const os = OS_NAMES[platform] ?? platform;
|
||||
const cpu = ARCH_NAMES[arch] ?? arch;
|
||||
return `opkssh-${os}-${cpu}${platform === "win32" ? ".exe" : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What ctx.process.ensureBinary needs for OPKSSH.
|
||||
*
|
||||
* The pinned version is trusted by its built-in checksums. OPKSSH_VERSION
|
||||
* picks another release, and then OPKSSH_SHA256 must give its checksum.
|
||||
*
|
||||
* The Docker image bakes the binary into /app/opkssh-bundled (the
|
||||
* opkssh-downloader stage), which is checked first so an offline install
|
||||
* never downloads. OPKSSH_BUNDLED_DIR points somewhere else.
|
||||
*/
|
||||
export function binarySpec(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: string = process.platform,
|
||||
arch: string = process.arch,
|
||||
cwd: string = process.cwd(),
|
||||
): PluginBinarySpec {
|
||||
const name = binaryName(platform, arch);
|
||||
const version = env.OPKSSH_VERSION || DEFAULT_VERSION;
|
||||
const sha256 =
|
||||
version === DEFAULT_VERSION
|
||||
? DEFAULT_CHECKSUMS[name]
|
||||
: env.OPKSSH_SHA256?.trim().toLowerCase();
|
||||
if (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)) {
|
||||
throw new Error(
|
||||
`OPKSSH ${version} has no trusted SHA-256 checksum for ${name}`,
|
||||
);
|
||||
}
|
||||
const bundledDir = env.OPKSSH_BUNDLED_DIR || path.join(cwd, "opkssh-bundled");
|
||||
return {
|
||||
name,
|
||||
version,
|
||||
sha256,
|
||||
url: `https://github.com/openpubkey/opkssh/releases/download/${version}/${name}`,
|
||||
prebuilt: [path.join(bundledDir, name)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { load as loadYaml } from "js-yaml";
|
||||
|
||||
export const CONFIG_FILE = "config.yml";
|
||||
|
||||
const DOCS_URL = "https://docs.termix.site/features/authentication/opkssh";
|
||||
const OPKSSH_DOCS_URL =
|
||||
"https://github.com/openpubkey/opkssh/blob/main/docs/config.md";
|
||||
|
||||
const TEMPLATE = `
|
||||
# OPKSSH Configuration
|
||||
# OPKSSH Documentation: ${OPKSSH_DOCS_URL}
|
||||
# Termix Documentation: ${DOCS_URL}
|
||||
`;
|
||||
|
||||
export interface ProviderInfo {
|
||||
alias: string;
|
||||
issuer: string;
|
||||
redirectUris: string[];
|
||||
}
|
||||
|
||||
export type ConfigCheck =
|
||||
| { ok: true; configPath: string; providers: ProviderInfo[] }
|
||||
| { ok: false; configPath: string; error: string };
|
||||
|
||||
function dockerHint(): string {
|
||||
const dataDir = process.env.DATA_DIR;
|
||||
return dataDir && dataDir.startsWith("/app")
|
||||
? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user."
|
||||
: "";
|
||||
}
|
||||
|
||||
/** Providers with an alias and issuer; issuers lose their scheme. */
|
||||
export function parseProviders(content: string): ProviderInfo[] {
|
||||
const parsed = loadYaml(content) as {
|
||||
providers?: Array<{
|
||||
alias?: unknown;
|
||||
issuer?: unknown;
|
||||
redirect_uris?: unknown;
|
||||
}>;
|
||||
} | null;
|
||||
if (!parsed?.providers || !Array.isArray(parsed.providers)) return [];
|
||||
return parsed.providers
|
||||
.filter(
|
||||
(p) => typeof p?.alias === "string" && typeof p?.issuer === "string",
|
||||
)
|
||||
.map((p) => ({
|
||||
alias: p.alias as string,
|
||||
issuer: (p.issuer as string).replace(/^https?:\/\//, ""),
|
||||
redirectUris: Array.isArray(p.redirect_uris)
|
||||
? p.redirect_uris.filter((u): u is string => typeof u === "string")
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the config, writing a template when there is none. The file must
|
||||
* have a providers section with at least one active provider.
|
||||
*/
|
||||
export async function checkConfig(configPath: string): Promise<ConfigCheck> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(configPath, "utf8");
|
||||
} catch {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, TEMPLATE, "utf8");
|
||||
} catch {
|
||||
// Reported below either way.
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
configPath,
|
||||
error: `OPKSSH configuration not found. A template config file has been created at:\n${configPath}\n\nPlease edit this file and configure your OIDC provider (Google, GitHub, Microsoft, etc.).\nSee documentation: ${OPKSSH_DOCS_URL}${dockerHint()}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!content.includes("providers:")) {
|
||||
return {
|
||||
ok: false,
|
||||
configPath,
|
||||
error: `OPKSSH configuration is missing 'providers' section. Please edit the config file at:\n${configPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
const hasActiveProvider = content.split("\n").some((line) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.startsWith("- alias:") || trimmed.startsWith("issuer:");
|
||||
});
|
||||
if (!hasActiveProvider) {
|
||||
return {
|
||||
ok: false,
|
||||
configPath,
|
||||
error: `OPKSSH configuration has no active providers. Please edit the config file at:\n${configPath}\n\nUncomment and configure at least one OIDC provider.\nSee documentation: ${OPKSSH_DOCS_URL}${dockerHint()}`,
|
||||
};
|
||||
}
|
||||
|
||||
let providers: ProviderInfo[] = [];
|
||||
try {
|
||||
providers = parseProviders(content);
|
||||
} catch {
|
||||
// OPKSSH reports a broken file itself when it starts.
|
||||
}
|
||||
return { ok: true, configPath, providers };
|
||||
}
|
||||
|
||||
function isLocalHost(host: string): boolean {
|
||||
const bare = host.replace(/^\[|\]$/g, "");
|
||||
return (
|
||||
bare === "localhost" ||
|
||||
bare === "127.0.0.1" ||
|
||||
bare === "::1" ||
|
||||
bare === "0:0:0:0:0:0:0:1" ||
|
||||
bare.startsWith("localhost:") ||
|
||||
bare.startsWith("127.0.0.1:")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OPKSSH's redirect_uris are the candidate ports for its own callback
|
||||
* listener on this server, so they must be localhost. The public URL the
|
||||
* identity provider redirects to is passed separately as
|
||||
* --remote-redirect-uri; it never goes in this field.
|
||||
*/
|
||||
export function validateRedirectUris(
|
||||
providers: ProviderInfo[],
|
||||
callbackUrl: string,
|
||||
): { ok: true } | { ok: false; message: string } {
|
||||
const issues: string[] = [];
|
||||
for (const provider of providers) {
|
||||
const nonLocal = provider.redirectUris.filter((uri) => {
|
||||
try {
|
||||
return !isLocalHost(new URL(uri).hostname);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (nonLocal.length > 0) {
|
||||
issues.push(
|
||||
`Provider '${provider.alias}': non-localhost entries in redirect_uris: ${nonLocal.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (issues.length === 0) return { ok: true };
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
`OPKSSH configuration error: 'redirect_uris' must only contain localhost URLs.\n\n` +
|
||||
`${issues.join("\n")}\n\n` +
|
||||
`This field is OPKSSH's local callback listener, it must be localhost (or omitted to use ` +
|
||||
`the defaults http://localhost:3000/login-callback, :10001, :11110). ` +
|
||||
`The public Termix callback URL is supplied automatically by Termix via --remote-redirect-uri; ` +
|
||||
`you do not put it here. Register the public Termix URL with your OAuth provider instead ` +
|
||||
`(${callbackUrl}).\n\n` +
|
||||
`Fix: remove the non-localhost entries above, or delete the whole 'redirect_uris' block to use defaults.\n\n` +
|
||||
`Docs: ${DOCS_URL}`,
|
||||
};
|
||||
}
|
||||
|
||||
export { DOCS_URL, OPKSSH_DOCS_URL };
|
||||
@@ -0,0 +1,251 @@
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Replicates openpubkey's client/choosers/web_chooser.go IssuerToName().
|
||||
// OPKSSH's /select handler keys its providerMap by this derived name, NOT by the
|
||||
// `alias` field in config.yml. We need the same mapping so we can normalize any
|
||||
// `op=` query param we receive (which can be alias, issuer with protocol, or
|
||||
// issuer without protocol depending on client version) to what OPKSSH expects.
|
||||
function opksshIssuerToName(issuer: string): string | null {
|
||||
if (!issuer) return null;
|
||||
const withScheme =
|
||||
issuer.startsWith("http://") || issuer.startsWith("https://")
|
||||
? issuer
|
||||
: `https://${issuer}`;
|
||||
if (withScheme.startsWith("https://accounts.google.com")) return "google";
|
||||
if (withScheme.startsWith("https://login.microsoftonline.com"))
|
||||
return "azure";
|
||||
if (withScheme.startsWith("https://gitlab.com")) return "gitlab";
|
||||
if (withScheme.startsWith("https://issuer.hello.coop")) return "hello";
|
||||
if (withScheme.startsWith("https://")) {
|
||||
const host = withScheme.slice("https://".length).split("/")[0];
|
||||
return host || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeSelectOpParam(
|
||||
rawOp: string,
|
||||
providers: Array<{ alias: string; issuer: string }>,
|
||||
): string {
|
||||
if (!rawOp) return rawOp;
|
||||
const knownNames = new Set(
|
||||
providers
|
||||
.map((p) => opksshIssuerToName(p.issuer))
|
||||
.filter((n): n is string => typeof n === "string" && n.length > 0),
|
||||
);
|
||||
if (knownNames.has(rawOp)) return rawOp;
|
||||
|
||||
const derivedFromRaw = opksshIssuerToName(rawOp);
|
||||
if (derivedFromRaw && knownNames.has(derivedFromRaw)) return derivedFromRaw;
|
||||
|
||||
const matchByAlias = providers.find((p) => p.alias === rawOp);
|
||||
if (matchByAlias) {
|
||||
const name = opksshIssuerToName(matchByAlias.issuer);
|
||||
if (name) return name;
|
||||
}
|
||||
|
||||
return rawOp;
|
||||
}
|
||||
|
||||
type OpksshErrorPageOptions = {
|
||||
title: string;
|
||||
heading: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
requestId?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
export function renderErrorPage(opts: OpksshErrorPageOptions): string {
|
||||
const title = escapeHtml(opts.title);
|
||||
const heading = escapeHtml(opts.heading);
|
||||
const message = escapeHtml(opts.message);
|
||||
const detailsBlock = opts.details
|
||||
? `<pre class="details">${escapeHtml(opts.details)}</pre>`
|
||||
: "";
|
||||
const requestIdBlock = opts.requestId
|
||||
? `<p class="request-id">Request ID: ${escapeHtml(opts.requestId)}</p>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${title}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #18181b;
|
||||
color: #fafafa;
|
||||
padding: 1rem;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
background: #27272a;
|
||||
padding: 3rem 2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 {
|
||||
color: #fafafa;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
p {
|
||||
color: #9ca3af;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
p + p { margin-top: 0.5rem; }
|
||||
.details {
|
||||
color: #d4d4d8;
|
||||
text-align: left;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: #0f0f11;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.request-id {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${heading}</h1>
|
||||
<p>${message}</p>
|
||||
${detailsBlock}
|
||||
${requestIdBlock}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Points every URL in a page OPKSSH served at its proxied path, so the
|
||||
* browser keeps talking to Termix instead of this server's localhost.
|
||||
* `basePath` is the proxied prefix, e.g. "/plugin-api/opkssh/chooser/<id>".
|
||||
*/
|
||||
export function rewriteOpksshHtml(html: string, basePath: string): string {
|
||||
const localHostPattern = "(?:localhost|127\\.0\\.0\\.1)";
|
||||
const attrPatterns = ["action", "href", "src", "formaction"];
|
||||
for (const attr of attrPatterns) {
|
||||
html = html.replace(
|
||||
new RegExp(`${attr}="(/[^"]*)`, "g"),
|
||||
`${attr}="${basePath}$1`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(`${attr}='(/[^']*)`, "g"),
|
||||
`${attr}='${basePath}$1`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const attr of ["href", "action", "src", "formaction"]) {
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`${attr}=["']?http:\\/\\/${localHostPattern}:\\d+\\/([^"'\\s]*)`,
|
||||
"g",
|
||||
),
|
||||
`${attr}="${basePath}/$1`,
|
||||
);
|
||||
}
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(window\\.location\\.href\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(window\\.location\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(fetch\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(location\\.assign\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(location\\.replace\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
// XMLHttpRequest.open("GET", "http://localhost:PORT/path", ...)
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(\\.open\\(["']\\w+["']\\s*,\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(<meta[^>]+http-equiv=["']refresh["'][^>]+content=["'][^;]+;\\s*url=)http:\\/\\/${localHostPattern}:\\d+\\/([^"']+)(["'][^>]*>)`,
|
||||
"gi",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(data-[\\w-]+=["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
const baseTag = `<base href="${basePath}/">`;
|
||||
|
||||
if (html.includes("<base")) {
|
||||
html = html.replace(/<base[^>]*>/i, baseTag);
|
||||
} else if (html.includes("<head>")) {
|
||||
html = html.replace(/<head>/i, `<head>${baseTag}`);
|
||||
} else {
|
||||
html = `<!DOCTYPE html><html><head>${baseTag}</head><body>${html}</body></html>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { tokens as tokensTable } from "./tables.js";
|
||||
import { createTokenStore } from "./token-store.js";
|
||||
import { createAuthSessions } from "./auth-session.js";
|
||||
import { binarySpec } from "./binary.js";
|
||||
import { createOpksshProvider } from "./provider.js";
|
||||
import { PUBLIC_PATHS, registerRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(tokensTable);
|
||||
const tokens = createTokenStore(ctx, table);
|
||||
|
||||
// Fetched once per activation, in the background: the Docker image ships a
|
||||
// copy, anywhere else the first boot downloads it. A sign-in waits for it.
|
||||
let binary: Promise<string> | null = null;
|
||||
const binaryPath = () => {
|
||||
if (!binary) {
|
||||
binary = ctx.process.ensureBinary(binarySpec()).catch((error) => {
|
||||
binary = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return binary;
|
||||
};
|
||||
binaryPath().catch((error) =>
|
||||
ctx.log.warn(
|
||||
`OPKSSH binary is not available yet: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
|
||||
const sessions = createAuthSessions(ctx, { tokens, binaryPath });
|
||||
ctx.disposables.add(() => sessions.closeAll());
|
||||
|
||||
ctx.auth.registerSshAuthProvider(createOpksshProvider(ctx, tokens, sessions));
|
||||
|
||||
registerRoutes(
|
||||
ctx.http.router<Router>({ public: PUBLIC_PATHS }),
|
||||
ctx,
|
||||
sessions,
|
||||
tokens,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -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 { TokenStore } from "./token-store.js";
|
||||
|
||||
export const AUTH_TYPE = "opkssh";
|
||||
|
||||
const REQUIRED_MESSAGE =
|
||||
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.";
|
||||
|
||||
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
|
||||
|
||||
/**
|
||||
* Hosts with auth type "opkssh" connect with a cached certificate. Without
|
||||
* one, the terminal is asked for a browser sign-in ("opkssh_auth_required"),
|
||||
* which startInteraction runs.
|
||||
*/
|
||||
export function createOpksshProvider(
|
||||
ctx: PluginContext,
|
||||
tokens: TokenStore,
|
||||
sessions: AuthSessions,
|
||||
): PluginSshAuthProvider {
|
||||
return {
|
||||
type: AUTH_TYPE,
|
||||
labelKey: "hosts.filterAuthOpkssh",
|
||||
needsUserInteraction: true,
|
||||
supportsBackground: false,
|
||||
interaction: AUTH_TYPE,
|
||||
prepare: async (config, host, env) => {
|
||||
const certificate = await tokens.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,
|
||||
flag: "requiresOPKSSHAuth",
|
||||
};
|
||||
}
|
||||
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:
|
||||
"OPKSSH 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("OPKSSH authentication failed, forgetting the certificate");
|
||||
void tokens.remove(env.userId, env.hostId).catch(() => undefined);
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: AUTH_TYPE,
|
||||
message:
|
||||
"OPKSSH authentication failed or expired. Please authenticate again.",
|
||||
};
|
||||
},
|
||||
startInteraction: async (request) => {
|
||||
await sessions.start({
|
||||
userId: request.userId,
|
||||
hostId: request.hostId,
|
||||
socket: request.socket as SignInSocket,
|
||||
requestOrigin: request.requestOrigin,
|
||||
});
|
||||
},
|
||||
cancelInteraction: async (request) => {
|
||||
if (request.requestId) await sessions.cancel(request.requestId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import axios from "axios";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
normalizeSelectOpParam,
|
||||
renderErrorPage,
|
||||
rewriteOpksshHtml,
|
||||
} from "./html.js";
|
||||
import {
|
||||
PLUGIN_PATH,
|
||||
type AuthSession,
|
||||
type AuthSessions,
|
||||
} from "./auth-session.js";
|
||||
import type { TokenStore } from "./token-store.js";
|
||||
|
||||
/**
|
||||
* The browser reaches these without a Termix session: the sign-in may finish
|
||||
* in a different browser, and the identity provider redirects to /callback.
|
||||
* Each is matched to its sign-in by the request id, the OAuth state or the
|
||||
* opkssh_request_id cookie.
|
||||
*/
|
||||
export const PUBLIC_PATHS = [
|
||||
"/chooser/:requestId",
|
||||
"/chooser/:requestId/*",
|
||||
"/callback",
|
||||
"/callback/:requestId",
|
||||
"/callback/:requestId/*",
|
||||
];
|
||||
|
||||
const REQUEST_COOKIE = "opkssh_request_id";
|
||||
const MAX_HOPS = 4;
|
||||
|
||||
function isLocalHostname(host: string): boolean {
|
||||
const bare = host.split(":")[0];
|
||||
return bare === "127.0.0.1" || bare === "localhost" || bare === "[::1]";
|
||||
}
|
||||
|
||||
function readCookie(req: Request, name: string): string | undefined {
|
||||
const header = req.headers.cookie;
|
||||
if (!header) return undefined;
|
||||
for (const part of header.split(";")) {
|
||||
const [key, ...rest] = part.trim().split("=");
|
||||
if (key === name) return decodeURIComponent(rest.join("="));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function param(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
|
||||
}
|
||||
|
||||
export function registerRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
sessions: AuthSessions,
|
||||
tokens: TokenStore,
|
||||
): void {
|
||||
/** The plugin's public path prefix on this install, base path included. */
|
||||
const prefix = (req: Request): string => {
|
||||
const base = new URL(ctx.http.baseUrl(req)).pathname.replace(/\/$/, "");
|
||||
return `${base}${PLUGIN_PATH}`;
|
||||
};
|
||||
|
||||
const pathAfter = (req: Request, marker: string): string => {
|
||||
const full = req.originalUrl || req.url;
|
||||
const index = full.indexOf(marker);
|
||||
return index === -1 ? "" : full.slice(index + marker.length);
|
||||
};
|
||||
|
||||
const sessionNotFound = (res: Response, requestId: string) => {
|
||||
res.status(404).send(
|
||||
renderErrorPage({
|
||||
title: "Session Not Found",
|
||||
heading: "Session Not Found",
|
||||
message:
|
||||
"This authentication session has expired or is invalid. Please close this window and try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
async function actingUserWithAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<{ userId: string; hostId: number } | null> {
|
||||
const userId = ctx.currentActor();
|
||||
const hostId = Number(param(req.params.hostId));
|
||||
if (!userId) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return null;
|
||||
}
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||
res.status(400).json({ error: "Invalid host id" });
|
||||
return null;
|
||||
}
|
||||
const access = await ctx.hosts.checkAccess(hostId, "connect");
|
||||
if (!access.hasAccess) {
|
||||
res.status(404).json({ error: "Host not found" });
|
||||
return null;
|
||||
}
|
||||
return { userId, hostId };
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/opkssh/token/{hostId}:
|
||||
* get:
|
||||
* summary: Get the cached OPKSSH certificate status for a host
|
||||
* description: Whether the current user has a valid cached certificate for this host, and when it expires.
|
||||
* tags: [OPKSSH]
|
||||
* parameters:
|
||||
* - name: hostId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate status.
|
||||
* 400:
|
||||
* description: Invalid host id.
|
||||
* 404:
|
||||
* description: Host not found or not accessible.
|
||||
*/
|
||||
router.get("/token/:hostId", async (req: Request, res: Response) => {
|
||||
const target = await actingUserWithAccess(req, res);
|
||||
if (!target) return;
|
||||
res.json(await tokens.status(target.userId, target.hostId));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/opkssh/token/{hostId}:
|
||||
* delete:
|
||||
* summary: Forget the cached OPKSSH certificate for a host
|
||||
* description: The next connection to the host asks for a browser sign-in again.
|
||||
* tags: [OPKSSH]
|
||||
* parameters:
|
||||
* - name: hostId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate removed, or there was none.
|
||||
* 400:
|
||||
* description: Invalid host id.
|
||||
* 404:
|
||||
* description: Host not found or not accessible.
|
||||
*/
|
||||
router.delete("/token/:hostId", async (req: Request, res: Response) => {
|
||||
const target = await actingUserWithAccess(req, res);
|
||||
if (!target) return;
|
||||
await tokens.remove(target.userId, target.hostId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Follows OPKSSH's /select hops until it names the identity provider:
|
||||
* /select to /select/ on the chooser, then /login on the callback listener,
|
||||
* then the provider's authorize URL.
|
||||
*/
|
||||
async function proxySelect(
|
||||
req: Request,
|
||||
res: Response,
|
||||
session: AuthSession,
|
||||
targetPath: string,
|
||||
): Promise<void> {
|
||||
const requestId = session.requestId;
|
||||
const rawQs = targetPath.includes("?")
|
||||
? targetPath.slice(targetPath.indexOf("?"))
|
||||
: "";
|
||||
let qs = rawQs;
|
||||
if (rawQs) {
|
||||
try {
|
||||
const params = new URLSearchParams(rawQs.replace(/^\?/, ""));
|
||||
const rawOp = params.get("op");
|
||||
if (rawOp) {
|
||||
const mapped = normalizeSelectOpParam(rawOp, session.providers);
|
||||
if (mapped !== rawOp) {
|
||||
params.set("op", mapped);
|
||||
qs = `?${params.toString()}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// keep the raw query
|
||||
}
|
||||
}
|
||||
|
||||
const chooserHost = `127.0.0.1:${session.localPort}`;
|
||||
const startUrl = `http://${chooserHost}/select/${qs}`;
|
||||
|
||||
const fetchUpstream = async (url: string) => {
|
||||
let hostHeader = chooserHost;
|
||||
try {
|
||||
hostHeader = new URL(url).host;
|
||||
} catch {
|
||||
// keep the chooser host
|
||||
}
|
||||
const response = await axios({
|
||||
method: "GET",
|
||||
url,
|
||||
maxRedirects: 0,
|
||||
validateStatus: () => true,
|
||||
timeout: 10000,
|
||||
responseType: "text",
|
||||
transformResponse: (value) => value,
|
||||
headers: { host: hostHeader },
|
||||
});
|
||||
const location = response.headers["location"];
|
||||
const contentType = response.headers["content-type"];
|
||||
return {
|
||||
status: response.status,
|
||||
location: typeof location === "string" ? location : undefined,
|
||||
contentType: typeof contentType === "string" ? contentType : "",
|
||||
body:
|
||||
typeof response.data === "string"
|
||||
? response.data
|
||||
: String(response.data ?? ""),
|
||||
targetUrl: url,
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
let response = await fetchUpstream(startUrl);
|
||||
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
||||
if (response.status < 300 || response.status >= 400) break;
|
||||
const location = response.location;
|
||||
if (!location) break;
|
||||
|
||||
if (location.startsWith("/")) {
|
||||
let currentHost = chooserHost;
|
||||
try {
|
||||
currentHost = new URL(response.targetUrl).host;
|
||||
} catch {
|
||||
// keep the chooser host
|
||||
}
|
||||
response = await fetchUpstream(`http://${currentHost}${location}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(location)) {
|
||||
const parsed = new URL(location);
|
||||
if (isLocalHostname(parsed.host)) {
|
||||
if (!session.callbackPort) {
|
||||
const port = parseInt(parsed.port, 10);
|
||||
if (!Number.isNaN(port)) session.callbackPort = port;
|
||||
}
|
||||
// Send the browser through the proxy so it gets the state cookie
|
||||
// OPKSSH sets on /login; following it here would swallow it.
|
||||
res.redirect(
|
||||
302,
|
||||
`${prefix(req)}/chooser/${requestId}${parsed.pathname}${parsed.search}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let external: URL | null = null;
|
||||
if (
|
||||
response.status >= 300 &&
|
||||
response.status < 400 &&
|
||||
response.location &&
|
||||
/^https?:\/\//i.test(response.location)
|
||||
) {
|
||||
try {
|
||||
const parsed = new URL(response.location);
|
||||
if (!isLocalHostname(parsed.host)) external = parsed;
|
||||
} catch {
|
||||
external = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (external) {
|
||||
const state = external.searchParams.get("state");
|
||||
if (state) sessions.registerOAuthState(state, requestId);
|
||||
res.redirect(302, external.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyPreview = response.body.slice(0, 512);
|
||||
ctx.log.error(
|
||||
`OPKSSH /select did not produce an OAuth redirect (status ${response.status})`,
|
||||
);
|
||||
res.status(502).send(
|
||||
renderErrorPage({
|
||||
title: "OPKSSH error",
|
||||
heading: "Failed to get OAuth redirect",
|
||||
message:
|
||||
"OPKSSH did not return an external OAuth provider URL. " +
|
||||
"This typically indicates a configuration mismatch between the provider's redirect_uris " +
|
||||
"and the Termix callback path. Check the server log for the OPKSSH response body.",
|
||||
details: [
|
||||
`Upstream: ${response.targetUrl}`,
|
||||
`Status: ${response.status}`,
|
||||
response.location ? `Location: ${response.location}` : "",
|
||||
`Content-Type: ${response.contentType || "(none)"}`,
|
||||
"",
|
||||
bodyPreview
|
||||
? `Body (first 512 chars):\n${bodyPreview}`
|
||||
: "Body: (empty)",
|
||||
]
|
||||
.filter((line, index) => line || index === 4)
|
||||
.join("\n"),
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.log.error(`Error proxying OPKSSH /select: ${message}`);
|
||||
res.status(502).send(
|
||||
renderErrorPage({
|
||||
title: "OPKSSH error",
|
||||
heading: "Failed to reach OPKSSH service",
|
||||
message:
|
||||
"Termix could not connect to the local OPKSSH authentication service. " +
|
||||
"The OPKSSH process may have exited or is not listening yet.",
|
||||
details: `Upstream: ${startUrl}\nError: ${message}`,
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/opkssh/chooser/{requestId}:
|
||||
* get:
|
||||
* summary: Proxy the OPKSSH provider chooser page and its resources
|
||||
* description: Public. Serves OPKSSH's local chooser for one sign-in, rewriting its URLs to this path. Any subpath is proxied too.
|
||||
* tags: [OPKSSH]
|
||||
* parameters:
|
||||
* - name: requestId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Chooser page content.
|
||||
* 302:
|
||||
* description: Redirect to the identity provider or the callback listener.
|
||||
* 404:
|
||||
* description: Sign-in not found.
|
||||
* 502:
|
||||
* description: OPKSSH did not answer as expected.
|
||||
*/
|
||||
router.use("/chooser/:requestId", async (req: Request, res: Response) => {
|
||||
const requestId = param(req.params.requestId);
|
||||
const session = sessions.get(requestId);
|
||||
if (!session) {
|
||||
sessionNotFound(res, requestId);
|
||||
return;
|
||||
}
|
||||
if (!session.localPort) {
|
||||
res.status(500).send(
|
||||
renderErrorPage({
|
||||
title: "Error",
|
||||
heading: "Authentication Error",
|
||||
message:
|
||||
"Failed to load authentication page. OPKSSH process may not be ready yet. Please try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const base = prefix(req);
|
||||
const chooserBase = `${base}/chooser/${requestId}`;
|
||||
const callbackBase = `${base}/callback/${requestId}`;
|
||||
const targetPath = pathAfter(req, `/chooser/${requestId}`) || "/chooser";
|
||||
|
||||
if (targetPath.startsWith("/select")) {
|
||||
await proxySelect(req, res, session, targetPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// The browser is sent here for OPKSSH's login listener so it receives
|
||||
// the listener's cookies.
|
||||
const onCallbackListener =
|
||||
/^\/login(?:\?|$)/.test(targetPath) ||
|
||||
/^\/login-callback(?:\?|$)/.test(targetPath);
|
||||
const upstreamPort =
|
||||
onCallbackListener && session.callbackPort
|
||||
? session.callbackPort
|
||||
: session.localPort;
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
method: req.method,
|
||||
url: `http://127.0.0.1:${upstreamPort}${targetPath}`,
|
||||
headers: { ...req.headers, host: `127.0.0.1:${upstreamPort}` },
|
||||
data: req.body,
|
||||
timeout: 10000,
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 0,
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === "transfer-encoding") continue;
|
||||
if (lower === "location") {
|
||||
const location = String(value);
|
||||
if (location.startsWith("/")) {
|
||||
res.setHeader(key, `${chooserBase}${location}`);
|
||||
continue;
|
||||
}
|
||||
const local = location.match(
|
||||
/^http:\/\/(?:localhost|127\.0\.0\.1):(\d+)(\/.*)?$/,
|
||||
);
|
||||
if (local) {
|
||||
const port = parseInt(local[1], 10);
|
||||
const path = local[2] || "/";
|
||||
const toCallback =
|
||||
(session.callbackPort && port === session.callbackPort) ||
|
||||
(port !== session.localPort &&
|
||||
(path.includes("login") || path.includes("callback")));
|
||||
res.setHeader(
|
||||
key,
|
||||
`${toCallback ? callbackBase : chooserBase}${path}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const state = new URL(location).searchParams.get("state");
|
||||
if (state) sessions.registerOAuthState(state, requestId);
|
||||
} catch {
|
||||
// not a URL
|
||||
}
|
||||
res.setHeader(key, location);
|
||||
} else if (lower === "set-cookie") {
|
||||
// Scope OPKSSH's cookies to the proxied callback, where the state
|
||||
// cookie set by /login has to arrive.
|
||||
const cookies = Array.isArray(value) ? value : [String(value)];
|
||||
res.setHeader(
|
||||
key,
|
||||
cookies.map((cookie) =>
|
||||
/;\s*path=/i.test(cookie)
|
||||
? cookie
|
||||
.replace(/;\s*domain=[^;]*/gi, "")
|
||||
.replace(/;\s*path=[^;]*/gi, `; Path=${base}/callback/`)
|
||||
: `${cookie.replace(/;\s*domain=[^;]*/gi, "")}; Path=${base}/callback/`,
|
||||
),
|
||||
);
|
||||
} else if (value !== undefined) {
|
||||
res.setHeader(key, value as string | string[]);
|
||||
}
|
||||
}
|
||||
|
||||
// Survives the identity provider round trip, for when OPKSSH
|
||||
// redirects with script and no state was captured.
|
||||
res.cookie(REQUEST_COOKIE, requestId, {
|
||||
path: `${base}/`,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const contentType = String(response.headers["content-type"] || "");
|
||||
const data = Buffer.from(response.data as ArrayBuffer);
|
||||
if (contentType.includes("text/html")) {
|
||||
res
|
||||
.status(response.status)
|
||||
.send(rewriteOpksshHtml(data.toString("utf-8"), chooserBase));
|
||||
} else {
|
||||
res.status(response.status).send(data);
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
`Error proxying OPKSSH chooser: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
res.status(500).send(
|
||||
renderErrorPage({
|
||||
title: "Error",
|
||||
heading: "Error",
|
||||
message: "Failed to load authentication page. Please try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/opkssh/callback:
|
||||
* get:
|
||||
* summary: OAuth callback from the identity provider for an OPKSSH sign-in
|
||||
* description: Public. The redirect URI registered with the identity provider. Finds the sign-in by OAuth state or the opkssh_request_id cookie and forwards to its proxied callback listener.
|
||||
* tags: [OPKSSH]
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Forwarded to the sign-in's callback listener.
|
||||
* 401:
|
||||
* description: No sign-in matches this callback.
|
||||
* 503:
|
||||
* description: OPKSSH's callback listener is not ready yet.
|
||||
*/
|
||||
router.get("/callback", (req: Request, res: Response) => {
|
||||
const base = prefix(req);
|
||||
const state = typeof req.query.state === "string" ? req.query.state : "";
|
||||
let session = state ? sessions.takeByOAuthState(state) : undefined;
|
||||
if (!session) {
|
||||
const cookieId = readCookie(req, REQUEST_COOKIE);
|
||||
if (cookieId) {
|
||||
session = sessions.get(cookieId);
|
||||
if (session) res.clearCookie(REQUEST_COOKIE, { path: `${base}/` });
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
ctx.log.warn("OPKSSH callback matched no sign-in");
|
||||
res
|
||||
.status(401)
|
||||
.send("Authentication callback failed: unable to identify session");
|
||||
return;
|
||||
}
|
||||
if (!session.callbackPort) {
|
||||
res.status(503).send("OPKSSH callback listener not ready yet");
|
||||
return;
|
||||
}
|
||||
const query = req.url.includes("?")
|
||||
? req.url.slice(req.url.indexOf("?"))
|
||||
: "";
|
||||
res.redirect(302, `${base}/callback/${session.requestId}${query}`);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/opkssh/callback/{requestId}:
|
||||
* get:
|
||||
* summary: Proxy OPKSSH's local callback listener for one sign-in
|
||||
* description: Public. Forwards to OPKSSH's login-callback listener, which prints the key and certificate once the code exchange succeeds. Any subpath is proxied too.
|
||||
* tags: [OPKSSH]
|
||||
* parameters:
|
||||
* - name: requestId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The listener's page.
|
||||
* 404:
|
||||
* description: Sign-in not found.
|
||||
* 500:
|
||||
* description: The listener is not ready.
|
||||
*/
|
||||
router.use("/callback/:requestId", async (req: Request, res: Response) => {
|
||||
const requestId = param(req.params.requestId);
|
||||
const session = sessions.get(requestId);
|
||||
if (!session) {
|
||||
sessionNotFound(res, requestId);
|
||||
return;
|
||||
}
|
||||
if (!session.callbackPort) {
|
||||
res.status(500).send(
|
||||
renderErrorPage({
|
||||
title: "Error",
|
||||
heading: "Callback Error",
|
||||
message:
|
||||
"OPKSSH callback listener not ready. Please try authenticating again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackBase = `${prefix(req)}/callback/${requestId}`;
|
||||
const after = pathAfter(req, `/callback/${requestId}`);
|
||||
// The listener serves /login-callback whatever the public path was.
|
||||
const targetPath =
|
||||
after === "" || after.startsWith("?") ? `/login-callback${after}` : after;
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
method: req.method,
|
||||
url: `http://127.0.0.1:${session.callbackPort}${targetPath}`,
|
||||
headers: { ...req.headers, host: `127.0.0.1:${session.callbackPort}` },
|
||||
data: req.body,
|
||||
timeout: 10000,
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 0,
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === "transfer-encoding" || value === undefined) continue;
|
||||
if (lower === "location" && String(value).startsWith("/")) {
|
||||
res.setHeader(key, `${callbackBase}${String(value)}`);
|
||||
} else {
|
||||
res.setHeader(key, value as string | string[]);
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = String(response.headers["content-type"] || "");
|
||||
const data = Buffer.from(response.data as ArrayBuffer);
|
||||
if (contentType.includes("text/html")) {
|
||||
res
|
||||
.status(response.status)
|
||||
.send(rewriteOpksshHtml(data.toString("utf-8"), callbackBase));
|
||||
} else {
|
||||
res.status(response.status).send(data);
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
`Error handling OPKSSH callback: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
res.status(500).send(
|
||||
renderErrorPage({
|
||||
title: "Error",
|
||||
heading: "Error",
|
||||
message: "An unexpected error occurred. Please try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* One cached certificate per user and host. Adopted from core's
|
||||
* opkssh_tokens, so the column and index names are the legacy ones.
|
||||
*
|
||||
* sshCert and privateKey are sealed with ctx.secrets.seal. Rows written by
|
||||
* 2.8 were encrypted with the user's data key instead; they do not unseal and
|
||||
* are treated as expired, which costs one browser sign-in.
|
||||
*/
|
||||
export const tokens = adoptLegacyTable(
|
||||
"opkssh_tokens",
|
||||
defineTable(
|
||||
"tokens",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
sshCert: text().notNull(),
|
||||
privateKey: text().notNull(),
|
||||
email: text(),
|
||||
sub: text(),
|
||||
issuer: text(),
|
||||
audience: text(),
|
||||
createdAt: text().notNull().defaultNow(),
|
||||
expiresAt: varchar().notNull(),
|
||||
lastUsed: text(),
|
||||
},
|
||||
{
|
||||
uniques: [
|
||||
{
|
||||
name: "idx_opkssh_tokens_user_host",
|
||||
columns: ["userId", "hostId"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [tokens];
|
||||
@@ -0,0 +1,142 @@
|
||||
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 */
|
||||
|
||||
/** How long a certificate is cached after sign-in. */
|
||||
export const TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface StoredCertificate {
|
||||
sshCert: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
export interface CertificateIdentity {
|
||||
email?: string;
|
||||
sub?: string;
|
||||
issuer?: string;
|
||||
audience?: string;
|
||||
}
|
||||
|
||||
export interface TokenStatus {
|
||||
exists: boolean;
|
||||
expiresAt?: string;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export type TokenStore = ReturnType<typeof createTokenStore>;
|
||||
|
||||
export function createTokenStore(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> {
|
||||
const existing = await find(userId, hostId);
|
||||
if (!existing) return false;
|
||||
await (await client()).delete(table).where(byUserAndHost(userId, hostId));
|
||||
await ctx.db.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
function expired(row: { expiresAt: string }): boolean {
|
||||
const expiresAt = new Date(row.expiresAt).getTime();
|
||||
return !Number.isFinite(expiresAt) || expiresAt <= Date.now();
|
||||
}
|
||||
|
||||
return {
|
||||
async save(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
certificate: StoredCertificate,
|
||||
identity: CertificateIdentity,
|
||||
): Promise<string> {
|
||||
const expiresAt = new Date(Date.now() + TOKEN_LIFETIME_MS).toISOString();
|
||||
const values = {
|
||||
sshCert: await ctx.secrets.seal(certificate.sshCert),
|
||||
privateKey: await ctx.secrets.seal(certificate.privateKey),
|
||||
email: identity.email ?? null,
|
||||
sub: identity.sub ?? null,
|
||||
issuer: identity.issuer ?? null,
|
||||
audience: identity.audience ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt,
|
||||
lastUsed: null,
|
||||
};
|
||||
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();
|
||||
return expiresAt;
|
||||
},
|
||||
|
||||
/**
|
||||
* The usable certificate, or null. An expired row, or one that does not
|
||||
* unseal (written by 2.8), is deleted.
|
||||
*/
|
||||
async get(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<StoredCertificate | null> {
|
||||
const row = await find(userId, hostId);
|
||||
if (!row) return null;
|
||||
if (expired(row)) {
|
||||
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;
|
||||
}
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(table)
|
||||
.set({ lastUsed: new Date().toISOString() })
|
||||
.where(byUserAndHost(userId, hostId));
|
||||
await ctx.db.persist({ lazy: true });
|
||||
return { sshCert, privateKey };
|
||||
},
|
||||
|
||||
async status(userId: string, hostId: number): Promise<TokenStatus> {
|
||||
const row = await find(userId, hostId);
|
||||
if (!row) return { exists: false };
|
||||
if (expired(row)) {
|
||||
await remove(userId, hostId);
|
||||
return { exists: false };
|
||||
}
|
||||
return { exists: true, expiresAt: row.expiresAt, email: row.email };
|
||||
},
|
||||
|
||||
remove,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
const DOCS_URL = "https://docs.termix.site/features/authentication/opkssh";
|
||||
|
||||
/** The host editor's "opkssh" auth method: nothing to fill in, just a pointer. */
|
||||
export function OpksshAuthEditor() {
|
||||
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,150 @@
|
||||
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" | "completed" | "error";
|
||||
|
||||
interface OpksshDialogProps {
|
||||
authUrl: string;
|
||||
stage: SignInStage;
|
||||
error?: string;
|
||||
providers?: Array<{ alias: string; issuer: string }>;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
onSelectProvider?: (alias: string) => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
/** The browser sign-in the terminal shows while OPKSSH waits. */
|
||||
export function OpksshDialog({
|
||||
authUrl,
|
||||
stage,
|
||||
error,
|
||||
providers,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
onSelectProvider,
|
||||
backgroundColor,
|
||||
}: OpksshDialogProps) {
|
||||
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" && (
|
||||
<>
|
||||
{providers && providers.length > 0 && onSelectProvider ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.alias}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onSelectProvider(provider.alias)}
|
||||
className="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"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{t("dialog.signInWith", {
|
||||
provider:
|
||||
provider.alias.charAt(0).toUpperCase() +
|
||||
provider.alias.slice(1),
|
||||
})}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : authUrl ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onOpenUrl}
|
||||
className="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"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{t("dialog.openBrowser")}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(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>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</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="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { OpksshDialog, type SignInStage } from "./OpksshDialog";
|
||||
|
||||
/** 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;
|
||||
providers?: Array<{ alias: string; issuer: string }>;
|
||||
}
|
||||
|
||||
/** How long the sign-in dialog waits before giving up on the connection. */
|
||||
const DIALOG_TIMEOUT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* Runs the OPKSSH browser sign-in for a terminal: asks the server to start
|
||||
* it, shows the provider chooser, and reconnects once the certificate is
|
||||
* stored.
|
||||
*/
|
||||
export function OpksshOverlay({
|
||||
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 "opkssh_auth_required":
|
||||
hold(true);
|
||||
if (failedRef.current) {
|
||||
clearTimer();
|
||||
setDialog(null);
|
||||
hold(false);
|
||||
fail(t("errors.authFailed"));
|
||||
} else {
|
||||
failedRef.current = true;
|
||||
send("opkssh_start_auth", { hostId: message.hostId });
|
||||
}
|
||||
break;
|
||||
case "opkssh_status":
|
||||
if (message.stage === "chooser") {
|
||||
setDialog({
|
||||
authUrl: typeof message.url === "string" ? message.url : "",
|
||||
requestId:
|
||||
typeof message.requestId === "string" ? message.requestId : "",
|
||||
stage: "chooser",
|
||||
providers: Array.isArray(message.providers)
|
||||
? (message.providers as DialogState["providers"])
|
||||
: undefined,
|
||||
});
|
||||
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 "opkssh_completed":
|
||||
clearTimer();
|
||||
setDialog(null);
|
||||
send("opkssh_auth_completed", connectPayload());
|
||||
break;
|
||||
case "opkssh_error":
|
||||
failedRef.current = true;
|
||||
showError(message.requestId, String(message.error ?? ""));
|
||||
break;
|
||||
case "opkssh_timeout":
|
||||
failedRef.current = true;
|
||||
showError(message.requestId, t("errors.timeout"));
|
||||
break;
|
||||
case "opkssh_config_error":
|
||||
showError(
|
||||
message.requestId,
|
||||
String(message.instructions || 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("opkssh_cancel", { requestId: dialog.requestId });
|
||||
}
|
||||
setDialog(null);
|
||||
if (heldRef.current) {
|
||||
heldRef.current = false;
|
||||
holdConnectTimeout(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OpksshDialog
|
||||
authUrl={dialog.authUrl}
|
||||
stage={dialog.stage}
|
||||
error={dialog.error}
|
||||
providers={dialog.providers}
|
||||
onCancel={close}
|
||||
onOpenUrl={() => {
|
||||
window.open(dialog.authUrl, "_blank");
|
||||
}}
|
||||
onSelectProvider={(alias) => {
|
||||
if (!dialog.authUrl) return;
|
||||
window.open(
|
||||
`${dialog.authUrl}/select?op=${encodeURIComponent(alias)}`,
|
||||
"_blank",
|
||||
);
|
||||
setDialog((current) =>
|
||||
current ? { ...current, stage: "waiting" } : null,
|
||||
);
|
||||
}}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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/opkssh/callback";
|
||||
const LEGACY_CALLBACK_PATH = "host/opkssh-callback";
|
||||
|
||||
/** The public callback URI to register with the 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,25 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { OpksshAuthEditor } from "./OpksshAuthEditor";
|
||||
import { OpksshOverlay } from "./OpksshOverlay";
|
||||
import { RedirectUriSetting } from "./RedirectUriSetting";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerSshAuthEditor({
|
||||
authType: "opkssh",
|
||||
titleKey: "hosts.filterAuthOpkssh",
|
||||
component: OpksshAuthEditor,
|
||||
});
|
||||
|
||||
// The browser sign-in while a terminal connects to an OPKSSH host.
|
||||
app.registerSlotContribution("terminal.overlay", {
|
||||
actionId: "opkssh.signIn",
|
||||
titleKey: "dialog.title",
|
||||
kind: "component",
|
||||
component: OpksshOverlay as unknown as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
});
|
||||
|
||||
app.registerSettingsComponent("redirectUri", RedirectUriSetting);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
binaryName,
|
||||
binarySpec,
|
||||
DEFAULT_VERSION,
|
||||
} from "../../src/backend/binary.js";
|
||||
import {
|
||||
parseProviders,
|
||||
validateRedirectUris,
|
||||
} from "../../src/backend/config.js";
|
||||
import {
|
||||
normalizeSelectOpParam,
|
||||
rewriteOpksshHtml,
|
||||
} from "../../src/backend/html.js";
|
||||
|
||||
describe("config", () => {
|
||||
it("reads providers and strips the issuer scheme", () => {
|
||||
expect(
|
||||
parseProviders(`providers:
|
||||
- alias: google
|
||||
issuer: https://accounts.google.com
|
||||
redirect_uris:
|
||||
- http://localhost:3000/login-callback
|
||||
- alias: broken
|
||||
`),
|
||||
).toEqual([
|
||||
{
|
||||
alias: "google",
|
||||
issuer: "accounts.google.com",
|
||||
redirectUris: ["http://localhost:3000/login-callback"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts localhost redirect_uris only, naming the public callback", () => {
|
||||
const callback = "https://termix.test/plugin-api/opkssh/callback";
|
||||
expect(
|
||||
validateRedirectUris(
|
||||
[
|
||||
{
|
||||
alias: "a",
|
||||
issuer: "x",
|
||||
redirectUris: ["http://127.0.0.1:10001/login-callback"],
|
||||
},
|
||||
],
|
||||
callback,
|
||||
),
|
||||
).toEqual({ ok: true });
|
||||
const refused = validateRedirectUris(
|
||||
[{ alias: "a", issuer: "x", redirectUris: [callback] }],
|
||||
callback,
|
||||
);
|
||||
expect(refused.ok).toBe(false);
|
||||
expect(refused.ok === false && refused.message).toContain(callback);
|
||||
});
|
||||
});
|
||||
|
||||
describe("html", () => {
|
||||
it("sends the page's links through the proxied path", () => {
|
||||
const html = rewriteOpksshHtml(
|
||||
'<html><head></head><body><a href="/select?op=google">g</a><form action="http://localhost:4567/login"></form></body></html>',
|
||||
"/plugin-api/opkssh/chooser/req-1",
|
||||
);
|
||||
expect(html).toContain(
|
||||
'href="/plugin-api/opkssh/chooser/req-1/select?op=google"',
|
||||
);
|
||||
expect(html).toContain('action="/plugin-api/opkssh/chooser/req-1/login"');
|
||||
expect(html).toContain('<base href="/plugin-api/opkssh/chooser/req-1/">');
|
||||
});
|
||||
|
||||
it("maps a provider alias to the name OPKSSH's chooser uses", () => {
|
||||
const providers = [{ alias: "work", issuer: "accounts.google.com" }];
|
||||
expect(normalizeSelectOpParam("work", providers)).toBe("google");
|
||||
expect(normalizeSelectOpParam("google", providers)).toBe("google");
|
||||
expect(normalizeSelectOpParam("other", providers)).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("binary", () => {
|
||||
it("names the release asset per platform", () => {
|
||||
expect(binaryName("linux", "x64")).toBe("opkssh-linux-amd64");
|
||||
expect(binaryName("darwin", "arm64")).toBe("opkssh-osx-arm64");
|
||||
expect(binaryName("win32", "x64")).toBe("opkssh-windows-amd64.exe");
|
||||
});
|
||||
|
||||
it("pins the default release and checks the Docker copy first", () => {
|
||||
const spec = binarySpec({}, "linux", "x64", "/app");
|
||||
expect(spec).toMatchObject({
|
||||
name: "opkssh-linux-amd64",
|
||||
version: DEFAULT_VERSION,
|
||||
url: `https://github.com/openpubkey/opkssh/releases/download/${DEFAULT_VERSION}/opkssh-linux-amd64`,
|
||||
prebuilt: [path.join("/app", "opkssh-bundled", "opkssh-linux-amd64")],
|
||||
});
|
||||
expect(spec.sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(
|
||||
binarySpec({ OPKSSH_BUNDLED_DIR: "/srv/opk" }, "linux", "x64").prebuilt,
|
||||
).toEqual([path.join("/srv/opk", "opkssh-linux-amd64")]);
|
||||
});
|
||||
|
||||
it("needs a checksum for any other release", () => {
|
||||
expect(() => binarySpec({ OPKSSH_VERSION: "v9" }, "linux", "x64")).toThrow(
|
||||
/no trusted SHA-256/,
|
||||
);
|
||||
const sha = "a".repeat(64);
|
||||
expect(
|
||||
binarySpec({ OPKSSH_VERSION: "v9", OPKSSH_SHA256: sha }, "linux", "x64"),
|
||||
).toMatchObject({ version: "v9", sha256: sha });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import http from "node:http";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createFakeProcess,
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type FakeProcess,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type {
|
||||
PluginHostSummary,
|
||||
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;
|
||||
|
||||
/** Core's 2.8 opkssh_tokens, as db/index.ts created it. */
|
||||
export const LEGACY_DDL = `
|
||||
CREATE TABLE IF NOT EXISTS opkssh_tokens (
|
||||
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,
|
||||
sub TEXT,
|
||||
issuer TEXT,
|
||||
audience TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used TEXT,
|
||||
UNIQUE(user_id, host_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export const PROVIDERS_CONFIG = `providers:
|
||||
- alias: google
|
||||
issuer: https://accounts.google.com
|
||||
client_id: abc
|
||||
scopes: openid email
|
||||
`;
|
||||
|
||||
/** 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;
|
||||
dataDir: string;
|
||||
processes: FakeProcess[];
|
||||
provider: () => PluginSshAuthProvider;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; headers?: Record<string, string> },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any; location: string | null }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function startServer(
|
||||
options: {
|
||||
capabilities?: string[];
|
||||
hosts?: PluginHostSummary[];
|
||||
settings?: Record<string, unknown>;
|
||||
before?: (sqlite: TestDb["sqlite"]) => void;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir, { before: options.before });
|
||||
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);
|
||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opkssh-"));
|
||||
const processes: FakeProcess[] = [];
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: options.capabilities ?? manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
hosts: options.hosts ?? [
|
||||
{
|
||||
id: 7,
|
||||
userId: "user-1",
|
||||
name: "box",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "root",
|
||||
} as PluginHostSummary,
|
||||
],
|
||||
settings: options.settings,
|
||||
baseUrl: "https://termix.test",
|
||||
process: {
|
||||
run: () => {
|
||||
const child = createFakeProcess();
|
||||
processes.push(child);
|
||||
return child;
|
||||
},
|
||||
ensureBinary: async (spec) => `/opt/${spec.name}`,
|
||||
},
|
||||
});
|
||||
// The plugin's data folder, somewhere the test can write.
|
||||
(mock.ctx as unknown as { files: unknown }).files = {
|
||||
dataDir: async () => dataDir,
|
||||
};
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") || undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
dataDir,
|
||||
processes,
|
||||
provider: () =>
|
||||
mock.auth.sshAuthProviders.find(
|
||||
(provider) => provider.type === "opkssh",
|
||||
)!,
|
||||
async request(method, requestPath, { user = "user-1", headers = {} } = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${requestPath}`, {
|
||||
method,
|
||||
redirect: "manual",
|
||||
headers: { ...(user ? { "x-test-user": user } : {}), ...headers },
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
body: parsed,
|
||||
location: response.headers.get("location"),
|
||||
};
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,341 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
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 {
|
||||
HOST,
|
||||
LEGACY_DDL,
|
||||
PROVIDERS_CONFIG,
|
||||
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);
|
||||
|
||||
const KEY = `-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAA
|
||||
-----END OPENSSH PRIVATE KEY-----`;
|
||||
const CERT = "ssh-ed25519-cert-v01@openssh.com AAAAC3NzaC1lZDI1NTE5";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
certs.applyCertificateAuth.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function writeConfig(content = PROVIDERS_CONFIG) {
|
||||
await fs.writeFile(path.join(server!.dataDir, "config.yml"), content);
|
||||
}
|
||||
|
||||
/** Starts a sign-in and returns its request id and what the socket saw. */
|
||||
async function startSignIn(origin = "https://termix.test") {
|
||||
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: origin,
|
||||
payload: {},
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
describe("adopting opkssh_tokens", () => {
|
||||
it("keeps the 2.8 rows and the unique index", async () => {
|
||||
const db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO opkssh_tokens (user_id, host_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-1", 7, "cert", "key", "2099-01-01T00:00:00.000Z");
|
||||
},
|
||||
});
|
||||
const rows = db.sqlite.prepare("SELECT * FROM p_opkssh_tokens").all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(() =>
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_opkssh_tokens (user_id, host_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-1", 7, "c", "k", "2099-01-01T00:00:00.000Z"),
|
||||
).toThrow(/UNIQUE/);
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE name = 'opkssh_tokens'")
|
||||
.get(),
|
||||
).toBeUndefined();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("creates the table on a fresh install", async () => {
|
||||
const db = await createTestDb(pluginDir);
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'p_opkssh_tokens'",
|
||||
)
|
||||
.get(),
|
||||
).toBeTruthy();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("activate", () => {
|
||||
it("registers the opkssh auth type and its routes", async () => {
|
||||
server = await startServer();
|
||||
expect(server.provider()).toMatchObject({
|
||||
type: "opkssh",
|
||||
interaction: "opkssh",
|
||||
supportsBackground: false,
|
||||
});
|
||||
expect(server.mock.httpRouters).toHaveLength(1);
|
||||
expect(server.mock.binaries[0]?.name).toMatch(/^opkssh-/);
|
||||
});
|
||||
|
||||
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("the opkssh provider", () => {
|
||||
it("asks for a sign-in without a cached certificate", async () => {
|
||||
server = await startServer();
|
||||
const outcome = await server.provider().prepare({}, HOST, env());
|
||||
expect(outcome).toMatchObject({
|
||||
status: "interaction-required",
|
||||
interaction: "opkssh",
|
||||
flag: "requiresOPKSSHAuth",
|
||||
});
|
||||
});
|
||||
|
||||
it("connects with the certificate a sign-in stored", async () => {
|
||||
server = await startServer();
|
||||
await writeConfig();
|
||||
await startSignIn();
|
||||
server.processes[0].emitStdout(`${KEY}\n${CERT}\n`);
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
server!.db.sqlite.prepare("SELECT * FROM p_opkssh_tokens").all(),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
const outcome = await server.provider().prepare(config, HOST, env());
|
||||
expect(outcome).toEqual({ status: "ready" });
|
||||
expect(certs.applyCertificateAuth).toHaveBeenCalledWith(
|
||||
config,
|
||||
{},
|
||||
{ privateKey: KEY, certificate: CERT },
|
||||
"root",
|
||||
);
|
||||
const row = server.db.sqlite
|
||||
.prepare("SELECT ssh_cert FROM p_opkssh_tokens")
|
||||
.get() as { ssh_cert: string };
|
||||
expect(row.ssh_cert).not.toContain("cert-v01");
|
||||
});
|
||||
|
||||
it("treats a 2.8 row it cannot unseal as expired", async () => {
|
||||
server = await startServer({
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO opkssh_tokens (user_id, host_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-1", 7, "v2:old", "v2:old", "2099-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_opkssh_tokens").all(),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("forgets the certificate when the server refuses it", async () => {
|
||||
server = await startServer();
|
||||
await writeConfig();
|
||||
await startSignIn();
|
||||
server.processes[0].emitStdout(`${KEY}\n${CERT}\n`);
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
server!.db.sqlite.prepare("SELECT * FROM p_opkssh_tokens").all(),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
const followUp = server.provider().onAuthFailed!(HOST, env(), {
|
||||
error: new Error("All configured authentication methods failed"),
|
||||
retries: 0,
|
||||
canRetry: true,
|
||||
methodNotAvailable: false,
|
||||
});
|
||||
expect(followUp).toMatchObject({ status: "interaction-required" });
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
server!.db.sqlite.prepare("SELECT * FROM p_opkssh_tokens").all(),
|
||||
).toHaveLength(0),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the browser sign-in", () => {
|
||||
it("runs opkssh with the plugin's config and the public callback", async () => {
|
||||
server = await startServer();
|
||||
await writeConfig();
|
||||
const socket = await startSignIn();
|
||||
const run = server.mock.processRuns[0];
|
||||
expect(run.file).toMatch(/opkssh-/);
|
||||
expect(run.args).toContain(
|
||||
`--config-path=${path.join(server.dataDir, "config.yml")}`,
|
||||
);
|
||||
expect(run.args).toContain(
|
||||
"--remote-redirect-uri=https://termix.test/plugin-api/opkssh/callback",
|
||||
);
|
||||
|
||||
server.processes[0].emitStderr(
|
||||
"Opening browser to http://localhost:4567/chooser\n",
|
||||
);
|
||||
const status = socket.sent.find((m) => m.type === "opkssh_status");
|
||||
expect(status).toMatchObject({
|
||||
stage: "chooser",
|
||||
url: expect.stringMatching(
|
||||
/^https:\/\/termix\.test\/plugin-api\/opkssh\/chooser\/[\w-]+$/,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the old callback for an install upgraded from 2.8", async () => {
|
||||
server = await startServer({ settings: { legacyCallback: true } });
|
||||
await writeConfig();
|
||||
await startSignIn();
|
||||
expect(server.mock.processRuns[0].args).toContain(
|
||||
"--remote-redirect-uri=https://termix.test/host/opkssh-callback",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes a template and reports a missing config", async () => {
|
||||
server = await startServer();
|
||||
const socket = await startSignIn();
|
||||
expect(socket.sent[0]).toMatchObject({ type: "opkssh_config_error" });
|
||||
expect(
|
||||
await fs.readFile(path.join(server.dataDir, "config.yml"), "utf8"),
|
||||
).toContain("OPKSSH Configuration");
|
||||
expect(server.mock.processRuns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses redirect_uris that are not localhost", async () => {
|
||||
server = await startServer();
|
||||
await writeConfig(
|
||||
`${PROVIDERS_CONFIG} redirect_uris:\n - https://termix.example/host/opkssh-callback\n`,
|
||||
);
|
||||
const socket = await startSignIn();
|
||||
expect(socket.sent[0]).toMatchObject({ type: "opkssh_config_error" });
|
||||
expect(String(socket.sent[0].error)).toMatch(/must only contain localhost/);
|
||||
});
|
||||
|
||||
it("kills the process when the terminal goes away", async () => {
|
||||
server = await startServer();
|
||||
await writeConfig();
|
||||
const socket = await startSignIn();
|
||||
socket.close();
|
||||
await vi.waitFor(() =>
|
||||
expect(server!.processes[0].killed).toContain("SIGTERM"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routes", () => {
|
||||
it("reports and forgets a cached certificate", async () => {
|
||||
server = await startServer();
|
||||
expect((await server.request("GET", "/token/7")).body).toEqual({
|
||||
exists: false,
|
||||
});
|
||||
await writeConfig();
|
||||
await startSignIn();
|
||||
server.processes[0].emitStdout(`${KEY}\n${CERT}\n`);
|
||||
await vi.waitFor(async () =>
|
||||
expect((await server!.request("GET", "/token/7")).body.exists).toBe(true),
|
||||
);
|
||||
expect((await server.request("DELETE", "/token/7")).status).toBe(200);
|
||||
expect((await server.request("GET", "/token/7")).body.exists).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a host the user cannot reach, and anonymous callers", async () => {
|
||||
server = await startServer();
|
||||
expect((await server.request("GET", "/token/99")).status).toBe(404);
|
||||
expect((await server.request("GET", "/token/7", { user: "" })).status).toBe(
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards the identity provider's callback to the sign-in it belongs to", async () => {
|
||||
server = await startServer();
|
||||
await writeConfig();
|
||||
const socket = await startSignIn();
|
||||
server.processes[0].emitStderr(
|
||||
"Opening browser to http://localhost:4567/chooser\n",
|
||||
);
|
||||
const requestId = String(
|
||||
socket.sent.find((m) => m.type === "opkssh_status")?.requestId,
|
||||
);
|
||||
const cookie = { cookie: `opkssh_request_id=${requestId}` };
|
||||
|
||||
// OPKSSH's callback listener is not up yet.
|
||||
expect(
|
||||
(
|
||||
await server.request("GET", "/callback?code=x", {
|
||||
user: "",
|
||||
headers: cookie,
|
||||
})
|
||||
).status,
|
||||
).toBe(503);
|
||||
|
||||
server.processes[0].emitStderr("listening on http://127.0.0.1:5555/\n");
|
||||
const forwarded = await server.request("GET", "/callback?code=x&state=s", {
|
||||
user: "",
|
||||
headers: cookie,
|
||||
});
|
||||
expect(forwarded.status).toBe(302);
|
||||
expect(forwarded.location).toBe(
|
||||
`/plugin-api/opkssh/callback/${requestId}?code=x&state=s`,
|
||||
);
|
||||
|
||||
expect(
|
||||
(await server.request("GET", "/callback?code=x", { user: "" })).status,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it("answers an unknown sign-in with a not-found page", async () => {
|
||||
server = await startServer();
|
||||
const response = await server.request("GET", "/chooser/nope", { user: "" });
|
||||
expect(response.status).toBe(404);
|
||||
expect(String(response.body)).toContain("Session Not Found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
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 { OpksshOverlay } from "../../src/frontend/OpksshOverlay";
|
||||
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("opkssh 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([
|
||||
"opkssh.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/opkssh/callback",
|
||||
);
|
||||
expect(redirectUri(true, "https://t.example/")).toBe(
|
||||
"https://t.example/host/opkssh-callback",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpksshOverlay", () => {
|
||||
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(<OpksshOverlay {...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: "opkssh_auth_required", hostId: 7 });
|
||||
expect(props.send).toHaveBeenCalledWith("opkssh_start_auth", { hostId: 7 });
|
||||
expect(props.holdConnectTimeout).toHaveBeenCalledWith(true);
|
||||
|
||||
emit({ type: "opkssh_auth_required", hostId: 7 });
|
||||
expect(props.fail).toHaveBeenCalledWith("errors.authFailed");
|
||||
});
|
||||
|
||||
it("shows the chooser, cancels it, and reconnects once signed in", () => {
|
||||
const { props, emit } = setup();
|
||||
emit({ type: "opkssh_auth_required", hostId: 7 });
|
||||
emit({
|
||||
type: "opkssh_status",
|
||||
stage: "chooser",
|
||||
requestId: "req-1",
|
||||
url: "https://termix.test/plugin-api/opkssh/chooser/req-1",
|
||||
providers: [{ alias: "google", issuer: "accounts.google.com" }],
|
||||
});
|
||||
expect(screen.getByText("dialog.title")).toBeTruthy();
|
||||
|
||||
emit({ type: "opkssh_completed", requestId: "req-1" });
|
||||
expect(props.send).toHaveBeenCalledWith("opkssh_auth_completed", {
|
||||
hostId: 7,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
expect(screen.queryByText("dialog.title")).toBeNull();
|
||||
|
||||
emit({
|
||||
type: "opkssh_status",
|
||||
stage: "chooser",
|
||||
requestId: "req-2",
|
||||
url: "https://termix.test/plugin-api/opkssh/chooser/req-2",
|
||||
});
|
||||
fireEvent.click(screen.getByText("common.cancel"));
|
||||
expect(props.send).toHaveBeenCalledWith("opkssh_cancel", {
|
||||
requestId: "req-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a config error", () => {
|
||||
const { emit } = setup();
|
||||
emit({
|
||||
type: "opkssh_config_error",
|
||||
requestId: "",
|
||||
error: "OPKSSH configuration not found.",
|
||||
});
|
||||
expect(screen.getByText("OPKSSH configuration not found.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user