mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: move sso and ldap logins into plugins (C2)
This commit is contained in:
1 parent
5c18435105
commit
925da4a661
223 files changed
+25778
-6820
No files matched your search
@@ -0,0 +1,125 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: {
|
||||
user?: string;
|
||||
body?: unknown;
|
||||
form?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any; location: string | null }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
export async function startServer(
|
||||
options: {
|
||||
capabilities?: string[];
|
||||
permissions?: string[];
|
||||
linkedUsers?: Record<string, number>;
|
||||
loginAttemptLimit?: number;
|
||||
before?: (sqlite: TestDb["sqlite"]) => void;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir, { before: options.before });
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username, is_admin) VALUES (?, ?, 1)")
|
||||
.run("admin", "admin");
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: options.capabilities ?? manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions,
|
||||
linkedUsers: options.linkedUsers,
|
||||
loginAttemptLimit: options.loginAttemptLimit,
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") || undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(
|
||||
method,
|
||||
path,
|
||||
{ user = "admin", body, form, headers = {} } = {},
|
||||
) {
|
||||
const payload =
|
||||
form !== undefined
|
||||
? new URLSearchParams(form).toString()
|
||||
: body !== undefined && method !== "GET"
|
||||
? JSON.stringify(body)
|
||||
: undefined;
|
||||
const response = await realFetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(form !== undefined
|
||||
? { "content-type": "application/x-www-form-urlencoded" }
|
||||
: payload !== undefined
|
||||
? { "content-type": "application/json" }
|
||||
: {}),
|
||||
...headers,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
body: parsed,
|
||||
location: response.headers.get("location"),
|
||||
};
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers.js";
|
||||
|
||||
const directory = vi.hoisted(() => ({
|
||||
users: {} as Record<
|
||||
string,
|
||||
{ dn: string; password: string; attrs: Record<string, string> }
|
||||
>,
|
||||
adminDns: [] as string[],
|
||||
servicePassword: "svc",
|
||||
binds: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("ldapjs", () => {
|
||||
function createClient() {
|
||||
return {
|
||||
bind: (dn: string, password: string, cb: (err?: Error) => void) => {
|
||||
directory.binds.push(dn);
|
||||
if (dn === "cn=service" && password === directory.servicePassword) {
|
||||
return cb();
|
||||
}
|
||||
const user = Object.values(directory.users).find((u) => u.dn === dn);
|
||||
if (user && user.password === password) 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> = {};
|
||||
cb(null, {
|
||||
on: (event: string, fn: (value?: unknown) => void) => {
|
||||
listeners[event] = fn;
|
||||
},
|
||||
});
|
||||
queueMicrotask(() => {
|
||||
if (base === "ou=groups") {
|
||||
for (const dn of directory.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 ? directory.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?.();
|
||||
});
|
||||
},
|
||||
unbind: () => {},
|
||||
};
|
||||
}
|
||||
return { default: { createClient }, createClient };
|
||||
});
|
||||
|
||||
const CONFIG = {
|
||||
host: "ldap.example",
|
||||
port: 389,
|
||||
bindDN: "cn=service",
|
||||
bindPassword: "svc",
|
||||
userSearchBase: "ou=people",
|
||||
userSearchFilter: "(uid={{username}})",
|
||||
usernameAttribute: "uid",
|
||||
displayNameAttribute: "cn",
|
||||
groupSearchBase: "ou=groups",
|
||||
adminGroup: "admins",
|
||||
allowedUsers: "@example.com",
|
||||
};
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
directory.users = {
|
||||
bob: {
|
||||
dn: "uid=bob,ou=people",
|
||||
password: "hunter2",
|
||||
attrs: { uid: "bob", cn: "Bob Builder", mail: "bob@example.com" },
|
||||
},
|
||||
};
|
||||
directory.adminDns = [];
|
||||
directory.binds = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function withDirectory(options: Parameters<typeof startServer>[0] = {}) {
|
||||
server = await startServer(options);
|
||||
const created = await server.request("POST", "/providers", {
|
||||
body: { name: "Corp LDAP", config: CONFIG },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
return { s: server, id: created.body.id as number };
|
||||
}
|
||||
|
||||
function verify(
|
||||
s: TestServer,
|
||||
instance: string,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
const method = s.mock.auth.loginMethods[0];
|
||||
return method.verify!(
|
||||
{ body, query: {}, headers: {}, ip: "10.0.0.1" },
|
||||
instance,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LDAP sign-in", () => {
|
||||
it("binds as the service, then as the user, and returns the identity", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
directory.adminDns = ["uid=bob,ou=people"];
|
||||
const identity = await verify(s, String(id), {
|
||||
username: "bob",
|
||||
password: "hunter2",
|
||||
rememberMe: true,
|
||||
});
|
||||
expect(identity).toEqual({
|
||||
kind: "external",
|
||||
provider: `ldap:${id}`,
|
||||
subject: "bob",
|
||||
email: "bob@example.com",
|
||||
name: "Bob Builder",
|
||||
isAdmin: true,
|
||||
allowedUsers: "@example.com",
|
||||
legacy: { identifier: `ldap:${id}:bob`, providerRowId: id },
|
||||
rememberMe: true,
|
||||
rateLimitKey: `${id}:bob`,
|
||||
});
|
||||
expect(directory.binds).toEqual(["cn=service", "uid=bob,ou=people"]);
|
||||
});
|
||||
|
||||
it("is not an admin when the directory says so", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
const identity = await verify(s, String(id), {
|
||||
username: "bob",
|
||||
password: "hunter2",
|
||||
});
|
||||
expect(identity).toMatchObject({ isAdmin: false });
|
||||
});
|
||||
|
||||
it("refuses a wrong password and an unknown user the same way", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob", password: "nope" }),
|
||||
).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: "Invalid username or password",
|
||||
});
|
||||
await expect(
|
||||
verify(s, String(id), { username: "mallory", password: "x" }),
|
||||
).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: "Invalid username or password",
|
||||
});
|
||||
expect(s.mock.auth.loginFailures.get(`10.0.0.1|${id}:bob`)).toBe(1);
|
||||
});
|
||||
|
||||
it("locks out after too many failures, like password login", async () => {
|
||||
const { s, id } = await withDirectory({ loginAttemptLimit: 2 });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob", password: "nope" }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
}
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob", password: "hunter2" }),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
});
|
||||
|
||||
it("escapes the username in the search filter", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob)(uid=*", password: "hunter2" }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it("refuses a disabled or unknown directory", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
await s.request("PUT", `/providers/${id}`, { body: { enabled: false } });
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob", password: "hunter2" }),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
await expect(
|
||||
verify(s, "999", { username: "bob", password: "hunter2" }),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
expect(await s.mock.auth.loginMethods[0].describe!()).toEqual([
|
||||
{ id: String(id), label: "Corp LDAP", enabled: false, type: "ldap" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("needs a directory, a username and a password", async () => {
|
||||
const { s } = await withDirectory();
|
||||
await expect(
|
||||
verify(s, null as never, { username: "bob" }),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory admin routes", () => {
|
||||
it("seal the bind password and never send it back", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
const stored = s.db.sqlite
|
||||
.prepare("SELECT config FROM p_ldap_providers WHERE id = ?")
|
||||
.get(id) as { config: string };
|
||||
expect(JSON.parse(stored.config).bindPassword).toMatch(/^sealed:/);
|
||||
|
||||
const listed = await s.request("GET", "/providers");
|
||||
expect(listed.body.providers[0]).toMatchObject({
|
||||
name: "Corp LDAP",
|
||||
hasBindPassword: true,
|
||||
});
|
||||
expect(listed.body.providers[0].config.bindPassword).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keep the bind password when an edit leaves it empty", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
const updated = await s.request("PUT", `/providers/${id}`, {
|
||||
body: { config: { host: "ldap2.example", bindPassword: "" } },
|
||||
});
|
||||
expect(updated.body).toMatchObject({
|
||||
hasBindPassword: true,
|
||||
config: { host: "ldap2.example" },
|
||||
});
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob", password: "hunter2" }),
|
||||
).resolves.toMatchObject({ subject: "bob" });
|
||||
});
|
||||
|
||||
it("reject a directory with missing fields", async () => {
|
||||
server = await startServer();
|
||||
const response = await server.request("POST", "/providers", {
|
||||
body: { name: "X", config: { host: "a" } },
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("will not delete a directory people still sign in with", async () => {
|
||||
const { s, id } = await withDirectory({
|
||||
linkedUsers: { "ldap:1": 1 },
|
||||
});
|
||||
expect(id).toBe(1);
|
||||
expect((await s.request("DELETE", `/providers/${id}`)).status).toBe(409);
|
||||
});
|
||||
|
||||
it("delete a directory nobody uses", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
expect((await s.request("DELETE", `/providers/${id}`)).status).toBe(200);
|
||||
expect(await s.mock.auth.loginMethods[0].describe!()).toEqual([]);
|
||||
});
|
||||
|
||||
it("need ldap.manage", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
for (const [method, path] of [
|
||||
["GET", "/providers"],
|
||||
["POST", "/providers"],
|
||||
["PUT", "/providers/1"],
|
||||
["DELETE", "/providers/1"],
|
||||
]) {
|
||||
expect((await server.request(method, path, { body: {} })).status).toBe(
|
||||
403,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities", () => {
|
||||
it.each(["db:own", "auth:provide", "network:serve"])(
|
||||
"activate fails closed without %s",
|
||||
async (capability) => {
|
||||
const { manifest } = await import("./helpers.js");
|
||||
await expect(
|
||||
startServer({
|
||||
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("cannot read a bind password without secrets:own", async () => {
|
||||
const { manifest } = await import("./helpers.js");
|
||||
server = await startServer({
|
||||
capabilities: manifest.capabilities.filter((c) => c !== "secrets:own"),
|
||||
});
|
||||
const response = await server.request("POST", "/providers", {
|
||||
body: { name: "Corp LDAP", config: CONFIG },
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user