diff --git a/lib/adminUi.js b/lib/adminUi.js
index 8206f80..c058b71 100644
--- a/lib/adminUi.js
+++ b/lib/adminUi.js
@@ -18,6 +18,12 @@ const { escapeHtml } = require("./web");
const { getEnv } = require("./env");
const { issuerForReq } = require("./oidc/discovery");
+// Saltcorn native markup primitives -- the same ones core admin pages use, so
+// these pages inherit the active theme by construction.
+const { mkTable, post_btn, renderForm, link, alert, tags } = require("@saltcorn/markup");
+const { p, code, pre, div, span, strong } = tags;
+const Form = require("@saltcorn/data/models/form");
+
const ADMIN_ROLE_ID = 1;
@@ -105,28 +111,24 @@ const navPills = (req, links) => {
};
-// Returns the INNER page content only (Bootstrap sub-nav + a small residual
-// style for the non-table classes the body markup uses + body). The page is
-// wrapped in the active Saltcorn theme by res.sendWrap at the call sites, so
-// there is no /
/ here.
-const layout = (req, body) => {
- const base = constants.ADMIN_BASE_PATH;
- const links = [
- [base, "Dashboard"],
- [`${base}/clients`, "Clients"],
- [`${base}/groups`, "Groups"],
- [`${base}/saml-sps`, "SAML SPs"],
- [`${base}/ldap`, "LDAP"],
+const IDP_NAV = [
+ [constants.ADMIN_BASE_PATH, "Dashboard"],
+ [constants.ADMIN_BASE_PATH + "/clients", "Clients"],
+ [constants.ADMIN_BASE_PATH + "/groups", "Groups"],
+ [constants.ADMIN_BASE_PATH + "/saml-sps", "SAML SPs"],
+ [constants.ADMIN_BASE_PATH + "/ldap", "LDAP"],
+];
+
+
+// Render an admin page in the ACTIVE Saltcorn theme: breadcrumbs + sub-nav +
+// content segments (cards or strings), via res.sendWrap. The one rendering path;
+// pages build content with mkTable / renderForm / post_btn (no raw page HTML).
+const adminPage = (req, res, title, ...segments) => {
+ const above = [
+ { type: "breadcrumbs", crumbs: [{ text: "Settings", href: "/settings" }, { text: "saltcorn-idp" }, { text: title }] },
+ navPills(req, IDP_NAV),
];
- return `
-${navPills(req, links)}
-${body}
-${escapeHtml(constants.PLUGIN_NAME)} v${escapeHtml(constants.PLUGIN_VERSION)}
`;
+ res.sendWrap(`saltcorn-idp ${title}`, { above: above.concat(segments) });
};
@@ -143,23 +145,25 @@ const dashboard = async (req, res) => {
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.
- `;
- res.sendWrap("saltcorn-idp dashboard", layout(req, body));
+ const kvRows = [
+ { k: "Env ID", v: code(escapeHtml(env ? env.env_id : "?")) },
+ { k: "Bootstrapped at", v: escapeHtml(env ? env.bootstrapped_at : "") },
+ { k: "Issuer", v: code(escapeHtml(issuer)) },
+ { k: "Active signing key (kid)", v: code(escapeHtml(active ? active.kid : "(none)")) },
+ { k: "Active key status", v: escapeHtml(active ? constants.KEY_STATUS.ACTIVE : "(none)") },
+ { k: "Active key created at", v: escapeHtml(active ? active.created_at : "") },
+ { k: "Signing alg", v: escapeHtml(active ? active.alg : "") },
+ { k: "Published JWKS keys", v: escapeHtml(jwks.keys.length) },
+ { k: "Discovery", v: link(constants.WELL_KNOWN_OPENID, constants.WELL_KNOWN_OPENID) },
+ { k: "JWKS", v: link(constants.JWKS_PATH, constants.JWKS_PATH) },
+ ];
+ const kv = mkTable([{ label: "Setting", key: "k" }, { label: "Value", key: (r) => r.v }], kvRows);
+ const rotate = p({ class: "text-muted" }, "Generates a new active signing key (new kid). The previous key keeps verifying issued tokens (stays in JWKS as ", code("retiring"), ") until its grace window elapses, then drops out.") +
+ post_btn(constants.ADMIN_BASE_PATH + "/rotate-key", "Rotate signing key", req.csrfToken(), { req, btnClass: "btn-warning", confirm: true });
+ adminPage(req, res, "dashboard",
+ { type: "card", title: "Identity provider", contents: kv },
+ { type: "card", title: "Signing key rotation", contents: rotate }
+ );
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] dashboard failed:`, e);
@@ -189,32 +193,30 @@ const groupsPage = async (req, res) => {
}
try {
const all = await groups.listGroups();
- let rows = "";
+ const groupRows = [];
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 memberList = members.map((member) =>
+ span({ class: "d-inline-block me-2 mb-1" },
+ code(escapeHtml(member.user_email)) + " " +
+ post_btn(constants.ADMIN_BASE_PATH + "/groups/removemember", "x", req.csrfToken(), { req, btnClass: "btn-outline-danger", small: true, formClass: "d-inline", body: { group_id: g.id, user_email: member.user_email } }))
+ ).join("");
+ const addMember = ``;
+ groupRows.push({
+ name: code(escapeHtml(g.name)),
+ members: (memberList || span({ class: "text-muted" }, "(none)")) + " " + addMember,
+ actions: post_btn(constants.ADMIN_BASE_PATH + "/groups/delete", "delete", req.csrfToken(), { req, btnClass: "btn-danger", small: true, body: { id: g.id }, confirm: true }),
+ });
}
- 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.sendWrap("saltcorn-idp groups", layout(req, body));
+ const groupsTable = groupRows.length === 0
+ ? p({ class: "text-muted" }, "no groups yet")
+ : mkTable([{ label: "Group", key: (r) => r.name }, { label: "Members", key: (r) => r.members }, { label: "", key: (r) => r.actions }], groupRows);
+ const createForm = new Form({ action: constants.ADMIN_BASE_PATH + "/groups/create", submitLabel: "Create", fields: [{ name: "name", label: "Group name", type: "String", required: true }] });
+ adminPage(req, res, "groups",
+ { type: "card", title: "Groups", contents:
+ p({ class: "text-muted" }, "The OIDC ", code("groups"), " claim = each user's Saltcorn role (as ", code("role:<name>"), ") plus these custom groups (as ", code("group:<name>"), ").") + groupsTable },
+ { type: "card", title: "Create group", contents: renderForm(createForm, req.csrfToken()) }
+ );
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] groups page failed:`, e);
@@ -303,37 +305,36 @@ const clientsPage = async (req, res) => {
}
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 || "")}
-
- `;
- }
- const body = `
- Clients (relying parties)
- client_id label auth redirect URIs scope ${rows || 'no clients yet '}
- Register client
- `;
- res.sendWrap("saltcorn-idp clients", layout(req, body));
+ const clientsTable = all.length === 0
+ ? p({ class: "text-muted" }, "no clients yet")
+ : mkTable([
+ { label: "client_id", key: (c) => code(escapeHtml(c.client_id)) },
+ { label: "label", key: (c) => escapeHtml(c.label || "") },
+ { label: "auth", key: (c) => escapeHtml(c.token_auth_method) },
+ { label: "redirect URIs", key: (c) => JSON.parse(c.redirect_uris).map((u) => code(escapeHtml(u))).join(" ") },
+ { label: "scope", key: (c) => escapeHtml(c.scope || "") },
+ { label: "", key: (c) => post_btn(constants.ADMIN_BASE_PATH + "/clients/delete", "delete", req.csrfToken(), { req, btnClass: "btn-danger", small: true, body: { client_id: c.client_id }, confirm: true }) },
+ ], all);
+ const regForm = new Form({
+ action: constants.ADMIN_BASE_PATH + "/clients/create",
+ submitLabel: "Register",
+ values: { auth_method: "none", scope: "openid email profile groups" },
+ fields: [
+ { name: "client_id", label: "client_id", type: "String", required: true },
+ { name: "label", label: "label", type: "String" },
+ { name: "redirect_uris", label: "redirect URIs (one per line)", type: "String", fieldview: "textarea" },
+ { name: "auth_method", label: "auth method", input_type: "select", options: [
+ { label: "none (public + PKCE)", value: "none" },
+ { label: "client_secret_basic (confidential)", value: "client_secret_basic" },
+ { label: "client_secret_post (confidential)", value: "client_secret_post" },
+ ] },
+ { name: "scope", label: "scope", type: "String" },
+ ],
+ });
+ adminPage(req, res, "clients",
+ { type: "card", title: "Clients (relying parties)", contents: clientsTable },
+ { type: "card", title: "Register client", contents: renderForm(regForm, req.csrfToken()) }
+ );
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] clients page failed:`, e);
@@ -373,13 +374,13 @@ const createClientHandler = async (req, res) => {
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.sendWrap("client secret", layout(req, body));
+ adminPage(req, res, "client secret",
+ { type: "card", title: "Client registered", contents:
+ p("client_id: ", code(escapeHtml(created.client_id))) +
+ p("Client secret (shown once - copy it now):") +
+ pre({ class: "user-select-all p-2 bg-light text-dark border rounded", style: "white-space:pre-wrap;word-break:break-all" }, escapeHtml(created.secret)) +
+ p(link(constants.ADMIN_BASE_PATH + "/clients", "Back to clients")) }
+ );
} else {
res.redirect(constants.ADMIN_BASE_PATH + "/clients");
}
@@ -405,32 +406,32 @@ const samlSpsPage = async (req, res) => {
}
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)}delete
- `;
- }
- 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.
- entityID label ACS URLs req signed cert ${rows || 'no SPs yet '}
- Register SP
- ${csrfField(req)}
- entityID
- label
- ACS URLs (one per line)
- signing cert (PEM, optional)
- require signed AuthnRequests
- register
- `;
- res.sendWrap("saltcorn-idp saml sps", layout(req, body));
+ const spsTable = all.length === 0
+ ? p({ class: "text-muted" }, "no SPs yet")
+ : mkTable([
+ { label: "entityID", key: (s) => code(escapeHtml(s.entity_id)) },
+ { label: "label", key: (s) => escapeHtml(s.label || "") },
+ { label: "ACS URLs", key: (s) => samlSps.acsUrls(s).map((u) => code(escapeHtml(u))).join(" ") },
+ { label: "req signed", key: (s) => s.want_authn_requests_signed ? "yes" : "no" },
+ { label: "cert", key: (s) => s.signing_cert ? "yes" : "no" },
+ { label: "", key: (s) => post_btn(constants.ADMIN_BASE_PATH + "/saml-sps/delete", "delete", req.csrfToken(), { req, btnClass: "btn-danger", small: true, body: { entity_id: s.entity_id }, confirm: true }) },
+ ], all);
+ const regForm = new Form({
+ action: constants.ADMIN_BASE_PATH + "/saml-sps/create",
+ submitLabel: "Register",
+ fields: [
+ { name: "entity_id", label: "entityID", type: "String", required: true },
+ { name: "label", label: "label", type: "String" },
+ { name: "acs_urls", label: "ACS URLs (one per line)", type: "String", fieldview: "textarea" },
+ { name: "signing_cert", label: "signing cert (PEM, optional)", type: "String", fieldview: "textarea" },
+ { name: "want_signed", label: "require signed AuthnRequests", type: "Bool" },
+ ],
+ });
+ adminPage(req, res, "saml sps",
+ { type: "card", title: "SAML service providers", contents:
+ p({ class: "text-muted" }, "Only registered SPs receive assertions, and only at an allow-listed ACS URL. A signing cert enables (and \"require signed\" enforces) AuthnRequest signature verification.") + spsTable },
+ { type: "card", title: "Register SP", contents: renderForm(regForm, req.csrfToken()) }
+ );
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml sps page failed:`, e);
@@ -529,7 +530,7 @@ const ldapListenerSection = async (req) => {
`;
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).
+ One LDAPS listener serves all tenants on this Saltcorn instance; it is configured on the public site (read-only here).
${statusTable}`;
}
const stored = await ldapSettings.getStoredConfig();
@@ -539,19 +540,19 @@ const ldapListenerSection = async (req) => {
const restartBanner = ldapAppliedDiffers(applied, runtime)
? `⚠ LDAP settings changed — restart Saltcorn for them to take effect.
` : "";
const envNote = (runtime.portFromEnv || runtime.hostFromEnv)
- ? `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.
` : "";
+ ? `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.
` : "";
const exposureWarn = (stored.host && !ldapSettings.isLoopbackHost(stored.host))
? `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.
+ One LDAPS listener serves all tenants on this Saltcorn instance. Changes apply on the next Saltcorn restart.
${errBanner}${restartBanner}${envNote}
${statusTable}
${csrfField(req)}
- Enable LDAP listener
- Bind host
- Port
+ Enable LDAP listener
+ Bind host
+ Port
${exposureWarn}
- save listener settings
+ save listener settings
`;
};
@@ -564,20 +565,23 @@ const ldapServicePage = async (req, res) => {
try {
const dn = await serviceAccount.getServiceDn();
const listener = await ldapListenerSection(req);
- const body = `
- ${listener}
- 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
- save
-
- Clear
- ${csrfField(req)}clear service account `;
- res.sendWrap("saltcorn-idp ldap", layout(req, body));
+ const setForm = new Form({
+ action: constants.ADMIN_BASE_PATH + "/ldap/service",
+ submitLabel: "Save",
+ fields: [
+ { name: "dn", label: "service DN", type: "String", attributes: { placeholder: "cn=svc,ou=people,dc=saltcorn,dc=local" } },
+ { name: "password", label: "password", type: "String", input_type: "password" },
+ ],
+ });
+ adminPage(req, res, "ldap",
+ { type: "card", title: "LDAP listener", contents: listener },
+ { type: "card", title: "LDAP service account", contents:
+ p({ class: "text-muted" }, "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.") +
+ p("Configured service DN: ", dn ? code(escapeHtml(dn)) : span({ class: "text-muted" }, "(none)")) +
+ renderForm(setForm, req.csrfToken()) },
+ { type: "card", title: "Clear", contents:
+ post_btn(constants.ADMIN_BASE_PATH + "/ldap/service/clear", "Clear service account", req.csrfToken(), { req, btnClass: "btn-outline-danger", confirm: true }) }
+ );
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] ldap service page failed:`, e);
diff --git a/lib/configWorkflow.js b/lib/configWorkflow.js
index 8526a6e..e467a39 100644
--- a/lib/configWorkflow.js
+++ b/lib/configWorkflow.js
@@ -1,9 +1,13 @@
-// Minimal configuration_workflow. Its only job is to make saltcorn-idp show the
-// standard "Configure" cog on the Settings -> Plugins list, consistent with
-// other plugins (the cog renders iff the module exports configuration_workflow
-// -- see server/routes/plugins.js cfg_link). saltcorn-idp is actually configured
-// from its own admin dashboard under ADMIN_BASE_PATH (/admin/idp), so the single
-// step just links there.
+// The plugin "gear" on Settings -> Plugins always opens /plugins/configure/,
+// which renders THIS workflow's form (the core configure route has no redirect
+// hook). saltcorn-idp's real settings live in its own admin dashboard, so this
+// single step bounces straight there with NO extra click.
+//
+// NOTE: blurb MUST be an ARRAY -- the array path is rendered raw (form.ts join),
+// whereas a string blurb is run through the text() XSS whitelist, which strips
+// `,
+ ` `,
+ `Opening the saltcorn-idp dashboard… Open the saltcorn-idp dashboard
`,
+ ],
fields: []
})
}
diff --git a/lib/constants.js b/lib/constants.js
index b884211..351e36a 100644
--- a/lib/constants.js
+++ b/lib/constants.js
@@ -5,7 +5,7 @@
// crypto.js; protocol/policy values live here.
const PLUGIN_NAME = "saltcorn-idp";
-const PLUGIN_VERSION = "0.0.3";
+const PLUGIN_VERSION = "0.0.5";
// Public OIDC/OAuth2 + machine endpoints live under this path and are
// CSRF-exempt. Admin (browser, CSRF-protected) pages live under ADMIN_BASE_PATH.
diff --git a/package.json b/package.json
index 34f4a47..69616ea 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "saltcorn-idp",
- "version": "0.0.3",
+ "version": "0.0.5",
"description": "Saltcorn plugin: turns Saltcorn into an SSO Identity Provider (OIDC/OAuth2, LDAP with groups, and SAML 2.0). Per-tenant asymmetric signing keys sealed at rest; multi-tenant. See VENDORING.md for the dependency-ownership/security posture.",
"main": "index.js",
"scripts": {