15 KiB
Architecture
saltcorn-idp turns a Saltcorn instance into an SSO Identity Provider that
speaks OIDC/OAuth2, LDAP (with groups), and SAML 2.0. This document describes
how the plugin integrates with Saltcorn, how its modules are laid out, how
routing is split between public machine endpoints and CSRF-protected admin
pages, and how a single identity/group model feeds all three protocols. For
protocol-specific detail see the sibling docs: ./oidc.md,
./ldap.md, ./saml.md,
./configuration.md, and
./operations.md.
Integration model
Plugin export surface (routes, not authentication)
The plugin entry point is index.js. It exports the standard Saltcorn plugin
shape:
module.exports = {
sc_plugin_api_version: 1,
plugin_name: PLUGIN_NAME, // "saltcorn-idp"
onLoad: onLoad,
routes: routes
};
The plugin integrates through the routes export, not through Saltcorn's
authentication export. This is deliberate: the IdP does not replace or wrap
Saltcorn's own login. Instead it mounts its own HTTP endpoints (OIDC, SAML,
admin UI) as plugin routes and, where a user needs to log in, redirects to
Saltcorn's existing /auth/login flow and reads back the resulting
req.user. Saltcorn remains the authentication authority; the plugin layers
the federation protocols on top.
routes is assembled in lib/routes.js by concatenating four route arrays:
const routes = [].concat(oidcRoutes, interactionRoutes, samlRoutes, adminRoutes);
| Route group | Source module |
|---|---|
oidcRoutes |
lib/oidc/routes.js |
interactionRoutes |
lib/oidc/interactions.js |
samlRoutes |
lib/saml/routes.js |
adminRoutes |
lib/adminUi.js |
onLoad lifecycle
onLoad(cfg) in index.js runs once per tenant schema in multi-tenant mode,
so per-tenant tables and the per-tenant signing key are created for each
tenant. The steps, in order:
createAllTables()(lib/schema.js) -- idempotent DDL for the_idp_*tables.initEnvIfMissing()(lib/env.js) -- create the singleton_idp_envrow if absent; returns the env row.ensureActiveKey()(lib/keys.js) -- bootstrap or fetch the per-tenant RSA signing keypair; returns key metadata includingkid.ensureSamlCert()(lib/saml/idp.js) -- ensure the per-tenant SAML self-signed signing certificate exists.- If
env.bootstrapped_atis not set:markBootstrapped(env.env_id)records the first-run timestamp and logs abootstrappedline; otherwise aloadedline is logged. Both log lines includeenv_idand signingkid. ensureCsrfBypass()-- register the CSRF exemption for the/idp/namespace (see below).startLdap()(lib/ldap/server.js) -- start the LDAPS listener. This only binds ifSALTCORN_IDP_LDAP_PORTis set; a module-level guard ensures the listener starts once per process despiteonLoadrunning per tenant.
A failure in the bootstrap steps (1-6) is logged and re-thrown so it is visible
-- but only after step 7 is attempted. startLdap() runs in its own
try/catch and its errors are logged but not re-thrown, so an LDAP bind
failure never blocks the rest of the plugin from loading. The decoupling is
deliberate: a transient bootstrap error must not silently skip the LDAP bind,
and conversely an LDAP bind failure must not take down OIDC/SAML.
CSRF bypass for /idp
ensureCsrfBypass() (index.js) registers the public /idp/ namespace as
CSRF-exempt at the Saltcorn level. The endpoints under /idp/ are
machine-driven (OIDC discovery, JWKS, authorize/token/userinfo, SAML message
exchange) and are never submitted from Saltcorn browser forms; oidc-provider
manages its own CSRF/state. The mechanism:
- Read the global (root-state)
disable_csrf_routesconfig viagetState().getConfig("disable_csrf_routes", ""). - Split it on commas, trim, and drop empties.
- If the wanted entry
IDP_BASE_PATH + "/"(that is,/idp/) is not already present, append it and persist withgetState().setConfig(...).
This is a global config evaluated at startup. Admin pages under /admin/idp
are deliberately not added, so they remain CSRF-protected. The individual
OIDC and SAML route definitions also carry noCsrf: true on each entry as a
second, route-level signal (see the routing split below).
Issuer derivation per request/host
The OIDC issuer is derived per request in lib/oidc/discovery.js by
issuerForReq(req):
- Prefer the tenant's configured
base_url(getState().getConfig("base_url", "")) -- the trustworthy source. - If
base_urlis empty, fall back toreq.protocol + "://" + req.get("host")and log a warning. - Strip trailing slashes, then append
IDP_BASE_PATH(/idp).
For example, base_url = "https://example.com" yields the issuer
https://example.com/idp. The issuer must exactly match the URL prefix a
relying party used to fetch /idp/.well-known/openid-configuration.
Security note (from the source comment): the request-host fallback is
vulnerable to Host-header injection -- a forged Host could poison the
advertised issuer and endpoints. base_url should be set in any multi-tenant
or proxied deployment; the fallback exists for single-tenant localhost/dev.
The same issuerForReq derivation also keys the per-issuer cache of
oidc-provider instances and the per-issuer SAML IdP entity, so issuer is the
effective per-tenant routing key for protocol endpoints. See
./configuration.md for base_url guidance.
Module layout
The plugin code lives under idp/:
index.js Plugin entry: onLoad lifecycle, CSRF bypass, exports
lib/
routes.js Aggregates oidc + interaction + saml + admin route arrays
constants.js Single source of truth for paths, table names, signing params
schema.js Idempotent DDL for all _idp_* tables
env.js _idp_env singleton (env_id, bootstrap timestamp)
keys.js Per-tenant RSA signing key lifecycle + JWKS
crypto.js AES-256-GCM sealing, HKDF derivation, RSA/JWK helpers
claims.js Saltcorn user -> OIDC claims + SAML attributes
groups.js Group membership model (roles-as-groups + custom groups)
clients.js OIDC relying-party (client) registry
web.js HTML escaping helper
adminUi.js Admin pages + admin route definitions (role-gated, CSRF)
oidc/
routes.js OIDC endpoint definitions; delegate() + body re-stream
provider.js Per-issuer oidc-provider instance build + cache
adapter.js oidc-provider storage adapter (single _idp_oidc_store table)
discovery.js issuerForReq() issuer derivation
interactions.js Login + consent interaction handlers
ldap/
server.js LDAPS listener startup + bind retry
bind.js Bind handler (user + service account)
search.js Search handler (people + groupOfNames)
dn.js DN construction + RFC 4514 escaping
tenant.js Tenant extraction/validation from DN
harden.js Per-IP bind lockout
serviceAccount.js Service-account credential storage
vendor.js Single ldapjs require chokepoint + per-message byte cap
saml/
idp.js IdP entity construction + signing certificate management
routes.js SAML endpoint handlers (metadata, sso, init, slo)
sps.js Service Provider registry + ACS allow-list enforcement
scripts/
installIdpTenant.js / .sh Per-tenant installer (Postgres multi-tenant)
Each protocol lives in its own subdirectory (lib/oidc, lib/ldap,
lib/saml). The shared modules at lib/* are reused across all three:
lib/crypto.js-- AES-256-GCM sealing of private key material and HKDF-based key derivation (used by OIDC signing keys, SAML certificate, client secrets, and the LDAP service-account password).lib/keys.js-- the RS256 signing key lifecycle and JWKS, consumed by the OIDC provider.lib/claims.jsandlib/groups.js-- the single identity/group source shared by OIDC, LDAP, and SAML (detailed below).lib/constants.js-- one source of truth for route paths, table names, signing algorithm (RS256), and RSA modulus size (2048).
Routing split: public /idp vs admin /admin/idp
Routing is split into two namespaces, defined in lib/constants.js:
| Constant | Value | Purpose |
|---|---|---|
IDP_BASE_PATH |
/idp |
Public, machine-driven, CSRF-exempt |
ADMIN_BASE_PATH |
/admin/idp |
Browser admin pages, CSRF-protected |
Public machine endpoints (/idp, CSRF-exempt)
Defined in lib/oidc/routes.js, lib/oidc/interactions.js, and
lib/saml/routes.js. Every entry carries noCsrf: true. The OIDC endpoints in
lib/oidc/routes.js are handled by a single delegate callback that hands the
request to the per-issuer oidc-provider instance:
| URL (constant) | Methods |
|---|---|
/idp/.well-known/openid-configuration (WELL_KNOWN_OPENID) |
GET |
/idp/jwks (JWKS_PATH) |
GET |
/idp/auth (AUTH_PATH) |
GET, POST |
/idp/auth/:uid (AUTH_RESUME_PATH) |
GET, POST |
/idp/token (TOKEN_PATH) |
POST |
/idp/me (USERINFO_PATH) |
GET, POST |
The plugin-hosted interaction endpoints (lib/oidc/interactions.js):
| URL (constant) | Methods |
|---|---|
/idp/interaction/:uid (INTERACTION_PATH) |
GET |
/idp/interaction/:uid/confirm (INTERACTION_CONFIRM_PATH) |
POST |
The SAML endpoints (lib/saml/routes.js), all CSRF-exempt:
| URL | Methods | Purpose |
|---|---|---|
/idp/saml/metadata |
GET | IdP metadata |
/idp/saml/sso |
GET, POST | SP-initiated SSO |
/idp/saml/init |
GET | IdP-initiated SSO |
/idp/saml/slo |
GET, POST | Single Logout |
The delegate function in lib/oidc/routes.js adapts Saltcorn/Express
requests to oidc-provider's raw-stream handler. It looks up the per-issuer
provider via getProviderEntry(req), strips the /idp prefix to produce a
mount-relative URL while preserving originalUrl for issuer derivation, and
for non-GET/HEAD requests calls restreamBody() to rebuild a readable stream
carrying the body that Saltcorn's parser already drained (using req.rawBody
when available, otherwise re-encoding req.body as JSON or urlencoded).
Admin pages (/admin/idp, CSRF-protected)
Defined in lib/adminUi.js. These are server-rendered HTML pages and form
POSTs. They are not added to disable_csrf_routes, so Saltcorn's standard CSRF
protection applies; every form embeds a hidden _csrf field. Access is gated
to the admin role: lib/adminUi.js defines ADMIN_ROLE_ID = 1 locally and
checks req.user.role_id === ADMIN_ROLE_ID (via isAdmin(req) on GET pages
and requireAdmin(req, res) on POST handlers), returning 403 "admin only" to
non-admins. The admin UI covers dashboard, OIDC clients, groups, SAML SPs, and
the LDAP service account. See ./operations.md for the full
admin route list and workflows.
Identity and groups: a single source of truth
The plugin renders identity from Saltcorn's own user model and exposes one group model across all three protocols.
Group model
lib/groups.js defines effectiveGroups(user), the single source of truth for
a user's groups. A user's effective groups are the union of:
- their Saltcorn role, exposed as a group with the
role:prefix (looked up in_sc_rolesbyuser.role_id, emitted asrole:<role name>); and - their custom group memberships, stored in
_idp_group_membersjoined to_idp_groups, emitted with thegroup:prefix asgroup:<group name>.
The two prefixes (role: and group:) guarantee a Saltcorn role and a custom
group with the same name never collide. Custom groups are managed in the admin
UI and stored in _idp_groups (id, name unique, description,
created_at) plus the _idp_group_members junction (group_id, user_id).
One model, three protocol renderings
The same effectiveGroups() output feeds every protocol, so group membership
has exactly one definition:
| Protocol | Rendering | Source |
|---|---|---|
| OIDC | groups claim: a JSON array of prefixed strings, e.g. ["role:admin", "group:engineering"], included when the groups scope is granted |
lib/claims.js oidcClaims() calls groups.effectiveGroups(user) |
| LDAP | memberOf on the user entry (inetOrgPerson) and groupOfNames group entries, with each group mapped to a group DN |
lib/ldap/search.js consumes groups.effectiveGroups(user) |
| SAML | a groups attribute in the AttributeStatement, comma-joined for the MVP |
lib/claims.js samlAttributes() calls groups.effectiveGroups(user) |
lib/claims.js is the central claim mapper. oidcClaims(user, sub, grantedScopes) builds claims gated by granted scopes:
| Scope | Claims emitted |
|---|---|
openid |
sub (always present) |
email |
email, email_verified (!!user.verified_on) |
profile |
name (user._attributes.name, falling back to user.email) |
groups |
groups (from effectiveGroups) |
samlAttributes(user) returns email (the user's email) and groups (the
comma-joined effective groups), reusing the same group source. Because all
three renderings derive from effectiveGroups(), roles-as-groups and custom
groups stay consistent across OIDC, LDAP, and SAML without duplicated logic.
Multi-tenancy overview
The plugin is multi-tenant aware. In multi-tenant Saltcorn (Postgres
schema-per-tenant), onLoad runs once per tenant schema, so each tenant gets
its own _idp_* tables, its own RSA signing key (_idp_keys), and its own
SAML signing certificate (_idp_saml). Tenant identity flows through the
protocols as follows:
- OIDC/SAML: the issuer is derived per request by
issuerForReq(req)from the tenant'sbase_url(or the request host as a dev fallback). The issuer keys the per-issuer cache ofoidc-providerinstances and the SAML IdP entity, so each tenant signs with its own keys under its own issuer URL. - LDAP: the tenant is encoded as an extra
dc=<tenant>component in the DN, immediately before thedc=saltcorn,dc=localbase;lib/ldap/tenant.jsextracts and validates it and denies unknown or cross-tenant access.
Both the issuer-host fallback and the LDAP DN tenant handling carry security
considerations (Host-header injection on the issuer; cross-tenant denial on
LDAP). For the detailed tenancy model, installation per tenant, and the
configuration knobs, see ./configuration.md and
./operations.md.