Stopped hand-rolling HTML. Using Saltcorn APIs.
This commit is contained in:
parent
f8c1f164f6
commit
84e139245c
4 changed files with 167 additions and 159 deletions
296
lib/adminUi.js
296
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 <html>/<head>/<body> 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 `<style>
|
||||
.muted { color: #888; }
|
||||
form.inline { display: inline; }
|
||||
input[type=text] { padding: 0.2rem; }
|
||||
button:not(.btn) { padding: 0.2rem 0.5rem; cursor: pointer; }
|
||||
</style>
|
||||
${navPills(req, links)}
|
||||
${body}
|
||||
<p class="text-muted small mt-3">${escapeHtml(constants.PLUGIN_NAME)} v${escapeHtml(constants.PLUGIN_VERSION)}</p>`;
|
||||
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 = `
|
||||
<table class="table table-sm table-bordered">
|
||||
<tr><th>Env ID</th><td><code>${escapeHtml(env ? env.env_id : "?")}</code></td></tr>
|
||||
<tr><th>Bootstrapped at</th><td>${escapeHtml(env ? env.bootstrapped_at : "")}</td></tr>
|
||||
<tr><th>Issuer</th><td><code>${escapeHtml(issuer)}</code></td></tr>
|
||||
<tr><th>Active signing key (kid)</th><td><code>${escapeHtml(active ? active.kid : "(none)")}</code></td></tr>
|
||||
<tr><th>Active key status</th><td>${escapeHtml(active ? constants.KEY_STATUS.ACTIVE : "(none)")}</td></tr>
|
||||
<tr><th>Active key created at</th><td>${escapeHtml(active ? active.created_at : "")}</td></tr>
|
||||
<tr><th>Signing alg</th><td>${escapeHtml(active ? active.alg : "")}</td></tr>
|
||||
<tr><th>Published JWKS keys</th><td>${escapeHtml(jwks.keys.length)}</td></tr>
|
||||
<tr><th>Discovery</th><td><a href="${escapeHtml(constants.WELL_KNOWN_OPENID)}">${escapeHtml(constants.WELL_KNOWN_OPENID)}</a></td></tr>
|
||||
<tr><th>JWKS</th><td><a href="${escapeHtml(constants.JWKS_PATH)}">${escapeHtml(constants.JWKS_PATH)}</a></td></tr>
|
||||
</table>
|
||||
<h2>Signing key rotation</h2>
|
||||
<p class="muted">Generates a new active signing key (new kid). The previous key keeps verifying issued tokens (stays in JWKS as <code>retiring</code>) until its grace window elapses, then drops out.</p>
|
||||
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/rotate-key">${csrfField(req)}<button>rotate signing key</button></form>`;
|
||||
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 += `<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>
|
||||
<td>${memberHtml || '<span class="muted">(none)</span>'}
|
||||
<form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/addmember">${csrfField(req)}<input type="hidden" name="group_id" value="${escapeHtml(g.id)}"><input type="text" name="email" placeholder="user email"> <button>add</button></form>
|
||||
</td>
|
||||
<td><form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/delete">${csrfField(req)}<input type="hidden" name="id" value="${escapeHtml(g.id)}"><button>delete</button></form></td>
|
||||
</tr>`;
|
||||
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 = `<form class="d-inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/addmember">${csrfField(req)}<input type="hidden" name="group_id" value="${escapeHtml(g.id)}"><input type="text" class="form-control form-control-sm d-inline-block w-auto me-1" name="email" placeholder="user email"><button class="btn btn-primary btn-sm">add</button></form>`;
|
||||
groupRows.push({
|
||||
name: code(escapeHtml(g.name)),
|
||||
members: (memberList || span({ class: "text-muted" }, "(none)")) + "<br>" + 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 = `
|
||||
<h1>Groups</h1>
|
||||
<p class="muted">The OIDC <code>groups</code> claim = each user's Saltcorn role (as <code>role:<name></code>) plus these custom groups (as <code>group:<name></code>).</p>
|
||||
<table class="table table-sm table-bordered"><tr><th>Group</th><th>Members</th><th></th></tr>${rows || '<tr><td colspan="3" class="muted">no groups yet</td></tr>'}</table>
|
||||
<h2>Create group</h2>
|
||||
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/create">${csrfField(req)}
|
||||
<input type="text" name="name" placeholder="group name" required> <button>create</button>
|
||||
</form>`;
|
||||
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) => `<code>${escapeHtml(u)}</code>`).join("<br>");
|
||||
rows += `<tr>
|
||||
<td><code>${escapeHtml(c.client_id)}</code></td>
|
||||
<td>${escapeHtml(c.label || "")}</td>
|
||||
<td>${escapeHtml(c.token_auth_method)}</td>
|
||||
<td>${uris}</td>
|
||||
<td>${escapeHtml(c.scope || "")}</td>
|
||||
<td><form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients/delete">${csrfField(req)}<input type="hidden" name="client_id" value="${escapeHtml(c.client_id)}"><button>delete</button></form></td>
|
||||
</tr>`;
|
||||
}
|
||||
const body = `
|
||||
<h1>Clients (relying parties)</h1>
|
||||
<table class="table table-sm table-bordered"><tr><th>client_id</th><th>label</th><th>auth</th><th>redirect URIs</th><th>scope</th><th></th></tr>${rows || '<tr><td colspan="6" class="muted">no clients yet</td></tr>'}</table>
|
||||
<h2>Register client</h2>
|
||||
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients/create">${csrfField(req)}
|
||||
<p>client_id <input type="text" name="client_id" required></p>
|
||||
<p>label <input type="text" name="label"></p>
|
||||
<p>redirect URIs (one per line)<br><textarea name="redirect_uris" rows="3" cols="50"></textarea></p>
|
||||
<p>auth method
|
||||
<select name="auth_method">
|
||||
<option value="none">none (public + PKCE)</option>
|
||||
<option value="client_secret_basic">client_secret_basic (confidential)</option>
|
||||
<option value="client_secret_post">client_secret_post (confidential)</option>
|
||||
</select>
|
||||
</p>
|
||||
<p>scope <input type="text" name="scope" value="openid email profile groups"></p>
|
||||
<button>register</button>
|
||||
</form>`;
|
||||
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("<br>") },
|
||||
{ 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 = `
|
||||
<h1>Client registered</h1>
|
||||
<p>client_id: <code>${escapeHtml(created.client_id)}</code></p>
|
||||
<p>Client secret (shown once - copy it now):</p>
|
||||
<p><code>${escapeHtml(created.secret)}</code></p>
|
||||
<p><a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients">Back to clients</a></p>`;
|
||||
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) => `<code>${escapeHtml(u)}</code>`).join("<br>");
|
||||
rows += `<tr>
|
||||
<td><code>${escapeHtml(s.entity_id)}</code></td>
|
||||
<td>${escapeHtml(s.label || "")}</td>
|
||||
<td>${urls}</td>
|
||||
<td>${s.want_authn_requests_signed ? "yes" : "no"}</td>
|
||||
<td>${s.signing_cert ? "yes" : "no"}</td>
|
||||
<td><form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/saml-sps/delete">${csrfField(req)}<input type="hidden" name="entity_id" value="${escapeHtml(s.entity_id)}"><button>delete</button></form></td>
|
||||
</tr>`;
|
||||
}
|
||||
const body = `
|
||||
<h1>SAML service providers</h1>
|
||||
<p class="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.</p>
|
||||
<table class="table table-sm table-bordered"><tr><th>entityID</th><th>label</th><th>ACS URLs</th><th>req signed</th><th>cert</th><th></th></tr>${rows || '<tr><td colspan="6" class="muted">no SPs yet</td></tr>'}</table>
|
||||
<h2>Register SP</h2>
|
||||
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/saml-sps/create">${csrfField(req)}
|
||||
<p>entityID <input type="text" name="entity_id" required></p>
|
||||
<p>label <input type="text" name="label"></p>
|
||||
<p>ACS URLs (one per line)<br><textarea name="acs_urls" rows="3" cols="60"></textarea></p>
|
||||
<p>signing cert (PEM, optional)<br><textarea name="signing_cert" rows="4" cols="60"></textarea></p>
|
||||
<p><label><input type="checkbox" name="want_signed" value="1"> require signed AuthnRequests</label></p>
|
||||
<button>register</button>
|
||||
</form>`;
|
||||
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("<br>") },
|
||||
{ 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) => {
|
|||
</table>`;
|
||||
if (!isRootTenant()) {
|
||||
return `<h2>LDAP listener</h2>
|
||||
<p class="muted">One LDAPS listener serves all tenants on this Saltcorn instance; it is configured on the <strong>public site</strong> (read-only here).</p>
|
||||
<p class="text-muted">One LDAPS listener serves all tenants on this Saltcorn instance; it is configured on the <strong>public site</strong> (read-only here).</p>
|
||||
${statusTable}`;
|
||||
}
|
||||
const stored = await ldapSettings.getStoredConfig();
|
||||
|
|
@ -539,19 +540,19 @@ const ldapListenerSection = async (req) => {
|
|||
const restartBanner = ldapAppliedDiffers(applied, runtime)
|
||||
? `<p style="color:#b00"><strong>⚠ LDAP settings changed — restart Saltcorn for them to take effect.</strong></p>` : "";
|
||||
const envNote = (runtime.portFromEnv || runtime.hostFromEnv)
|
||||
? `<p class="muted">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.</p>` : "";
|
||||
? `<p class="text-muted">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.</p>` : "";
|
||||
const exposureWarn = (stored.host && !ldapSettings.isLoopbackHost(stored.host))
|
||||
? `<p style="color:#b00">Warning: bind host <code>${escapeHtml(stored.host)}</code> is beyond loopback — the listener will be reachable from the network. Ensure this is intended and firewalled.</p>` : "";
|
||||
return `<h2>LDAP listener (this server)</h2>
|
||||
<p class="muted">One LDAPS listener serves all tenants on this Saltcorn instance. Changes apply on the next Saltcorn restart.</p>
|
||||
<p class="text-muted">One LDAPS listener serves all tenants on this Saltcorn instance. Changes apply on the next Saltcorn restart.</p>
|
||||
${errBanner}${restartBanner}${envNote}
|
||||
${statusTable}
|
||||
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap/settings">${csrfField(req)}
|
||||
<p><label><input type="checkbox" name="enabled" value="on" ${stored.enabled ? "checked" : ""}> Enable LDAP listener</label></p>
|
||||
<p>Bind host <input type="text" name="host" value="${escapeHtml(stored.host || "")}" placeholder="${escapeHtml(constants.LDAP_DEFAULT_HOST)}" size="22"></p>
|
||||
<p>Port <input type="number" name="port" value="${stored.port !== null ? escapeHtml(String(stored.port)) : ""}" min="${escapeHtml(String(constants.LDAP_PORT_MIN))}" max="${escapeHtml(String(constants.LDAP_PORT_MAX))}"></p>
|
||||
<p><label><input type="checkbox" class="form-check-input me-1" name="enabled" value="on" ${stored.enabled ? "checked" : ""}> Enable LDAP listener</label></p>
|
||||
<p>Bind host <input type="text" class="form-control form-control-sm d-inline-block w-auto" name="host" value="${escapeHtml(stored.host || "")}" placeholder="${escapeHtml(constants.LDAP_DEFAULT_HOST)}" size="22"></p>
|
||||
<p>Port <input type="number" class="form-control form-control-sm d-inline-block w-auto" name="port" value="${stored.port !== null ? escapeHtml(String(stored.port)) : ""}" min="${escapeHtml(String(constants.LDAP_PORT_MIN))}" max="${escapeHtml(String(constants.LDAP_PORT_MAX))}"></p>
|
||||
${exposureWarn}
|
||||
<button>save listener settings</button>
|
||||
<button class="btn btn-primary btn-sm">save listener settings</button>
|
||||
</form>`;
|
||||
};
|
||||
|
||||
|
|
@ -564,20 +565,23 @@ const ldapServicePage = async (req, res) => {
|
|||
try {
|
||||
const dn = await serviceAccount.getServiceDn();
|
||||
const listener = await ldapListenerSection(req);
|
||||
const body = `
|
||||
${listener}
|
||||
<h1>LDAP service account</h1>
|
||||
<p class="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>
|
||||
<table class="table table-sm table-bordered"><tr><th>Configured service DN</th><td>${dn ? `<code>${escapeHtml(dn)}</code>` : '<span class="muted">(none)</span>'}</td></tr></table>
|
||||
<h2>Set service account</h2>
|
||||
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap/service">${csrfField(req)}
|
||||
<p>service DN <input type="text" name="dn" placeholder="cn=svc,ou=people,dc=saltcorn,dc=local" size="55"></p>
|
||||
<p>password <input type="password" name="password"></p>
|
||||
<button>save</button>
|
||||
</form>
|
||||
<h2>Clear</h2>
|
||||
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap/service/clear">${csrfField(req)}<button>clear service account</button></form>`;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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/<name>,
|
||||
// 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
|
||||
// <script>/<meta>. CSP allows 'unsafe-inline', so the inline script redirect is
|
||||
// the primary path; the <noscript> meta-refresh and the visible button are
|
||||
// fallbacks (the link is what shows if script/meta are ever stripped).
|
||||
|
||||
const Workflow = require("@saltcorn/data/models/workflow");
|
||||
const Form = require("@saltcorn/data/models/form");
|
||||
|
|
@ -17,11 +21,11 @@ const configurationWorkflow = () =>
|
|||
name: "saltcorn-idp",
|
||||
form: async () =>
|
||||
new Form({
|
||||
blurb:
|
||||
"saltcorn-idp is configured from its own admin dashboard " +
|
||||
"(OIDC clients, groups, SAML SPs, LDAP, signing-key rotation).<br><br>" +
|
||||
`<a class="btn btn-primary" role="button" href="${ADMIN_BASE_PATH}">` +
|
||||
"Open the saltcorn-idp dashboard</a>",
|
||||
blurb: [
|
||||
`<script>window.location.replace(${JSON.stringify(ADMIN_BASE_PATH)});</script>`,
|
||||
`<noscript><meta http-equiv="refresh" content="0; url=${ADMIN_BASE_PATH}"></noscript>`,
|
||||
`<p>Opening the saltcorn-idp dashboard… <a class="btn btn-primary" role="button" href="${ADMIN_BASE_PATH}">Open the saltcorn-idp dashboard</a></p>`,
|
||||
],
|
||||
fields: []
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue