# 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`](./oidc.md), [`./ldap.md`](./ldap.md), [`./saml.md`](./saml.md), [`./configuration.md`](./configuration.md), and [`./operations.md`](./operations.md). ## Integration model ### Plugin export surface (routes, not authentication) The plugin entry point is `index.js`. It exports the standard Saltcorn plugin shape: ```js 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: ```js 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`](./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`](./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:`); and - their **custom group memberships**, stored in `_idp_group_members` joined to `_idp_groups`, emitted with the `group:` prefix as `group:`. 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=` 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`](./configuration.md) and [`./operations.md`](./operations.md).