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.

-
${csrfField(req)}
`; - 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 += `
${csrfField(req)}${escapeHtml(label)}

`; - } - rows += ` - ${escapeHtml(g.name)} - ${memberHtml || '(none)'} -
${csrfField(req)}
- -
${csrfField(req)}
- `; + 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 = `
${csrfField(req)}
`; + 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>).

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

Create group

-
${csrfField(req)} - -
`; - 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 || "")} -
${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.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)}
- `; - } - 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.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)} -

-

Bind host

-

Port

+

+

Bind host

+

Port

${exposureWarn} - +
`; }; @@ -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

- -
-

Clear

-
${csrfField(req)}
`; - 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": {