// Admin UI for saltcorn-idp: a read-only dashboard plus group management.
// All pages are admin-gated (role_id 1) and live under /admin/idp (CSRF-protected
// browser forms — note these are NOT under the CSRF-exempt /idp namespace).
const crypto = require("crypto");
const constants = require("./constants");
const keys = require("./keys");
const groups = require("./groups");
const clients = require("./clients");
const samlSps = require("./saml/sps");
const serviceAccount = require("./ldap/serviceAccount");
const ldapSettings = require("./ldap/settings");
const db = require("@saltcorn/data/db");
const User = require("@saltcorn/data/models/user");
const { escapeHtml } = require("./web");
const { getEnv } = require("./env");
const { issuerForReq } = require("./oidc/discovery");
const ADMIN_ROLE_ID = 1;
const isAdmin = (req) => {
return !!(req && req.user && req.user.role_id === ADMIN_ROLE_ID);
};
// URL-scheme guards for admin-supplied URLs. A SAML ACS is always an http(s)
// web endpoint, so require that (a "javascript:" ACS would otherwise become a
// form action). OIDC redirect_uris may use native/custom schemes, so for those
// only reject the dangerous executable schemes.
const DANGEROUS_SCHEME = /^\s*(javascript|data|vbscript|file)\s*:/i;
const isHttpUrl = (u) => {
try {
const p = new URL(String(u)).protocol;
return p === "http:" || p === "https:";
} catch (e) {
return false;
}
};
// True if a PEM X.509 cert parses and is currently within its validity window.
// A null/empty cert is "ok" here (means "no cert supplied"); callers decide.
const certCurrentlyValid = (pem) => {
if (!pem) {
return true;
}
try {
const x = new crypto.X509Certificate(pem);
const now = Date.now();
const nb = Date.parse(x.validFrom);
const na = Date.parse(x.validTo);
return Number.isFinite(nb) && Number.isFinite(na) && now >= nb && now <= na;
} catch (e) {
return false;
}
};
// Lightweight per-session rate limit on admin MUTATIONS (defence-in-depth on top
// of the role gate). In-memory sliding window keyed by session id (falls back to
// IP). Generous enough never to trip normal admin use or the gates.
const adminHits = new Map();
const adminRateOk = (req) => {
const key = (req.sessionID || (req.session && req.session.id) || (req.connection && req.connection.remoteAddress) || "anon");
const now = Date.now();
const window = constants.ADMIN_RATE_WINDOW_MS;
const hits = (adminHits.get(key) || []).filter((t) => now - t < window);
if (hits.length >= constants.ADMIN_RATE_MAX) {
adminHits.set(key, hits);
return false;
}
hits.push(now);
adminHits.set(key, hits);
return true;
};
const csrfField = (req) => {
const token = req.csrfToken ? req.csrfToken() : "";
return ``;
};
const layout = (title, body) => {
return `
Generates a new active signing key (new kid). The previous key keeps verifying issued tokens (stays in JWKS as retiring) until its grace window elapses, then drops out.
`;
res.type("text/html").send(layout("saltcorn-idp dashboard", body));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] dashboard failed:`, e);
res.status(500).type("text/plain").send("dashboard unavailable");
}
};
const rotateKeyHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
try {
await keys.rotateActiveKey();
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] key rotation failed:`, e);
}
res.redirect(constants.ADMIN_BASE_PATH);
};
const groupsPage = async (req, res) => {
if (!isAdmin(req)) {
res.status(403).type("text/plain").send("admin only");
return;
}
try {
const all = await groups.listGroups();
let rows = "";
for (const g of all) {
const members = await groups.membersOf(g.id);
let memberHtml = "";
for (const member of members) {
// memberships are keyed by email now; the row already carries it.
const label = member.user_email;
memberHtml += ` `;
}
rows += `
${escapeHtml(g.name)}
${memberHtml || '(none)'}
`;
}
const body = `
Groups
The OIDC groups claim = each user's Saltcorn role (as role:<name>) plus these custom groups (as group:<name>).
Group
Members
${rows || '
no groups yet
'}
Create group
`;
res.type("text/html").send(layout("saltcorn-idp groups", body));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] groups page failed:`, e);
res.status(500).type("text/plain").send("groups unavailable");
}
};
const requireAdmin = (req, res) => {
if (!isAdmin(req)) {
res.status(403).type("text/plain").send("admin only");
return false;
}
// requireAdmin gates the mutating handlers; throttle POSTs per session.
if (req.method === "POST" && !adminRateOk(req)) {
res.status(429).type("text/plain").send("too many requests");
return false;
}
return true;
};
const createGroupHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const name = String((req.body && req.body.name) || "").trim();
if (name) {
try {
await groups.createGroup(name);
} catch (e) {
// unique-name violation or similar; ignore and re-render
}
}
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
};
const deleteGroupHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const id = parseInt(req.body && req.body.id, 10);
if (Number.isFinite(id)) {
await groups.deleteGroup(id);
}
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
};
const addMemberHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
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.email);
}
}
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
};
const removeMemberHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const groupId = parseInt(req.body && req.body.group_id, 10);
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");
};
const clientsPage = async (req, res) => {
if (!isAdmin(req)) {
res.status(403).type("text/plain").send("admin only");
return;
}
try {
const all = await clients.listClients();
let rows = "";
for (const c of all) {
const uris = JSON.parse(c.redirect_uris).map((u) => `${escapeHtml(u)}`).join(" ");
rows += `
Only registered SPs receive assertions, and only at an allow-listed ACS URL. A signing cert enables (and "require signed" enforces) AuthnRequest signature verification.
entityID
label
ACS URLs
req signed
cert
${rows || '
no SPs yet
'}
Register SP
`;
res.type("text/html").send(layout("saltcorn-idp saml sps", body));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml sps page failed:`, e);
res.status(500).type("text/plain").send("saml sps unavailable");
}
};
const createSamlSpHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const entityId = String((req.body && req.body.entity_id) || "").trim();
const acsUrls = parseUris(req.body && req.body.acs_urls);
// An SP with no ACS URL is unusable and a hazard: the SSO/SLO handlers would
// have nothing to validate a request-supplied ACS against. Reject it here so
// a no-ACS SP never reaches the registry (defence-in-depth over the request
// handlers, which also reject an empty allow-list).
const signingCert = String((req.body && req.body.signing_cert) || "").trim() || null;
// Reject: no entityId, no/empty ACS list, any ACS that is not an http(s) URL
// (a SAML ACS is always a web endpoint; this blocks a "javascript:" ACS from
// becoming the auto-POST form action), or an expired/unparseable signing cert.
if (!entityId || acsUrls.length === 0 || !acsUrls.every(isHttpUrl) || !certCurrentlyValid(signingCert)) {
res.redirect(constants.ADMIN_BASE_PATH + "/saml-sps");
return;
}
try {
await samlSps.createSp({
entityId: entityId,
label: String((req.body && req.body.label) || "").trim(),
acsUrls: acsUrls,
signingCert: signingCert,
wantSigned: !!(req.body && req.body.want_signed)
});
} catch (e) {
// duplicate entity_id or similar; fall through to re-render
}
res.redirect(constants.ADMIN_BASE_PATH + "/saml-sps");
};
const deleteSamlSpHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const entityId = String((req.body && req.body.entity_id) || "").trim();
if (entityId) {
await samlSps.deleteSp(entityId);
}
res.redirect(constants.ADMIN_BASE_PATH + "/saml-sps");
};
// The LDAP listener is process-global (one per instance), so its host/port/enable
// settings are editable only on the root/public site; tenant admins see them
// read-only. isRootTenant() is true on a single-tenant (SQLite) deployment.
const isRootTenant = () => {
if (!db.is_it_multi_tenant || !db.is_it_multi_tenant()) {
return true;
}
const def = String((db.connectObj && db.connectObj.default_schema) || "public").toLowerCase();
const cur = String((db.getTenantSchema && db.getTenantSchema()) || "").toLowerCase();
return cur === def;
};
// Whether the running listener (applied) differs from the resolved desired
// settings (runtime) -- i.e. a restart is needed for an edit to take effect.
const ldapAppliedDiffers = (applied, runtime) => {
const a = applied || { enabled: false };
if (!a.enabled && !runtime.enabled) {
return false;
}
if (!!a.enabled !== !!runtime.enabled) {
return true;
}
return a.host !== runtime.host || a.port !== runtime.port;
};
const LDAP_SETTINGS_ERRORS = {
port: "Port must be an integer between 1 and 65535.",
host: "Bind host contains invalid characters.",
noport: "Enabling the LDAP listener requires a valid port."
};
const ldapListenerSection = async (req) => {
const runtime = await ldapSettings.resolveRuntime();
const applied = await ldapSettings.getApplied();
const effective = runtime.enabled ? `${escapeHtml(runtime.host)}:${escapeHtml(String(runtime.port))}` : "disabled";
const running = (applied && applied.enabled) ? `${escapeHtml(applied.host)}:${escapeHtml(String(applied.port))}` : "disabled";
const statusTable = `
Currently running
${running}
Effective after restart
${effective}
`;
if (!isRootTenant()) {
return `
LDAP listener
One LDAPS listener serves all tenants on this Saltcorn instance; it is configured on the public site (read-only here).
An LDAP environment variable (${escapeHtml(constants.LDAP_PORT_ENV)} / ${escapeHtml(constants.LDAP_HOST_ENV)}) is set and currently OVERRIDES the values below until it is removed.
Warning: bind host ${escapeHtml(stored.host)} is beyond loopback — the listener will be reachable from the network. Ensure this is intended and firewalled.
` : "";
return `
LDAP listener (this server)
One LDAPS listener serves all tenants on this Saltcorn instance. Changes apply on the next Saltcorn restart.
A service DN + password for the search-then-bind flow (an application binds as this DN, searches for a user, then re-binds as that user to validate the password). The password is sealed at rest and never displayed.