mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user