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

@@ -4,7 +4,8 @@
* users.oidc_identifier packed provider and subject into one string:
* "ldap:<provider>:<id>", "github:<provider>:<id>", or a bare OIDC subject
* whose provider was users.sso_provider_id. Each becomes a
* user_external_identities row. TOTP enrolment moves in totp-migration.ts.
* user_external_identities row; LDAP ones under provider "ldap:<provider>",
* the id the ldap plugin signs in with. TOTP enrolment moves in totp-migration.ts.
*
* Lossless: the old column is left as it is. Idempotent: rows that already
* exist are skipped, so it runs on every boot.
@@ -16,7 +17,7 @@ import {
createCurrentUserRepository,
} from "../../database/repositories/factory.js";
/** Matches LEGACY_OIDC_PROVIDER_ID in auth/legacy/oidc-login.ts. */
/** The provider id the sso plugin gives its env-configured provider. */
const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
export interface ExternalIdentityMigrationResult {
@@ -34,11 +35,16 @@ export function parseLegacyIdentifier(
const prefixed = /^(ldap|github):([^:]+):(.+)$/.exec(identifier);
if (prefixed) {
const provider = prefixed[2];
const missing = provider === "null" || provider === "undefined";
// LDAP providers moved to their own table in 2.9 and their identities
// carry the "ldap:" prefix so their ids cannot clash with SSO ones.
if (prefixed[1] === "ldap") {
return missing
? null
: { providerId: `ldap:${provider}`, subject: prefixed[3] };
}
return {
providerId:
provider === "null" || provider === "undefined"
? LEGACY_OIDC_PROVIDER_ID
: provider,
providerId: missing ? LEGACY_OIDC_PROVIDER_ID : provider,
subject: prefixed[3],
};
}
@@ -0,0 +1,145 @@
/**
* Moves 2.8 LDAP directories into the ldap plugin.
*
* 2.8 kept LDAP and SSO providers in one sso_providers table. The sso plugin
* adopts that table (as p_sso_providers) and ignores its LDAP rows; this
* copies them into p_ldap_providers once the ldap plugin has created it, then
* deletes them from the source.
*
* Ids are kept, because users.oidc_identifier ("ldap:<id>:<uid>") and their
* identities ("ldap:<id>") name them. Bare "<id>" identities from before the
* prefix are renamed too. It runs before the ldap plugin first activates, so
* the plugin cannot have used an id yet; if it somehow has, the old row gets
* a fresh id and only its bare identities follow it.
*
* Needs neither plugin to be running, only their tables. Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import { resolveDatabaseDialect } from "../../database/db/dialect.js";
import { runStatement, selectRows } from "./raw-rows.js";
const TARGET = "p_ldap_providers";
const SOURCES = ["p_sso_providers", "sso_providers"];
interface LegacyRow {
id: number;
name: string;
enabled: unknown;
display_order: number;
config: string;
created_at: string;
updated_at: string;
}
export interface LdapProviderMigrationResult {
/** Directories copied into the plugin's table. */
moved: number;
/** Of those, how many needed a new id. */
renumbered: number;
}
async function tableExists(name: string): Promise<boolean> {
try {
await selectRows(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
return true;
} catch {
return false;
}
}
function isOn(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "t";
}
async function renameIdentities(from: string, to: string): Promise<void> {
await runStatement(
sql`UPDATE user_external_identities SET provider_id = ${to} WHERE provider_id = ${from}`,
);
}
/** Explicit ids leave a Postgres sequence behind them. */
async function syncSequence(): Promise<void> {
if (resolveDatabaseDialect() !== "postgres") return;
await runStatement(
sql`SELECT setval(pg_get_serial_sequence(${TARGET}, 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${sql.identifier(TARGET)}))`,
);
}
async function insertRow(row: LegacyRow, id: number | null): Promise<number> {
const enabled = isOn(row.enabled);
const values = sql`${row.name}, ${resolveDatabaseDialect() === "sqlite" ? (enabled ? 1 : 0) : enabled}, ${row.display_order ?? 0}, ${row.config}, ${row.created_at}, ${row.updated_at}`;
if (id !== null) {
await runStatement(
sql`INSERT INTO ${sql.identifier(TARGET)} (id, name, enabled, display_order, config, created_at, updated_at) VALUES (${id}, ${values})`,
);
return id;
}
await runStatement(
sql`INSERT INTO ${sql.identifier(TARGET)} (name, enabled, display_order, config, created_at, updated_at) VALUES (${values})`,
);
const [inserted] = await selectRows<{ id: number }>(
sql`SELECT MAX(id) AS id FROM ${sql.identifier(TARGET)}`,
);
return Number(inserted.id);
}
export async function runLdapProviderMigration(): Promise<LdapProviderMigrationResult> {
const result: LdapProviderMigrationResult = { moved: 0, renumbered: 0 };
try {
if (!(await tableExists(TARGET))) return result;
for (const source of SOURCES) {
if (!(await tableExists(source))) continue;
const rows = await selectRows<LegacyRow>(
sql`SELECT id, name, enabled, display_order, config, created_at, updated_at FROM ${sql.identifier(source)} WHERE type = 'ldap' ORDER BY id`,
);
// Rows whose id is free keep it; the rest go last, so a fresh id can
// never land on one that is still waiting to be copied.
const clashing: LegacyRow[] = [];
for (const row of rows) {
const taken = await selectRows(
sql`SELECT id FROM ${sql.identifier(TARGET)} WHERE id = ${row.id}`,
);
if (taken.length > 0) {
clashing.push(row);
continue;
}
await insertRow(row, row.id);
await renameIdentities(String(row.id), `ldap:${row.id}`);
await runStatement(
sql`DELETE FROM ${sql.identifier(source)} WHERE id = ${row.id}`,
);
result.moved++;
}
if (clashing.length > 0) await syncSequence();
for (const row of clashing) {
const id = await insertRow(row, null);
// "ldap:<id>" already names the plugin's own directory, so only the
// bare ids from before the prefix can follow this one.
await renameIdentities(String(row.id), `ldap:${id}`);
await runStatement(
sql`DELETE FROM ${sql.identifier(source)} WHERE id = ${row.id}`,
);
result.moved++;
result.renumbered++;
}
}
if (result.moved > 0) await syncSequence();
if (result.moved > 0) {
databaseLogger.info("Moved LDAP directories into the ldap plugin", {
operation: "ldap_provider_migration",
...result,
});
}
} catch (error) {
databaseLogger.error("LDAP directory migration failed", error, {
operation: "ldap_provider_migration_failed",
});
}
return result;
}
@@ -0,0 +1,12 @@
/**
* Core data that moves into a plugin's tables once they exist. Runs whenever
* a plugin's migrations applied something, at boot or when a plugin is
* enabled later, so the data is in place before the plugin's activate runs. Each move is
* idempotent and does nothing until its target table exists.
*/
import { runLdapProviderMigration } from "./ldap-provider-migration.js";
export async function runPluginDataMoves(): Promise<void> {
await runLdapProviderMigration();
}
+5 -9
View File
@@ -1,14 +1,10 @@
const CALLBACK_PATH = "/oidc-callback";
export function getDesktopOidcCallbackUrl(value: unknown): string | null {
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
return `http://localhost:${port}${CALLBACK_PATH}`;
}
/**
* Whether a redirect login's return address is the desktop app's loopback
* listener or the mobile app's scheme, which get the token in the URL
* because they cannot read a cookie.
*/
export function isOidcTokenCallback(value: string): boolean {
if (value.startsWith("termix-mobile:")) return true;
-4
View File
@@ -163,10 +163,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
name: "API Keys",
description: "API key management",
},
{
name: "SSO",
description: "Single sign-on provider configuration",
},
{
name: "Vault",
description: "HashiCorp Vault SSH signing profiles",
-34
View File
@@ -85,37 +85,3 @@ export async function decryptSystemSecret(stored: string): Promise<string> {
decipher.final(),
]).toString("utf8");
}
/** Fields inside an SSO provider config that must not be stored readable. */
export const SSO_SECRET_FIELDS = ["client_secret", "bindPassword"] as const;
export async function encryptSsoConfigSecrets(
config: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const out = { ...config };
for (const field of SSO_SECRET_FIELDS) {
const value = out[field];
if (typeof value === "string" && value) {
out[field] = await encryptSystemSecret(value);
}
}
return out;
}
export async function decryptSsoConfigSecrets(
config: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const out = { ...config };
for (const field of SSO_SECRET_FIELDS) {
const value = out[field];
if (typeof value === "string" && value) {
try {
out[field] = await decryptSystemSecret(value);
} catch {
// A secret we cannot read must not take the whole provider down;
// login will fail with a clearer error downstream.
}
}
}
return out;
}