/** * Keyboard Interactive Authentication Modal * Global modal for handling SSH keyboard-interactive authentication (2FA/MFA) * This modal displays prompts from the SSH server and collects user responses. */ import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useI18n } from "../application/i18n/I18nProvider"; import { Button } from "./ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; export interface KeyboardInteractivePrompt { prompt: string; echo: boolean; } export interface KeyboardInteractiveRequest { requestId: string; sessionId?: string; hostId?: string; scope?: "terminal" | "external"; name: string; instructions: string; prompts: KeyboardInteractivePrompt[]; hostname?: string; savedPassword?: string | null; /** When false, hide save-password UI (second-factor / EDR challenges). Default true. */ allowSavePassword?: boolean; } type KeyboardInteractiveServerPromptInput = Pick< KeyboardInteractiveRequest, "name" | "instructions" | "prompts" | "hostname" >; /** Formats the server-supplied keyboard-interactive prompt block for display. */ export function formatKeyboardInteractiveServerPrompt(request: KeyboardInteractiveServerPromptInput): string { const lines: string[] = []; const name = request.name?.trim(); const hostname = request.hostname?.trim(); const instructions = request.instructions?.trim(); const hasServerText = !!instructions || !!(name && name !== hostname); if (!hasServerText) return ""; if (name && name !== hostname) { lines.push(name.endsWith(":") ? name : `${name}:`); } if (instructions) { for (const line of instructions.split(/\r?\n/).map((part) => part.trim()).filter(Boolean)) { lines.push(`| ${line}`); } } for (const prompt of request.prompts || []) { const promptText = prompt.prompt?.trim(); if (promptText) { lines.push(`| ${promptText}`); } } return lines.join("\n"); } const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => { if (prompt.echo) return false; const lower = prompt.prompt.toLowerCase(); if (!lower.includes("password") && !lower.includes("passwd")) return false; // Keep aligned with electron/bridges/sshAuthHelper.cjs OTP_PROMPT_PATTERN so // the modal never prefills the host login password into a second-factor field // (#2150). Backend also omits savedPassword for those challenges; this is // defense in depth if a caller still passes it. if ( lower.includes("one-time") || lower.includes("otp") || lower.includes("verification") || lower.includes("token") || lower.includes("code") || lower.includes("passcode") || lower.includes("2fa") || lower.includes("mfa") || lower.includes("two-factor") || lower.includes("two factor") || lower.includes("multi-factor") || lower.includes("multi factor") || lower.includes("second factor") || lower.includes("secondary password") || lower.includes("secondary authentication") || lower.includes("second password") || lower.includes("additional password") || lower.includes("re-enter password") || lower.includes("reenter password") || lower.includes("confirm password") || lower.includes("edr") || lower.includes("duo") ) { return false; } return true; }; interface KeyboardInteractiveModalProps { request: KeyboardInteractiveRequest | null; onSubmit: (requestId: string, responses: string[], savePassword?: string) => boolean | Promise; onCancel: (requestId: string) => boolean | Promise; } export const KeyboardInteractiveModal: React.FC = ({ request, onSubmit, onCancel, }) => { const { t } = useI18n(); const [responses, setResponses] = useState([]); const [showPasswords, setShowPasswords] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [savePassword, setSavePassword] = useState(false); // Index of the first password prompt (if any) const passwordPromptIndex = useMemo(() => { if (!request) return -1; return request.prompts.findIndex(p => isAPasswordPrompt(p)); }, [request]); // Reset state when request changes useEffect(() => { if (request) { const initial = request.prompts.map(() => ""); // Auto-fill saved password into the password prompt if (request.savedPassword && passwordPromptIndex >= 0) { initial[passwordPromptIndex] = request.savedPassword; } setResponses(initial); setShowPasswords(request.prompts.map(() => false)); setIsSubmitting(false); setSavePassword(false); } }, [request, passwordPromptIndex]); const handleResponseChange = useCallback((index: number, value: string) => { setResponses((prev) => { const updated = [...prev]; updated[index] = value; return updated; }); }, []); const toggleShowPassword = useCallback((index: number) => { setShowPasswords((prev) => { const updated = [...prev]; updated[index] = !updated[index]; return updated; }); }, []); const canSavePassword = request?.allowSavePassword !== false; const handleSubmit = useCallback(async () => { if (!request || isSubmitting) return; setIsSubmitting(true); const passwordToSave = canSavePassword && savePassword && passwordPromptIndex >= 0 ? responses[passwordPromptIndex] : undefined; try { const submitted = await onSubmit(request.requestId, responses, passwordToSave); if (!submitted) setIsSubmitting(false); } catch { setIsSubmitting(false); } }, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex, canSavePassword]); const handleCancel = useCallback(async () => { if (!request || isSubmitting) return; setIsSubmitting(true); try { const cancelled = await onCancel(request.requestId); if (!cancelled) setIsSubmitting(false); } catch { setIsSubmitting(false); } }, [request, onCancel, isSubmitting]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !isSubmitting) { e.preventDefault(); void handleSubmit(); } }, [handleSubmit, isSubmitting] ); if (!request) return null; const serverPromptText = formatKeyboardInteractiveServerPrompt(request); const title = t("keyboard.interactive.title"); const description = request.hostname ? t("keyboard.interactive.descWithHost", { hostname: request.hostname }) : t("keyboard.interactive.desc"); return ( {/* intentionally non-dismissable */}}> e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()} >
{title} {description}
{serverPromptText && (
              {serverPromptText}
            
)} {request.prompts.map((prompt, index) => { const isPassword = !prompt.echo; const showPassword = showPasswords[index]; const showPromptLabel = !(serverPromptText && request.prompts.length === 1); // Clean up prompt text (remove trailing colon and whitespace) const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim(); return (
{showPromptLabel && ( )}
handleResponseChange(index, e.target.value)} onKeyDown={handleKeyDown} placeholder="" className={isPassword ? "pr-10" : undefined} autoFocus={index === 0} disabled={isSubmitting} /> {isPassword && ( )}
{/* Save password checkbox - first-factor password prompts only */} {canSavePassword && index === passwordPromptIndex && ( )}
); })}
); }; export default KeyboardInteractiveModal;