304 lines
15 KiB
Markdown
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).
|