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

300 lines
16 KiB
Markdown

# Configuration Reference
This is the complete configuration reference for the `saltcorn-idp` plugin. It
covers the environment variables the plugin reads, the tunable constants baked
into `lib/constants.js` and `lib/crypto.js`, every `_idp_*` database table and
its columns, and the admin UI pages. Every value below is grounded in the plugin
source; file and constant names are given so they can be verified.
For protocol-specific detail see the sibling docs:
[`./architecture.md`](./architecture.md) (integration model and multi-tenancy
overview), [`./oidc.md`](./oidc.md), [`./ldap.md`](./ldap.md),
[`./saml.md`](./saml.md), [`./operations.md`](./operations.md), and
[`./security.md`](./security.md).
---
## Environment variables
The plugin itself reads only the three variables below. They are read in
`lib/crypto.js` (`SALTCORN_SESSION_SECRET`) and `lib/ldap/server.js` /
`lib/constants.js` (the two LDAP variables).
| Variable | Required | Default | Effect |
|----------|----------|---------|--------|
| `SALTCORN_SESSION_SECRET` | Yes (see note) | None. Falls back to Saltcorn's `session_secret` config when unset; if neither is available, deriving the KEK throws `"saltcorn-idp: SALTCORN_SESSION_SECRET not available; cannot derive KEK"`. | Source material for two derivations: (1) the at-rest KEK (AES-256-GCM, via HKDF-SHA256) that seals RSA signing keys, the SAML private key, OIDC client secrets, and the LDAP service-account password; (2) the deterministic oidc-provider cookie-signing keys (so interaction/session cookies survive a restart). Rotating this value re-derives the KEK and invalidates all previously sealed data. |
| `SALTCORN_IDP_LDAP_PORT` | No | Unset. When unset, `startLdap()` returns early and no LDAP listener is started. | Enables the in-process LDAPS listener and sets its TCP port. Parsed with `parseInt(..., 10)`; a non-finite value is ignored (no listener). The listener binds only in the cluster primary process. Constant name: `LDAP_PORT_ENV`. |
| `SALTCORN_IDP_LDAP_HOST` | No | `127.0.0.1` (constant `LDAP_DEFAULT_HOST`). An empty or whitespace-only value also falls back to the default. | Interface the LDAPS listener binds to. Loopback by default so LDAP is not network-exposed; set to `0.0.0.0` to serve LDAP clients on other hosts. Binding beyond loopback logs a network-exposure warning. Constant name: `LDAP_HOST_ENV`. |
Notes:
- `SALTCORN_SESSION_SECRET` "required" means the plugin cannot seal/unseal data
without it. In a normal Saltcorn deployment the `session_secret` config
provides the fallback, so the explicit environment variable is required only
where that config is not present (for example, code paths outside a request
context).
- `SALTCORN_JWT_SECRET` is **not** read by this plugin. It appears in the
Postgres dev instance's `env.sh` but is consumed by Saltcorn core, not by
`saltcorn-idp`. (Verified: no reference to `SALTCORN_JWT_SECRET` exists in the
plugin source.)
- `SALTCORN_MULTI_TENANT` is likewise **not** read directly by the plugin. The
plugin detects multi-tenancy by calling Saltcorn's `db.is_it_multi_tenant()`
(used in `lib/ldap/tenant.js`), which reflects Saltcorn's own configuration.
### Related Saltcorn configuration (not plugin env vars)
These are Saltcorn config values (read/written through `getState().getConfig` /
`setConfig`), not environment variables, but they affect plugin behavior:
| Config key | Effect |
|------------|--------|
| `base_url` | Preferred source for OIDC issuer and SAML entityID derivation (`lib/oidc/discovery.js`). When unset, the issuer is derived from the request `Host` header and a warning is logged: `"base_url not set; deriving issuer from request Host ... Set base_url to prevent Host-header issuer poisoning."` Set `base_url` in any multi-tenant or proxied deployment. |
| `session_secret` | Fallback source for the KEK when `SALTCORN_SESSION_SECRET` is unset (`lib/crypto.js`). |
| `disable_csrf_routes` | The plugin appends `/idp/` to this list during `onLoad` so the machine-driven `/idp` endpoints are CSRF-exempt (`index.js`). Admin pages under `/admin/idp` remain CSRF-protected. |
---
## Tunable constants
All values below are defined in `lib/constants.js` unless marked otherwise.
Crypto byte sizes live in `lib/crypto.js`.
### Signing
| Constant | Value | Effect |
|----------|-------|--------|
| `SIGNING_ALG` | `RS256` | Algorithm for OIDC id_token signing (and the required SAML signature is RSA-SHA256, below). |
| `RSA_MODULUS_BITS` | `2048` | RSA key size for generated signing keypairs. |
| `KEY_STATUS.ACTIVE` | `"active"` | Key currently used to sign new tokens. |
| `KEY_STATUS.RETIRING` | `"retiring"` | Key no longer signing but still published in JWKS for verification. |
| `KEY_STATUS.RETIRED` | `"retired"` | Key fully retired (not in JWKS). |
### Crypto byte sizes (`lib/crypto.js`)
| Constant | Value | Effect |
|----------|-------|--------|
| `GCM_ALGORITHM` | `aes-256-gcm` | At-rest sealing cipher. |
| `HKDF_HASH` | `sha256` | Hash for HKDF key derivation and the session-secret cache hash. |
| `IV_BYTES` | `12` | AES-GCM IV length (96-bit). |
| `KEK_BYTES` | `32` | Derived KEK length (256-bit). |
| `KID_BYTES` | `16` | Random bytes for a generated signing key id (`kid`), hex-encoded. |
| `KEK_INFO` | `saltcorn-idp:at-rest:aes-gcm-key:v1` | HKDF `info` for KEK derivation (domain separation). |
| `KEK_SALT` | `saltcorn-idp:at-rest:aes-gcm-salt:v1` | HKDF `salt` for KEK derivation. |
### LDAP
| Constant | Value | Effect |
|----------|-------|--------|
| `LDAP_BASE_DN` | `dc=saltcorn,dc=local` | Directory base DN. Multi-tenant DNs add a `dc=<tenant>` component before the base. |
| `LDAP_PEOPLE_OU` | `ou=people,dc=saltcorn,dc=local` | OU for user entries. |
| `LDAP_GROUPS_OU` | `ou=groups,dc=saltcorn,dc=local` | OU for group entries. |
| `LDAP_MAX_MSG_BYTES` | `262144` (256 * 1024) | DoS guard: cap on inbound bytes per LDAP message (the BER parser has no size limit). The byte counter is reset on each parsed-message boundary, so this is a per-message cap, not a connection-lifetime quota -- a per-connection counter would let an attacker kill a legitimate long-lived connection after a few normal operations. The connection is destroyed if a single message exceeds the cap. Enforced in `lib/ldap/vendor.js`; see [`./security.md`](./security.md) for the full rationale. |
| `LDAP_MAX_FILTER_DEPTH` | `32` | DoS guard: maximum search-filter nesting depth. |
| `LDAP_BIND_MAX_ATTEMPTS` | `5` | Max listener bind attempts on a transient failure (`EADDRINUSE`/`EACCES`). |
| `LDAP_BIND_RETRY_BASE_MS` | `500` | Bind retry backoff base; delay = base * attempt (linear). |
The LDAP simple-bind handler also enforces a credential length cap, defined
locally in `lib/ldap/bind.js` rather than in `lib/constants.js`:
| Constant | Value | Effect |
|----------|-------|--------|
| `MAX_CRED_LEN` (`lib/ldap/bind.js`) | `1024` | DoS/abuse guard on the bind handler: a bind with an empty DN, an empty password, or a password longer than this many characters is rejected as invalid credentials (and counts as a failure for the per-IP lockout) before any bcrypt or constant-time comparison runs. |
Additional LDAP runtime values set in `lib/ldap/server.js`:
| Setting | Value | Effect |
|---------|-------|--------|
| `server.maxConnections` | `256` | Maximum concurrent LDAP connections. |
| self-signed cert | RSA 2048-bit, SHA256 | Generated per startup for LDAPS (dev); production supplies a cert via config. |
### SAML
| Constant | Value | Effect |
|----------|-------|--------|
| `SAML_MAX_MSG_B64_BYTES` | `65536` (64 * 1024) | DoS guard: cap on the base64 `SAMLRequest`/`SAMLResponse` accepted before decoding. |
| `SAML_MAX_XML_BYTES` | `262144` (256 * 1024) | DoS guard: cap on inflated XML size (DEFLATE can expand roughly 1000:1; prevents a memory bomb). |
| `SAML_AUTHN_CONTEXT` | `urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport` | AuthnContext class advertised for password login. |
| `SAML_SIG_ALG` | `http://www.w3.org/2001/04/xmldsig-more#rsa-sha256` | Required signature algorithm (RSA-SHA256) on signed SAML messages. |
| `XML_CRYPTO_MIN` | `6.1.2` | Minimum acceptable `xml-crypto` version (2025 SAML-signature CVEs patched); asserted at load. |
### Plugin metadata and path namespaces
| Constant | Value |
|----------|-------|
| `PLUGIN_NAME` | `saltcorn-idp` |
| `PLUGIN_VERSION` | `0.0.1` |
| `IDP_BASE_PATH` | `/idp` |
| `ADMIN_BASE_PATH` | `/admin/idp` |
The admin role gate is `ADMIN_ROLE_ID = 1`, defined in `lib/adminUi.js` (admin
pages require `req.user.role_id === 1`).
---
## Database tables
All tables are prefixed `_idp_` and created idempotently during `onLoad` by
`createAllTables()` in `lib/schema.js`. Raw DDL is schema-qualified with
`db.getTenantSchemaPrefix()` so each Postgres tenant gets its own tables; on
SQLite the prefix is empty. Sealed key/secret material is stored as hex `TEXT`.
The auto-increment primary key on `_idp_groups` uses `integer` on SQLite and
`serial` on Postgres.
### `_idp_env`
Singleton per-tenant row tracking first-run bootstrap state and an instance
label.
| Column | Type | Notes |
|--------|------|-------|
| `env_id` | `TEXT PRIMARY KEY` | UUID assigned on first init (`crypto.randomUUID()`). |
| `env_label` | `TEXT` | Optional instance label; initialized to NULL. |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
| `bootstrapped_at` | `TEXT` | ISO 8601 timestamp set by `markBootstrapped`; NULL until first bootstrap. |
### `_idp_keys`
Per-tenant RSA signing keypairs and lifecycle. Index: `_idp_keys_status` on
`status`.
| Column | Type | Notes |
|--------|------|-------|
| `kid` | `TEXT PRIMARY KEY` | Key id (hex). |
| `alg` | `TEXT NOT NULL` | Signing algorithm (`RS256`). |
| `public_jwk` | `TEXT NOT NULL` | Public JWK, JSON-stringified. |
| `private_ciphertext` | `TEXT NOT NULL` | Sealed private key PEM (hex). |
| `private_iv` | `TEXT NOT NULL` | AES-GCM IV (hex). |
| `private_tag` | `TEXT NOT NULL` | AES-GCM auth tag (hex). |
| `status` | `TEXT NOT NULL DEFAULT 'active'` | One of `active`, `retiring`, `retired`. |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
| `retire_after` | `TEXT` | Optional retire-after timestamp. |
### `_idp_oidc_store`
Single table backing the oidc-provider storage adapter (all model types).
Indexes: `_idp_oidc_store_uid` on `uid`, `_idp_oidc_store_grant` on `grant_id`,
`_idp_oidc_store_usercode` on `user_code`.
| Column | Type | Notes |
|--------|------|-------|
| `model` | `TEXT NOT NULL` | oidc-provider model type. Part of PK. |
| `id` | `TEXT NOT NULL` | Instance id. Part of PK. |
| `payload` | `TEXT NOT NULL` | Serialized model state (JSON). |
| `uid` | `TEXT` | Interaction uid (nullable). |
| `grant_id` | `TEXT` | Grant reference (nullable). |
| `user_code` | `TEXT` | Device user code (nullable). |
| `expires_at` | `INTEGER` | Unix epoch seconds (nullable). |
| PRIMARY KEY | `(model, id)` | Composite. |
### `_idp_groups`
Custom groups (in addition to Saltcorn roles).
| Column | Type | Notes |
|--------|------|-------|
| `id` | `integer`/`serial` `PRIMARY KEY` | Auto-increment (SQLite `integer`, Postgres `serial`). |
| `name` | `TEXT NOT NULL UNIQUE` | Group name. |
| `description` | `TEXT` | Optional description. |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
### `_idp_group_members`
User-to-group junction. Index: `_idp_group_members_user` on `user_id`.
| Column | Type | Notes |
|--------|------|-------|
| `group_id` | `INTEGER NOT NULL` | Part of PK. |
| `user_id` | `INTEGER NOT NULL` | Saltcorn user id. Part of PK. |
| PRIMARY KEY | `(group_id, user_id)` | Composite. |
### `_idp_clients`
Registered OIDC relying parties. Confidential clients' secrets are sealed at
rest (hex columns); public clients (`token_auth_method = 'none'`) have none.
| Column | Type | Notes |
|--------|------|-------|
| `client_id` | `TEXT PRIMARY KEY` | Client identifier. |
| `label` | `TEXT` | Display name. |
| `token_auth_method` | `TEXT NOT NULL DEFAULT 'none'` | `none`, `client_secret_basic`, or `client_secret_post`. |
| `redirect_uris` | `TEXT NOT NULL` | JSON array. |
| `grant_types` | `TEXT NOT NULL` | JSON array. |
| `response_types` | `TEXT NOT NULL` | JSON array. |
| `scope` | `TEXT` | Space-separated scope string. |
| `secret_ciphertext` | `TEXT` | Sealed secret (hex); NULL for public clients. |
| `secret_iv` | `TEXT` | AES-GCM IV (hex); NULL for public clients. |
| `secret_tag` | `TEXT` | AES-GCM auth tag (hex); NULL for public clients. |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
### `_idp_saml`
Per-tenant SAML signing material (singleton row): a self-signed X.509 cert
advertised in IdP metadata plus its sealed private key used to sign assertions.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `TEXT PRIMARY KEY` | Fixed row id. |
| `cert` | `TEXT NOT NULL` | X.509 certificate (PEM, plaintext). |
| `private_ciphertext` | `TEXT NOT NULL` | Sealed private key (hex). |
| `private_iv` | `TEXT NOT NULL` | AES-GCM IV (hex). |
| `private_tag` | `TEXT NOT NULL` | AES-GCM auth tag (hex). |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
### `_idp_saml_sps`
Registered SAML service providers. The IdP only issues an assertion to a
registered SP and only to one of its allow-listed ACS URLs. The optional
`signing_cert` (public, not sealed) enables AuthnRequest signature
verification; `want_authn_requests_signed` gates enforcement.
| Column | Type | Notes |
|--------|------|-------|
| `entity_id` | `TEXT PRIMARY KEY` | SP entityID. |
| `label` | `TEXT` | Display name. |
| `acs_urls` | `TEXT NOT NULL` | JSON array of allow-listed ACS URLs. |
| `signing_cert` | `TEXT` | Public X.509 cert (PEM); nullable. |
| `want_authn_requests_signed` | `INTEGER NOT NULL DEFAULT 0` | 0/1 boolean; gates signature enforcement. |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
### `_idp_ldap_service`
LDAP service-account credentials for the search-then-bind flow (single row). The
password is sealed at rest (hex columns), like client secrets.
| Column | Type | Notes |
|--------|------|-------|
| `dn` | `TEXT` | Service bind DN. |
| `secret_ciphertext` | `TEXT` | Sealed password (hex). |
| `secret_iv` | `TEXT` | AES-GCM IV (hex). |
| `secret_tag` | `TEXT` | AES-GCM auth tag (hex). |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
---
## Admin UI pages
The admin UI is defined in `lib/adminUi.js`. All pages live under
`/admin/idp` (constant `ADMIN_BASE_PATH`), are gated to `role_id = 1`, and use
CSRF-protected browser forms (a hidden `_csrf` field is rendered via
`req.csrfToken()`). Non-admins receive HTTP 403 `admin only`.
| Path | Method | Page / action |
|------|--------|---------------|
| `/admin/idp` | GET | Dashboard: env id, bootstrapped-at, issuer, active signing kid, signing alg, published JWKS key count, discovery and JWKS links. |
| `/admin/idp/` | GET | Dashboard (alias). |
| `/admin/idp/clients` | GET | List OIDC clients and the registration form. |
| `/admin/idp/clients/create` | POST | Register a new OIDC client (returns the one-time secret for confidential clients). |
| `/admin/idp/clients/delete` | POST | Delete an OIDC client by `client_id`. |
| `/admin/idp/groups` | GET | List custom groups with members and add/remove member controls. |
| `/admin/idp/groups/create` | POST | Create a custom group by `name`. |
| `/admin/idp/groups/delete` | POST | Delete a group by `id`. |
| `/admin/idp/groups/addmember` | POST | Add a user to a group by email. |
| `/admin/idp/groups/removemember` | POST | Remove a user from a group. |
| `/admin/idp/saml-sps` | GET | List SAML service providers and the registration form. |
| `/admin/idp/saml-sps/create` | POST | Register a SAML SP (rejects empty entityID or empty ACS list). |
| `/admin/idp/saml-sps/delete` | POST | Delete a SAML SP by `entity_id`. |
| `/admin/idp/ldap` | GET | Show the configured LDAP service DN and set/clear forms (password never displayed). |
| `/admin/idp/ldap/service` | POST | Set the LDAP service-account DN and password (password sealed at rest). |
| `/admin/idp/ldap/service/clear` | POST | Clear the LDAP service account. |
The shared navigation bar links: Dashboard, Clients, Groups, SAML SPs, LDAP.