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

304 lines
15 KiB
Markdown

# 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:<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`](./configuration.md) and
[`./operations.md`](./operations.md).