feat: add auth extension points and one ssh connect pipeline

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

No files matched your search

+267
View File
@@ -0,0 +1,267 @@
import { vi } from "vitest";
export interface FakeUser {
id: string;
username: string;
passwordHash: string;
isAdmin: boolean;
isOidc: boolean;
oidcIdentifier?: string | null;
ssoProviderId?: number | null;
totpEnabled?: boolean;
totpSecret?: string | null;
totpBackupCodes?: string | null;
}
/** In-memory stand-ins for the repositories the login pipeline touches. */
export function createAuthState() {
return {
users: new Map<string, FakeUser>(),
identities: [] as Array<{
id: number;
userId: string;
providerId: string;
subject: string;
email: string | null;
}>,
factors: [] as Array<{
userId: string;
pluginId: string;
factorId: string;
}>,
roles: [] as Array<{ userId: string; roleName: string }>,
settings: new Map<string, string>(),
audits: [] as Array<Record<string, unknown>>,
trusted: new Set<string>(),
trustedAdded: [] as string[],
pendingTokens: new Map<string, string>(),
};
}
export type AuthState = ReturnType<typeof createAuthState>;
export function fakeFactory(state: AuthState) {
return {
createCurrentUserRepository: () => ({
findById: async (id: string) => state.users.get(id) ?? null,
findByUsername: async (username: string) =>
[...state.users.values()].find((u) => u.username === username) ?? null,
findByOidcIdentifier: async (identifier: string) =>
[...state.users.values()].find(
(u) => u.oidcIdentifier === identifier,
) ?? null,
countAll: async () => state.users.size,
listAll: async () => [...state.users.values()],
createFirstSsoUser: async (user: FakeUser) => {
const isFirstUser = state.users.size === 0;
const created = {
...user,
isAdmin: isFirstUser || !!user.isAdmin,
};
state.users.set(user.id, created);
return { user: created, isFirstUser };
},
update: async (id: string, changes: Partial<FakeUser>) => {
const user = state.users.get(id);
if (!user) return null;
Object.assign(user, changes);
return user;
},
delete: async (id: string) => state.users.delete(id),
}),
createCurrentUserAuthRepository: () => ({
findIdentity: async (providerId: string, subject: string) =>
state.identities.find(
(row) => row.providerId === providerId && row.subject === subject,
) ?? null,
linkIdentity: async (input: {
userId: string;
providerId: string;
subject: string;
email?: string | null;
}) => {
const existing = state.identities.find(
(row) =>
row.providerId === input.providerId &&
row.subject === input.subject,
);
if (existing) {
existing.userId = input.userId;
return;
}
state.identities.push({
id: state.identities.length + 1,
userId: input.userId,
providerId: input.providerId,
subject: input.subject,
email: input.email ?? null,
});
},
listSecondFactors: async (userId: string) =>
state.factors.filter((row) => row.userId === userId),
recordSecondFactor: async (
userId: string,
pluginId: string,
factorId: string,
) => {
if (
!state.factors.some(
(row) =>
row.userId === userId &&
row.pluginId === pluginId &&
row.factorId === factorId,
)
) {
state.factors.push({ userId, pluginId, factorId });
}
},
removeSecondFactor: async (
userId: string,
pluginId: string,
factorId: string,
) => {
state.factors = state.factors.filter(
(row) =>
!(
row.userId === userId &&
row.pluginId === pluginId &&
row.factorId === factorId
),
);
return true;
},
clearSecondFactors: async (userId: string) => {
state.factors = state.factors.filter((row) => row.userId !== userId);
return 1;
},
}),
createCurrentRoleRepository: () => ({
assignRoleNameToUser: async (input: {
userId: string;
roleName: string;
}) => {
state.roles.push({ userId: input.userId, roleName: input.roleName });
return true;
},
switchUserRoleName: async (input: {
userId: string;
addRoleName: string;
removeRoleName: string;
}) => {
state.roles = state.roles.filter(
(role) =>
!(
role.userId === input.userId &&
role.roleName === input.removeRoleName
),
);
state.roles.push({ userId: input.userId, roleName: input.addRoleName });
},
listUserRoles: async (userId: string) =>
state.roles
.filter((role) => role.userId === userId)
.map((role, index) => ({ roleId: index, roleName: role.roleName })),
removeRoleFromUser: async () => true,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings.get(key) ?? null,
getBoolean: async (key: string, fallback: boolean) =>
state.settings.has(key) ? state.settings.get(key) === "true" : fallback,
set: async (key: string, value: string) => {
state.settings.set(key, value);
},
delete: async (key: string) => state.settings.delete(key),
}),
getCurrentSettingValue: (key: string) => state.settings.get(key) ?? null,
createCurrentTrustedDeviceRepository: () => ({
deleteByUserId: async () => {},
}),
createCurrentSsoProviderRepository: () => ({
listEnabledPublic: async () => [],
}),
};
}
export function fakeAuthManager(state: AuthState) {
let tokenCount = 0;
return {
generateJWTToken: vi.fn(
async (userId: string, options: { pendingTOTP?: boolean } = {}) => {
if (!options.pendingTOTP) return `jwt-${++tokenCount}`;
const token = `pending-${++tokenCount}`;
state.pendingTokens.set(token, userId);
return token;
},
),
verifyJWTToken: vi.fn(async (token: string) => {
const userId = state.pendingTokens.get(token);
return userId ? { userId, pendingTOTP: true } : null;
}),
authenticateUser: vi.fn(async () => true),
authenticateOIDCUser: vi.fn(async () => true),
authenticateWebAuthnUser: vi.fn(async () => true),
registerOIDCUser: vi.fn(async () => {}),
isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) =>
state.trusted.has(`${userId}:${fingerprint}`),
),
addTrustedDevice: vi.fn(async (userId: string, fingerprint: string) => {
state.trustedAdded.push(`${userId}:${fingerprint}`);
}),
getSecureCookieOptions: vi.fn((_req: unknown, maxAge: number) => ({
httpOnly: true,
maxAge,
})),
getClearCookieOptions: vi.fn(() => ({ httpOnly: true })),
getUserDataKey: vi.fn(() => Buffer.alloc(32, 7)),
};
}
export function fakeRequest(overrides: Record<string, unknown> = {}) {
const headers: Record<string, string> = {
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/120.0",
"x-termix-device-id": "a".repeat(64),
...(overrides.headers as Record<string, string> | undefined),
};
return {
body: {},
query: {},
params: {},
cookies: {},
ip: "10.0.0.1",
socket: { remoteAddress: "10.0.0.1" },
...overrides,
headers,
get: (name: string) => headers[name.toLowerCase()],
} as Record<string, unknown> & { headers: Record<string, string> };
}
export function fakeResponse() {
const res = {
statusCode: 200,
body: undefined as unknown,
cookies: [] as Array<{ name: string; value: string; options: unknown }>,
cleared: [] as string[],
redirectedTo: null as string | null,
status(code: number) {
res.statusCode = code;
return res;
},
json(body: unknown) {
res.body = body;
return res;
},
cookie(name: string, value: string, options: unknown) {
res.cookies.push({ name, value, options });
return res;
},
clearCookie(name: string) {
res.cleared.push(name);
return res;
},
redirect(url: string) {
res.redirectedTo = url;
return res;
},
};
return res;
}
@@ -0,0 +1,383 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createAuthState,
fakeAuthManager,
fakeRequest,
fakeResponse,
type AuthState,
} from "./auth-test-helpers.js";
const h = vi.hoisted(() => ({
state: null as unknown as AuthState,
manager: null as unknown as ReturnType<
typeof import("./auth-test-helpers.js").fakeAuthManager
>,
providerConfig: null as unknown,
providerType: "oidc",
idTokenClaims: null as unknown,
ldap: {
users: {} as Record<
string,
{ dn: string; password: string; attrs: Record<string, string> }
>,
adminDns: [] as string[],
},
ssoProvider: { id: 4, type: "ldap", enabled: true },
}));
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("./auth-test-helpers.js");
const names = [
...Object.keys(helpers.fakeFactory(helpers.createAuthState())),
];
const mocked = Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
return {
...mocked,
createCurrentSsoProviderRepository: () => ({
findById: async () => h.ssoProvider,
listEnabledPublic: async () => [],
}),
};
});
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => h.manager },
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
h.state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../hosts/automation-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return { authLogger: log, databaseLogger: log, sshLogger: log, logger: log };
});
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
},
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../database/routes/user-oidc-utils.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../database/routes/user-oidc-utils.js")
>();
return {
...actual,
loadProviderConfig: async () =>
h.providerConfig
? {
config: h.providerConfig,
providerType: h.providerType,
providerDbId: 3,
}
: null,
verifyOIDCToken: async () => h.idTokenClaims,
};
});
vi.mock("ldapjs", () => {
function createClient() {
let bound: string | null = null;
return {
bind: (dn: string, password: string, cb: (err?: Error) => void) => {
if (dn === "cn=service") {
bound = dn;
return cb();
}
const user = Object.values(h.ldap.users).find((u) => u.dn === dn);
if (user && user.password === password) {
bound = dn;
return cb();
}
cb(new Error("invalid credentials"));
},
search: (
base: string,
options: { filter: string },
cb: (err: Error | null, res: unknown) => void,
) => {
const listeners: Record<string, (value?: unknown) => void> = {};
const res = {
on: (event: string, fn: (value?: unknown) => void) => {
listeners[event] = fn;
},
};
cb(null, res);
queueMicrotask(() => {
if (base === "ou=groups") {
for (const dn of h.ldap.adminDns) {
if (options.filter.includes(dn)) {
listeners.searchEntry?.({
dn: { toString: () => "cn=admins,ou=groups" },
attributes: [{ type: "cn", values: ["admins"] }],
});
}
}
} else {
const match = /uid=([^)]+)/.exec(options.filter);
const user = match ? h.ldap.users[match[1]] : undefined;
if (user) {
listeners.searchEntry?.({
dn: { toString: () => user.dn },
attributes: Object.entries(user.attrs).map(([type, value]) => ({
type,
values: [value],
})),
});
}
}
listeners.end?.();
});
void bound;
},
unbind: () => {},
};
}
return { default: { createClient }, createClient };
});
const { handleOidcCallback } = await import("../../auth/legacy/oidc-login.js");
const { verifyLdapLogin } = await import("../../auth/legacy/ldap-login.js");
const { respondWithLogin, respondWithRedirectLogin, runLogin } =
await import("../../auth/login-pipeline.js");
beforeEach(() => {
h.state = createAuthState();
h.manager = fakeAuthManager(h.state);
h.providerType = "oidc";
h.providerConfig = {
client_id: "client",
client_secret: "secret",
issuer_url: "https://idp.example",
authorization_url: "https://idp.example/auth",
token_url: "https://idp.example/token",
identifier_path: "sub",
name_path: "name",
scopes: "openid",
admin_group: "termix-admins",
group_claim: "groups",
};
h.idTokenClaims = {
sub: "sub-1",
sid: "sid-1",
name: "Alice",
email: "alice@example.com",
nonce: "nonce-1",
groups: ["termix-admins"],
};
for (const [key, value] of Object.entries({
oidc_backend_callback_state1: "https://termix.example/users/oidc/callback",
oidc_frontend_origin_state1: "https://termix.example",
oidc_remember_me_state1: "false",
oidc_state_state1: "nonce-1",
oidc_provider_state1: "3",
})) {
h.state.settings.set(key, value);
}
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (url === "https://idp.example/token") {
return new Response(
JSON.stringify({ id_token: "jwt", access_token: "at" }),
);
}
return new Response("{}", { status: 404 });
}),
);
h.ldap.users = {
bob: {
dn: "uid=bob,ou=people",
password: "hunter2",
attrs: { uid: "bob", cn: "Bob Builder", mail: "bob@example.com" },
},
};
h.ldap.adminDns = [];
});
describe("OIDC through the pipeline", () => {
it("turns the callback into an identity with the old identifier and claims", async () => {
const identity = await handleOidcCallback(
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
);
expect(identity).toMatchObject({
kind: "external",
provider: "3",
subject: "sub-1",
legacyIdentifier: "sub-1",
name: "Alice",
isAdmin: true,
oidcSub: "sub-1",
oidcSid: "sid-1",
ssoProviderId: 3,
returnTo: "https://termix.example",
});
// The one-time state is consumed.
expect(h.state.settings.has("oidc_state_state1")).toBe(false);
});
it("signs in and redirects with a cookie, like the old callback", async () => {
const req = fakeRequest({ query: { code: "c", state: "state1" } });
const identity = await handleOidcCallback(req as never);
const res = fakeResponse();
await respondWithRedirectLogin(
req as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
expect(res.cleared).toContain("jwt");
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
const [user] = [...h.state.users.values()];
expect(user).toMatchObject({ oidcIdentifier: "sub-1", isAdmin: true });
expect(h.state.audits).toContainEqual(
expect.objectContaining({ action: "login", userId: user.id }),
);
});
it("rejects a nonce mismatch without a session", async () => {
h.idTokenClaims = { ...(h.idTokenClaims as object), nonce: "other" };
await expect(
handleOidcCallback(
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
),
).rejects.toMatchObject({ status: 401 });
});
it("namespaces GitHub users the way they were stored", async () => {
h.providerType = "github";
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (url === "https://idp.example/token") {
return new Response(JSON.stringify({ access_token: "at" }));
}
if (url === "https://api.github.com/user") {
return new Response(JSON.stringify({ id: 42, login: "octo" }));
}
return new Response("[]");
}),
);
const identity = await handleOidcCallback(
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
);
expect(identity).toMatchObject({
provider: "3",
subject: "42",
legacyIdentifier: "github:3:42",
name: "octo",
});
});
});
describe("LDAP through the pipeline", () => {
const providerConfig = {
host: "ldap.example",
bindDN: "cn=service",
bindPassword: "svc",
userSearchBase: "ou=people",
userSearchFilter: "(uid={{username}})",
groupSearchBase: "ou=groups",
adminGroup: "admins",
};
it("binds, finds the user and signs in with the old identifier", async () => {
h.providerConfig = providerConfig;
h.ldap.adminDns = ["uid=bob,ou=people"];
const req = fakeRequest({
body: { providerId: 4, username: "bob", password: "hunter2" },
headers: { "x-electron-app": "true" },
});
const identity = await verifyLdapLogin({
body: req.body as never,
ip: "10.0.0.1",
});
expect(identity).toMatchObject({
provider: "4",
subject: "bob",
legacyIdentifier: "ldap:4:bob",
isAdmin: true,
name: "Bob Builder",
});
const res = fakeResponse();
await respondWithLogin(req as never, res as never, identity, {
methodId: "ldap",
rememberMe: false,
});
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
// Native clients now get the token for LDAP too.
expect(res.body).toMatchObject({
success: true,
token: expect.any(String),
});
});
it("rejects a wrong password", async () => {
h.providerConfig = providerConfig;
await expect(
verifyLdapLogin({
body: { providerId: 4, username: "bob", password: "wrong" },
ip: "10.0.0.2",
}),
).rejects.toMatchObject({ status: 401 });
});
it("runs TOTP for an LDAP user who enrolled", async () => {
h.providerConfig = providerConfig;
h.state.users.set("u-bob", {
id: "u-bob",
username: "Bob Builder",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:4:bob",
totpEnabled: true,
totpSecret: "JBSWY3DPEHPK3PXP",
});
const identity = await verifyLdapLogin({
body: { providerId: 4, username: "bob", password: "hunter2" },
ip: "10.0.0.3",
});
const result = await runLogin(fakeRequest() as never, identity, {
methodId: "ldap",
rememberMe: false,
});
expect(result.kind).toBe("second-factor");
});
});
@@ -0,0 +1,729 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import bcrypt from "bcryptjs";
import speakeasy from "speakeasy";
import {
createAuthState,
fakeAuthManager,
fakeRequest,
fakeResponse,
type AuthState,
} from "./auth-test-helpers.js";
const h = vi.hoisted(() => ({
state: null as unknown as AuthState,
manager: null as unknown as ReturnType<
typeof import("./auth-test-helpers.js").fakeAuthManager
>,
trustedProxy: false,
}));
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("./auth-test-helpers.js");
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
return Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
});
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => h.manager },
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
h.state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../hosts/automation-events.js", () => ({
notifyAutomationInternalEvent: vi.fn(),
}));
vi.mock("../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return {
authLogger: log,
databaseLogger: log,
sshLogger: log,
logger: log,
systemLogger: log,
};
});
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
},
}));
vi.mock("../../utils/trusted-proxy-auth.js", () => ({
isTrustedProxyAuthEnabled: () => h.trustedProxy,
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
const {
runLogin,
respondWithLogin,
respondWithRedirectLogin,
verifySecondFactorAndRespond,
resetPendingLoginsForTests,
} = await import("../../auth/login-pipeline.js");
const { verifyPasswordLogin } =
await import("../../auth/builtin-login-methods.js");
const { getPasswordLoginStatus } = await import("../../auth/core-auth.js");
const { registerLoginMethod, registerSecondFactor } =
await import("../../auth/registry.js");
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
const PASSWORD = "correct-horse";
const totpSecret = speakeasy.generateSecret({ name: "test" }).base32;
function addUser(
overrides: Partial<import("./auth-test-helpers.js").FakeUser> = {},
) {
const user = {
id: "u1",
username: "alice",
passwordHash: bcrypt.hashSync(PASSWORD, 4),
isAdmin: false,
isOidc: false,
totpEnabled: false,
...overrides,
};
h.state.users.set(user.id, user);
return user;
}
beforeEach(() => {
h.state = createAuthState();
h.manager = fakeAuthManager(h.state);
h.trustedProxy = false;
resetPendingLoginsForTests();
loginRateLimiter.resetAttempts("10.0.0.1", "alice");
loginRateLimiter.resetTOTPAttempts("u1");
delete process.env.ALLOW_PASSWORD_LOGIN;
});
async function passwordLogin(body: Record<string, unknown>, headers = {}) {
const req = fakeRequest({ body, headers });
const res = fakeResponse();
const identity = await verifyPasswordLogin(req as never);
await respondWithLogin(req as never, res as never, identity, {
methodId: "password",
rememberMe: !!body.rememberMe,
});
return res;
}
describe("password login through the pipeline", () => {
it("issues the same JWT, cookie, audit and body the old route did", async () => {
addUser({ isAdmin: true });
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.authenticateUser).toHaveBeenCalledWith(
"u1",
PASSWORD,
"web",
);
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({
rememberMe: false,
deviceType: "web",
deviceInfo: expect.any(String),
}),
);
// Session timeout default: 24 hours.
expect(res.cookies).toEqual([
{
name: "jwt",
value: "jwt-1",
options: { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 },
},
]);
expect(res.body).toMatchObject({
success: true,
is_admin: true,
username: "alice",
});
expect(res.body).not.toHaveProperty("token");
expect(h.state.audits).toEqual([
expect.objectContaining({
userId: "u1",
username: "alice",
action: "login",
resourceType: "session",
success: true,
}),
]);
});
it("remembers the session for 30 days and follows session_timeout_hours otherwise", async () => {
addUser();
const remembered = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
expect((remembered.cookies[0].options as { maxAge: number }).maxAge).toBe(
30 * 24 * 60 * 60 * 1000,
);
h.state.settings.set("session_timeout_hours", "2");
const short = await passwordLogin({
username: "alice",
password: PASSWORD,
});
expect((short.cookies[0].options as { maxAge: number }).maxAge).toBe(
2 * 60 * 60 * 1000,
);
});
it("returns the token in the body for the desktop and mobile apps", async () => {
addUser();
const res = await passwordLogin(
{ username: "alice", password: PASSWORD },
{ "x-electron-app": "true" },
);
expect(res.body).toMatchObject({ token: "jwt-1" });
});
it("rejects a wrong password with the old status and message", async () => {
addUser();
await expect(
verifyPasswordLogin(
fakeRequest({ body: { username: "alice", password: "nope" } }) as never,
),
).rejects.toMatchObject({
status: 401,
message: "Invalid username or password",
});
expect(h.state.audits).toEqual([]);
});
it("refuses a user whose data key does not open with the password", async () => {
addUser();
h.manager.authenticateUser.mockResolvedValueOnce(false);
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
});
const identity = await verifyPasswordLogin(req as never);
await expect(
runLogin(req as never, identity, {
methodId: "password",
rememberMe: false,
}),
).rejects.toMatchObject({ status: 401, message: "Incorrect password" });
});
});
describe("second factors", () => {
function enrolTotp() {
addUser({
totpEnabled: true,
totpSecret,
totpBackupCodes: JSON.stringify(["BACKUP01"]),
});
h.state.factors.push({ userId: "u1", pluginId: "core", factorId: "totp" });
}
it("stops for TOTP with the pending token the old route returned", async () => {
enrolTotp();
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(res.cookies).toEqual([]);
expect(res.body).toMatchObject({
success: true,
requires_totp: true,
temp_token: "pending-1",
rememberMe: false,
second_factors: [
expect.objectContaining({ id: "totp", pluginId: "core" }),
],
});
expect(h.manager.generateJWTToken).toHaveBeenCalledWith("u1", {
pendingTOTP: true,
expiresIn: "10m",
});
expect(h.state.audits).toEqual([]);
});
it("finishes the login with a valid code and trusts the device when remembered", async () => {
enrolTotp();
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
const tempToken = (first.body as { temp_token: string }).temp_token;
const req = fakeRequest({
body: {
temp_token: tempToken,
totp_code: speakeasy.totp({ secret: totpSecret, encoding: "base32" }),
rememberMe: true,
},
});
const res = fakeResponse();
await verifySecondFactorAndRespond(req as never, res as never, "totp");
expect(res.statusCode).toBe(200);
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
expect(res.body).toMatchObject({
success: true,
username: "alice",
totp_enabled: true,
});
expect(h.state.trustedAdded).toHaveLength(1);
expect(h.state.audits.at(-1)).toMatchObject({ action: "login" });
});
it("accepts a backup code once, stored encrypted", async () => {
enrolTotp();
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
const tempToken = (first.body as { temp_token: string }).temp_token;
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: { temp_token: tempToken, totp_code: "BACKUP01" },
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(200);
// Re-encrypted after use, and the code is gone.
expect(h.state.users.get("u1")!.totpBackupCodes).not.toContain("BACKUP01");
});
it("rejects a wrong code without a session", async () => {
enrolTotp();
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
totp_code: "000000",
},
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(401);
expect(res.cookies).toEqual([]);
});
it("skips the factor on a trusted device", async () => {
enrolTotp();
h.manager.isTrustedDevice.mockResolvedValueOnce(true);
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
});
it("fails closed when an enrolled factor's plugin is gone", async () => {
addUser();
h.state.factors.push({
userId: "u1",
pluginId: "gone",
factorId: "yubikey",
});
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
});
const identity = await verifyPasswordLogin(req as never);
await expect(
runLogin(req as never, identity, {
methodId: "password",
rememberMe: false,
}),
).rejects.toMatchObject({ status: 403, code: "second_factor_unavailable" });
expect(h.manager.generateJWTToken).not.toHaveBeenCalled();
expect(h.state.audits).toEqual([
expect.objectContaining({
action: "login_blocked_second_factor",
success: false,
}),
]);
});
it("runs a plugin's second factor", async () => {
addUser();
h.state.factors.push({
userId: "u1",
pluginId: "fixture",
factorId: "pin",
});
const dispose = registerSecondFactor({
id: "pin",
pluginId: "fixture",
labelKey: "fixture.pin",
isEnrolled: async () => true,
verify: async (_userId, body) => body.pin === "1234",
});
try {
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
});
expect(first.body).toMatchObject({
second_factors: [expect.objectContaining({ id: "pin" })],
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
pin: "1234",
},
}) as never,
res as never,
"pin",
);
expect(res.statusCode).toBe(200);
} finally {
dispose();
}
});
it("lets a method that already proved a factor skip the step", async () => {
enrolTotp();
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
);
expect(result.kind).toBe("session");
});
});
describe("external identities", () => {
const external = {
kind: "external" as const,
provider: "3",
subject: "sub-1",
email: "a@example.com",
name: "Alice",
legacyIdentifier: "sub-1",
ssoProviderId: 3,
};
it("provisions the first user as admin and links the identity", async () => {
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("session");
const [user] = [...h.state.users.values()];
expect(user).toMatchObject({
username: "Alice",
isAdmin: true,
isOidc: true,
});
expect(h.state.identities).toEqual([
expect.objectContaining({
userId: user.id,
providerId: "3",
subject: "sub-1",
}),
]);
expect(h.state.roles).toContainEqual({
userId: user.id,
roleName: "admin",
});
});
it("refuses a new user when provisioning is off, and one outside the allowed list", async () => {
addUser({ id: "someone", username: "someone" });
await expect(
runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
}),
).rejects.toMatchObject({ code: "registration_disabled" });
h.state.settings.set("oidc_auto_provision", "true");
await expect(
runLogin(
fakeRequest() as never,
{ ...external, allowedUsers: "@other.org" },
{ methodId: "oidc", rememberMe: false },
),
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it("re-checks the allowed list for existing users", async () => {
addUser({ id: "u9", username: "Alice", isOidc: true, passwordHash: "" });
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
await expect(
runLogin(
fakeRequest() as never,
{ ...external, allowedUsers: "@other.org" },
{ methodId: "oidc", rememberMe: false },
),
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it("finds an account from before the identity table by its old identifier", async () => {
addUser({
id: "old",
username: "ldap-bob",
isOidc: true,
passwordHash: "",
oidcIdentifier: "ldap:4:bob",
});
const result = await runLogin(
fakeRequest() as never,
{
kind: "external",
provider: "4",
subject: "bob",
name: "Bob",
legacyIdentifier: "ldap:4:bob",
ssoProviderId: 4,
},
{ methodId: "ldap", rememberMe: false },
);
expect(result.kind).toBe("session");
expect(h.state.users.size).toBe(1);
expect(h.state.identities).toEqual([
expect.objectContaining({
userId: "old",
providerId: "4",
subject: "bob",
}),
]);
});
it("keeps admin in step with the provider's admin group", async () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
isAdmin: true,
});
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
await runLogin(
fakeRequest() as never,
{ ...external, isAdmin: false },
{ methodId: "oidc", rememberMe: false },
);
expect(h.state.users.get("u9")!.isAdmin).toBe(false);
});
it("runs enrolled second factors for SSO users too", async () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
totpEnabled: true,
totpSecret,
});
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("second-factor");
});
});
describe("redirect logins", () => {
const identity = {
kind: "external" as const,
provider: "3",
subject: "sub-1",
name: "Alice",
returnTo: "https://termix.example",
ssoProviderId: 3,
oidcSub: "sub-1",
oidcSid: "sid-1",
};
it("sets the cookie and redirects with success, carrying the SSO claims", async () => {
const res = fakeResponse();
await respondWithRedirectLogin(
fakeRequest() as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
oidcSub: "sub-1",
oidcSid: "sid-1",
ssoProviderId: 3,
}),
);
});
it("puts the token in the URL for app callbacks", async () => {
const res = fakeResponse();
await respondWithRedirectLogin(
fakeRequest() as never,
res as never,
{ ...identity, returnTo: "http://localhost:4567/oidc-callback" },
{ methodId: "oidc", rememberMe: false },
() => true,
);
expect(res.redirectedTo).toMatch(/success=true/);
expect(res.redirectedTo).toMatch(/token=jwt-/);
expect(res.cookies).toEqual([]);
});
it("sends a second-factor step back with a pending cookie", async () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
totpEnabled: true,
totpSecret,
});
h.state.identities.push({
id: 1,
userId: "u9",
providerId: "3",
subject: "sub-1",
email: null,
});
const res = fakeResponse();
await respondWithRedirectLogin(
fakeRequest() as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toContain("second_factor=1");
expect(res.redirectedTo).toContain("second_factors=totp");
expect(res.cookies[0]).toMatchObject({ name: "termix_pending_login" });
// The pending login keeps the SSO claims for the session.
const verify = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
cookies: { termix_pending_login: res.cookies[0].value },
body: {
totp_code: speakeasy.totp({ secret: totpSecret, encoding: "base32" }),
},
}) as never,
verify as never,
"totp",
);
expect(verify.statusCode).toBe(200);
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
"u9",
expect.objectContaining({ oidcSid: "sid-1", ssoProviderId: 3 }),
);
});
it("redirects provisioning refusals with the old error codes", async () => {
addUser({ id: "someone", username: "someone" });
const res = fakeResponse();
await respondWithRedirectLogin(
fakeRequest() as never,
res as never,
identity,
{ methodId: "oidc", rememberMe: false },
() => false,
);
expect(res.redirectedTo).toBe(
"https://termix.example/?error=registration_disabled",
);
});
});
describe("lockout guard", () => {
it("keeps password login on, flagged forced, when nothing else can sign anyone in", async () => {
h.state.settings.set("allow_password_login", "false");
expect(await getPasswordLoginStatus()).toEqual({
allowed: true,
forced: true,
});
});
it("honours the setting when another method is enabled", async () => {
h.state.settings.set("allow_password_login", "false");
const dispose = registerLoginMethod({
id: "fixture-sso",
pluginId: "fixture",
labelKey: "fixture",
kind: "redirect",
describe: async () => [{ id: "1", label: "Fixture", enabled: true }],
});
try {
expect(await getPasswordLoginStatus()).toEqual({
allowed: false,
forced: false,
});
await expect(
verifyPasswordLogin(
fakeRequest({
body: { username: "alice", password: PASSWORD },
}) as never,
),
).rejects.toMatchObject({ status: 403 });
} finally {
dispose();
}
});
it("counts trusted proxy login as another way in", async () => {
h.state.settings.set("allow_password_login", "false");
h.trustedProxy = true;
expect(await getPasswordLoginStatus()).toEqual({
allowed: false,
forced: false,
});
});
});