Fixes
This commit is contained in:
parent
ad31f61fab
commit
b4f8daf421
8 changed files with 392 additions and 289 deletions
|
|
@ -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
51
lib/configStore.js
Normal 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
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
29
lib/env.js
29
lib/env.js
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
185
lib/keys.js
185
lib/keys.js
|
|
@ -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 };
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
337
lib/schema.js
337
lib/schema.js
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue