diff --git a/src/backend/hosts/auth-manager.ts b/src/backend/hosts/auth-manager.ts index 523ac3949..411e644d9 100644 --- a/src/backend/hosts/auth-manager.ts +++ b/src/backend/hosts/auth-manager.ts @@ -123,6 +123,16 @@ export class SSHAuthManager { return; } + // FortiToken-style prompt: asks the user to type a code OR the literal + // word "push" (e.g. "Enter your Token or type 'push' to receive a push + // notification:"). Unlike JumpCloud/DUO's confirm-only push, this needs a + // text field, so it must not fall into the empty-answerable push pattern + // below. Checked first since its text also contains "push notification". + const fortiTokenPattern = /type\s+['"]?push['"]?/i; + const isFortiTokenPrompt = promptTexts.some((p) => + fortiTokenPattern.test(p), + ); + // JumpCloud Protect / DUO-style push MFA: a menu choice ("Choose [1] Push, // or [2] TOTP:") followed by an empty-answerable confirm ("Press enter to // send Push request:"). Checked before the TOTP regex because the menu @@ -130,7 +140,8 @@ export class SSHAuthManager { // misrouted into the numeric-code flow. const pushPromptPattern = /choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i; - const isPushPrompt = promptTexts.some((p) => pushPromptPattern.test(p)); + const isPushPrompt = + !isFortiTokenPrompt && promptTexts.some((p) => pushPromptPattern.test(p)); if (isPushPrompt) { sshLogger.info("Push/menu MFA prompt detected", { diff --git a/src/backend/tests/hosts/auth-manager.test.ts b/src/backend/tests/hosts/auth-manager.test.ts index 2edcc9d7f..7bfefe6d7 100644 --- a/src/backend/tests/hosts/auth-manager.test.ts +++ b/src/backend/tests/hosts/auth-manager.test.ts @@ -170,6 +170,46 @@ describe("SSHAuthManager.handleKeyboardInteractive", () => { } }); + it("routes a FortiToken prompt to the totp flow instead of the push-confirm flow", () => { + const { manager, sent } = createManager(); + const finish = vi.fn(); + + manager.handleKeyboardInteractive( + "", + "", + "", + [ + { + prompt: + "Enter your Token or type 'push' to receive a push notification: ", + echo: true, + }, + ], + finish, + { username: "root", authType: "none" }, + ); + + expect(sent).toEqual([ + { + type: "connection_log", + data: { + stage: "auth", + level: "info", + message: "TOTP verification required", + }, + }, + { + type: "totp_required", + prompt: + "Enter your Token or type 'push' to receive a push notification: ", + }, + ]); + + manager.context.keyboardInteractiveFinish?.(["push"]); + + expect(finish).toHaveBeenCalledWith(["push"]); + }); + it("routes Warpgate prompts to the warpgate flow, not the generic path", () => { const { manager, sent } = createManager(); const finish = vi.fn(); diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index ba06b0e36..dbb09e03f 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2182,6 +2182,7 @@ "mfaPromptRequired": "Authentication Required", "mfaPushRequired": "Push Authentication Required", "mfaMenuPlaceholder": "Enter your response", + "mfaCodeOrPushPlaceholder": "Code or \"push\"", "mfaWaitingApproval": "Waiting for approval on your device...", "mfaSendRequest": "Send Request", "warpgateAuthRequired": "Warpgate Authentication Required", diff --git a/src/ui/ssh/dialogs/TOTPDialog.tsx b/src/ui/ssh/dialogs/TOTPDialog.tsx index e327baecb..dd2883b87 100644 --- a/src/ui/ssh/dialogs/TOTPDialog.tsx +++ b/src/ui/ssh/dialogs/TOTPDialog.tsx @@ -40,6 +40,9 @@ export function TOTPDialog({ const isMenu = mode === "menu"; const isTotp = mode === "totp"; const showWaiting = waiting || (isPush && pushSubmitted); + // FortiToken-style prompts ask for a numeric code or the literal word + // "push", so the field can't be restricted to digits only. + const allowsPushKeyword = isTotp && /push/i.test(prompt); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -114,6 +117,15 @@ export function TOTPDialog({ placeholder={t("terminal.mfaMenuPlaceholder")} className="rounded-none bg-muted/50 border-border text-center text-sm tracking-widest" /> + ) : allowsPushKeyword ? ( + ) : (