mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: add auth extension points and one ssh connect pipeline
This commit is contained in:
1 parent
d3c209af13
commit
d774df8b41
159 files changed
+43243
-7451
No files matched your search
@@ -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" });
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user