// 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 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 ` ${escapeHtml(title)} ${body}

${escapeHtml(constants.PLUGIN_NAME)} v${escapeHtml(constants.PLUGIN_VERSION)}

`; }; const dashboard = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { // Opportunistically sweep RETIRING keys whose grace window has elapsed to // RETIRED (drops them from JWKS) so admins don't need a separate cron. await keys.retireExpiredKeys(); const env = await getEnv(); const jwks = await keys.getJwks(); const active = await keys.getActiveKeyMeta(); const issuer = issuerForReq(req); const body = `
Env ID${escapeHtml(env ? env.env_id : "?")}
Bootstrapped at${escapeHtml(env ? env.bootstrapped_at : "")}
Issuer${escapeHtml(issuer)}
Active signing key (kid)${escapeHtml(active ? active.kid : "(none)")}
Active key status${escapeHtml(active ? constants.KEY_STATUS.ACTIVE : "(none)")}
Active key created at${escapeHtml(active ? active.created_at : "")}
Signing alg${escapeHtml(active ? active.alg : "")}
Published JWKS keys${escapeHtml(jwks.keys.length)}
Discovery${escapeHtml(constants.WELL_KNOWN_OPENID)}
JWKS${escapeHtml(constants.JWKS_PATH)}

Signing key rotation

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.

${csrfField(req)}
`; 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) { const u = await User.findOne({ id: member.user_id }); const label = u ? u.email : ("user#" + member.user_id); memberHtml += `
${csrfField(req)}${escapeHtml(label)}

`; } rows += ` ${escapeHtml(g.name)} ${memberHtml || '(none)'}
${csrfField(req)}
${csrfField(req)}
`; } const body = `

Groups

The OIDC groups claim = each user's Saltcorn role (as role:<name>) plus these custom groups (as group:<name>).

${rows || ''}
GroupMembers
no groups yet

Create group

${csrfField(req)}
`; 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) { const u = await User.findOne({ email: email }); if (u) { await groups.addMember(groupId, u.id); } } 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 userId = parseInt(req.body && req.body.user_id, 10); if (Number.isFinite(groupId) && Number.isFinite(userId)) { await groups.removeMember(groupId, userId); } 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 += ` ${escapeHtml(c.client_id)} ${escapeHtml(c.label || "")} ${escapeHtml(c.token_auth_method)} ${uris} ${escapeHtml(c.scope || "")}
${csrfField(req)}
`; } const body = `

Clients (relying parties)

${rows || ''}
client_idlabelauthredirect URIsscope
no clients yet

Register client

${csrfField(req)}

client_id

label

redirect URIs (one per line)

auth method

scope

`; res.type("text/html").send(layout("saltcorn-idp clients", body)); } catch (e) { // eslint-disable-next-line no-console console.error(`[${constants.PLUGIN_NAME}] clients page failed:`, e); res.status(500).type("text/plain").send("clients unavailable"); } }; const parseUris = (text) => { return String(text || "").split(/[\r\n]+/).map((s) => s.trim()).filter(Boolean); }; const createClientHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } const clientId = String((req.body && req.body.client_id) || "").trim(); const redirectUris = parseUris(req.body && req.body.redirect_uris); // Reject executable-scheme redirect URIs (javascript:/data:/...). Custom // native-app schemes + loopback are allowed (oidc-provider validates the rest). if (!clientId || redirectUris.some((u) => DANGEROUS_SCHEME.test(u))) { res.redirect(constants.ADMIN_BASE_PATH + "/clients"); return; } let created = null; try { created = await clients.createClient({ clientId: clientId, label: String((req.body && req.body.label) || "").trim(), redirectUris: redirectUris, authMethod: String((req.body && req.body.auth_method) || "none"), scope: String((req.body && req.body.scope) || "").trim() }); } catch (e) { res.redirect(constants.ADMIN_BASE_PATH + "/clients"); return; } if (created && created.secret) { const body = `

Client registered

client_id: ${escapeHtml(created.client_id)}

Client secret (shown once - copy it now):

${escapeHtml(created.secret)}

Back to clients

`; res.type("text/html").send(layout("client secret", body)); } else { res.redirect(constants.ADMIN_BASE_PATH + "/clients"); } }; const deleteClientHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } const clientId = String((req.body && req.body.client_id) || "").trim(); if (clientId) { await clients.deleteClient(clientId); } res.redirect(constants.ADMIN_BASE_PATH + "/clients"); }; const samlSpsPage = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const all = await samlSps.listSps(); let rows = ""; for (const s of all) { const urls = samlSps.acsUrls(s).map((u) => `${escapeHtml(u)}`).join("
"); rows += ` ${escapeHtml(s.entity_id)} ${escapeHtml(s.label || "")} ${urls} ${s.want_authn_requests_signed ? "yes" : "no"} ${s.signing_cert ? "yes" : "no"}
${csrfField(req)}
`; } const body = `

SAML service providers

Only registered SPs receive assertions, and only at an allow-listed ACS URL. A signing cert enables (and "require signed" enforces) AuthnRequest signature verification.

${rows || ''}
entityIDlabelACS URLsreq signedcert
no SPs yet

Register SP

${csrfField(req)}

entityID

label

ACS URLs (one per line)

signing cert (PEM, optional)

`; 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"); }; const ldapServicePage = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const dn = await serviceAccount.getServiceDn(); const body = `

LDAP service account

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.

Configured service DN${dn ? `${escapeHtml(dn)}` : '(none)'}

Set service account

${csrfField(req)}

service DN

password

Clear

${csrfField(req)}
`; res.type("text/html").send(layout("saltcorn-idp ldap", body)); } catch (e) { // eslint-disable-next-line no-console console.error(`[${constants.PLUGIN_NAME}] ldap service page failed:`, e); res.status(500).type("text/plain").send("ldap admin unavailable"); } }; const setLdapServiceHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } const dn = String((req.body && req.body.dn) || "").trim(); const password = String((req.body && req.body.password) || ""); if (dn && password) { await serviceAccount.setServiceAccount(dn, password); } res.redirect(constants.ADMIN_BASE_PATH + "/ldap"); }; const clearLdapServiceHandler = async (req, res) => { if (!requireAdmin(req, res)) { return; } await serviceAccount.setServiceAccount(null, null); res.redirect(constants.ADMIN_BASE_PATH + "/ldap"); }; const adminRoutes = [ { url: constants.ADMIN_BASE_PATH, method: "get", callback: dashboard }, { url: constants.ADMIN_BASE_PATH + "/", method: "get", callback: dashboard }, { url: constants.ADMIN_BASE_PATH + "/rotate-key", method: "post", callback: rotateKeyHandler }, { url: constants.ADMIN_BASE_PATH + "/clients", method: "get", callback: clientsPage }, { url: constants.ADMIN_BASE_PATH + "/clients/create", method: "post", callback: createClientHandler }, { url: constants.ADMIN_BASE_PATH + "/clients/delete", method: "post", callback: deleteClientHandler }, { url: constants.ADMIN_BASE_PATH + "/groups", method: "get", callback: groupsPage }, { url: constants.ADMIN_BASE_PATH + "/groups/create", method: "post", callback: createGroupHandler }, { url: constants.ADMIN_BASE_PATH + "/groups/delete", method: "post", callback: deleteGroupHandler }, { url: constants.ADMIN_BASE_PATH + "/groups/addmember", method: "post", callback: addMemberHandler }, { url: constants.ADMIN_BASE_PATH + "/groups/removemember", method: "post", callback: removeMemberHandler }, { url: constants.ADMIN_BASE_PATH + "/saml-sps", method: "get", callback: samlSpsPage }, { url: constants.ADMIN_BASE_PATH + "/saml-sps/create", method: "post", callback: createSamlSpHandler }, { url: constants.ADMIN_BASE_PATH + "/saml-sps/delete", method: "post", callback: deleteSamlSpHandler }, { url: constants.ADMIN_BASE_PATH + "/ldap", method: "get", callback: ldapServicePage }, { url: constants.ADMIN_BASE_PATH + "/ldap/service", method: "post", callback: setLdapServiceHandler }, { url: constants.ADMIN_BASE_PATH + "/ldap/service/clear", method: "post", callback: clearLdapServiceHandler } ]; module.exports = { adminRoutes, isAdmin, escapeHtml };