sc-idp/docs/architecture.md
2026-06-01 16:40:54 -05:00

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:

  1. createAllTables() (lib/schema.js) -- idempotent DDL for the _idp_* tables.
  2. initEnvIfMissing() (lib/env.js) -- create the singleton _idp_env row if absent; returns the env row.
  3. ensureActiveKey() (lib/keys.js) -- bootstrap or fetch the per-tenant RSA signing keypair; returns key metadata including kid.
  4. ensureSamlCert() (lib/saml/idp.js) -- ensure the per-tenant SAML self-signed signing certificate exists.
  5. If env.bootstrapped_at is not set: markBootstrapped(env.env_id) records the first-run timestamp and logs a bootstrapped line; otherwise a loaded line is logged. Both log lines include env_id and signing kid.
  6. ensureCsrfBypass() -- register the CSRF exemption for the /idp/ namespace (see below).
  7. startLdap() (lib/ldap/server.js) -- start the LDAPS listener. This only binds if SALTCORN_IDP_LDAP_PORT is set; a module-level guard ensures the listener starts once per process despite onLoad running 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:

  1. Read the global (root-state) disable_csrf_routes config via getState().getConfig("disable_csrf_routes", "").
  2. Split it on commas, trim, and drop empties.
  3. If the wanted entry IDP_BASE_PATH + "/" (that is, /idp/) is not already present, append it and persist with getState().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):

  1. Prefer the tenant's configured base_url (getState().getConfig("base_url", "")) -- the trustworthy source.
  2. If base_url is empty, fall back to req.protocol + "://" + req.get("host") and log a warning.
  3. 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.js and lib/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_roles by user.role_id, emitted as role:<role name>); and
  • their custom group memberships, stored in _idp_group_members joined to _idp_groups, emitted with the group: prefix as group:<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's base_url (or the request host as a dev fallback). The issuer keys the per-issuer cache of oidc-provider instances 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 the dc=saltcorn,dc=local base; lib/ldap/tenant.js extracts 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.