feat: move sso and ldap logins into plugins (C2)

This commit is contained in:
LukeGus committed 2026-09-24 18:23:20 -05:00
1 parent 5c18435105
commit 925da4a661
223 files changed
+25778 -6820

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { providers } from "./tables.js";
import { createProviderStore } from "./providers.js";
import { createLdapLogin, METHOD_ID } from "./ldap.js";
import { registerLdapRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(providers);
const store = createProviderStore(ctx, table);
// One form per enabled directory.
ctx.auth.registerLoginMethod({
id: METHOD_ID,
labelKey: "loginWithLdap",
icon: "server",
kind: "form",
external: true,
describe: async () =>
(await store.listRows()).map((row) => ({
id: String(row.id),
label: row.name,
enabled: !!row.enabled,
type: "ldap",
})),
verify: createLdapLogin(ctx, store),
});
registerLdapRoutes(ctx.http.router<Router>(), ctx, store);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+205
View File
@@ -0,0 +1,205 @@
/**
* LDAP sign-in as a form login method: a service bind to find the user, a
* bind as the user to check the password, then an optional admin group
* lookup. Core finds or provisions the user and issues the session.
*/
import ldap from "ldapjs";
import {
LoginMethodError,
type PluginContext,
type PluginLoginRequest,
type PluginVerifiedIdentity,
} from "@termix/plugin-sdk/backend";
import type { ProviderStore } from "./providers.js";
import type { LdapConfig } from "./types.js";
export const METHOD_ID = "ldap";
export function ldapEscapeFilter(value: string): string {
return value.replace(
/[\\*()\x00]/g,
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
);
}
/** The provider id identities from this directory are stored under. */
export function identityProvider(providerId: number): string {
return `ldap:${providerId}`;
}
function createClient(config: LdapConfig): ldap.Client {
const useTLS = !!config.useTLS;
const url = `${useTLS ? "ldaps" : "ldap"}://${config.host}:${config.port || 389}`;
return ldap.createClient({
url,
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
});
}
function bind(client: ldap.Client, dn: string, password: string) {
return new Promise<void>((resolve, reject) => {
client.bind(dn, password, (err) => (err ? reject(err) : resolve()));
});
}
function search(
client: ldap.Client,
base: string,
filter: string,
attributes: string[],
): Promise<ldap.SearchEntry[]> {
return new Promise((resolve, reject) => {
const entries: ldap.SearchEntry[] = [];
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
if (err) return reject(err);
res.on("searchEntry", (entry) => entries.push(entry));
res.on("error", reject);
res.on("end", () => resolve(entries));
});
});
}
function unbind(client: ldap.Client): void {
try {
client.unbind();
} catch {
// best effort
}
}
function firstValue(entry: ldap.SearchEntry, key: string): string {
const attr = entry.attributes.find((a) => a.type === key);
if (!attr) return "";
return Array.isArray(attr.values) ? attr.values[0] : String(attr.values);
}
export function createLdapLogin(ctx: PluginContext, store: ProviderStore) {
return async function verify(
request: PluginLoginRequest,
instanceId: string | null,
): Promise<PluginVerifiedIdentity> {
const body = request.body ?? {};
const providerId = Number(instanceId ?? body.providerId);
const username = typeof body.username === "string" ? body.username : "";
const password = typeof body.password === "string" ? body.password : "";
if (!Number.isInteger(providerId) || !username || !password) {
throw new LoginMethodError(
"providerId, username, and password are required",
400,
);
}
// Same limits as password login: an LDAP bind is a password guess.
const clientIp = request.ip || "unknown";
const rateLimitKey = `${providerId}:${username}`;
const lock = await ctx.auth.loginRateLimit.isLocked(clientIp, rateLimitKey);
if (lock.locked) {
const error = new LoginMethodError(
"Too many login attempts. Please try again later.",
429,
);
Object.assign(error, { remainingTime: lock.remainingTime });
throw error;
}
const provider = await store.find(providerId);
if (!provider || !provider.enabled) {
throw new LoginMethodError("LDAP provider not found", 404);
}
const config = provider.config;
if (
!config.host ||
!config.bindDN ||
!config.userSearchBase ||
!config.userSearchFilter
) {
throw new LoginMethodError("LDAP provider is misconfigured", 500);
}
const refuse = async (reason: string) => {
await ctx.auth.loginRateLimit.recordFailure(clientIp, rateLimitKey);
ctx.log.warn(`LDAP login refused for ${username}: ${reason}`);
return new LoginMethodError("Invalid username or password", 401);
};
const serviceClient = createClient(config);
try {
await bind(serviceClient, config.bindDN, config.bindPassword);
const filter = config.userSearchFilter.replace(
/\{\{username\}\}/g,
ldapEscapeFilter(username),
);
const entries = await search(
serviceClient,
config.userSearchBase,
filter,
[
config.usernameAttribute || "uid",
config.displayNameAttribute || "cn",
"mail",
"email",
],
);
if (entries.length === 0) throw await refuse("user not found");
const userEntry = entries[0];
const userDN = userEntry.dn.toString();
const uid =
firstValue(userEntry, config.usernameAttribute || "uid") || username;
const displayName =
firstValue(userEntry, config.displayNameAttribute || "cn") || username;
const email =
firstValue(userEntry, "mail") || firstValue(userEntry, "email") || "";
const userClient = createClient(config);
try {
await bind(userClient, userDN, password);
} catch {
throw await refuse("wrong password");
} finally {
unbind(userClient);
}
let isAdmin: boolean | undefined;
if (config.adminGroup && config.groupSearchBase) {
isAdmin = false;
try {
const groups = await search(
serviceClient,
config.groupSearchBase,
`(member=${ldapEscapeFilter(userDN)})`,
["cn", "dn"],
);
isAdmin = groups.some(
(group) =>
firstValue(group, "cn") === config.adminGroup ||
group.dn.toString() === config.adminGroup,
);
} catch (error) {
ctx.log.warn(`LDAP group check failed: ${String(error)}`);
}
}
return {
kind: "external",
provider: identityProvider(providerId),
subject: uid,
email: email || null,
name: displayName,
isAdmin,
allowedUsers: config.allowedUsers ?? null,
legacy: {
identifier: `ldap:${providerId}:${uid}`,
providerRowId: providerId,
},
rememberMe: !!body.rememberMe,
rateLimitKey,
};
} finally {
unbind(serviceClient);
}
};
}
+141
View File
@@ -0,0 +1,141 @@
import { and, asc, desc, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { LdapConfig, ProviderRow } from "./types.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands the table back untyped and the drizzle handle is the
// server's own, so both are typed at this module's edge.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
const LEGACY_PREFIXES = ["encoded:", "encrypted:"];
export type ProviderStore = ReturnType<typeof createProviderStore>;
export function createProviderStore(ctx: PluginContext, table: Table) {
const client = () => ctx.db.client<Drizzle>();
/** Opens the bind password: sealed, or the 2.8 base64 forms. */
async function openSecret(value: string): Promise<string> {
const prefix = LEGACY_PREFIXES.find((p) => value.startsWith(p));
if (prefix) {
return Buffer.from(value.slice(prefix.length), "base64").toString("utf8");
}
return (await ctx.secrets.unseal(value)) ?? value;
}
async function parseConfig(raw: string): Promise<LdapConfig> {
let config: Record<string, unknown>;
try {
config = JSON.parse(raw);
} catch {
config = {};
}
if (typeof config.bindPassword === "string" && config.bindPassword) {
config.bindPassword = await openSecret(config.bindPassword);
}
return config as unknown as LdapConfig;
}
async function sealConfig(config: Record<string, unknown>): Promise<string> {
const out = { ...config };
if (typeof out.bindPassword === "string" && out.bindPassword) {
out.bindPassword = await ctx.secrets.seal(out.bindPassword);
}
return JSON.stringify(out);
}
async function findRow(id: number): Promise<ProviderRow | null> {
const drizzle = await client();
const rows = (await drizzle
.select()
.from(table)
.where(eq(table.id, id))
.limit(1)) as ProviderRow[];
return rows[0] ?? null;
}
return {
parseConfig,
findRow,
async listRows(): Promise<ProviderRow[]> {
const drizzle = await client();
return (await drizzle
.select()
.from(table)
.orderBy(asc(table.displayOrder), asc(table.id))) as ProviderRow[];
},
async find(id: number): Promise<{
row: ProviderRow;
enabled: boolean;
config: LdapConfig;
} | null> {
const row = await findRow(id);
if (!row) return null;
return {
row,
enabled: !!row.enabled,
config: await parseConfig(row.config),
};
},
async create(input: {
name: string;
enabled: boolean;
displayOrder: number;
config: Record<string, unknown>;
}): Promise<ProviderRow> {
const drizzle = await client();
const now = new Date().toISOString();
await drizzle.insert(table).values({
name: input.name,
enabled: input.enabled,
displayOrder: input.displayOrder,
config: await sealConfig(input.config),
createdAt: now,
updatedAt: now,
});
await ctx.db.persist();
// No returning() on every engine: find the row by what was written.
const rows = (await drizzle
.select()
.from(table)
.where(and(eq(table.name, input.name), eq(table.createdAt, now)))
.orderBy(desc(table.id))
.limit(1)) as ProviderRow[];
return rows[0];
},
async update(
id: number,
values: Partial<{
name: string;
enabled: boolean;
displayOrder: number;
config: Record<string, unknown>;
}>,
): Promise<ProviderRow | null> {
const drizzle = await client();
const { config, ...rest } = values;
await drizzle
.update(table)
.set({
...rest,
...(config ? { config: await sealConfig(config) } : {}),
updatedAt: new Date().toISOString(),
})
.where(eq(table.id, id));
await ctx.db.persist();
return findRow(id);
},
async remove(id: number): Promise<void> {
const drizzle = await client();
await drizzle.delete(table).where(eq(table.id, id));
await ctx.db.persist();
},
};
}
+265
View File
@@ -0,0 +1,265 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { identityProvider } from "./ldap.js";
import type { ProviderStore } from "./providers.js";
import { REQUIRED_FIELDS, type ProviderRow } from "./types.js";
function fail(
ctx: PluginContext,
res: Response,
message: string,
error: unknown,
) {
ctx.log.error(
message,
error instanceof Error ? error : new Error(String(error)),
);
res.status(500).json({ error: message });
}
function missingFields(config: Record<string, unknown>): string[] {
return REQUIRED_FIELDS.filter((field) => !config[field]);
}
export function registerLdapRoutes(
router: Router,
ctx: PluginContext,
store: ProviderStore,
): void {
router.use(ctx.rbac.require("manage") as never);
async function present(row: ProviderRow) {
const { bindPassword, ...config } = (await store.parseConfig(
row.config,
)) as unknown as Record<string, unknown>;
return {
id: row.id,
name: row.name,
enabled: !!row.enabled,
displayOrder: row.displayOrder,
config,
hasBindPassword: typeof bindPassword === "string" && !!bindPassword,
};
}
/**
* @openapi
* /plugin-api/ldap/providers:
* get:
* summary: List LDAP directories
* description: Every configured LDAP directory. The bind password is never sent, only whether one is set. Needs ldap.manage.
* tags:
* - LDAP
* responses:
* 200:
* description: Directories.
* 403:
* description: Missing permission.
*/
router.get("/providers", async (_req: Request, res: Response) => {
try {
const rows = await store.listRows();
res.json({ providers: await Promise.all(rows.map(present)) });
} catch (error) {
fail(ctx, res, "Failed to list LDAP providers", error);
}
});
/**
* @openapi
* /plugin-api/ldap/providers:
* post:
* summary: Add an LDAP directory
* description: Needs ldap.manage.
* tags:
* - LDAP
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* enabled:
* type: boolean
* config:
* type: object
* responses:
* 201:
* description: Directory added.
* 400:
* description: Missing fields.
* 403:
* description: Missing permission.
*/
router.post("/providers", async (req: Request, res: Response) => {
try {
const {
name,
enabled = true,
displayOrder = 0,
config = {},
} = req.body ?? {};
if (typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "Provider name is required" });
return;
}
const missing = missingFields(config);
if (missing.length > 0) {
res.status(400).json({
error: `Missing required LDAP fields: ${missing.join(", ")}`,
});
return;
}
const row = await store.create({
name: name.trim(),
enabled: !!enabled,
displayOrder: Number(displayOrder) || 0,
config: { ...(config as Record<string, unknown>) },
});
await ctx.audit.record({
action: "ldap_provider_create",
resourceType: "ldap_provider",
resourceId: String(row.id),
resourceName: row.name,
success: true,
});
res.status(201).json(await present(row));
} catch (error) {
fail(ctx, res, "Failed to create LDAP provider", error);
}
});
/**
* @openapi
* /plugin-api/ldap/providers/{id}:
* put:
* summary: Update an LDAP directory
* description: Config fields are merged over the stored ones; an empty bind password keeps the stored one. Needs ldap.manage.
* tags:
* - LDAP
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Directory updated.
* 400:
* description: Missing fields.
* 403:
* description: Missing permission.
* 404:
* description: Directory not found.
*/
router.put("/providers/:id", async (req: Request, res: Response) => {
try {
const id = Number.parseInt(String(req.params.id), 10);
const row = Number.isInteger(id) ? await store.findRow(id) : null;
if (!row) {
res.status(404).json({ error: "Provider not found" });
return;
}
const { name, enabled, displayOrder, config } = req.body ?? {};
const values: Parameters<ProviderStore["update"]>[1] = {};
if (name !== undefined) {
if (typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "Provider name is required" });
return;
}
values.name = name.trim();
}
if (enabled !== undefined) values.enabled = !!enabled;
if (displayOrder !== undefined) {
values.displayOrder = Number(displayOrder) || 0;
}
if (config && typeof config === "object") {
const incoming = { ...(config as Record<string, unknown>) };
if (!incoming.bindPassword) delete incoming.bindPassword;
const merged = {
...((await store.parseConfig(row.config)) as unknown as Record<
string,
unknown
>),
...incoming,
};
const missing = missingFields(merged);
if (missing.length > 0) {
res.status(400).json({
error: `Missing required LDAP fields: ${missing.join(", ")}`,
});
return;
}
values.config = merged;
}
const updated = await store.update(id, values);
await ctx.audit.record({
action: "ldap_provider_update",
resourceType: "ldap_provider",
resourceId: String(id),
resourceName: updated?.name ?? row.name,
success: true,
});
res.json(await present(updated ?? row));
} catch (error) {
fail(ctx, res, "Failed to update LDAP provider", error);
}
});
/**
* @openapi
* /plugin-api/ldap/providers/{id}:
* delete:
* summary: Delete an LDAP directory
* description: Refused while users still sign in with it. Needs ldap.manage.
* tags:
* - LDAP
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Directory deleted.
* 403:
* description: Missing permission.
* 404:
* description: Directory not found.
* 409:
* description: Users are still linked to it.
*/
router.delete("/providers/:id", async (req: Request, res: Response) => {
try {
const id = Number.parseInt(String(req.params.id), 10);
const row = Number.isInteger(id) ? await store.findRow(id) : null;
if (!row) {
res.status(404).json({ error: "Provider not found" });
return;
}
if ((await ctx.auth.countLinkedUsers(identityProvider(id))) > 0) {
res.status(409).json({
error:
"Users still sign in with this directory. Disable it instead, or remove those users first.",
});
return;
}
await store.remove(id);
await ctx.audit.record({
action: "ldap_provider_delete",
resourceType: "ldap_provider",
resourceId: String(id),
resourceName: row.name,
success: true,
});
res.json({ message: "LDAP provider deleted" });
} catch (error) {
fail(ctx, res, "Failed to delete LDAP provider", error);
}
});
}
+24
View File
@@ -0,0 +1,24 @@
import {
boolean,
defineTable,
id,
integer,
text,
timestamp,
} from "@termix/plugin-sdk/db";
/**
* LDAP directories. Rows from 2.8 are copied in from sso_providers by core's
* boot migration, keeping their ids, because identities point at them.
*/
export const providers = defineTable("providers", {
id: id(),
name: text().notNull(),
enabled: boolean().notNull().default(true),
displayOrder: integer().notNull().default(0),
config: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
});
export const tables = [providers];
+34
View File
@@ -0,0 +1,34 @@
export interface LdapConfig {
host: string;
port: number;
useTLS: boolean;
bindDN: string;
bindPassword: string;
userSearchBase: string;
userSearchFilter: string;
usernameAttribute: string;
displayNameAttribute: string;
groupSearchBase?: string;
adminGroup?: string;
allowedUsers?: string;
}
export interface ProviderRow {
id: number;
name: string;
enabled: boolean;
displayOrder: number;
config: string;
createdAt: string;
updatedAt: string;
}
export const REQUIRED_FIELDS = [
"host",
"port",
"bindDN",
"bindPassword",
"userSearchBase",
"userSearchFilter",
"usernameAttribute",
] as const;