feat: add auth extension points and one ssh connect pipeline

This commit is contained in:
LukeGus committed 2026-09-23 00:44:33 -05:00
1 parent d3c209af13
commit d774df8b41
159 files changed
+43243 -7451

No files matched your search

+267
View File
@@ -0,0 +1,267 @@
import { vi } from "vitest";
export interface FakeUser {
id: string;
username: string;
passwordHash: string;
isAdmin: boolean;
isOidc: boolean;
oidcIdentifier?: string | null;
ssoProviderId?: number | null;
totpEnabled?: boolean;
totpSecret?: string | null;
totpBackupCodes?: string | null;
}
/** In-memory stand-ins for the repositories the login pipeline touches. */
export function createAuthState() {
return {
users: new Map<string, FakeUser>(),
identities: [] as Array<{
id: number;
userId: string;
providerId: string;
subject: string;
email: string | null;
}>,
factors: [] as Array<{
userId: string;
pluginId: string;
factorId: string;
}>,
roles: [] as Array<{ userId: string; roleName: string }>,
settings: new Map<string, string>(),
audits: [] as Array<Record<string, unknown>>,
trusted: new Set<string>(),
trustedAdded: [] as string[],
pendingTokens: new Map<string, string>(),
};
}
export type AuthState = ReturnType<typeof createAuthState>;
export function fakeFactory(state: AuthState) {
return {
createCurrentUserRepository: () => ({
findById: async (id: string) => state.users.get(id) ?? null,
findByUsername: async (username: string) =>
[...state.users.values()].find((u) => u.username === username) ?? null,
findByOidcIdentifier: async (identifier: string) =>
[...state.users.values()].find(
(u) => u.oidcIdentifier === identifier,
) ?? null,
countAll: async () => state.users.size,
listAll: async () => [...state.users.values()],
createFirstSsoUser: async (user: FakeUser) => {
const isFirstUser = state.users.size === 0;
const created = {
...user,
isAdmin: isFirstUser || !!user.isAdmin,
};
state.users.set(user.id, created);
return { user: created, isFirstUser };
},
update: async (id: string, changes: Partial<FakeUser>) => {
const user = state.users.get(id);
if (!user) return null;
Object.assign(user, changes);
return user;
},
delete: async (id: string) => state.users.delete(id),
}),
createCurrentUserAuthRepository: () => ({
findIdentity: async (providerId: string, subject: string) =>
state.identities.find(
(row) => row.providerId === providerId && row.subject === subject,
) ?? null,
linkIdentity: async (input: {
userId: string;
providerId: string;
subject: string;
email?: string | null;
}) => {
const existing = state.identities.find(
(row) =>
row.providerId === input.providerId &&
row.subject === input.subject,
);
if (existing) {
existing.userId = input.userId;
return;
}
state.identities.push({
id: state.identities.length + 1,
userId: input.userId,
providerId: input.providerId,
subject: input.subject,
email: input.email ?? null,
});
},
listSecondFactors: async (userId: string) =>
state.factors.filter((row) => row.userId === userId),
recordSecondFactor: async (
userId: string,
pluginId: string,
factorId: string,
) => {
if (
!state.factors.some(
(row) =>
row.userId === userId &&
row.pluginId === pluginId &&
row.factorId === factorId,
)
) {
state.factors.push({ userId, pluginId, factorId });
}
},
removeSecondFactor: async (
userId: string,
pluginId: string,
factorId: string,
) => {
state.factors = state.factors.filter(
(row) =>
!(
row.userId === userId &&
row.pluginId === pluginId &&
row.factorId === factorId
),
);
return true;
},
clearSecondFactors: async (userId: string) => {
state.factors = state.factors.filter((row) => row.userId !== userId);
return 1;
},
}),
createCurrentRoleRepository: () => ({
assignRoleNameToUser: async (input: {
userId: string;
roleName: string;
}) => {
state.roles.push({ userId: input.userId, roleName: input.roleName });
return true;
},
switchUserRoleName: async (input: {
userId: string;
addRoleName: string;
removeRoleName: string;
}) => {
state.roles = state.roles.filter(
(role) =>
!(
role.userId === input.userId &&
role.roleName === input.removeRoleName
),
);
state.roles.push({ userId: input.userId, roleName: input.addRoleName });
},
listUserRoles: async (userId: string) =>
state.roles
.filter((role) => role.userId === userId)
.map((role, index) => ({ roleId: index, roleName: role.roleName })),
removeRoleFromUser: async () => true,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings.get(key) ?? null,
getBoolean: async (key: string, fallback: boolean) =>
state.settings.has(key) ? state.settings.get(key) === "true" : fallback,
set: async (key: string, value: string) => {
state.settings.set(key, value);
},
delete: async (key: string) => state.settings.delete(key),
}),
getCurrentSettingValue: (key: string) => state.settings.get(key) ?? null,
createCurrentTrustedDeviceRepository: () => ({
deleteByUserId: async () => {},
}),
createCurrentSsoProviderRepository: () => ({
listEnabledPublic: async () => [],
}),
};
}
export function fakeAuthManager(state: AuthState) {
let tokenCount = 0;
return {
generateJWTToken: vi.fn(
async (userId: string, options: { pendingTOTP?: boolean } = {}) => {
if (!options.pendingTOTP) return `jwt-${++tokenCount}`;
const token = `pending-${++tokenCount}`;
state.pendingTokens.set(token, userId);
return token;
},
),
verifyJWTToken: vi.fn(async (token: string) => {
const userId = state.pendingTokens.get(token);
return userId ? { userId, pendingTOTP: true } : null;
}),
authenticateUser: vi.fn(async () => true),
authenticateOIDCUser: vi.fn(async () => true),
authenticateWebAuthnUser: vi.fn(async () => true),
registerOIDCUser: vi.fn(async () => {}),
isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) =>
state.trusted.has(`${userId}:${fingerprint}`),
),
addTrustedDevice: vi.fn(async (userId: string, fingerprint: string) => {
state.trustedAdded.push(`${userId}:${fingerprint}`);
}),
getSecureCookieOptions: vi.fn((_req: unknown, maxAge: number) => ({
httpOnly: true,
maxAge,
})),
getClearCookieOptions: vi.fn(() => ({ httpOnly: true })),
getUserDataKey: vi.fn(() => Buffer.alloc(32, 7)),
};
}
export function fakeRequest(overrides: Record<string, unknown> = {}) {
const headers: Record<string, string> = {
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/120.0",
"x-termix-device-id": "a".repeat(64),
...(overrides.headers as Record<string, string> | undefined),
};
return {
body: {},
query: {},
params: {},
cookies: {},
ip: "10.0.0.1",
socket: { remoteAddress: "10.0.0.1" },
...overrides,
headers,
get: (name: string) => headers[name.toLowerCase()],
} as Record<string, unknown> & { headers: Record<string, string> };
}
export function fakeResponse() {
const res = {
statusCode: 200,
body: undefined as unknown,
cookies: [] as Array<{ name: string; value: string; options: unknown }>,
cleared: [] as string[],
redirectedTo: null as string | null,
status(code: number) {
res.statusCode = code;
return res;
},
json(body: unknown) {
res.body = body;
return res;
},
cookie(name: string, value: string, options: unknown) {
res.cookies.push({ name, value, options });
return res;
},
clearCookie(name: string) {
res.cleared.push(name);
return res;
},
redirect(url: string) {
res.redirectedTo = url;
return res;
},
};
return res;
}
@@ -0,0 +1,383 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createAuthState,
fakeAuthManager,
fakeRequest,
fakeResponse,
type AuthState,
} from "./auth-test-helpers.js";
const h = vi.hoisted(() => ({
state: null as unknown as AuthState,
manager: null as unknown as ReturnType<
typeof import("./auth-test-helpers.js").fakeAuthManager
>,
providerConfig: null as unknown,
providerType: "oidc",
idTokenClaims: null as unknown,
ldap: {
users: {} as Record<
string,
{ dn: string; password: string; attrs: Record<string, string> }
>,
adminDns: [] as string[],
},
ssoProvider: { id: 4, type: "ldap", enabled: true },
}));
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("./auth-test-helpers.js");
const names = [
...Object.keys(helpers.fakeFactory(helpers.createAuthState())),
];
const mocked = Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
return {
...mocked,
createCurrentSsoProviderRepository: () => ({
findById: async () => h.ssoProvider,
listEnabledPublic: async () => [],
}),
};
});
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => h.manager },
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
h.state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../hosts/automation-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return { authLogger: log, databaseLogger: log, sshLogger: log, logger: log };
});
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
},
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../database/routes/user-oidc-utils.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../database/routes/user-oidc-utils.js")
>();
return {
...actual,
loadProviderConfig: async () =>
h.providerConfig
? {
config: h.providerConfig,
providerType: h.providerType,
providerDbId: 3,
}
: null,
verifyOIDCToken: async () => h.idTokenClaims,
};
});
vi.mock("ldapjs", () => {
function createClient() {
let bound: string | null = null;
return {
bind: (dn: string, password: string, cb: (err?: Error) => void) => {
if (dn === "cn=service") {
bound = dn;
return cb();
}
const user = Object.values(h.ldap.users).find((u) => u.dn === dn);
if (user && user.password === password) {
bound = dn;
return cb();
}
cb(new Error("invalid credentials"));
},
search: (
base: string,
options: { filter: string },
cb: (err: Error | null, res: unknown) => void,
) => {
const listeners: Record<string, (value?: unknown) => void> = {};
const res = {
on: (event: string, fn: (value?: unknown) => void) => {
listeners[event] = fn;
},
};
cb(null, res);
queueMicrotask(() => {
if (base === "ou=groups") {
for (const dn of h.ldap.adminDns) {
if (options.filter.includes(dn)) {
listeners.searchEntry?.({
dn: { toString: () => "cn=admins,ou=groups" },
attributes: [{ type: "cn", values: ["admins"] }],
});
}
}
} else {
const match = /uid=([^)]+)/.exec(options.filter);
const user = match ? h.ldap.users[match[1]] : undefined;
if (user) {
listeners.searchEntry?.({
dn: { toString: () => user.dn },
attributes: Object.entries(user.attrs).map(([type, value]) => ({
type,
values: [value],
})),
});
}
}
listeners.end?.();
});
void bound;
},
unbind: () => {},
};
}
return { default: { createClient }, createClient };
});
const { handleOidcCallback } = await import("../../auth/legacy/oidc-login.js");
const { verifyLdapLogin } = await import("../../auth/legacy/ldap-login.js");
const { respondWithLogin, respondWithRedirectLogin, runLogin } =
await import("../../auth/login-pipeline.js");
beforeEach(() => {
h.state = createAuthState();
h.manager = fakeAuthManager(h.state);
h.providerType = "oidc";
h.providerConfig = {
client_id: "client",
client_secret: "secret",
issuer_url: "https://idp.example",
authorization_url: "https://idp.example/auth",
token_url: "https://idp.example/token",
identifier_path: "sub",
name_path: "name",
scopes: "openid",
admin_group: "termix-admins",
group_claim: "groups",
};
h.idTokenClaims = {
sub: "sub-1",
sid: "sid-1",
name: "Alice",
email: "alice@example.com",
nonce: "nonce-1",
groups: ["termix-admins"],
};
for (const [key, value] of Object.entries({
oidc_backend_callback_state1: "https://termix.example/users/oidc/callback",
oidc_frontend_origin_state1: "https://termix.example",
oidc_remember_me_state1: "false",
oidc_state_state1: "nonce-1",
oidc_provider_state1: "3",
})) {
h.state.settings.set(key, value);
}
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (url === "https://idp.example/token") {
return new Response(
JSON.stringify({ id_token: "jwt", access_token: "at" }),
);
}
return new Response("{}", { status: 404 });
}),
);
h.ldap.users = {
bob: {
dn: "uid=bob,ou=people",
password: "hunter2",
attrs: { uid: "bob", cn: "Bob Builder", mail: "bob@example.com" },
},
};
h.ldap.adminDns = [];
});
describe("OIDC through the pipeline", () => {
it("turns the callback into an identity with the old identifier and claims", async () => {
const identity = await handleOidcCallback(
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
);
expect(identity).toMatchObject({
kind: "external",
provider: "3",
subject: "sub-1",
legacyIdentifier: "sub-1",
name: "Alice",
isAdmin: true,
oidcSub: "sub-1",
oidcSid: "sid-1",
ssoProviderId: 3,
returnTo: "https://termix.example",
});
// The one-time state is consumed.
expect(h.state.settings.has("oidc_state_state1")).toBe(false);
});
it("signs in and redirects with a cookie, like the old callback", async () => {
const req = fakeRequest({ query: { code: "c", state: "state1" } });
const identity = await handleOidcCallback(req as never);
const res = fakeResponse();
await respondWithRedirectLogin(
req as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
expect(res.cleared).toContain("jwt");
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
const [user] = [...h.state.users.values()];
expect(user).toMatchObject({ oidcIdentifier: "sub-1", isAdmin: true });
expect(h.state.audits).toContainEqual(
expect.objectContaining({ action: "login", userId: user.id }),
);
});
it("rejects a nonce mismatch without a session", async () => {
h.idTokenClaims = { ...(h.idTokenClaims as object), nonce: "other" };
await expect(
handleOidcCallback(
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
),
).rejects.toMatchObject({ status: 401 });
});
it("namespaces GitHub users the way they were stored", async () => {
h.providerType = "github";
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (url === "https://idp.example/token") {
return new Response(JSON.stringify({ access_token: "at" }));
}
if (url === "https://api.github.com/user") {
return new Response(JSON.stringify({ id: 42, login: "octo" }));
}
return new Response("[]");
}),
);
const identity = await handleOidcCallback(
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
);
expect(identity).toMatchObject({
provider: "3",
subject: "42",
legacyIdentifier: "github:3:42",
name: "octo",
});
});
});
describe("LDAP through the pipeline", () => {
const providerConfig = {
host: "ldap.example",
bindDN: "cn=service",
bindPassword: "svc",
userSearchBase: "ou=people",
userSearchFilter: "(uid={{username}})",
groupSearchBase: "ou=groups",
adminGroup: "admins",
};
it("binds, finds the user and signs in with the old identifier", async () => {
h.providerConfig = providerConfig;
h.ldap.adminDns = ["uid=bob,ou=people"];
const req = fakeRequest({
body: { providerId: 4, username: "bob", password: "hunter2" },
headers: { "x-electron-app": "true" },
});
const identity = await verifyLdapLogin({
body: req.body as never,
ip: "10.0.0.1",
});
expect(identity).toMatchObject({
provider: "4",
subject: "bob",
legacyIdentifier: "ldap:4:bob",
isAdmin: true,
name: "Bob Builder",
});
const res = fakeResponse();
await respondWithLogin(req as never, res as never, identity, {
methodId: "ldap",
rememberMe: false,
});
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
// Native clients now get the token for LDAP too.
expect(res.body).toMatchObject({
success: true,
token: expect.any(String),
});
});
it("rejects a wrong password", async () => {
h.providerConfig = providerConfig;
await expect(
verifyLdapLogin({
body: { providerId: 4, username: "bob", password: "wrong" },
ip: "10.0.0.2",
}),
).rejects.toMatchObject({ status: 401 });
});
it("runs TOTP for an LDAP user who enrolled", async () => {
h.providerConfig = providerConfig;
h.state.users.set("u-bob", {
id: "u-bob",
username: "Bob Builder",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:4:bob",
totpEnabled: true,
totpSecret: "JBSWY3DPEHPK3PXP",
});
const identity = await verifyLdapLogin({
body: { providerId: 4, username: "bob", password: "hunter2" },
ip: "10.0.0.3",
});
const result = await runLogin(fakeRequest() as never, identity, {
methodId: "ldap",
rememberMe: false,
});
expect(result.kind).toBe("second-factor");
});
});
@@ -0,0 +1,729 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import bcrypt from "bcryptjs";
import speakeasy from "speakeasy";
import {
createAuthState,
fakeAuthManager,
fakeRequest,
fakeResponse,
type AuthState,
} from "./auth-test-helpers.js";
const h = vi.hoisted(() => ({
state: null as unknown as AuthState,
manager: null as unknown as ReturnType<
typeof import("./auth-test-helpers.js").fakeAuthManager
>,
trustedProxy: false,
}));
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("./auth-test-helpers.js");
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
return Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
});
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => h.manager },
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
h.state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../hosts/automation-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return {
authLogger: log,
databaseLogger: log,
sshLogger: log,
logger: log,
systemLogger: log,
};
});
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
},
}));
vi.mock("../../utils/trusted-proxy-auth.js", () => ({
isTrustedProxyAuthEnabled: () => h.trustedProxy,
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
const {
runLogin,
respondWithLogin,
respondWithRedirectLogin,
verifySecondFactorAndRespond,
resetPendingLoginsForTests,
} = await import("../../auth/login-pipeline.js");
const { verifyPasswordLogin } =
await import("../../auth/builtin-login-methods.js");
const { getPasswordLoginStatus } = await import("../../auth/core-auth.js");
const { registerLoginMethod, registerSecondFactor } =
await import("../../auth/registry.js");
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
const PASSWORD = "correct-horse";
const totpSecret = speakeasy.generateSecret({ name: "test" }).base32;
function addUser(
overrides: Partial<import("./auth-test-helpers.js").FakeUser> = {},
) {
const user = {
id: "u1",
username: "alice",
passwordHash: bcrypt.hashSync(PASSWORD, 4),
isAdmin: false,
isOidc: false,
totpEnabled: false,
...overrides,
};
h.state.users.set(user.id, user);
return user;
}
beforeEach(() => {
h.state = createAuthState();
h.manager = fakeAuthManager(h.state);
h.trustedProxy = false;
resetPendingLoginsForTests();
loginRateLimiter.resetAttempts("10.0.0.1", "alice");
loginRateLimiter.resetTOTPAttempts("u1");
delete process.env.ALLOW_PASSWORD_LOGIN;
});
async function passwordLogin(body: Record<string, unknown>, headers = {}) {
const req = fakeRequest({ body, headers });
const res = fakeResponse();
const identity = await verifyPasswordLogin(req as never);
await respondWithLogin(req as never, res as never, identity, {
methodId: "password",
rememberMe: !!body.rememberMe,
});
return res;
}
describe("password login through the pipeline", () => {
it("issues the same JWT, cookie, audit and body the old route did", async () => {
addUser({ isAdmin: true });
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.authenticateUser).toHaveBeenCalledWith(
"u1",
PASSWORD,
"web",
);
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({
rememberMe: false,
deviceType: "web",
deviceInfo: expect.any(String),
}),
);
// Session timeout default: 24 hours.
expect(res.cookies).toEqual([
{
name: "jwt",
value: "jwt-1",
options: { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 },
},
]);
expect(res.body).toMatchObject({
success: true,
is_admin: true,
username: "alice",
});
expect(res.body).not.toHaveProperty("token");
expect(h.state.audits).toEqual([
expect.objectContaining({
userId: "u1",
username: "alice",
action: "login",
resourceType: "session",
success: true,
}),
]);
});
it("remembers the session for 30 days and follows session_timeout_hours otherwise", async () => {
addUser();
const remembered = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
expect((remembered.cookies[0].options as { maxAge: number }).maxAge).toBe(
30 * 24 * 60 * 60 * 1000,
);
h.state.settings.set("session_timeout_hours", "2");
const short = await passwordLogin({
username: "alice",
password: PASSWORD,
});
expect((short.cookies[0].options as { maxAge: number }).maxAge).toBe(
2 * 60 * 60 * 1000,
);
});
it("returns the token in the body for the desktop and mobile apps", async () => {
addUser();
const res = await passwordLogin(
{ username: "alice", password: PASSWORD },
{ "x-electron-app": "true" },
);
expect(res.body).toMatchObject({ token: "jwt-1" });
});
it("rejects a wrong password with the old status and message", async () => {
addUser();
await expect(
verifyPasswordLogin(
fakeRequest({ body: { username: "alice", password: "nope" } }) as never,
),
).rejects.toMatchObject({
status: 401,
message: "Invalid username or password",
});
expect(h.state.audits).toEqual([]);
});
it("refuses a user whose data key does not open with the password", async () => {
addUser();
h.manager.authenticateUser.mockResolvedValueOnce(false);
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
});
const identity = await verifyPasswordLogin(req as never);
await expect(
runLogin(req as never, identity, {
methodId: "password",
rememberMe: false,
}),
).rejects.toMatchObject({ status: 401, message: "Incorrect password" });
});
});
describe("second factors", () => {
function enrolTotp() {
addUser({
totpEnabled: true,
totpSecret,
totpBackupCodes: JSON.stringify(["BACKUP01"]),
});
h.state.factors.push({ userId: "u1", pluginId: "core", factorId: "totp" });
}
it("stops for TOTP with the pending token the old route returned", async () => {
enrolTotp();
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(res.cookies).toEqual([]);
expect(res.body).toMatchObject({
success: true,
requires_totp: true,
temp_token: "pending-1",
rememberMe: false,
second_factors: [
expect.objectContaining({ id: "totp", pluginId: "core" }),
],
});
expect(h.manager.generateJWTToken).toHaveBeenCalledWith("u1", {
pendingTOTP: true,
expiresIn: "10m",
});
expect(h.state.audits).toEqual([]);
});
it("finishes the login with a valid code and trusts the device when remembered", async () => {
enrolTotp();
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
const tempToken = (first.body as { temp_token: string }).temp_token;
const req = fakeRequest({
body: {
temp_token: tempToken,
totp_code: speakeasy.totp({ secret: totpSecret, encoding: "base32" }),
rememberMe: true,
},
});
const res = fakeResponse();
await verifySecondFactorAndRespond(req as never, res as never, "totp");
expect(res.statusCode).toBe(200);
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
expect(res.body).toMatchObject({
success: true,
username: "alice",
totp_enabled: true,
});
expect(h.state.trustedAdded).toHaveLength(1);
expect(h.state.audits.at(-1)).toMatchObject({ action: "login" });
});
it("accepts a backup code once, stored encrypted", async () => {
enrolTotp();
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
const tempToken = (first.body as { temp_token: string }).temp_token;
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: { temp_token: tempToken, totp_code: "BACKUP01" },
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(200);
// Re-encrypted after use, and the code is gone.
expect(h.state.users.get("u1")!.totpBackupCodes).not.toContain("BACKUP01");
});
it("rejects a wrong code without a session", async () => {
enrolTotp();
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
totp_code: "000000",
},
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(401);
expect(res.cookies).toEqual([]);
});
it("skips the factor on a trusted device", async () => {
enrolTotp();
h.manager.isTrustedDevice.mockResolvedValueOnce(true);
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
});
it("fails closed when an enrolled factor's plugin is gone", async () => {
addUser();
h.state.factors.push({
userId: "u1",
pluginId: "gone",
factorId: "yubikey",
});
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
});
const identity = await verifyPasswordLogin(req as never);
await expect(
runLogin(req as never, identity, {
methodId: "password",
rememberMe: false,
}),
).rejects.toMatchObject({ status: 403, code: "second_factor_unavailable" });
expect(h.manager.generateJWTToken).not.toHaveBeenCalled();
expect(h.state.audits).toEqual([
expect.objectContaining({
action: "login_blocked_second_factor",
success: false,
}),
]);
});
it("runs a plugin's second factor", async () => {
addUser();
h.state.factors.push({
userId: "u1",
pluginId: "fixture",
factorId: "pin",
});
const dispose = registerSecondFactor({
id: "pin",
pluginId: "fixture",
labelKey: "fixture.pin",
isEnrolled: async () => true,
verify: async (_userId, body) => body.pin === "1234",
});
try {
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
expect(first.body).toMatchObject({
second_factors: [expect.objectContaining({ id: "pin" })],
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
pin: "1234",
},
}) as never,
res as never,
"pin",
);
expect(res.statusCode).toBe(200);
} finally {
dispose();
}
});
it("lets a method that already proved a factor skip the step", async () => {
enrolTotp();
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
);
expect(result.kind).toBe("session");
});
});
describe("external identities", () => {
const external = {
kind: "external" as const,
provider: "3",
subject: "sub-1",
email: "a@example.com",
name: "Alice",
legacyIdentifier: "sub-1",
ssoProviderId: 3,
};
it("provisions the first user as admin and links the identity", async () => {
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("session");
const [user] = [...h.state.users.values()];
expect(user).toMatchObject({
username: "Alice",
isAdmin: true,
isOidc: true,
});
expect(h.state.identities).toEqual([
expect.objectContaining({
userId: user.id,
providerId: "3",
subject: "sub-1",
}),
]);
expect(h.state.roles).toContainEqual({
userId: user.id,
roleName: "admin",
});
});
it("refuses a new user when provisioning is off, and one outside the allowed list", async () => {
addUser({ id: "someone", username: "someone" });
await expect(
runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
}),
).rejects.toMatchObject({ code: "registration_disabled" });
h.state.settings.set("oidc_auto_provision", "true");
await expect(
runLogin(
fakeRequest() as never,
{ ...external, allowedUsers: "@other.org" },
{ methodId: "oidc", rememberMe: false },
),
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it("re-checks the allowed list for existing users", async () => {
addUser({ id: "u9", username: "Alice", isOidc: true, passwordHash: "" });
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
await expect(
runLogin(
fakeRequest() as never,
{ ...external, allowedUsers: "@other.org" },
{ methodId: "oidc", rememberMe: false },
),
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it("finds an account from before the identity table by its old identifier", async () => {
addUser({
id: "old",
username: "ldap-bob",
isOidc: true,
passwordHash: "",
oidcIdentifier: "ldap:4:bob",
});
const result = await runLogin(
fakeRequest() as never,
{
kind: "external",
provider: "4",
subject: "bob",
name: "Bob",
legacyIdentifier: "ldap:4:bob",
ssoProviderId: 4,
},
{ methodId: "ldap", rememberMe: false },
);
expect(result.kind).toBe("session");
expect(h.state.users.size).toBe(1);
expect(h.state.identities).toEqual([
expect.objectContaining({
userId: "old",
providerId: "4",
subject: "bob",
}),
]);
});
it("keeps admin in step with the provider's admin group", async () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
isAdmin: true,
});
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
await runLogin(
fakeRequest() as never,
{ ...external, isAdmin: false },
{ methodId: "oidc", rememberMe: false },
);
expect(h.state.users.get("u9")!.isAdmin).toBe(false);
});
it("runs enrolled second factors for SSO users too", async () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
totpEnabled: true,
totpSecret,
});
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("second-factor");
});
});
describe("redirect logins", () => {
const identity = {
kind: "external" as const,
provider: "3",
subject: "sub-1",
name: "Alice",
returnTo: "https://termix.example",
ssoProviderId: 3,
oidcSub: "sub-1",
oidcSid: "sid-1",
};
it("sets the cookie and redirects with success, carrying the SSO claims", async () => {
const res = fakeResponse();
await respondWithRedirectLogin(
fakeRequest() as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
oidcSub: "sub-1",
oidcSid: "sid-1",
ssoProviderId: 3,
}),
);
});
it("puts the token in the URL for app callbacks", async () => {
const res = fakeResponse();
await respondWithRedirectLogin(
fakeRequest() as never,
res as never,
{ ...identity, returnTo: "http://localhost:4567/oidc-callback" },
{ methodId: "oidc", rememberMe: false },
() => true,
);
expect(res.redirectedTo).toMatch(/success=true/);
expect(res.redirectedTo).toMatch(/token=jwt-/);
expect(res.cookies).toEqual([]);
});
it("sends a second-factor step back with a pending cookie", async () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
totpEnabled: true,
totpSecret,
});
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
const res = fakeResponse();
await respondWithRedirectLogin(
fakeRequest() as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toContain("second_factor=1");
expect(res.redirectedTo).toContain("second_factors=totp");
expect(res.cookies[0]).toMatchObject({ name: "termix_pending_login" });
// The pending login keeps the SSO claims for the session.
const verify = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
cookies: { termix_pending_login: res.cookies[0].value },
body: {
totp_code: speakeasy.totp({ secret: totpSecret, encoding: "base32" }),
},
}) as never,
verify as never,
"totp",
);
expect(verify.statusCode).toBe(200);
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
"u9",
expect.objectContaining({ oidcSid: "sid-1", ssoProviderId: 3 }),
);
});
it("redirects provisioning refusals with the old error codes", async () => {
addUser({ id: "someone", username: "someone" });
const res = fakeResponse();
await respondWithRedirectLogin(
fakeRequest() as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toBe(
"https://termix.example/?error=registration_disabled",
);
});
});
describe("lockout guard", () => {
it("keeps password login on, flagged forced, when nothing else can sign anyone in", async () => {
h.state.settings.set("allow_password_login", "false");
expect(await getPasswordLoginStatus()).toEqual({
allowed: true,
forced: true,
});
});
it("honours the setting when another method is enabled", async () => {
h.state.settings.set("allow_password_login", "false");
const dispose = registerLoginMethod({
id: "fixture-sso",
pluginId: "fixture",
labelKey: "fixture",
kind: "redirect",
describe: async () => [{ id: "1", label: "Fixture", enabled: true }],
});
try {
expect(await getPasswordLoginStatus()).toEqual({
allowed: false,
forced: false,
});
await expect(
verifyPasswordLogin(
fakeRequest({
body: { username: "alice", password: PASSWORD },
}) as never,
),
).rejects.toMatchObject({ status: 403 });
} finally {
dispose();
}
});
it("counts trusted proxy login as another way in", async () => {
h.state.settings.set("allow_password_login", "false");
h.trustedProxy = true;
expect(await getPasswordLoginStatus()).toEqual({
allowed: false,
forced: false,
});
});
});
@@ -31,6 +31,8 @@ const state = vi.hoisted(() => ({
}[],
// Which users have admin.plugins.manage, keyed by userId.
managers: new Set<string>(["admin-1"]),
// Plugins the loader reports as running.
active: new Set<string>(),
}));
vi.mock("../../../utils/logger.js", () => ({
@@ -93,7 +95,18 @@ vi.mock("../../../utils/permission-manager.js", () => ({
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({
loader: { list: () => [], get: () => undefined },
loader: {
list: () => [],
get: (id: string) =>
state.active.has(id)
? {
id,
state: "active",
dir: "/nonexistent/plugin",
manifest: { id, locales: "locales" },
}
: undefined,
},
}),
activatePlugin: vi.fn(),
deactivatePlugin: vi.fn(),
@@ -166,6 +179,7 @@ describe("plugins route", () => {
state.plugins = new Map();
state.grants = [];
state.managers = new Set(["admin-1"]);
state.active = new Set();
const app = express();
app.use(express.json());
@@ -226,6 +240,94 @@ describe("plugins route", () => {
});
});
describe("GET /plugins/public-manifest", () => {
it("lists only running plugins with login or second-factor UI, and nothing sensitive", async () => {
state.plugins.set(
"login-plugin",
makePlugin({
id: "login-plugin",
manifestJson: JSON.stringify({
capabilities: ["auth:provide", "credentials:read"],
contributes: {
auth: { loginMethods: ["corp-sso"], sshAuthTypes: ["corp"] },
settings: { admin: [{ key: "clientSecret", type: "secret" }] },
permissions: [{ name: "manage" }],
},
}),
}),
);
state.plugins.set(
"factor-plugin",
makePlugin({
id: "factor-plugin",
manifestJson: JSON.stringify({
contributes: { auth: { secondFactors: ["pin"] } },
}),
}),
);
state.plugins.set(
"stopped-login",
makePlugin({
id: "stopped-login",
manifestJson: JSON.stringify({
contributes: { auth: { loginMethods: ["other"] } },
}),
}),
);
state.plugins.set(
"disabled-login",
makePlugin({
id: "disabled-login",
state: "disabled",
manifestJson: JSON.stringify({
contributes: { auth: { loginMethods: ["x"] } },
}),
}),
);
state.plugins.set("sample-plugin", makePlugin());
state.active = new Set([
"login-plugin",
"factor-plugin",
"disabled-login",
"sample-plugin",
]);
const res = await fetch(`${baseUrl}/plugins/public-manifest`);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([
"login-plugin",
"factor-plugin",
]);
expect(body[0].contributes).toEqual({
auth: { loginMethods: ["corp-sso"], secondFactors: [] },
});
for (const plugin of body) {
expect(Object.keys(plugin).sort()).toEqual(
[
"assetVersion",
"contributes",
"css",
"dependencies",
"enabled",
"frontend",
"id",
"locales",
"name",
"optionalDependencies",
"state",
"version",
].sort(),
);
}
const raw = JSON.stringify(body);
expect(raw).not.toContain("clientSecret");
expect(raw).not.toContain("credentials:read");
expect(raw).not.toContain("sshAuthTypes");
});
});
it("lists a plugin's declared and granted capabilities", async () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
@@ -9,6 +9,10 @@ vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({ findById, update: userUpdate }),
createCurrentTrustedDeviceRepository: () => ({}),
createCurrentUserSessionRepository: () => ({}),
createCurrentUserAuthRepository: () => ({
removeSecondFactor: vi.fn().mockResolvedValue(true),
recordSecondFactor: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("../../../utils/logger.js", () => ({
@@ -17,6 +17,8 @@ const state = vi.hoisted(() => ({
unlockedUsers: new Set<string>(),
updates: [] as { id: string; changes: Record<string, unknown> }[],
auditCalls: [] as Record<string, unknown>[],
secondFactors: [] as { userId: string; pluginId: string; factorId: string }[],
trustedDevicesCleared: [] as string[],
}));
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
@@ -89,6 +91,38 @@ vi.mock("../../../database/repositories/factory.js", () => ({
switchUserRoleName: async () => {},
assignRoleNameToUser: async () => {},
}),
createCurrentUserAuthRepository: () => ({
listSecondFactors: async (userId: string) =>
state.secondFactors.filter((row) => row.userId === userId),
removeSecondFactor: async (
userId: string,
pluginId: string,
factorId: string,
) => {
state.secondFactors = state.secondFactors.filter(
(row) =>
!(
row.userId === userId &&
row.pluginId === pluginId &&
row.factorId === factorId
),
);
return true;
},
clearSecondFactors: async (userId: string) => {
const before = state.secondFactors.length;
state.secondFactors = state.secondFactors.filter(
(row) => row.userId !== userId,
);
return before - state.secondFactors.length;
},
}),
createCurrentTrustedDeviceRepository: () => ({
deleteByUserId: async (userId: string) => {
state.trustedDevicesCleared.push(userId);
},
}),
getCurrentSettingValue: () => null,
}));
const { registerUserAdminRoutes } =
@@ -180,6 +214,8 @@ async function invoke(
beforeEach(() => {
state.currentUserId = "admin1";
state.secondFactors = [];
state.trustedDevicesCleared = [];
state.users = new Map([
[
"admin1",
@@ -356,3 +392,64 @@ describe("GET /admin/export/:userId", () => {
expect(res.statusCode).toBe(403);
});
});
describe("DELETE /admin/:userId/second-factors", () => {
it("clears every factor, including one whose plugin is gone, and audits", async () => {
state.secondFactors = [
{ userId: "target1", pluginId: "core", factorId: "totp" },
{ userId: "target1", pluginId: "gone-plugin", factorId: "yubikey" },
{ userId: "locked1", pluginId: "core", factorId: "totp" },
];
const res = await invoke("delete", "/admin/:userId/second-factors", {
params: { userId: "target1" },
});
expect(res.statusCode).toBe(200);
expect(state.secondFactors).toEqual([
{ userId: "locked1", pluginId: "core", factorId: "totp" },
]);
expect(state.trustedDevicesCleared).toContain("target1");
// TOTP's own reset cleared its secret.
expect(
state.updates.find((u) => u.id === "target1")?.changes,
).toMatchObject({ totpEnabled: false, totpSecret: null });
const audit = state.auditCalls.find(
(c) => c.action === "admin_reset_second_factors",
);
expect(audit).toMatchObject({ resourceId: "target1", success: true });
expect(JSON.parse(audit!.details as string).removed).toEqual(
expect.arrayContaining([
{ pluginId: "gone-plugin", factorId: "yubikey" },
]),
);
});
it("lists factors with their availability", async () => {
state.secondFactors = [
{ userId: "target1", pluginId: "gone-plugin", factorId: "yubikey" },
];
const res = await invoke("get", "/admin/:userId/second-factors", {
params: { userId: "target1" },
});
expect(res.statusCode).toBe(200);
expect((res.jsonBody as { factors: unknown[] }).factors).toEqual(
expect.arrayContaining([
expect.objectContaining({ factorId: "yubikey", available: false }),
]),
);
});
it("403s when the caller is not an admin", async () => {
state.currentUserId = "target1";
const res = await invoke("delete", "/admin/:userId/second-factors", {
params: { userId: "locked1" },
});
expect(res.statusCode).toBe(403);
});
it("404s for an unknown target", async () => {
const res = await invoke("delete", "/admin/:userId/second-factors", {
params: { userId: "ghost" },
});
expect(res.statusCode).toBe(404);
});
});
@@ -6,6 +6,24 @@ const mocks = vi.hoisted(() => ({
verify: vi.fn(),
user: vi.fn(),
update: vi.fn(),
manager: {
authenticateWebAuthnUser: vi.fn(),
authenticateOIDCUser: vi.fn(),
isTrustedDevice: vi.fn(),
generateJWTToken: vi.fn(),
getSecureCookieOptions: vi.fn(),
getClearCookieOptions: vi.fn(),
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => mocks.manager },
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: vi.fn(),
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
}));
vi.mock("../../../hosts/automation-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(),
}));
vi.mock("@simplewebauthn/server", () => ({
generateAuthenticationOptions: vi
@@ -17,6 +35,10 @@ vi.mock("@simplewebauthn/server", () => ({
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({ findById: mocks.user }),
createCurrentUserAuthRepository: () => ({
listSecondFactors: async () => [],
}),
createCurrentSettingsRepository: () => ({ get: async () => null }),
createCurrentWebauthnCredentialRepository: () => ({
findByCredentialId: vi.fn().mockResolvedValue({
id: 1,
@@ -30,7 +52,16 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
getCurrentSettingValue: vi.fn(),
}));
vi.mock("../../../utils/logger.js", () => ({ authLogger: { warn: vi.fn() } }));
vi.mock("../../../utils/logger.js", () => {
const log = {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return { authLogger: log, sshLogger: log, logger: log, databaseLogger: log };
});
vi.mock("../../../utils/user-agent-parser.js", () => ({
parseUserAgent: () => ({ type: "browser", deviceInfo: "test" }),
generateDeviceFingerprint: () => "device",
@@ -40,12 +71,7 @@ const { registerUserWebAuthnRoutes } =
await import("../../../database/routes/user-webauthn-routes.js");
type Handler = (req: Request, res: Response) => Promise<unknown>;
const handlers = new Map<string, Handler>();
const manager = {
authenticateWebAuthnUser: vi.fn().mockResolvedValue(true),
isTrustedDevice: vi.fn().mockResolvedValue(false),
generateJWTToken: vi.fn().mockResolvedValue("token"),
getSecureCookieOptions: vi.fn().mockReturnValue({ httpOnly: true }),
};
const manager = mocks.manager;
registerUserWebAuthnRoutes(
{
post: (path: string, ...callbacks: Handler[]) =>
@@ -62,6 +88,7 @@ registerUserWebAuthnRoutes(
function response() {
const res = { json: vi.fn(), status: vi.fn(), cookie: vi.fn() };
res.status.mockReturnValue(res);
res.cookie.mockReturnValue(res);
return res;
}
async function login(userVerification: string) {
@@ -78,13 +105,21 @@ async function login(userVerification: string) {
await handlers.get("/webauthn/authenticate/verify")!(
{
body: { challengeId, response: { id: "key" }, rememberMe: true },
} as Request,
get: () => undefined,
headers: {},
socket: {},
} as unknown as Request,
res as unknown as Response,
);
return res;
}
beforeEach(() => {
vi.clearAllMocks();
manager.authenticateWebAuthnUser.mockResolvedValue(true);
manager.authenticateOIDCUser.mockResolvedValue(true);
manager.generateJWTToken.mockResolvedValue("token");
manager.getSecureCookieOptions.mockReturnValue({ httpOnly: true });
manager.getClearCookieOptions.mockReturnValue({});
mocks.user.mockResolvedValue({
id: "user",
username: "alice",
@@ -134,7 +169,11 @@ describe("WebAuthn second factor policy", () => {
expect(res.cookie).not.toHaveBeenCalled();
});
it("allows users without TOTP to log in with a possession-only key", async () => {
mocks.user.mockResolvedValue({ id: "user", totpEnabled: false });
mocks.user.mockResolvedValue({
id: "user",
username: "alice",
totpEnabled: false,
});
expect((await login("discouraged")).cookie).toHaveBeenCalled();
});
});
@@ -0,0 +1,44 @@
import { EventEmitter } from "node:events";
import { vi } from "vitest";
/** Stands in for ssh2's Client: records connect() and lets a test drive events. */
export class FakeSshClient extends EventEmitter {
connectConfig: Record<string, unknown> | null = null;
ended = false;
forwardOut = vi.fn(
(
_srcIp: string,
_srcPort: number,
_dstIp: string,
_dstPort: number,
cb: (err: Error | undefined, stream: unknown) => void,
) => cb(undefined, { fakeStream: true }),
);
constructor() {
super();
FakeSshClient.instances.push(this);
}
connect(config: Record<string, unknown>) {
this.connectConfig = config;
const behaviour = FakeSshClient.nextBehaviour.shift() ?? "ready";
queueMicrotask(() => {
if (behaviour === "ready") this.emit("ready");
else if (behaviour === "auth-fail")
this.emit(
"error",
new Error("All configured authentication methods failed"),
);
else if (behaviour === "hang") return;
});
return this;
}
end() {
this.ended = true;
}
static nextBehaviour: Array<"ready" | "auth-fail" | "hang"> = [];
static instances: FakeSshClient[] = [];
}
@@ -0,0 +1,687 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import ssh2 from "ssh2";
import { FakeSshClient } from "./pipeline-test-helpers.js";
vi.mock("ssh2", async (importOriginal) => {
const actual = await importOriginal<typeof import("ssh2")>();
const { FakeSshClient: Fake } = await import("./pipeline-test-helpers.js");
return {
...actual,
default: {
...(actual as unknown as { default: object }).default,
Client: Fake,
},
Client: Fake,
};
});
const mocks = vi.hoisted(() => ({
verifier: () => {},
createHostVerifier: vi.fn(),
getOPKSSHToken: vi.fn(),
invalidateOPKSSHToken: vi.fn(),
setupOPKSSHCertAuth: vi.fn(),
setupCACertAuth: vi.fn(),
getVaultCert: vi.fn(),
deleteVaultCert: vi.fn(),
applyAgentAuth: vi.fn(),
performPortKnocking: vi.fn(),
createJumpHostChain: vi.fn(),
createSocks5Connection: vi.fn(),
resolveHostById: vi.fn(),
}));
vi.mock("../../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
return { logger: log, sshLogger: log, authLogger: log, fileLogger: log };
});
vi.mock("../../../hosts/host-key-verifier.js", () => ({
SSHHostKeyVerifier: {
preloadHostData: async () => null,
createHostVerifier: mocks.createHostVerifier,
},
}));
vi.mock("../../../hosts/opkssh-auth.js", () => ({
getOPKSSHToken: mocks.getOPKSSHToken,
invalidateOPKSSHToken: mocks.invalidateOPKSSHToken,
}));
vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({
setupOPKSSHCertAuth: mocks.setupOPKSSHCertAuth,
setupCACertAuth: mocks.setupCACertAuth,
}));
vi.mock("../../../hosts/vault-signer-auth.js", () => ({
getVaultCert: mocks.getVaultCert,
deleteVaultCert: mocks.deleteVaultCert,
}));
vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({
applyAgentAuth: mocks.applyAgentAuth,
performPortKnocking: mocks.performPortKnocking,
}));
vi.mock("../../../hosts/jump-host-chain.js", () => ({
createJumpHostChain: mocks.createJumpHostChain,
}));
vi.mock("../../../utils/socks5-helper.js", () => ({
createSocks5Connection: mocks.createSocks5Connection,
}));
vi.mock("../../../hosts/ssh-dns.js", () => ({
resolveSshConnectConfigHost: async (config: unknown) => config,
}));
vi.mock("../../../hosts/host-resolver.js", () => ({
resolveHostById: mocks.resolveHostById,
}));
vi.mock("../../../hosts/ssh-connection-pool.js", () => ({
withConnection: async (
_key: string,
factory: () => Promise<unknown>,
fn: (client: unknown) => Promise<unknown>,
) => fn(await factory()),
}));
import {
buildConnectConfig,
getPurposeDefaults,
} from "../../../hosts/connect/build-connect-config.js";
import {
connectHost,
getConnectionPoolKey,
SshConnectError,
withHostConnection,
} from "../../../hosts/connect/connect-host.js";
import {
getSshAuthProvider,
listCredentialTypes,
registerSshAuthProvider,
setSshAuthTypeOwnerSource,
} from "../../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js";
import { TAILSCALE_CHECK_TIMEOUT_MS } from "../../../auth/legacy-providers.js";
import type { SshConnectHost } from "../../../hosts/connect/types.js";
const plainKey = ssh2.utils.generateKeyPairSync("ed25519").private;
const encryptedKey = ssh2.utils.generateKeyPairSync("ed25519", {
passphrase: "secret",
cipher: "aes256-cbc",
}).private;
function host(overrides: Partial<SshConnectHost> = {}): SshConnectHost {
return {
id: 7,
ip: "10.0.0.7",
port: 22,
username: "root",
userId: "owner-1",
authType: "password",
password: "hunter2",
...overrides,
};
}
async function build(
target: SshConnectHost,
purpose: Parameters<typeof buildConnectConfig>[1]["purpose"] = "terminal",
) {
return buildConnectConfig(target, {
userId: "user-1",
purpose,
client: new FakeSshClient() as never,
});
}
beforeEach(() => {
vi.clearAllMocks();
FakeSshClient.nextBehaviour = [];
FakeSshClient.instances = [];
mocks.createHostVerifier.mockResolvedValue(mocks.verifier);
mocks.applyAgentAuth.mockImplementation(async (config) => {
config.agent = "agent-object";
return { socketPath: "/tmp/agent.sock" };
});
mocks.setupOPKSSHCertAuth.mockImplementation(async (config) => {
config.authHandler = "cert-handler";
});
mocks.setupCACertAuth.mockImplementation(async (config) => {
config.authHandler = "ca-handler";
});
});
describe("buildConnectConfig per auth type", () => {
it("password puts the password in the config", async () => {
const { config, outcome } = await build(host());
expect(outcome).toEqual({ status: "ready" });
expect(config).toMatchObject({
host: "10.0.0.7",
port: 22,
username: "root",
password: "hunter2",
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 5,
readyTimeout: 120000,
});
expect(config.hostVerifier).toBe(mocks.verifier);
});
it("password leaves the password out when keyboard-interactive is forced", async () => {
const { config, outcome } = await build(
host({ forceKeyboardInteractive: true }),
);
expect(outcome.status).toBe("ready");
expect(config.password).toBeUndefined();
});
it("password without a password is an error", async () => {
const { outcome } = await build(host({ password: null }));
expect(outcome).toMatchObject({ status: "error", code: "missing-secret" });
});
it("key sets privateKey, passphrase and keeps a password fallback", async () => {
const { config, outcome } = await build(
host({
authType: "key",
key: encryptedKey,
keyPassword: "secret",
password: "fallback",
}),
);
expect(outcome.status).toBe("ready");
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
expect(config.passphrase).toBe("secret");
expect(config.password).toBe("fallback");
expect(mocks.setupCACertAuth).not.toHaveBeenCalled();
});
it("key with a CA certificate grafts the certificate", async () => {
const { config } = await build(
host({
authType: "key",
key: plainKey,
password: null,
certPublicKey: "ssh-ed25519-cert-v01@openssh.com AAAA",
}),
);
expect(mocks.setupCACertAuth).toHaveBeenCalledOnce();
expect(config.authHandler).toBe("ca-handler");
});
it("key with a failing CA certificate falls back to key only", async () => {
mocks.setupCACertAuth.mockRejectedValueOnce(new Error("bad cert"));
const { outcome } = await build(
host({ authType: "key", key: plainKey, certPublicKey: "junk" }),
);
expect(outcome.status).toBe("ready");
});
it("an encrypted key without its passphrase asks for one", async () => {
const { outcome } = await build(
host({ authType: "key", key: encryptedKey }),
);
expect(outcome).toMatchObject({
status: "error",
code: "passphrase-required",
});
});
it("a broken key is an invalid-key error", async () => {
const { outcome } = await build(
host({ authType: "key", key: "not a key" }),
);
expect(outcome).toMatchObject({ status: "error", code: "invalid-key" });
});
it("credential behaves like whichever secret the resolver left", async () => {
const keyed = await build(
host({ authType: "credential", key: plainKey, password: null }),
);
expect(Buffer.isBuffer(keyed.config.privateKey)).toBe(true);
const passworded = await build(host({ authType: "credential" }));
expect(passworded.config.password).toBe("hunter2");
const empty = await build(host({ authType: "credential", password: null }));
expect(empty.outcome).toMatchObject({ code: "missing-secret" });
});
it("agent hands the config to the agent helper", async () => {
const { config, outcome } = await build(
host({
authType: "agent",
password: null,
terminalConfig: { agentSocketPath: "/tmp/agent.sock" },
}),
);
expect(outcome.status).toBe("ready");
expect(config.agent).toBe("agent-object");
expect(mocks.applyAgentAuth).toHaveBeenCalledWith(config, {
agentSocketPath: "/tmp/agent.sock",
});
});
it("agent errors are reported", async () => {
mocks.applyAgentAuth.mockResolvedValueOnce({ error: "no socket" });
const { outcome } = await build(host({ authType: "agent" }));
expect(outcome).toEqual({
status: "error",
code: "failed",
message: "no socket",
});
});
it("none adds no credentials", async () => {
const { config, outcome } = await build(
host({ authType: "none", password: null }),
);
expect(outcome.status).toBe("ready");
expect(config.password).toBeUndefined();
expect(config.privateKey).toBeUndefined();
expect(config.tryKeyboard).toBe(true);
});
it("opkssh without a cached token asks for a sign-in", async () => {
mocks.getOPKSSHToken.mockResolvedValueOnce(null);
const { outcome } = await build(host({ authType: "opkssh" }));
expect(outcome).toMatchObject({
status: "interaction-required",
interaction: "opkssh",
});
expect(mocks.getOPKSSHToken).toHaveBeenCalledWith("user-1", 7);
});
it("opkssh and stepca use the cached certificate", async () => {
for (const authType of ["opkssh", "stepca"]) {
mocks.getOPKSSHToken.mockResolvedValueOnce({
privateKey: "k",
sshCert: "c",
});
const { config, outcome } = await build(host({ authType }));
expect(outcome.status).toBe("ready");
expect(config.authHandler).toBe("cert-handler");
}
expect(mocks.setupOPKSSHCertAuth).toHaveBeenCalledTimes(2);
});
it("vault needs a profile, then a cached certificate", async () => {
const noProfile = await build(host({ authType: "vault" }));
expect(noProfile.outcome).toMatchObject({ status: "error" });
mocks.getVaultCert.mockResolvedValueOnce(null);
const noCert = await build(
host({ authType: "vault", vaultProfile: { id: 3 } }),
);
// The file manager turns the flag into requiresVaultAuth in its 401.
expect(noCert.outcome).toMatchObject({
status: "interaction-required",
interaction: "vault",
flag: "requiresVaultAuth",
});
mocks.getVaultCert.mockResolvedValueOnce({
privateKey: "k",
sshCert: "c",
});
const ready = await build(
host({ authType: "vault", vaultProfile: { id: 3 } }),
);
expect(ready.outcome.status).toBe("ready");
expect(mocks.getVaultCert).toHaveBeenLastCalledWith("user-1", 3);
});
it("tailscale turns off keyboard-interactive, with long timeouts in the terminal", async () => {
const terminal = await build(host({ authType: "tailscale" }));
expect(terminal.config).toMatchObject({
tryKeyboard: false,
readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS,
timeout: TAILSCALE_CHECK_TIMEOUT_MS,
});
const fleet = await build(host({ authType: "tailscale" }), "fleet");
expect(fleet.config.tryKeyboard).toBe(false);
expect(fleet.config.readyTimeout).toBe(30000);
});
it("tailscale retries once with +password, then reports the failure", async () => {
ensureCoreSshAuthProviders();
const provider = getSshAuthProvider("tailscale")!;
const env = {} as never;
const failure = new Error("All configured authentication methods failed");
const first = provider.onAuthFailed!(host({ authType: "tailscale" }), env, {
error: failure,
retries: 0,
canRetry: true,
methodNotAvailable: false,
});
expect(first).toMatchObject({
status: "retry",
patch: { username: "root+password", tryKeyboard: false },
});
const second = provider.onAuthFailed!(
host({ authType: "tailscale" }),
env,
{
error: failure,
retries: 1,
canRetry: true,
methodNotAvailable: false,
},
);
expect(second).toMatchObject({ status: "error" });
});
it("a type nobody provides names the plugin that would", async () => {
setSshAuthTypeOwnerSource(() => [
{ type: "made-up", pluginId: "made", pluginName: "Made Up" },
]);
const { outcome } = await build(host({ authType: "made-up" }));
expect(outcome).toEqual({
status: "error",
code: "provider-missing",
message: "This host uses made-up, which needs the Made Up plugin",
});
setSshAuthTypeOwnerSource(() => []);
});
it("jump hops skip keyboard-interactive for auth type none", async () => {
const { config } = await build(
host({ authType: "none", password: null }),
"jump-host",
);
expect(config.tryKeyboard).toBe(false);
expect(mocks.createHostVerifier).toHaveBeenCalledWith(
7,
"10.0.0.7",
22,
null,
"user-1",
true,
null,
);
});
});
describe("purpose defaults", () => {
it("keeps each transport's old keepalive numbers", () => {
expect(getPurposeDefaults("terminal")).toMatchObject({
keepaliveIntervalMs: 30000,
keepaliveCountMax: 5,
});
expect(getPurposeDefaults("file-manager")).toMatchObject({
keepaliveIntervalMs: 60000,
keepaliveCountMax: 5,
});
expect(getPurposeDefaults("fleet")).toMatchObject({
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
});
});
it("host keepalive settings apply where the transport honoured them", async () => {
const withSettings = host({
terminalConfig: { keepaliveInterval: 10, keepaliveCountMax: 2 },
});
const terminal = await build(withSettings, "terminal");
expect(terminal.config.keepaliveInterval).toBe(10000);
const fleet = await build(withSettings, "fleet");
expect(fleet.config.keepaliveInterval).toBe(30000);
});
});
describe("provider registry", () => {
it("registers and removes a plugin provider", async () => {
ensureCoreSshAuthProviders();
const dispose = registerSshAuthProvider({
type: "fixture",
pluginId: "fixture-plugin",
labelKey: "fixture.label",
credentialType: true,
prepare: async (config) => {
config.password = "from-plugin";
return { status: "ready" };
},
});
expect(listCredentialTypes()).toContain("fixture");
const { config } = await build(host({ authType: "fixture" }));
expect(config.password).toBe("from-plugin");
dispose();
expect(getSshAuthProvider("fixture")).toBeUndefined();
expect(listCredentialTypes()).not.toContain("fixture");
const { outcome } = await build(host({ authType: "fixture" }));
expect(outcome).toMatchObject({ code: "provider-missing" });
});
it("refuses to let a second plugin take over a type", () => {
ensureCoreSshAuthProviders();
expect(() =>
registerSshAuthProvider({
type: "password",
pluginId: "someone-else",
labelKey: "x",
prepare: async () => ({ status: "ready" }),
}),
).toThrow(/already provided by core/);
});
it("offers password and key as credential types", () => {
ensureCoreSshAuthProviders();
expect(listCredentialTypes()).toEqual(
expect.arrayContaining(["password", "key"]),
);
});
});
describe("connectHost", () => {
it("resolves the host for the acting user and connects", async () => {
mocks.resolveHostById.mockResolvedValueOnce(host());
const connection = await connectHost(7, {
userId: "user-1",
purpose: "fleet",
});
expect(mocks.resolveHostById).toHaveBeenCalledWith(7, "user-1");
const client = connection.client as unknown as FakeSshClient;
expect(client.connectConfig).toMatchObject({ password: "hunter2" });
connection.dispose();
expect(client.ended).toBe(true);
});
it("uses a shared-override resolution exactly as the resolver returned it", async () => {
mocks.resolveHostById.mockResolvedValueOnce(
host({ authType: "key", key: plainKey, password: null, username: "me" }),
);
const connection = await connectHost(7, {
userId: "recipient",
purpose: "fleet",
});
const config = (connection.client as unknown as FakeSshClient)
.connectConfig!;
expect(config.username).toBe("me");
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
expect(config.password).toBeUndefined();
});
it("fails for an unknown or forbidden host", async () => {
mocks.resolveHostById.mockResolvedValueOnce(null);
await expect(
connectHost(9, { userId: "user-1", purpose: "fleet" }),
).rejects.toThrow("Host not found or access denied");
});
it("throws the auth outcome instead of connecting", async () => {
mocks.getOPKSSHToken.mockResolvedValueOnce(null);
const attempt = connectHost(host({ authType: "opkssh" }), {
userId: "user-1",
purpose: "tmux",
});
await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
await expect(
connectHost(host({ authType: "opkssh" }), {
userId: "user-1",
purpose: "tmux",
}).catch((error) => error.code),
).resolves.toBe("opkssh-required");
});
it("goes through the jump chain and forwards to the target", async () => {
const jumpClient = new FakeSshClient();
mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient);
const connection = await connectHost(
host({ jumpHosts: [{ hostId: 2 }, { hostId: 3 }] }),
{ userId: "user-1", purpose: "fleet" },
);
expect(mocks.createJumpHostChain).toHaveBeenCalledWith(
[{ hostId: 2 }, { hostId: 3 }],
"owner-1",
);
expect(jumpClient.forwardOut).toHaveBeenCalledWith(
"127.0.0.1",
0,
"10.0.0.7",
22,
expect.any(Function),
);
const config = (connection.client as unknown as FakeSshClient)
.connectConfig!;
expect(config.sock).toEqual({ fakeStream: true });
connection.dispose();
expect(jumpClient.ended).toBe(true);
});
it("uses a SOCKS5 proxy when the host has one", async () => {
mocks.createSocks5Connection.mockResolvedValueOnce({ proxied: true });
const connection = await connectHost(
host({ useSocks5: true, socks5Host: "proxy", socks5Port: 1080 }),
{ userId: "user-1", purpose: "fleet" },
);
expect(mocks.createSocks5Connection).toHaveBeenCalledWith(
"10.0.0.7",
22,
expect.objectContaining({ socks5Host: "proxy", socks5Port: 1080 }),
);
expect(
(connection.client as unknown as FakeSshClient).connectConfig!.sock,
).toEqual({ proxied: true });
});
it("knocks before connecting when the host has a sequence", async () => {
await connectHost(
host({ portKnockSequence: [{ port: 7000 }, { port: 8000 }] }),
{ userId: "user-1", purpose: "fleet" },
);
expect(mocks.performPortKnocking).toHaveBeenCalledWith("10.0.0.7", [
{ port: 7000 },
{ port: 8000 },
]);
});
it("auto-fills the stored password in keyboard-interactive with nobody to ask", async () => {
FakeSshClient.nextBehaviour = ["hang"];
const attempt = connectHost(host(), { userId: "user-1", purpose: "fleet" });
await vi.waitFor(() => {
const last = FakeSshClient.instances.at(-1)!;
expect(last.connectConfig).not.toBeNull();
});
const client = FakeSshClient.instances.at(-1)!;
const finish = vi.fn();
client.emit(
"keyboard-interactive",
"",
"",
"",
[
{ prompt: "Password: ", echo: false },
{ prompt: "Something else: ", echo: true },
],
finish,
);
expect(finish).toHaveBeenCalledWith(["hunter2", ""]);
client.emit("ready");
await expect(attempt).resolves.toMatchObject({ client });
});
it("asks through the prompt channel when there is one", async () => {
FakeSshClient.nextBehaviour = ["hang"];
const ask = vi.fn().mockResolvedValue("123456");
const attempt = connectHost(host(), {
userId: "user-1",
purpose: "file-manager",
prompt: { ask },
});
await vi.waitFor(() =>
expect(FakeSshClient.instances.at(-1)!.connectConfig).not.toBeNull(),
);
const client = FakeSshClient.instances.at(-1)!;
const finish = vi.fn();
client.emit(
"keyboard-interactive",
"",
"",
"",
[
{ prompt: "Password: ", echo: false },
{ prompt: "Verification code: ", echo: true },
],
finish,
);
await vi.waitFor(() => expect(finish).toHaveBeenCalled());
expect(ask).toHaveBeenCalledWith({
kind: "totp",
prompt: "Verification code: ",
retry: false,
});
expect(finish).toHaveBeenCalledWith(["hunter2", "123456"]);
client.emit("ready");
await attempt;
});
it("times out when the server never answers", async () => {
FakeSshClient.nextBehaviour = ["hang"];
await expect(
connectHost(host(), {
userId: "user-1",
purpose: "fleet",
timeoutMs: 20,
}),
).rejects.toThrow("SSH connection timeout");
});
it("lets the provider react to an auth failure", async () => {
FakeSshClient.nextBehaviour = ["auth-fail"];
mocks.getOPKSSHToken.mockResolvedValueOnce({
privateKey: "k",
sshCert: "c",
});
await expect(
connectHost(host({ authType: "opkssh" }), {
userId: "user-1",
purpose: "fleet",
}),
).rejects.toThrow(/All configured authentication methods failed/);
await vi.waitFor(() =>
expect(mocks.invalidateOPKSSHToken).toHaveBeenCalledWith(
"user-1",
7,
"SSH auth failed",
),
);
});
it("pools under the old key shape", async () => {
expect(getConnectionPoolKey("fleet", host())).toBe(
"fleet:owner-1:10.0.0.7:22:root",
);
expect(
getConnectionPoolKey(
"stats",
host({ useSocks5: true, socks5Host: "p", socks5Port: 1 }),
),
).toBe("stats:owner-1:10.0.0.7:22:root:socks5:p:1");
const result = await withHostConnection(
"fleet:x",
host(),
{ userId: "user-1", purpose: "fleet" },
async (client) => (client as unknown as FakeSshClient).connectConfig,
);
expect(result).toMatchObject({ password: "hunter2" });
});
});
@@ -1,17 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../database/repositories/factory.js", () => ({
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findCredentialByIdForUser: async () => null,
}),
}));
vi.mock("../../utils/logger.js", () => ({
vi.mock("../../../utils/logger.js", () => ({
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { SSHAuthManager } from "../../hosts/auth-manager.js";
import { SSHAuthManager } from "../../../hosts/connect/terminal-prompt.js";
function createManager() {
const sent: Record<string, unknown>[] = [];
@@ -1,92 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const setupCACertAuth = vi.fn();
const warn = vi.fn();
vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({
setupCACertAuth: (...args: unknown[]) => setupCACertAuth(...args),
}));
vi.mock("../../../utils/logger.js", () => ({
fileLogger: { warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const { applyCACertIfPresent } =
await import("../../../hosts/file-manager/ca-cert-auth.js");
/**
* `setupCACertAuth` had no call site in the file manager at all, while its
* sibling `setupOPKSSHCertAuth` had two. So a host whose key is paired with a
* user-managed CA-signed certificate authenticated in the terminal and failed
* over SFTP, and OPKSSH certificates — going through the other helper — worked
* in both places.
*/
describe("applyCACertIfPresent", () => {
const client = {} as never;
const key = Buffer.from("private-key");
beforeEach(() => {
vi.clearAllMocks();
});
it("attaches a certificate the host carries", async () => {
const config: Record<string, unknown> = {};
await applyCACertIfPresent(
config,
client,
key,
{ certPublicKey: "ssh-rsa-cert-v01@openssh.com AAAA" },
"root",
"passphrase",
);
expect(setupCACertAuth).toHaveBeenCalledWith(
config,
client,
key,
"ssh-rsa-cert-v01@openssh.com AAAA",
"root",
"passphrase",
);
});
it("does nothing when there is no certificate", async () => {
// Most hosts. Touching the connection here would change key-only auth.
for (const certPublicKey of [undefined, null, "", " "]) {
await applyCACertIfPresent(
{},
client,
key,
{ certPublicKey },
"root",
undefined,
);
}
expect(setupCACertAuth).not.toHaveBeenCalled();
});
it("leaves the connection usable when the certificate is unusable", async () => {
// The private key on its own may still be accepted — which is what
// happened before this was wired up. Failing the connection here would
// turn a working setup into a broken one.
setupCACertAuth.mockRejectedValueOnce(new Error("bad cert format"));
await expect(
applyCACertIfPresent(
{},
client,
key,
{ certPublicKey: "garbage" },
"root",
undefined,
),
).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("CA certificate setup failed"),
expect.objectContaining({ operation: "sftp_ca_cert_auth_failed" }),
);
});
});
@@ -1,25 +0,0 @@
import fs from "fs";
import path from "path";
import { describe, expect, it } from "vitest";
describe("file manager Vault authentication", () => {
it("configures the cached Vault certificate before connecting", () => {
const source = fs.readFileSync(
path.resolve(__dirname, "../../../hosts/file-manager/index.ts"),
"utf8",
);
const vaultBranch = source.indexOf(
'resolvedCredentials.authType === "vault"',
);
const agentBranch = source.indexOf(
'resolvedCredentials.authType === "agent"',
vaultBranch,
);
const body = source.slice(vaultBranch, agentBranch);
expect(vaultBranch).toBeGreaterThan(-1);
expect(agentBranch).toBeGreaterThan(vaultBranch);
expect(body).toContain("setupVaultSshSignerAuth");
expect(body).toContain("requiresVaultAuth: true");
});
});
@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { getTmuxAuthBehavior } from "../../../hosts/tmux/auth-utils.js";
describe("getTmuxAuthBehavior", () => {
it("uses credentialless non-interactive authentication for Tailscale SSH", () => {
expect(getTmuxAuthBehavior("tailscale")).toEqual({
credentialless: true,
tryKeyboard: false,
});
});
it("preserves keyboard-interactive fallback for none authentication", () => {
expect(getTmuxAuthBehavior("none")).toEqual({
credentialless: true,
tryKeyboard: true,
});
});
it("does not treat password authentication as credentialless", () => {
expect(getTmuxAuthBehavior("password")).toEqual({
credentialless: false,
tryKeyboard: true,
});
});
});
@@ -0,0 +1,253 @@
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,
}));
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/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);
}
},
};
});
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
vi.mock("../../hosts/connect/connect-host.js", () => ({
connectHost: async (target: unknown, options: Record<string, unknown>) => {
h.connects.push({ target, options });
const client = Object.assign(new EventEmitter(), { end: vi.fn() });
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
},
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserAuthRepository: () => ({
recordSecondFactor: async (
userId: string,
pluginId: string,
factorId: string,
) => {
h.factors.push({ userId, pluginId, factorId });
},
removeSecondFactor: async () => true,
}),
}));
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 { getLoginMethod, getSecondFactor } =
await import("../../auth/registry.js");
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
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";
});
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("runs background work on a resolved host as its owner", 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 () => {}),
});
await ssh.connect({
id: 3,
ip: "h",
port: 22,
username: "u",
userId: "owner",
});
expect(h.connects[0].options).toMatchObject({ userId: "owner" });
await expect(ssh.connect(3)).rejects.toThrow(/acting user/);
});
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.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("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();
});
});
+5 -5
View File
@@ -69,8 +69,8 @@ vi.mock("../../plugins/permissions.js", () => ({
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: vi.fn() }));
const http = await import("../../plugins/http.js");
const pluginApi = (await import("../../database/routes/plugin-api-routes.js"))
.default;
const { mountPluginApi } =
await import("../../database/routes/plugin-api-routes.js");
function manifest(overrides: Record<string, unknown> = {}) {
return {
@@ -86,9 +86,9 @@ let server: Server | null = null;
async function startServer(): Promise<string> {
const app = express();
// Exactly how database.ts mounts it: no auth in front, because the plugin
// router brings its own.
app.use("/plugin-api", pluginApi);
// The same mount database.ts uses. It used to put core auth in front of the
// whole prefix, which made every public plugin route unreachable.
mountPluginApi(app);
return new Promise((resolve) => {
server = app.listen(0, "127.0.0.1", () => {
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createAuthState,
type AuthState,
} from "../../auth/auth-test-helpers.js";
const h = vi.hoisted(() => ({ state: null as unknown as AuthState }));
vi.mock("../../../database/repositories/factory.js", async () => {
const helpers = await import("../../auth/auth-test-helpers.js");
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
return Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
});
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const { runExternalIdentityMigration, parseLegacyIdentifier } =
await import("../../../utils/crypto-migration/external-identity-migration.js");
function user(id: string, extra: Record<string, unknown>) {
h.state.users.set(id, {
id,
username: id,
passwordHash: "",
isAdmin: false,
isOidc: true,
...extra,
});
}
beforeEach(() => {
h.state = createAuthState();
});
describe("parseLegacyIdentifier", () => {
it("splits the prefixed forms and keeps bare subjects on their provider", () => {
expect(parseLegacyIdentifier("ldap:4:bob", null)).toEqual({
providerId: "4",
subject: "bob",
});
expect(parseLegacyIdentifier("github:7:42", null)).toEqual({
providerId: "7",
subject: "42",
});
expect(parseLegacyIdentifier("ldap:4:cn=a:b", null)).toEqual({
providerId: "4",
subject: "cn=a:b",
});
expect(parseLegacyIdentifier("sub-123", 3)).toEqual({
providerId: "3",
subject: "sub-123",
});
expect(parseLegacyIdentifier("sub-123", null)).toEqual({
providerId: "legacy-oidc",
subject: "sub-123",
});
expect(parseLegacyIdentifier("github:null:42", null)).toEqual({
providerId: "legacy-oidc",
subject: "42",
});
});
});
describe("runExternalIdentityMigration", () => {
it("moves every identifier and TOTP enrolment without touching the old data", async () => {
user("ldap-user", { oidcIdentifier: "ldap:4:bob", ssoProviderId: 4 });
user("gh-user", { oidcIdentifier: "github:7:42", ssoProviderId: 7 });
user("oidc-user", {
oidcIdentifier: "sub-123",
ssoProviderId: 3,
totpEnabled: true,
});
user("env-user", { oidcIdentifier: "someone@example.com" });
user("local", {
isOidc: false,
passwordHash: "hash",
totpEnabled: true,
});
const result = await runExternalIdentityMigration();
expect(result).toEqual({ identities: 4, factors: 2, skipped: 0 });
expect(
h.state.identities.map(({ userId, providerId, subject }) => ({
userId,
providerId,
subject,
})),
).toEqual([
{ userId: "ldap-user", providerId: "4", subject: "bob" },
{ userId: "gh-user", providerId: "7", subject: "42" },
{ userId: "oidc-user", providerId: "3", subject: "sub-123" },
{
userId: "env-user",
providerId: "legacy-oidc",
subject: "someone@example.com",
},
]);
expect(h.state.factors).toEqual([
{ userId: "oidc-user", pluginId: "core", factorId: "totp" },
{ userId: "local", pluginId: "core", factorId: "totp" },
]);
// Lossless: the old column and flag stay.
expect(h.state.users.get("ldap-user")!.oidcIdentifier).toBe("ldap:4:bob");
expect(h.state.users.get("local")!.totpEnabled).toBe(true);
});
it("is idempotent", async () => {
user("ldap-user", { oidcIdentifier: "ldap:4:bob", totpEnabled: true });
await runExternalIdentityMigration();
const second = await runExternalIdentityMigration();
expect(second).toEqual({ identities: 0, factors: 0, skipped: 2 });
expect(h.state.identities).toHaveLength(1);
expect(h.state.factors).toHaveLength(1);
});
});