This commit is contained in:
Scott Duensing 2026-06-17 17:37:45 -05:00
parent ad31f61fab
commit b4f8daf421
8 changed files with 392 additions and 289 deletions

View file

@ -181,9 +181,9 @@ const groupsPage = async (req, res) => {
const members = await groups.membersOf(g.id);
let memberHtml = "";
for (const member of members) {
const u = await User.findOne({ id: member.user_id });
const label = u ? u.email : ("user#" + member.user_id);
memberHtml += `<form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/removemember">${csrfField(req)}<input type="hidden" name="group_id" value="${escapeHtml(g.id)}"><input type="hidden" name="user_id" value="${escapeHtml(member.user_id)}"><code>${escapeHtml(label)}</code> <button>x</button></form><br>`;
// memberships are keyed by email now; the row already carries it.
const label = member.user_email;
memberHtml += `<form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/removemember">${csrfField(req)}<input type="hidden" name="group_id" value="${escapeHtml(g.id)}"><input type="hidden" name="user_email" value="${escapeHtml(member.user_email)}"><code>${escapeHtml(label)}</code> <button>x</button></form><br>`;
}
rows += `<tr>
<td><code>${escapeHtml(g.name)}</code></td>
@ -259,9 +259,11 @@ const addMemberHandler = async (req, res) => {
const groupId = parseInt(req.body && req.body.group_id, 10);
const email = String((req.body && req.body.email) || "").trim();
if (Number.isFinite(groupId) && email) {
// Validate the user exists and store its CANONICAL email (matches how
// users.email is stored), so the membership key stays consistent.
const u = await User.findOne({ email: email });
if (u) {
await groups.addMember(groupId, u.id);
await groups.addMember(groupId, u.email);
}
}
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
@ -273,9 +275,9 @@ const removeMemberHandler = async (req, res) => {
return;
}
const groupId = parseInt(req.body && req.body.group_id, 10);
const userId = parseInt(req.body && req.body.user_id, 10);
if (Number.isFinite(groupId) && Number.isFinite(userId)) {
await groups.removeMember(groupId, userId);
const email = String((req.body && req.body.user_email) || "").trim();
if (Number.isFinite(groupId) && email) {
await groups.removeMember(groupId, email);
}
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
};

51
lib/configStore.js Normal file
View file

@ -0,0 +1,51 @@
// Durable per-tenant key/value storage in Saltcorn's _sc_config, used for
// saltcorn-idp state that is GENERATED during onLoad (env identity, signing
// keys, SAML cert).
//
// Why _sc_config and not a registered table or the plugin config blob:
// - It survives backup: backup_config dumps every non-fixed _sc_config key,
// and restore_config restores them BEFORE install_pack runs the plugin's
// onLoad -- so the restored value is already present when onLoad reads it
// (no onLoad-generates-then-restore-imports duplicate, which a registered
// table suffers because restore_tables runs AFTER onLoad). This matters
// because onLoad creates the env row, the active signing key, and the SAML
// cert if missing; a duplicate active key would break JWKS.
// - It does NOT write the plugin's own _sc_plugins row (Plugin.upsert during
// onLoad cascades plugin re-loading + duplicates rows). setConfig writes
// _sc_config only.
//
// Reads go DIRECT to _sc_config: getState().getConfig only surfaces keys in
// Saltcorn's known configTypes schema, so a custom plugin key is invisible
// through it even though it is stored and backed up. Writes go through setConfig
// (correct jsonb/text encoding on both backends; value wrapped as {v: ...}).
const db = require("@saltcorn/data/db");
const readKey = async (key) => {
const row = await db.selectMaybeOne("_sc_config", { key: key });
if (!row || row.value === null || row.value === undefined) {
return undefined;
}
let v = row.value;
if (typeof v === "string") {
try {
v = JSON.parse(v);
} catch (e) {
return undefined;
}
}
return v && typeof v === "object" && "v" in v ? v.v : v;
};
const writeKey = async (key, value) => {
const { getState } = require("@saltcorn/data/db/state");
await getState().setConfig(key, value);
};
module.exports = {
readKey,
writeKey
};

View file

@ -33,7 +33,11 @@ const USERINFO_PATH = IDP_BASE_PATH + "/me";
const INTERACTION_PATH = IDP_BASE_PATH + "/interaction/:uid";
const INTERACTION_CONFIRM_PATH = IDP_BASE_PATH + "/interaction/:uid/confirm";
// Plugin tables (all prefixed _idp_, created idempotently in onLoad).
// Plugin tables (prefixed _idp_). Runtime-written tables (groups, group_members,
// clients, saml_sps, ldap_service) are REGISTERED Saltcorn tables so they ride
// backup; _idp_oidc_store stays raw (transient sessions/tokens, regenerable).
// TABLE_ENV/TABLE_KEYS/TABLE_SAML remain only as LEGACY table names so the
// onLoad migration can read + drop them after moving their content to _sc_config.
const TABLE_ENV = "_idp_env";
const TABLE_KEYS = "_idp_keys";
const TABLE_OIDC_STORE = "_idp_oidc_store";
@ -41,6 +45,13 @@ const TABLE_GROUPS = "_idp_groups";
const TABLE_GROUP_MEMBERS = "_idp_group_members";
const TABLE_CLIENTS = "_idp_clients";
// onLoad-GENERATED state lives in _sc_config (see lib/configStore.js) so it is
// restored BEFORE onLoad and never duplicated: env identity, signing keys, SAML
// cert. (Legacy _idp_env/_idp_keys/_idp_saml tables are migrated + dropped.)
const CFG_ENV = "idp_env";
const CFG_KEYS = "idp_signing_keys";
const CFG_SAML = "idp_saml_cert";
// Signing.
const SIGNING_ALG = "RS256";
const RSA_MODULUS_BITS = 2048;
@ -138,6 +149,9 @@ module.exports = {
TABLE_GROUPS,
TABLE_GROUP_MEMBERS,
TABLE_CLIENTS,
CFG_ENV,
CFG_KEYS,
CFG_SAML,
SIGNING_ALG,
RSA_MODULUS_BITS,
KEY_STATUS,

View file

@ -1,19 +1,18 @@
// Singleton per-instance (per-tenant) environment row for saltcorn-idp. Tracks
// first-run bootstrap state and an instance label for the admin UI.
//
// No module-level cache: in multi-tenant mode a single un-keyed cache would
// serve one tenant's row to another. Reads are infrequent (onLoad + dashboard),
// so we query each time within the caller's tenant context.
// Stored in _sc_config (key CFG_ENV) rather than a _idp_env table: _sc_config
// survives backup and is restored BEFORE onLoad, so a restored instance keeps
// its env_id and onLoad does not create a duplicate (see lib/configStore.js).
const crypto = require("crypto");
const db = require("@saltcorn/data/db");
const { TABLE_ENV } = require("./constants");
const { CFG_ENV } = require("./constants");
const { readKey, writeKey } = require("./configStore");
const getEnv = async () => {
const rows = await db.select(TABLE_ENV, {});
return rows.length > 0 ? rows[0] : null;
return (await readKey(CFG_ENV)) || null;
};
@ -22,21 +21,23 @@ const initEnvIfMissing = async () => {
if (existing) {
return existing;
}
const now = new Date().toISOString();
const row = {
const env = {
env_id: crypto.randomUUID(),
env_label: null,
created_at: now,
created_at: new Date().toISOString(),
bootstrapped_at: null
};
await db.insert(TABLE_ENV, row, { noid: true });
return row;
await writeKey(CFG_ENV, env);
return env;
};
const markBootstrapped = async (envId) => {
const now = new Date().toISOString();
await db.updateWhere(TABLE_ENV, { bootstrapped_at: now }, { env_id: envId });
const env = await getEnv();
if (env && env.env_id === envId) {
env.bootstrapped_at = new Date().toISOString();
await writeKey(CFG_ENV, env);
}
};

View file

@ -37,17 +37,20 @@ const membersOf = async (groupId) => {
};
const addMember = async (groupId, userId) => {
const existing = await db.selectMaybeOne(TABLE_GROUP_MEMBERS, { group_id: groupId, user_id: userId });
// Memberships are keyed by the user's stable EMAIL (not the raw users.id), so a
// restore that reassigns user ids does not orphan/misattribute memberships.
// Callers pass the canonical email (resolved from the User row).
const addMember = async (groupId, email) => {
const existing = await db.selectMaybeOne(TABLE_GROUP_MEMBERS, { group_id: groupId, user_email: email });
if (existing) {
return;
}
await db.insert(TABLE_GROUP_MEMBERS, { group_id: groupId, user_id: userId }, { noid: true });
await db.insert(TABLE_GROUP_MEMBERS, { group_id: groupId, user_email: email }, { noid: true });
};
const removeMember = async (groupId, userId) => {
await db.deleteWhere(TABLE_GROUP_MEMBERS, { group_id: groupId, user_id: userId });
const removeMember = async (groupId, email) => {
await db.deleteWhere(TABLE_GROUP_MEMBERS, { group_id: groupId, user_email: email });
};
@ -57,7 +60,8 @@ const effectiveGroups = async (user) => {
if (role && role.role) {
out.push(ROLE_PREFIX + role.role);
}
const members = await db.select(TABLE_GROUP_MEMBERS, { user_id: user.id });
// user object already carries the canonical email; resolve groups by it.
const members = await db.select(TABLE_GROUP_MEMBERS, { user_email: user.email });
for (const member of members) {
const group = await db.selectMaybeOne(TABLE_GROUPS, { id: member.group_id });
if (group && group.name) {

View file

@ -1,91 +1,103 @@
// Signing-key lifecycle for the saltcorn-idp OIDC provider.
//
// Keys are per-tenant (stored in this tenant's _idp_keys). The private key is
// sealed at rest (AES-256-GCM under the plugin KEK); only the PUBLIC JWK is
// ever exposed, via JWKS. ensureActiveKey() is idempotent: it creates an active
// key only if none exists for the current tenant. getActiveKeyMeta() never
// touches private material so the dashboard can render without decrypting.
const db = require("@saltcorn/data/db");
// Keys are per-tenant, stored in _sc_config (key CFG_KEYS) as an array of key
// objects rather than a _idp_keys table. _sc_config survives backup AND is
// restored BEFORE onLoad, so a restored instance keeps its signing keys and
// ensureActiveKey() does not generate a duplicate ACTIVE key (a registered
// table, imported AFTER onLoad, would leave two ACTIVE keys -> broken JWKS).
//
// Each stored key object: { kid, alg, public_jwk (object), private ({ciphertext,
// iv, tag} hex from crypto.sealText), status, created_at, retire_after }. The
// private key is sealed at rest (AES-256-GCM under the plugin KEK); only the
// PUBLIC JWK is ever exposed, via JWKS.
const crypto = require("./crypto");
const constants = require("./constants");
const { readKey, writeKey } = require("./configStore");
// Generate + seal a fresh RSA signing keypair and insert it as the new ACTIVE
// key. Shared by ensureActiveKey (first key for a tenant) and rotateActiveKey
// (replacement key). Returns only safe metadata; the sealed private material is
// never handed back.
const insertActiveKey = async () => {
const loadKeys = async () => {
const arr = await readKey(constants.CFG_KEYS);
return Array.isArray(arr) ? arr : [];
};
const saveKeys = async (keys) => {
await writeKey(constants.CFG_KEYS, keys);
};
const byStatus = (keys, status) => keys.filter((k) => k.status === status);
// Generate + seal a fresh RSA signing keypair and append it as the new ACTIVE
// key. Returns only safe metadata; the sealed private material is never handed
// back. Caller persists the mutated array.
const buildActiveKey = () => {
const kid = crypto.newKid();
const pair = crypto.generateSigningKeyPair(constants.RSA_MODULUS_BITS);
const jwk = crypto.publicKeyToJwk(pair.publicKey, kid, constants.SIGNING_ALG);
const pem = crypto.exportPrivatePem(pair.privateKey);
const sealed = crypto.sealText(pem);
const now = new Date().toISOString();
const row = {
kid: kid,
alg: constants.SIGNING_ALG,
public_jwk: JSON.stringify(jwk),
private_ciphertext: sealed.ciphertext,
private_iv: sealed.iv,
private_tag: sealed.tag,
status: constants.KEY_STATUS.ACTIVE,
created_at: now,
retire_after: null
return {
kid: kid,
alg: constants.SIGNING_ALG,
public_jwk: jwk,
private: { ciphertext: sealed.ciphertext, iv: sealed.iv, tag: sealed.tag },
status: constants.KEY_STATUS.ACTIVE,
created_at: now,
retire_after: null
};
await db.insert(constants.TABLE_KEYS, row, { noid: true });
return { kid: kid, alg: constants.SIGNING_ALG, status: constants.KEY_STATUS.ACTIVE, created_at: now };
};
const ensureActiveKey = async () => {
const existing = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE });
const keys = await loadKeys();
const existing = byStatus(keys, constants.KEY_STATUS.ACTIVE)[0];
if (existing) {
return { kid: existing.kid, alg: existing.alg, status: existing.status, created_at: existing.created_at };
}
return await insertActiveKey();
const k = buildActiveKey();
keys.push(k);
await saveKeys(keys);
return { kid: k.kid, alg: k.alg, status: k.status, created_at: k.created_at };
};
const getJwks = async () => {
const active = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE });
const retiring = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.RETIRING });
const keys = active.concat(retiring).map((r) => JSON.parse(r.public_jwk));
return { keys: keys };
const keys = await loadKeys();
const pub = byStatus(keys, constants.KEY_STATUS.ACTIVE)
.concat(byStatus(keys, constants.KEY_STATUS.RETIRING))
.map((k) => k.public_jwk);
return { keys: pub };
};
const getActiveKeyMeta = async () => {
const row = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE });
if (!row) {
const k = byStatus(await loadKeys(), constants.KEY_STATUS.ACTIVE)[0];
if (!k) {
return null;
}
return { kid: row.kid, alg: row.alg, created_at: row.created_at };
return { kid: k.kid, alg: k.alg, created_at: k.created_at };
};
// Phase 1: decrypts and returns the private signing key for id_token signing.
const openPrivate = (k) => {
return crypto.openText(k.private).toString("utf8");
};
// Decrypts and returns the private signing key for id_token signing.
const getActiveSigningKey = async () => {
const row = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE });
if (!row) {
const k = byStatus(await loadKeys(), constants.KEY_STATUS.ACTIVE)[0];
if (!k) {
return null;
}
const pem = crypto.openText({
ciphertext: row.private_ciphertext,
iv: row.private_iv,
tag: row.private_tag
}).toString("utf8");
return {
kid: row.kid,
alg: row.alg,
privateKey: crypto.importPrivatePem(pem)
};
return { kid: k.kid, alg: k.alg, privateKey: crypto.importPrivatePem(openPrivate(k)) };
};
// Phase 1: the active private signing key as a JWK, for oidc-provider's jwks
// config (it signs id_tokens with it and serves the public half via JWKS).
const getActivePrivateJwk = async () => {
const key = await getActiveSigningKey();
if (!key) {
@ -96,63 +108,58 @@ const getActivePrivateJwk = async () => {
// All non-retired private signing keys as JWKs for oidc-provider's jwks config:
// the ACTIVE key FIRST (oidc-provider signs new tokens with the first key that
// matches the requested alg) followed by any RETIRING keys, so id_tokens issued
// BEFORE a rotation still verify against JWKS during the grace window. RETIRED
// keys are excluded. This is what actually keeps a rotated-out key in /idp/jwks
// (the provider builds JWKS from this set, not from keys.getJwks()).
// the ACTIVE key FIRST, then any RETIRING keys (so id_tokens issued before a
// rotation still verify against JWKS during the grace window). RETIRED excluded.
const getSigningPrivateJwks = async () => {
const active = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE });
const retiring = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.RETIRING });
const jwks = [];
for (const row of active.concat(retiring)) {
const pem = crypto.openText({
ciphertext: row.private_ciphertext,
iv: row.private_iv,
tag: row.private_tag
}).toString("utf8");
jwks.push(crypto.privateKeyToJwk(crypto.importPrivatePem(pem), row.kid, row.alg));
}
return jwks;
const keys = await loadKeys();
const ordered = byStatus(keys, constants.KEY_STATUS.ACTIVE).concat(byStatus(keys, constants.KEY_STATUS.RETIRING));
return ordered.map((k) => crypto.privateKeyToJwk(crypto.importPrivatePem(openPrivate(k)), k.kid, k.alg));
};
// Flip any RETIRING keys whose grace window has elapsed to RETIRED, dropping
// them from JWKS. Idempotent and cheap; called opportunistically on the admin
// dashboard GET and at the end of a rotation. NULL retire_after never matches
// (SQL "<=" of NULL is NULL, not true), so a key without a deadline is safe.
// them from JWKS. Idempotent. A null retire_after never expires.
const retireExpiredKeys = async () => {
const now = new Date().toISOString();
await db.updateWhere(
constants.TABLE_KEYS,
{ status: constants.KEY_STATUS.RETIRED },
{ status: constants.KEY_STATUS.RETIRING, retire_after: { lt: now, equal: true } }
);
const keys = await loadKeys();
let changed = false;
for (const k of keys) {
if (k.status === constants.KEY_STATUS.RETIRING && k.retire_after && k.retire_after <= now) {
k.status = constants.KEY_STATUS.RETIRED;
changed = true;
}
}
if (changed) {
await saveKeys(keys);
}
};
// Admin-triggered rotation. Generates+seals a NEW ACTIVE keypair (new kid) and
// demotes the prior ACTIVE key to RETIRING with retire_after = now + the grace
// window, so id_tokens signed by the old key keep verifying (it stays in JWKS)
// until the window elapses. The OIDC Provider is built per-issuer with the
// active private JWK baked in, so its cache is cleared here to force a rebuild
// against the new key. Also sweeps any already-expired RETIRING keys to RETIRED.
// Admin-triggered rotation. Generates a NEW ACTIVE keypair (new kid) and demotes
// the prior ACTIVE key to RETIRING with retire_after = now + the grace window, so
// id_tokens signed by the old key keep verifying until the window elapses. Also
// sweeps already-expired RETIRING keys to RETIRED and clears the provider cache.
const rotateActiveKey = async () => {
const prior = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE });
const keys = await loadKeys();
const prior = byStatus(keys, constants.KEY_STATUS.ACTIVE)[0];
if (prior) {
const retireAfter = new Date(Date.now() + constants.KEY_RETIRE_GRACE_MS).toISOString();
await db.updateWhere(
constants.TABLE_KEYS,
{ status: constants.KEY_STATUS.RETIRING, retire_after: retireAfter },
{ kid: prior.kid }
);
prior.status = constants.KEY_STATUS.RETIRING;
prior.retire_after = new Date(Date.now() + constants.KEY_RETIRE_GRACE_MS).toISOString();
}
const created = await insertActiveKey();
await retireExpiredKeys();
const k = buildActiveKey();
keys.push(k);
// sweep expired retiring keys in the same array before persisting
const now = new Date().toISOString();
for (const e of keys) {
if (e.status === constants.KEY_STATUS.RETIRING && e.retire_after && e.retire_after <= now) {
e.status = constants.KEY_STATUS.RETIRED;
}
}
await saveKeys(keys);
// Lazy-require to avoid a require cycle (provider.js requires this module).
const { clearProviderCache } = require("./oidc/provider");
clearProviderCache();
return created;
return { kid: k.kid, alg: k.alg, status: k.status, created_at: k.created_at };
};

View file

@ -16,6 +16,7 @@ const selfsigned = require("selfsigned");
const idpCrypto = require("../crypto");
const constants = require("../constants");
const { readKey, writeKey } = require("../configStore");
// Fail closed at load if xml-crypto is below the patched floor (2025 SAML
@ -63,8 +64,11 @@ const LOGIN_RESPONSE_TEMPLATE = {
};
// The SAML signing cert + sealed private key live in _sc_config (key CFG_SAML),
// not a _idp_saml table: _sc_config is restored BEFORE onLoad, so a restored
// instance keeps its cert and ensureSamlCert() does not generate a duplicate.
const ensureSamlCert = async () => {
const existing = await db.selectMaybeOne(constants.TABLE_SAML, { id: "default" });
const existing = await readKey(constants.CFG_SAML);
if (existing) {
return;
}
@ -73,28 +77,21 @@ const ensureSamlCert = async () => {
{ keyType: "rsa", keySize: 2048, algorithm: "sha256" }
);
const sealed = idpCrypto.sealText(pems.private);
await db.insert(constants.TABLE_SAML, {
id: "default",
cert: pems.cert,
private_ciphertext: sealed.ciphertext,
private_iv: sealed.iv,
private_tag: sealed.tag,
created_at: new Date().toISOString()
}, { noid: true });
await writeKey(constants.CFG_SAML, {
cert: pems.cert,
private: { ciphertext: sealed.ciphertext, iv: sealed.iv, tag: sealed.tag },
created_at: new Date().toISOString()
});
};
const getSamlCert = async () => {
const row = await db.selectMaybeOne(constants.TABLE_SAML, { id: "default" });
if (!row) {
const stored = await readKey(constants.CFG_SAML);
if (!stored) {
return null;
}
const key = idpCrypto.openText({
ciphertext: row.private_ciphertext,
iv: row.private_iv,
tag: row.private_tag
}).toString("utf8");
return { cert: row.cert, key: key };
const key = idpCrypto.openText(stored.private).toString("utf8");
return { cert: stored.cert, key: key };
};

View file

@ -1,56 +1,167 @@
// Idempotent DDL for the saltcorn-idp plugin tables. Portable subset: TEXT for
// strings/JSON/timestamps, INTEGER for booleans; no JSONB. Sealed key/secret
// material is stored as hex TEXT (see crypto.sealText). Table names come from
// constants so there is one source of truth.
// Schema setup for the saltcorn-idp plugin tables.
//
// MULTI-TENANCY: raw CREATE statements must be schema-qualified with
// db.getTenantSchemaPrefix() (e.g. "t1".) so tables land in the current tenant's
// Postgres schema -- otherwise unqualified DDL hits the wrong schema while
// Saltcorn's db.select reads the tenant-qualified one. On SQLite the prefix is
// "" (single schema), so the dev MAIN/TEST instances are unaffected.
// Two storage strategies, chosen by WHEN the state is written, so each survives
// Saltcorn backup/restore without fighting the plugin lifecycle:
//
// * onLoad-GENERATED state (env identity, signing keys, SAML cert) -> _sc_config
// (lib/configStore.js + env.js/keys.js/saml/idp.js). _sc_config is restored
// BEFORE onLoad, so a restored instance keeps these and onLoad does not
// regenerate a duplicate (a duplicate ACTIVE signing key would break JWKS).
//
// * RUNTIME-written state (groups, group_members, clients, saml_sps,
// ldap_service) -> REGISTERED Saltcorn tables (auto `id` PK + logical keys
// kept as fields). Written only by admin/UI actions (not onLoad/restore), so
// the catalog-recreate-on-restore path is clean; being in _sc_tables they
// ride backup. Existing db.select/insert code keeps working unchanged.
//
// * _idp_oidc_store stays RAW: transient OIDC sessions/grants/tokens, fine to
// regenerate (re-auth) on a restored host.
//
// _idp_group_members is RE-KEYED off raw users.id onto the stable user EMAIL, so
// reassigned user ids on restore do not orphan/misattribute memberships.
const db = require("@saltcorn/data/db");
const db = require("@saltcorn/data/db");
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const User = require("@saltcorn/data/models/user");
const { TABLE_ENV, TABLE_KEYS, TABLE_OIDC_STORE, TABLE_GROUPS, TABLE_GROUP_MEMBERS, TABLE_CLIENTS, TABLE_SAML, TABLE_SAML_SPS, TABLE_LDAP_SERVICE } = require("./constants");
const c = require("./constants");
const { readKey, writeKey } = require("./configStore");
const createIdpEnv = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_ENV} (
env_id TEXT PRIMARY KEY,
env_label TEXT,
created_at TEXT NOT NULL,
bootstrapped_at TEXT
)
`);
const GROUPS_FIELDS = [
{ name: "name", type: "String", is_unique: true, required: true },
{ name: "description", type: "String" },
{ name: "created_at", type: "String", required: true }
];
const GROUP_MEMBERS_FIELDS = [
{ name: "group_id", type: "Integer", required: true },
{ name: "user_email", type: "String", required: true }
];
const CLIENTS_FIELDS = [
{ name: "client_id", type: "String", is_unique: true, required: true },
{ name: "label", type: "String" },
{ name: "token_auth_method", type: "String", required: true, attributes: { default: "none" } },
{ name: "redirect_uris", type: "String", required: true },
{ name: "grant_types", type: "String", required: true },
{ name: "response_types", type: "String", required: true },
{ name: "scope", type: "String" },
{ name: "secret_ciphertext", type: "String" },
{ name: "secret_iv", type: "String" },
{ name: "secret_tag", type: "String" },
{ name: "created_at", type: "String", required: true }
];
const SAML_SPS_FIELDS = [
{ name: "entity_id", type: "String", is_unique: true, required: true },
{ name: "label", type: "String" },
{ name: "acs_urls", type: "String", required: true },
{ name: "signing_cert", type: "String" },
{ name: "want_authn_requests_signed", type: "Integer", required: true, attributes: { default: 0 } },
{ name: "created_at", type: "String", required: true }
];
const LDAP_SERVICE_FIELDS = [
{ name: "dn", type: "String" },
{ name: "secret_ciphertext", type: "String" },
{ name: "secret_iv", type: "String" },
{ name: "secret_tag", type: "String" },
{ name: "created_at", type: "String", required: true }
];
const rawTableExists = async (name) => {
if (db.isSQLite) {
const rs = await db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='${name}'`);
return rs.rows.length > 0;
}
const rs = await db.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema='${db.getTenantSchema()}' AND table_name='${name}'`
);
return rs.rows.length > 0;
};
const createIdpKeys = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_KEYS} (
kid TEXT PRIMARY KEY,
alg TEXT NOT NULL,
public_jwk TEXT NOT NULL,
private_ciphertext TEXT NOT NULL,
private_iv TEXT NOT NULL,
private_tag TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
retire_after TEXT
)
`);
await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_KEYS}_status ON ${pfx}${TABLE_KEYS} (status)`);
// Make `name` a registered Saltcorn table (auto id PK + fieldDefs). Idempotent.
// If a legacy raw table exists, migrate its rows (optionally transformed) then
// drop it. idp installs no CRUD wraps, so no suppression is needed here; if
// dev-deploy is co-installed, its wraps skip _idp_* infra tables.
const registerTable = async (name, fieldDefs, rowTransform) => {
if (Table.findOne({ name })) {
return;
}
const schema = db.getTenantSchemaPrefix();
let rows = [];
if (await rawTableExists(name)) {
rows = (await db.query(`SELECT * FROM ${schema}"${name}"`)).rows;
await db.query(`DROP TABLE ${schema}"${name}"`);
}
const t = await Table.create(name, { min_role_read: 1, min_role_write: 1 });
for (const f of fieldDefs) {
await Field.create({
table: t,
name: f.name,
label: f.label || f.name,
type: f.type,
is_unique: !!f.is_unique,
required: !!f.required,
attributes: f.attributes || {}
});
}
for (const r of rows) {
const row = rowTransform ? await rowTransform(r) : r;
if (!row) {
continue;
}
const clean = {};
for (const f of fieldDefs) {
if (row[f.name] !== undefined && row[f.name] !== null) {
clean[f.name] = row[f.name];
}
}
await db.insert(name, clean, { noid: true });
}
};
// Single table backing the oidc-provider storage adapter (all model types).
// Move a legacy onLoad-generated table into a _sc_config key, then drop it.
const migrateTableToConfig = async (legacyTable, cfgKey, toValue) => {
if (await readKey(cfgKey)) {
return;
}
if (!(await rawTableExists(legacyTable))) {
return;
}
const schema = db.getTenantSchemaPrefix();
const rows = (await db.query(`SELECT * FROM ${schema}${legacyTable}`)).rows;
const value = toValue(rows);
if (value !== undefined && value !== null) {
await writeKey(cfgKey, value);
}
await db.query(`DROP TABLE IF EXISTS ${schema}${legacyTable}`);
};
// Resolve a legacy membership row's integer user_id to the stable canonical email.
const memberRowToEmail = async (r) => {
if (r.user_email) {
return { group_id: r.group_id, user_email: r.user_email };
}
const u = await User.findOne({ id: r.user_id });
if (!u || !u.email) {
return null; // user gone -> drop the orphaned membership
}
return { group_id: r.group_id, user_email: u.email };
};
// Raw DDL for the transient OIDC adapter store (regenerable; stays unregistered).
const createIdpOidcStore = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_OIDC_STORE} (
CREATE TABLE IF NOT EXISTS ${pfx}${c.TABLE_OIDC_STORE} (
model TEXT NOT NULL,
id TEXT NOT NULL,
payload TEXT NOT NULL,
@ -61,127 +172,43 @@ const createIdpOidcStore = async () => {
PRIMARY KEY (model, id)
)
`);
await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_OIDC_STORE}_uid ON ${pfx}${TABLE_OIDC_STORE} (uid)`);
await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_OIDC_STORE}_grant ON ${pfx}${TABLE_OIDC_STORE} (grant_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_OIDC_STORE}_usercode ON ${pfx}${TABLE_OIDC_STORE} (user_code)`);
};
// Custom groups + the user<->group junction ("meet me" table).
const createIdpGroups = async () => {
const pfx = db.getTenantSchemaPrefix();
// Portable auto-increment PK: sqlite "integer primary key" auto-assigns
// rowids; postgres uses "serial". (AUTOINCREMENT is sqlite-only syntax.)
const serial = db.isSQLite ? "integer" : "serial";
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_GROUPS} (
id ${serial} PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL
)
`);
};
const createIdpGroupMembers = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_GROUP_MEMBERS} (
group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
PRIMARY KEY (group_id, user_id)
)
`);
await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_GROUP_MEMBERS}_user ON ${pfx}${TABLE_GROUP_MEMBERS} (user_id)`);
};
// Registered relying parties. Confidential clients' secrets are sealed at rest
// (hex columns); public clients (token_auth_method='none') have none.
const createIdpClients = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_CLIENTS} (
client_id TEXT PRIMARY KEY,
label TEXT,
token_auth_method TEXT NOT NULL DEFAULT 'none',
redirect_uris TEXT NOT NULL,
grant_types TEXT NOT NULL,
response_types TEXT NOT NULL,
scope TEXT,
secret_ciphertext TEXT,
secret_iv TEXT,
secret_tag TEXT,
created_at TEXT NOT NULL
)
`);
};
// Per-tenant SAML signing material: a self-signed X.509 cert (advertised in IdP
// metadata) + its sealed private key (used to sign assertions). Singleton row.
const createIdpSaml = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_SAML} (
id TEXT PRIMARY KEY,
cert TEXT NOT NULL,
private_ciphertext TEXT NOT NULL,
private_iv TEXT NOT NULL,
private_tag TEXT NOT NULL,
created_at TEXT NOT NULL
)
`);
};
// Registered SAML relying parties (service providers). The IdP only issues an
// assertion to a registered SP and only to one of its allow-listed ACS URLs, so
// a forged AuthnRequest cannot redirect a signed assertion to an attacker. The
// optional signing_cert (public, not sealed) enables AuthnRequest signature
// verification; want_authn_requests_signed (INTEGER 0/1) gates enforcement.
const createIdpSamlSps = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_SAML_SPS} (
entity_id TEXT PRIMARY KEY,
label TEXT,
acs_urls TEXT NOT NULL,
signing_cert TEXT,
want_authn_requests_signed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
`);
};
// LDAP service-account credentials (search-then-bind binder). Single row; the
// password is sealed at rest (hex columns), like client secrets.
const createIdpLdapService = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_LDAP_SERVICE} (
dn TEXT,
secret_ciphertext TEXT,
secret_iv TEXT,
secret_tag TEXT,
created_at TEXT NOT NULL
)
`);
await db.query(`CREATE INDEX IF NOT EXISTS ${c.TABLE_OIDC_STORE}_uid ON ${pfx}${c.TABLE_OIDC_STORE} (uid)`);
await db.query(`CREATE INDEX IF NOT EXISTS ${c.TABLE_OIDC_STORE}_grant ON ${pfx}${c.TABLE_OIDC_STORE} (grant_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS ${c.TABLE_OIDC_STORE}_usercode ON ${pfx}${c.TABLE_OIDC_STORE} (user_code)`);
};
const createAllTables = async () => {
await createIdpEnv();
await createIdpKeys();
// onLoad-generated state -> _sc_config (migrate legacy tables first).
await migrateTableToConfig(c.TABLE_ENV, c.CFG_ENV, (rows) => {
if (!rows.length) return null;
const e = rows[0];
return { env_id: e.env_id, env_label: e.env_label, created_at: e.created_at, bootstrapped_at: e.bootstrapped_at };
});
await migrateTableToConfig(c.TABLE_KEYS, c.CFG_KEYS, (rows) => rows.map((r) => ({
kid: r.kid,
alg: r.alg,
public_jwk: (typeof r.public_jwk === "string" ? JSON.parse(r.public_jwk) : r.public_jwk),
private: { ciphertext: r.private_ciphertext, iv: r.private_iv, tag: r.private_tag },
status: r.status,
created_at: r.created_at,
retire_after: r.retire_after
})));
await migrateTableToConfig(c.TABLE_SAML, c.CFG_SAML, (rows) => {
if (!rows.length) return null;
const r = rows[0];
return { cert: r.cert, private: { ciphertext: r.private_ciphertext, iv: r.private_iv, tag: r.private_tag }, created_at: r.created_at };
});
// Runtime-written state -> registered Saltcorn tables (ride backup).
await registerTable(c.TABLE_GROUPS, GROUPS_FIELDS);
await registerTable(c.TABLE_GROUP_MEMBERS, GROUP_MEMBERS_FIELDS, memberRowToEmail);
await registerTable(c.TABLE_CLIENTS, CLIENTS_FIELDS);
await registerTable(c.TABLE_SAML_SPS, SAML_SPS_FIELDS);
await registerTable(c.TABLE_LDAP_SERVICE, LDAP_SERVICE_FIELDS);
// Transient -> raw.
await createIdpOidcStore();
await createIdpGroups();
await createIdpGroupMembers();
await createIdpClients();
await createIdpSaml();
await createIdpSamlSps();
await createIdpLdapService();
};