feat: move opkssh and warpgate into plugins (C3)

This commit is contained in:
LukeGus committed 2026-09-24 19:28:19 -05:00
1 parent 925da4a661
commit 98d367d7e2
336 files changed
+22380 -4746

No files matched your search

+34
View File
@@ -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.
+29
View File
@@ -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": "驗證逾時。請再試一次。"
}
}
+51
View File
@@ -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";
+64
View File
@@ -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";
+19
View File
@@ -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"
}
}
+447
View File
@@ -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)));
},
};
}
+77
View File
@@ -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)],
};
}
+161
View File
@@ -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 };
+251
View File
@@ -0,0 +1,251 @@
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// 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;
}
+47
View File
@@ -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.
}
+88
View File
@@ -0,0 +1,88 @@
import type {
PluginContext,
PluginSshAuthProvider,
} from "@termix/plugin-sdk/backend";
import { applyCertificateAuth } from "@termix/plugin-sdk/ssh-certs";
import type { AuthSessions, SignInSocket } from "./auth-session.js";
import type { 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);
},
};
}
+622
View File
@@ -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,
}),
);
}
});
}
+48
View File
@@ -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];
+142
View File
@@ -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>
);
}
+25
View File
@@ -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 });
});
});
+193
View File
@@ -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" };
+341
View File
@@ -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();
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);