Files
Termix/src/backend/tests/plugins/ctx-ssh-auth.test.ts
T

752 lines
24 KiB
TypeScript

import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
granted: new Set<string>(),
connects: [] as Array<{ target: unknown; options: Record<string, unknown> }>,
factors: [] as Array<{ userId: string; pluginId: string; factorId: string }>,
actor: undefined as string | undefined,
cleared: [] as string[],
clearedAll: 0,
pooled: [] as string[],
sessionId: undefined as string | undefined,
revoked: [] as Array<{ userId: string; except?: string }>,
trustedCleared: [] as string[],
unrestricted: [] as string[],
policyError: null as Error | null,
logins: [] as Array<{ hostId: number; outcome: unknown }>,
connectError: null as Error | null,
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log };
});
vi.mock("../../plugins/permissions.js", async () => {
const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend");
return {
assertCapability: async (
pluginId: string,
capability: string,
declared: readonly string[],
) => {
if (!declared.includes(capability) || !h.granted.has(capability)) {
throw new PluginCapabilityError(pluginId, capability);
}
},
hasCapability: async (
_pluginId: string,
capability: string,
declared: readonly string[],
) => declared.includes(capability) && h.granted.has(capability),
hasCachedCapability: (
_pluginId: string,
capability: string,
declared: readonly string[],
) => declared.includes(capability) && h.granted.has(capability),
warmPluginGrants: () => {},
capabilityRefused: (pluginId: string, capability: string) =>
new PluginCapabilityError(pluginId, capability),
};
});
vi.mock("../../plugins/actor.js", () => ({
getActor: () => h.actor,
getActorSessionId: () => h.sessionId,
}));
vi.mock("../../auth/core-auth.js", () => ({
assertSecondFactorEnrollmentAllowed: () => {
if (h.policyError) throw h.policyError;
},
}));
vi.mock("../../hosts/ssh-connection-pool.js", () => ({
withConnection: async (
key: string,
factory: () => Promise<unknown>,
fn: (client: unknown) => Promise<unknown>,
) => {
h.pooled.push(key);
return fn(await factory());
},
connectionPool: {
clearKeyConnections: (key: string) => h.cleared.push(key),
clearAllConnections: () => {
h.clearedAll += 1;
},
},
}));
vi.mock("../../hosts/connect/connect-host.js", () => ({
resolveConnectHost: async (target: unknown) =>
typeof target === "number"
? {
id: target,
userId: "user-1",
ip: `10.0.0.${target}`,
port: 22,
username: "root",
}
: target,
connectHost: async (target: unknown, options: Record<string, unknown>) => {
h.connects.push({ target, options });
if (h.connectError) throw h.connectError;
const client = Object.assign(new EventEmitter(), { end: vi.fn() });
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
},
}));
vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: async (hostId: number, userId: string) => ({
id: hostId,
userId,
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "password",
password: "stored-secret",
}),
resolveHostBySyncId: async () => null,
}));
vi.mock("../../plugins/host-status-provider.js", () => ({
hostStatus: {
reportLogin: (hostId: number, outcome: unknown) =>
h.logins.push({ hostId, outcome }),
},
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserAuthRepository: () => ({
recordSecondFactor: async (
userId: string,
pluginId: string,
factorId: string,
) => {
h.factors.push({ userId, pluginId, factorId });
},
removeSecondFactor: async () => true,
}),
createCurrentSessionRepository: () => ({
revokeAllForUser: async (userId: string, except?: string) => {
h.revoked.push({ userId, except });
return 0;
},
clearRestrictionForUser: async (userId: string) => {
h.unrestricted.push(userId);
},
}),
createCurrentTrustedDeviceRepository: () => ({
deleteByUserId: async (userId: string) => {
h.trustedCleared.push(userId);
},
}),
}));
const { createPluginAuth, createPluginSsh } =
await import("../../plugins/ctx-ssh-auth.js");
const { DisposableBag } = await import("../../plugins/disposables.js");
const { getSshAuthProvider } =
await import("../../hosts/connect/auth-provider-registry.js");
const { classifyKeyboardInteractive } =
await import("../../hosts/connect/keyboard-interactive.js");
const { getLoginMethod, getSecondFactor } =
await import("../../auth/registry.js");
const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend");
const { hasAuthPolicies, clearAuthPolicies } =
await import("../../auth/policy.js");
function manifest(capabilities: string[], auth: Record<string, string[]> = {}) {
return {
id: "fixture",
name: "Fixture",
version: "1.0.0",
capabilities,
contributes: { auth },
} as never;
}
beforeEach(() => {
h.granted = new Set();
h.connects = [];
h.factors = [];
h.actor = "user-1";
h.cleared = [];
h.clearedAll = 0;
h.pooled = [];
h.logins = [];
h.connectError = null;
});
describe("ctx.ssh", () => {
it("refuses without ssh:connect and credentials:use, and audits the refusal", async () => {
const audit = vi.fn(async () => {});
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect"]),
bag: new DisposableBag("fixture"),
audit,
});
h.granted = new Set(["ssh:connect"]);
await expect(ssh.connect(7)).rejects.toBeInstanceOf(PluginCapabilityError);
expect(audit).toHaveBeenCalledWith(
"ssh_connect",
"host 7",
expect.objectContaining({ success: false }),
);
expect(h.connects).toEqual([]);
});
it("connects as the acting user through the pipeline and audits", async () => {
const audit = vi.fn(async () => {});
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"),
audit,
});
h.granted = new Set(["ssh:connect", "credentials:use"]);
await ssh.connect(7, { purpose: "docker" });
expect(h.connects).toEqual([
{
target: 7,
options: expect.objectContaining({
userId: "user-1",
purpose: "docker",
}),
},
]);
expect(audit).toHaveBeenCalledWith("ssh_connect", "host 7", {
success: true,
});
});
it("reports a login to a saved host's status, never to a plugin's own host", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
await ssh.connect(7);
await ssh.connect({ id: 9, ip: "h", port: 22, username: "u" } as never);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.logins).toEqual([{ hostId: 7, outcome: { ok: true } }]);
});
it("never reports a failed login to the status", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
h.connectError = new Error("All configured authentication methods failed");
await expect(ssh.connect(7)).rejects.toThrow();
h.connectError = new Error("Host key changed");
await expect(ssh.connect(8)).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.logins).toEqual([]);
});
it("never believes a host object's own userId", async () => {
h.actor = undefined;
h.granted = new Set(["ssh:connect", "credentials:use"]);
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
const forged = {
id: 3,
ip: "h",
port: 22,
username: "u",
userId: "owner",
};
await expect(ssh.connect(forged)).rejects.toThrow(/acting user/);
await expect(ssh.connect(3)).rejects.toThrow(/acting user/);
expect(h.connects).toEqual([]);
h.actor = "caller";
await ssh.connect(forged);
expect(h.connects[0].options).toMatchObject({ userId: "caller" });
expect(h.connects[0].target).toMatchObject({ userId: "caller" });
});
it("connects a copied redacted host with its stored login", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
const host = (await ssh.resolveHost(5))!;
expect((host as Record<string, unknown>).password).toBeUndefined();
expect(JSON.stringify(host)).not.toContain("stored-secret");
for (const symbol of Object.getOwnPropertySymbols(host)) {
expect(JSON.stringify((host as never)[symbol])).not.toContain(
"stored-secret",
);
}
await ssh.connect({ ...host, port: host.port || 22 });
expect(h.connects[0].target).toMatchObject({ password: "stored-secret" });
await ssh.connect({ ...host, password: "typed", authType: "password" });
expect(h.connects[1].target).toMatchObject({ password: "typed" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.logins.map((login) => login.hostId)).toEqual([5, 5]);
});
it("does not honor another plugin's resolved host", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const deps = {
manifest: manifest(["ssh:connect", "credentials:use"]),
audit: vi.fn(async () => {}),
};
const first = createPluginSsh({
...deps,
bag: new DisposableBag("fixture"),
});
const second = createPluginSsh({
...deps,
bag: new DisposableBag("fixture"),
});
const host = (await first.resolveHost(5))!;
await second.connect({ ...host });
expect(h.connects[0].target).not.toHaveProperty("password");
});
it("passes a given stream through to the pipeline, gated like any connect", async () => {
const stream = { throughSource: true };
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
h.granted = new Set(["ssh:connect"]);
await expect(ssh.connect(7, { sock: stream })).rejects.toBeInstanceOf(
PluginCapabilityError,
);
expect(h.connects).toEqual([]);
h.granted = new Set(["ssh:connect", "credentials:use"]);
await ssh.connect(7, { purpose: "tunnel", sock: stream });
expect(h.connects[0].options).toMatchObject({
purpose: "tunnel",
sock: stream,
});
});
it("closes open connections when the plugin is disposed", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const bag = new DisposableBag("fixture");
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag,
audit: vi.fn(async () => {}),
});
const connection = await ssh.connect(7);
await bag.disposeAll();
expect(
(connection.client as { end: ReturnType<typeof vi.fn> }).end,
).toHaveBeenCalled();
});
});
describe("ctx.ssh pooled connections", () => {
const both = ["ssh:connect", "credentials:use"];
function plugin(id: string) {
const bag = new DisposableBag(id);
const ssh = createPluginSsh({
manifest: { ...(manifest(both) as object), id } as never,
bag,
audit: vi.fn(async () => {}),
});
return { bag, ssh };
}
it("disabling one plugin leaves ctx.ssh and the pool working for others", async () => {
h.granted = new Set(both);
const metrics = plugin("host-metrics");
const other = plugin("fleets");
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => "a");
await other.ssh.withConnection(8, { pool: "fleet" }, async () => "b");
await metrics.bag.disposeAll();
expect(h.clearedAll).toBe(0);
expect(h.cleared).toEqual(["stats:user-1:10.0.0.7:22:root"]);
await expect(
other.ssh.withConnection(8, { pool: "fleet" }, async () => "still"),
).resolves.toBe("still");
});
it("dropPooled clears only this plugin's keys for that host", async () => {
h.granted = new Set(both);
const metrics = plugin("host-metrics");
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => 1);
await metrics.ssh.withConnection(8, { pool: "stats" }, async () => 1);
metrics.ssh.dropPooled("stats", 7);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.cleared).toEqual(["stats:user-1:10.0.0.7:22:root"]);
// Dropped keys are forgotten, so deactivate only clears what is left.
h.cleared = [];
await metrics.bag.disposeAll();
expect(h.cleared).toEqual(["stats:user-1:10.0.0.8:22:root"]);
});
it("dropPooled ignores another pool and an unknown host", async () => {
h.granted = new Set(both);
const metrics = plugin("host-metrics");
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => 1);
metrics.ssh.dropPooled("other", 7);
metrics.ssh.dropPooled("stats", 99);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.cleared).toEqual([]);
});
});
describe("ctx.auth", () => {
it("needs auth:provide and a contributes.auth entry to register anything", () => {
const auth = createPluginAuth({
manifest: manifest([], { sshAuthTypes: ["corp"] }),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
expect(() =>
auth.registerSshAuthProvider({
type: "corp",
labelKey: "corp",
prepare: async () => ({ status: "ready" }),
}),
).toThrow(PluginCapabilityError);
const declared = createPluginAuth({
manifest: manifest(["auth:provide"], { sshAuthTypes: ["corp"] }),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
expect(() =>
declared.registerSshAuthProvider({
type: "other",
labelKey: "other",
prepare: async () => ({ status: "ready" }),
}),
).toThrow(/contributes\.auth\.sshAuthTypes/);
});
it("registers an SSH auth type, checks the grant on use and removes it on dispose", async () => {
const bag = new DisposableBag("fixture");
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { sshAuthTypes: ["corp"] }),
bag,
audit: vi.fn(async () => {}),
});
auth.registerSshAuthProvider({
type: "corp",
labelKey: "corp",
prepare: async (config) => {
config.password = "from-corp";
return { status: "ready" };
},
});
const provider = getSshAuthProvider("corp")!;
expect(provider.pluginId).toBe("fixture");
await expect(
provider.prepare({} as never, {} as never, {} as never),
).rejects.toBeInstanceOf(PluginCapabilityError);
h.granted = new Set(["auth:provide"]);
const config: Record<string, unknown> = {};
await provider.prepare(config as never, {} as never, {} as never);
expect(config.password).toBe("from-corp");
await bag.disposeAll();
expect(getSshAuthProvider("corp")).toBeUndefined();
});
it("never calls a keyboard-interactive handler whose grant is missing", async () => {
h.granted = new Set();
const bag = new DisposableBag("fixture");
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
bag,
audit: vi.fn(async () => {}),
});
const detect = vi.fn(() => null);
const autoAnswerPasswords = vi.fn(() => true);
auth.registerKeyboardInteractiveHandler({
id: "gate",
label: "Gate",
detect,
autoAnswerPasswords,
});
classifyKeyboardInteractive(
{
name: "Gate sign-in",
instructions: "",
prompts: [{ prompt: "Password:", echo: false }],
},
{ id: 1, ip: "10.0.0.1", port: 22, username: "root", password: "pw" },
);
expect(detect).not.toHaveBeenCalled();
expect(autoAnswerPasswords).not.toHaveBeenCalled();
await bag.disposeAll();
});
it("registers a keyboard-interactive handler that sees only its own host settings", async () => {
h.granted = new Set(["auth:provide"]);
const bag = new DisposableBag("fixture");
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
bag,
audit: vi.fn(async () => {}),
});
const seen: Array<Record<string, unknown>> = [];
auth.registerKeyboardInteractiveHandler({
id: "gate",
label: "Gate",
detect: (round, _host, settings) => {
seen.push(settings);
return /gate/i.test(round.name)
? {
kind: "browser",
url: "https://gate",
code: "AB12",
instructions: "",
}
: null;
},
autoAnswerPasswords: (_host, settings) => settings.on === true,
});
const target = {
id: 1,
ip: "10.0.0.1",
port: 22,
username: "root",
password: "pw",
pluginSettings: { fixture: { on: true }, other: { secret: "x" } },
};
expect(
classifyKeyboardInteractive(
{ name: "Gate sign-in", instructions: "", prompts: [] },
target,
),
).toEqual({
kind: "browser",
id: "gate",
label: "Gate",
url: "https://gate",
code: "AB12",
instructions: "",
});
expect(seen[0]).toEqual({ on: true });
expect(
classifyKeyboardInteractive(
{
name: "",
instructions: "",
prompts: [{ prompt: "Password:", echo: false }],
},
target,
),
).toEqual({ kind: "auto", responses: ["pw"] });
await bag.disposeAll();
expect(
classifyKeyboardInteractive(
{ name: "Gate sign-in", instructions: "", prompts: [] },
target,
).kind,
).not.toBe("browser");
});
it("asks for a push/TOTP menu choice as text and a press-enter confirm as a push", () => {
const host = { id: 1, ip: "10.0.0.1", port: 22, username: "root" };
expect(
classifyKeyboardInteractive(
{
name: "",
instructions: "",
prompts: [{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
},
host,
),
).toEqual({ kind: "input", promptIndex: 0, isPush: false });
expect(
classifyKeyboardInteractive(
{
name: "",
instructions: "",
prompts: [{ prompt: "Press enter to send push", echo: false }],
},
host,
),
).toEqual({ kind: "input", promptIndex: 0, isPush: true });
});
it("refuses a keyboard-interactive handler that is not declared", () => {
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
expect(() =>
auth.registerKeyboardInteractiveHandler({
id: "other",
label: "Other",
detect: () => null,
}),
).toThrow(/contributes\.auth\.keyboardInteractive/);
const undeclared = createPluginAuth({
manifest: manifest([], { keyboardInteractive: ["gate"] }),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
expect(() =>
undeclared.registerKeyboardInteractiveHandler({
id: "gate",
label: "Gate",
detect: () => null,
}),
).toThrow(PluginCapabilityError);
});
it("registers login methods and second factors under the plugin, and records enrolment", async () => {
h.granted = new Set(["auth:provide"]);
const bag = new DisposableBag("fixture");
const audit = vi.fn(async () => {});
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], {
loginMethods: ["corp-sso"],
secondFactors: ["pin"],
}),
bag,
audit,
});
auth.registerLoginMethod({
id: "corp-sso",
labelKey: "corp",
kind: "redirect",
start: async () => ({ redirectUrl: "https://corp" }),
});
auth.registerSecondFactor({
id: "pin",
labelKey: "pin",
isEnrolled: async () => true,
verify: async () => true,
});
expect(getLoginMethod("corp-sso")?.pluginId).toBe("fixture");
expect(getSecondFactor("fixture", "pin")).toBeDefined();
await auth.recordEnrollment("user-1", "pin");
expect(h.factors).toEqual([
{ userId: "user-1", pluginId: "fixture", factorId: "pin" },
]);
expect(audit).toHaveBeenCalledWith(
"auth_factor_enrolled",
"pin for user-1",
{ success: true },
);
await bag.disposeAll();
expect(getLoginMethod("corp-sso")).toBeUndefined();
expect(getSecondFactor("fixture", "pin")).toBeUndefined();
});
it("revokes other sessions and trusted devices on enrolment, keeping the caller's session", async () => {
h.granted = new Set(["auth:provide"]);
h.factors = [];
h.revoked = [];
h.trustedCleared = [];
h.unrestricted = [];
h.policyError = null;
h.actor = "user-1";
h.sessionId = "session-a";
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
await auth.recordEnrollment("user-1", "pin");
expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]);
expect(h.trustedCleared).toEqual(["user-1"]);
expect(h.unrestricted).toEqual(["user-1"]);
h.actor = undefined;
h.sessionId = undefined;
});
it("needs auth:policy for a login policy and drops it on deactivate", async () => {
clearAuthPolicies();
const plain = createPluginAuth({
manifest: manifest(["auth:provide"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
expect(() => plain.policy.provide({ evaluate: () => ({}) })).toThrow(
PluginCapabilityError,
);
const bag = new DisposableBag("fixture");
const audit = vi.fn(async () => {});
const auth = createPluginAuth({
manifest: manifest(["auth:policy"]),
bag,
audit,
});
const remove = auth.policy.provide({ evaluate: () => ({}) });
expect(hasAuthPolicies()).toBe(true);
expect(audit).toHaveBeenCalledWith(
"auth_policy_provide",
"fixture sets login rules",
{ success: true },
);
remove();
expect(hasAuthPolicies()).toBe(false);
auth.policy.provide({ evaluate: () => ({}) });
await bag.disposeAll();
expect(hasAuthPolicies()).toBe(false);
});
it("lists second factors from every plugin", async () => {
h.granted = new Set(["auth:provide"]);
const bag = new DisposableBag("fixture");
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
bag,
audit: vi.fn(async () => {}),
});
auth.registerSecondFactor({
id: "pin",
labelKey: "pin",
isEnrolled: async () => false,
verify: async () => true,
});
expect(auth.listSecondFactors()).toContainEqual({
id: "pin",
pluginId: "fixture",
});
await bag.disposeAll();
});
it("refuses enrolment when core policy forbids second factors", async () => {
h.granted = new Set(["auth:provide"]);
h.factors = [];
const { LoginMethodError } = await import("@termix-ssh/plugin-sdk/backend");
h.policyError = new LoginMethodError("password login is off", 409);
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
await expect(auth.recordEnrollment("user-1", "pin")).rejects.toMatchObject({
status: 409,
});
expect(h.factors).toEqual([]);
h.policyError = null;
});
});