mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
299 lines
7.9 KiB
TypeScript
299 lines
7.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type {
|
|
PluginKeyboardInteractiveDecision,
|
|
PluginKeyboardInteractivePrompt,
|
|
PluginSshHost,
|
|
} from "@termix/plugin-sdk/backend";
|
|
import { SSHAuthManager } from "../../src/backend/keyboard-prompt.js";
|
|
|
|
/**
|
|
* Stands in for core's classifier (ctx.ssh.classifyKeyboardInteractive),
|
|
* which core's own tests cover. These tests are about what the terminal does
|
|
* with each decision: the socket messages, the answers and the timeouts.
|
|
*/
|
|
const PASSWORD = /password/i;
|
|
const FORTI = /type\s+['"]?push['"]?/i;
|
|
const PUSH =
|
|
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
|
|
const TOTP =
|
|
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i;
|
|
|
|
function classify(
|
|
round: {
|
|
name: string;
|
|
instructions: string;
|
|
prompts: PluginKeyboardInteractivePrompt[];
|
|
},
|
|
host: PluginSshHost,
|
|
): PluginKeyboardInteractiveDecision {
|
|
// Stands in for a plugin's keyboard-interactive handler.
|
|
if (/gateway sign-in/i.test(round.name)) {
|
|
return {
|
|
kind: "browser",
|
|
id: "gateway",
|
|
label: "Gateway",
|
|
url: round.instructions.match(/https?:\/\/\S+/)?.[0] ?? null,
|
|
code: round.instructions.match(/Security key: (\S+)/)?.[1] ?? "",
|
|
instructions: round.instructions,
|
|
};
|
|
}
|
|
const texts = round.prompts.map((p) => p.prompt);
|
|
const isPush =
|
|
!texts.some((t) => FORTI.test(t)) && texts.some((t) => PUSH.test(t));
|
|
if (!isPush) {
|
|
const totp = round.prompts.findIndex((p) => TOTP.test(p.prompt));
|
|
if (totp !== -1) return { kind: "totp", promptIndex: totp };
|
|
}
|
|
const stored = !!host.password && host.authType !== "none";
|
|
const first = round.prompts.findIndex(
|
|
(p) => !(PASSWORD.test(p.prompt) && stored),
|
|
);
|
|
if (first === -1) {
|
|
return {
|
|
kind: "auto",
|
|
responses: round.prompts.map((p) =>
|
|
PASSWORD.test(p.prompt) ? String(host.password) : "",
|
|
),
|
|
};
|
|
}
|
|
return { kind: "input", promptIndex: first, isPush: PUSH.test(texts[first]) };
|
|
}
|
|
|
|
const log = { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
|
|
function createManager() {
|
|
const sent: Record<string, unknown>[] = [];
|
|
const ws = { send: (data: string) => sent.push(JSON.parse(data)) } as any;
|
|
const manager = new SSHAuthManager({
|
|
ssh: { classifyKeyboardInteractive: classify },
|
|
log,
|
|
userId: "user-1",
|
|
ws,
|
|
hostId: 1,
|
|
isKeyboardInteractive: false,
|
|
keyboardInteractiveResponded: false,
|
|
keyboardInteractiveFinish: null,
|
|
totpPromptSent: false,
|
|
browserSignInId: null,
|
|
totpTimeout: null,
|
|
browserSignInTimeout: null,
|
|
totpAttempts: 0,
|
|
});
|
|
return { manager, sent };
|
|
}
|
|
|
|
describe("SSHAuthManager.handleKeyboardInteractive", () => {
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("routes a TOTP verification prompt to the totp flow", () => {
|
|
const { manager, sent } = createManager();
|
|
const finish = vi.fn();
|
|
|
|
manager.handleKeyboardInteractive(
|
|
"",
|
|
"",
|
|
"",
|
|
[{ prompt: "Verification code: ", 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: "Verification code: " },
|
|
]);
|
|
expect(finish).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("forwards echo:true for a JumpCloud-style push/TOTP menu prompt", () => {
|
|
const { manager, sent } = createManager();
|
|
const finish = vi.fn();
|
|
|
|
manager.handleKeyboardInteractive(
|
|
"",
|
|
"",
|
|
"",
|
|
[{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
|
|
finish,
|
|
{ username: "root", authType: "none" },
|
|
);
|
|
|
|
expect(sent).toEqual([
|
|
{
|
|
type: "connection_log",
|
|
data: {
|
|
stage: "auth",
|
|
level: "info",
|
|
message: "Password authentication required",
|
|
},
|
|
},
|
|
{
|
|
type: "password_required",
|
|
prompt: "Choose [1] Push, or [2] TOTP: ",
|
|
echo: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("silently auto-answers a plain password prompt when a stored password exists", () => {
|
|
const { manager, sent } = createManager();
|
|
const finish = vi.fn();
|
|
|
|
manager.handleKeyboardInteractive(
|
|
"",
|
|
"",
|
|
"",
|
|
[{ prompt: "Password: ", echo: false }],
|
|
finish,
|
|
{ username: "root", password: "hunter2", authType: "password" },
|
|
);
|
|
|
|
expect(finish).toHaveBeenCalledWith(["hunter2"]);
|
|
expect(sent).toEqual([]);
|
|
});
|
|
|
|
it("prompts the user for a push-confirm prompt and accepts an empty response", () => {
|
|
const { manager, sent } = createManager();
|
|
const finish = vi.fn();
|
|
|
|
manager.handleKeyboardInteractive(
|
|
"",
|
|
"",
|
|
"",
|
|
[{ prompt: "Press enter to send Push request: ", echo: true }],
|
|
finish,
|
|
{ username: "root", authType: "none" },
|
|
);
|
|
|
|
expect(sent).toEqual([
|
|
{
|
|
type: "connection_log",
|
|
data: {
|
|
stage: "auth",
|
|
level: "info",
|
|
message: "Password authentication required",
|
|
},
|
|
},
|
|
{
|
|
type: "password_required",
|
|
prompt: "Press enter to send Push request: ",
|
|
echo: true,
|
|
},
|
|
]);
|
|
|
|
manager.context.keyboardInteractiveFinish?.([""]);
|
|
|
|
expect(finish).toHaveBeenCalledWith([""]);
|
|
});
|
|
|
|
it("uses a longer timeout for push-style prompts than generic prompts", () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const { manager, sent } = createManager();
|
|
const finish = vi.fn();
|
|
|
|
manager.handleKeyboardInteractive(
|
|
"",
|
|
"",
|
|
"",
|
|
[{ prompt: "Press enter to send Push request: ", echo: true }],
|
|
finish,
|
|
{ username: "root", authType: "none" },
|
|
);
|
|
|
|
vi.advanceTimersByTime(180001);
|
|
expect(sent.some((m) => m.type === "error")).toBe(false);
|
|
|
|
vi.advanceTimersByTime(120000);
|
|
expect(sent.some((m) => m.type === "error")).toBe(true);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
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("sends a claimed browser round under its handler's name", () => {
|
|
const { manager, sent } = createManager();
|
|
const finish = vi.fn();
|
|
|
|
manager.handleKeyboardInteractive(
|
|
"Gateway sign-in",
|
|
"Visit https://gateway.example.com/auth to continue. Security key: AB12",
|
|
"",
|
|
[{ prompt: "Press enter once done: ", echo: true }],
|
|
finish,
|
|
{ username: "root", authType: "none" },
|
|
);
|
|
|
|
expect(sent).toEqual([
|
|
{
|
|
type: "connection_log",
|
|
data: {
|
|
stage: "auth",
|
|
level: "info",
|
|
message: "Gateway sign-in required",
|
|
},
|
|
},
|
|
{
|
|
type: "gateway_auth_required",
|
|
kind: "browser",
|
|
label: "Gateway",
|
|
url: "https://gateway.example.com/auth",
|
|
securityKey: "AB12",
|
|
instructions:
|
|
"Visit https://gateway.example.com/auth to continue. Security key: AB12",
|
|
},
|
|
]);
|
|
expect(manager.context.browserSignInId).toBe("gateway");
|
|
});
|
|
});
|