mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -47,7 +47,7 @@ beforeEach(() => {
|
||||
describe("parseLegacyIdentifier", () => {
|
||||
it("splits the prefixed forms and keeps bare subjects on their provider", () => {
|
||||
expect(parseLegacyIdentifier("ldap:4:bob", null)).toEqual({
|
||||
providerId: "4",
|
||||
providerId: "ldap:4",
|
||||
subject: "bob",
|
||||
});
|
||||
expect(parseLegacyIdentifier("github:7:42", null)).toEqual({
|
||||
@@ -55,7 +55,7 @@ describe("parseLegacyIdentifier", () => {
|
||||
subject: "42",
|
||||
});
|
||||
expect(parseLegacyIdentifier("ldap:4:cn=a:b", null)).toEqual({
|
||||
providerId: "4",
|
||||
providerId: "ldap:4",
|
||||
subject: "cn=a:b",
|
||||
});
|
||||
expect(parseLegacyIdentifier("sub-123", 3)).toEqual({
|
||||
@@ -94,7 +94,7 @@ describe("runExternalIdentityMigration", () => {
|
||||
subject,
|
||||
})),
|
||||
).toEqual([
|
||||
{ userId: "ldap-user", providerId: "4", subject: "bob" },
|
||||
{ userId: "ldap-user", providerId: "ldap:4", subject: "bob" },
|
||||
{ userId: "gh-user", providerId: "7", subject: "42" },
|
||||
{ userId: "oidc-user", providerId: "3", subject: "sub-123" },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The 2.8 to 2.9 LDAP split. LDAP rows in sso_providers (adopted or not yet)
|
||||
* move into the ldap plugin's table with their ids, their identities carry
|
||||
* the "ldap:" prefix, the SSO rows stay, and running it twice changes nothing.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({ db: null as unknown }));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
return { databaseLogger: log };
|
||||
});
|
||||
|
||||
const { runLdapProviderMigration } =
|
||||
await import("../../utils/crypto-migration/ldap-provider-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE IF NOT EXISTS sso_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
const TARGET_DDL = `
|
||||
CREATE TABLE IF NOT EXISTS "p_ldap_providers" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"name" text NOT NULL,
|
||||
"enabled" integer NOT NULL DEFAULT 1,
|
||||
"display_order" integer NOT NULL DEFAULT 0,
|
||||
"config" text NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
function setup(options: { adopted: boolean; target: boolean }) {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
${LEGACY_DDL}
|
||||
CREATE TABLE user_external_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
email TEXT
|
||||
);
|
||||
`);
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO sso_providers (id, name, type, enabled, config) VALUES (?, ?, ?, ?, ?)",
|
||||
);
|
||||
insert.run(3, "Keycloak", "oidc", 1, '{"client_id":"termix"}');
|
||||
insert.run(4, "Corp LDAP", "ldap", 1, '{"host":"ldap.example"}');
|
||||
insert.run(5, "Old LDAP", "ldap", 0, '{"host":"old.example"}');
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("u-oidc", "3", "sub-1");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("u-ldap", "ldap:4", "bob");
|
||||
if (options.adopted) {
|
||||
sqlite.exec(`ALTER TABLE sso_providers RENAME TO p_sso_providers;`);
|
||||
}
|
||||
if (options.target) sqlite.exec(TARGET_DDL);
|
||||
h.db = drizzle(sqlite);
|
||||
}
|
||||
|
||||
const all = (query: string) => sqlite.prepare(query).all();
|
||||
|
||||
describe("runLdapProviderMigration", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does nothing until the ldap plugin's table exists", async () => {
|
||||
setup({ adopted: false, target: false });
|
||||
expect(await runLdapProviderMigration()).toEqual({
|
||||
moved: 0,
|
||||
renumbered: 0,
|
||||
});
|
||||
expect(all("SELECT id FROM sso_providers")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the legacy table", false],
|
||||
["the adopted table", true],
|
||||
])("moves LDAP rows out of %s with their ids", async (_label, adopted) => {
|
||||
setup({ adopted, target: true });
|
||||
const source = adopted ? "p_sso_providers" : "sso_providers";
|
||||
|
||||
expect(await runLdapProviderMigration()).toEqual({
|
||||
moved: 2,
|
||||
renumbered: 0,
|
||||
});
|
||||
expect(
|
||||
all("SELECT id, name, enabled, config FROM p_ldap_providers ORDER BY id"),
|
||||
).toEqual([
|
||||
{
|
||||
id: 4,
|
||||
name: "Corp LDAP",
|
||||
enabled: 1,
|
||||
config: '{"host":"ldap.example"}',
|
||||
},
|
||||
{ id: 5, name: "Old LDAP", enabled: 0, config: '{"host":"old.example"}' },
|
||||
]);
|
||||
expect(all(`SELECT id, type FROM ${source}`)).toEqual([
|
||||
{ id: 3, type: "oidc" },
|
||||
]);
|
||||
expect(
|
||||
all("SELECT user_id, provider_id FROM user_external_identities"),
|
||||
).toEqual([
|
||||
{ user_id: "u-oidc", provider_id: "3" },
|
||||
{ user_id: "u-ldap", provider_id: "ldap:4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renames bare identities left from before the prefix", async () => {
|
||||
setup({ adopted: true, target: true });
|
||||
sqlite.exec(
|
||||
"UPDATE user_external_identities SET provider_id = '4' WHERE user_id = 'u-ldap'",
|
||||
);
|
||||
await runLdapProviderMigration();
|
||||
expect(
|
||||
all(
|
||||
"SELECT provider_id FROM user_external_identities WHERE user_id = 'u-ldap'",
|
||||
),
|
||||
).toEqual([{ provider_id: "ldap:4" }]);
|
||||
});
|
||||
|
||||
it("gives a row a new id when the plugin already used it", async () => {
|
||||
setup({ adopted: true, target: true });
|
||||
sqlite.exec(
|
||||
`INSERT INTO p_ldap_providers (id, name, config) VALUES (4, 'New', '{}')`,
|
||||
);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("u-bare", "4", "carol");
|
||||
|
||||
const result = await runLdapProviderMigration();
|
||||
expect(result).toEqual({ moved: 2, renumbered: 1 });
|
||||
const moved = all(
|
||||
"SELECT id FROM p_ldap_providers WHERE name = 'Corp LDAP'",
|
||||
) as Array<{ id: number }>;
|
||||
expect(moved[0].id).not.toBe(4);
|
||||
expect(
|
||||
all(
|
||||
"SELECT provider_id FROM user_external_identities WHERE user_id = 'u-bare'",
|
||||
),
|
||||
).toEqual([{ provider_id: `ldap:${moved[0].id}` }]);
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
setup({ adopted: true, target: true });
|
||||
await runLdapProviderMigration();
|
||||
expect(await runLdapProviderMigration()).toEqual({
|
||||
moved: 0,
|
||||
renumbered: 0,
|
||||
});
|
||||
expect(all("SELECT id FROM p_ldap_providers")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getDesktopOidcCallbackUrl,
|
||||
isOidcTokenCallback,
|
||||
} from "../../utils/oidc-desktop-callback.js";
|
||||
|
||||
describe("getDesktopOidcCallbackUrl", () => {
|
||||
it("uses localhost so browsers do not upgrade the loopback callback", () => {
|
||||
expect(getDesktopOidcCallbackUrl("17850")).toBe(
|
||||
"http://localhost:17850/oidc-callback",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["", "0", "65536", "17850/path", ["17850"]])(
|
||||
"rejects invalid callback port %j",
|
||||
(port) => {
|
||||
expect(getDesktopOidcCallbackUrl(port)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
|
||||
|
||||
describe("isOidcTokenCallback", () => {
|
||||
it.each([
|
||||
|
||||
@@ -8,14 +8,8 @@ vi.mock("../../utils/system-crypto.js", () => ({
|
||||
SystemCrypto: { getInstance: () => ({ getEncryptionKey }) },
|
||||
}));
|
||||
|
||||
const {
|
||||
decryptSsoConfigSecrets,
|
||||
decryptSystemSecret,
|
||||
encryptSsoConfigSecrets,
|
||||
encryptSystemSecret,
|
||||
isSystemEncrypted,
|
||||
SSO_SECRET_FIELDS,
|
||||
} = await import("../../utils/system-secret-crypto.js");
|
||||
const { decryptSystemSecret, encryptSystemSecret, isSystemEncrypted } =
|
||||
await import("../../utils/system-secret-crypto.js");
|
||||
|
||||
beforeEach(() => {
|
||||
getEncryptionKey.mockReset();
|
||||
@@ -103,55 +97,3 @@ describe("legacy compatibility", () => {
|
||||
await expect(decryptSystemSecret(sealed)).resolves.toBe("old-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSO provider config", () => {
|
||||
it("seals only the secret fields", async () => {
|
||||
const sealed = await encryptSsoConfigSecrets({
|
||||
client_id: "termix",
|
||||
client_secret: "shhh",
|
||||
bindPassword: "ldap-pw",
|
||||
issuer_url: "https://idp.example",
|
||||
});
|
||||
|
||||
expect(sealed.client_id).toBe("termix");
|
||||
expect(sealed.issuer_url).toBe("https://idp.example");
|
||||
expect(isSystemEncrypted(sealed.client_secret as string)).toBe(true);
|
||||
expect(isSystemEncrypted(sealed.bindPassword as string)).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips a whole config", async () => {
|
||||
const original = {
|
||||
client_id: "termix",
|
||||
client_secret: "shhh",
|
||||
bindPassword: "ldap-pw",
|
||||
};
|
||||
|
||||
const restored = await decryptSsoConfigSecrets(
|
||||
await encryptSsoConfigSecrets(original),
|
||||
);
|
||||
|
||||
expect(restored).toEqual(original);
|
||||
});
|
||||
|
||||
it("covers both secret fields", () => {
|
||||
expect([...SSO_SECRET_FIELDS]).toEqual(["client_secret", "bindPassword"]);
|
||||
});
|
||||
|
||||
it("leaves a config without secrets untouched", async () => {
|
||||
const config = { client_id: "termix", scopes: "openid" };
|
||||
|
||||
await expect(encryptSsoConfigSecrets(config)).resolves.toEqual(config);
|
||||
await expect(decryptSsoConfigSecrets(config)).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it("does not let one unreadable secret take down the provider", async () => {
|
||||
const restored = await decryptSsoConfigSecrets({
|
||||
client_id: "termix",
|
||||
client_secret: "sysenc:v1:bad",
|
||||
});
|
||||
|
||||
// The rest of the config survives; login fails later with a clearer error.
|
||||
expect(restored.client_id).toBe("termix");
|
||||
expect(restored.client_secret).toBe("sysenc:v1:bad");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user