From ad31f61fab80dc3066a0608dccbd886c493b24b0 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Mon, 1 Jun 2026 16:40:54 -0500 Subject: [PATCH] Initial commit. --- .gitattributes | 52 ++ .gitignore | 33 + README.md | 178 +++++ VENDORING.md | 94 +++ docs/architecture.md | 304 +++++++++ docs/configuration.md | 300 +++++++++ docs/ldap.md | 351 ++++++++++ docs/oidc.md | 440 ++++++++++++ docs/operations.md | 266 ++++++++ docs/saml.md | 478 +++++++++++++ docs/security.md | 396 +++++++++++ docs/testing.md | 138 ++++ index.js | 161 +++++ lib/adminUi.js | 547 +++++++++++++++ lib/claims.js | 41 ++ lib/clients.js | 95 +++ lib/constants.js | 169 +++++ lib/crypto.js | 168 +++++ lib/env.js | 47 ++ lib/groups.js | 79 +++ lib/keys.js | 168 +++++ lib/ldap/bind.js | 91 +++ lib/ldap/dn.js | 85 +++ lib/ldap/harden.js | 47 ++ lib/ldap/search.js | 192 ++++++ lib/ldap/server.js | 177 +++++ lib/ldap/serviceAccount.js | 56 ++ lib/ldap/tenant.js | 59 ++ lib/ldap/vendor.js | 62 ++ lib/oidc/adapter.js | 88 +++ lib/oidc/discovery.js | 43 ++ lib/oidc/interactions.js | 110 +++ lib/oidc/provider.js | 124 ++++ lib/oidc/routes.js | 89 +++ lib/routes.js | 16 + lib/saml/idp.js | 217 ++++++ lib/saml/routes.js | 545 +++++++++++++++ lib/saml/sps.js | 82 +++ lib/schema.js | 190 ++++++ lib/web.js | 16 + package-lock.json | 1252 +++++++++++++++++++++++++++++++++++ package.json | 35 + scripts/installIdpTenant.js | 99 +++ scripts/installIdpTenant.sh | 18 + test/adminGate.js | 180 +++++ test/e2e.js | 299 +++++++++ test/keyRotationGate.js | 127 ++++ test/ldapGate.js | 354 ++++++++++ test/ldapMultiTenantGate.js | 231 +++++++ test/mtGate.js | 295 +++++++++ test/samlGate.js | 279 ++++++++ test/samlMultiTenantGate.js | 217 ++++++ 52 files changed, 10180 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 VENDORING.md create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/ldap.md create mode 100644 docs/oidc.md create mode 100644 docs/operations.md create mode 100644 docs/saml.md create mode 100644 docs/security.md create mode 100644 docs/testing.md create mode 100644 index.js create mode 100644 lib/adminUi.js create mode 100644 lib/claims.js create mode 100644 lib/clients.js create mode 100644 lib/constants.js create mode 100644 lib/crypto.js create mode 100644 lib/env.js create mode 100644 lib/groups.js create mode 100644 lib/keys.js create mode 100644 lib/ldap/bind.js create mode 100644 lib/ldap/dn.js create mode 100644 lib/ldap/harden.js create mode 100644 lib/ldap/search.js create mode 100644 lib/ldap/server.js create mode 100644 lib/ldap/serviceAccount.js create mode 100644 lib/ldap/tenant.js create mode 100644 lib/ldap/vendor.js create mode 100644 lib/oidc/adapter.js create mode 100644 lib/oidc/discovery.js create mode 100644 lib/oidc/interactions.js create mode 100644 lib/oidc/provider.js create mode 100644 lib/oidc/routes.js create mode 100644 lib/routes.js create mode 100644 lib/saml/idp.js create mode 100644 lib/saml/routes.js create mode 100644 lib/saml/sps.js create mode 100644 lib/schema.js create mode 100644 lib/web.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/installIdpTenant.js create mode 100755 scripts/installIdpTenant.sh create mode 100644 test/adminGate.js create mode 100644 test/e2e.js create mode 100644 test/keyRotationGate.js create mode 100644 test/ldapGate.js create mode 100644 test/ldapMultiTenantGate.js create mode 100644 test/mtGate.js create mode 100644 test/samlGate.js create mode 100644 test/samlMultiTenantGate.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7d24aa4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,52 @@ +# Default line-ending handling for text files: normalize to LF in the repo, +# check out native on each platform. +* text=auto + +# Executable shell scripts (e.g. scripts/installIdpTenant.sh) must stay LF so +# the shebang works on Unix even when checked out on Windows. +*.sh text eol=lf + +# Collapse the lockfile in diffs / PR reviews and mark it generated. +package-lock.json linguist-generated=true -diff + +# Git LFS. Run `git lfs install` once per clone to activate the filters below. +# This plugin is currently code-only; these patterns are a safety net so any +# binary blob dropped into the tree is tracked correctly without anyone having +# to remember to update this file. + +# Archives +*.zip filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tar.gz filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.7z filter=lfs diff=lfs merge=lfs -text + +# Databases / snapshots +*.sqlite filter=lfs diff=lfs merge=lfs -text +*.sqlite3 filter=lfs diff=lfs merge=lfs -text +*.db filter=lfs diff=lfs merge=lfs -text + +# Images +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text + +# Documents +*.pdf filter=lfs diff=lfs merge=lfs -text + +# Fonts +*.ttf filter=lfs diff=lfs merge=lfs -text +*.otf filter=lfs diff=lfs merge=lfs -text +*.woff filter=lfs diff=lfs merge=lfs -text +*.woff2 filter=lfs diff=lfs merge=lfs -text + +# Audio / video +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.wav filter=lfs diff=lfs merge=lfs -text +*.webm filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac745c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Installed npm dependencies (oidc-provider, ldapjs + @ldapjs/*, samlify and the +# XML stack, selfsigned, ...). Restored from package-lock.json; never committed. +node_modules/ + +# NOTE: package-lock.json IS committed (kept under version control) so the +# pinned/overridden dependency tree -- including the security floors in the +# package.json "overrides" -- installs reproducibly. Do not add it here. + +# npm/yarn diagnostics and `npm pack` output. +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.tgz + +# Test coverage output. +coverage/ +.nyc_output/ + +# Local environment overrides / secrets. The plugin reads its secrets from the +# host Saltcorn process environment (SALTCORN_SESSION_SECRET for the KEK, +# SALTCORN_IDP_LDAP_PORT/HOST, etc.); a local .env must never be committed. +# Signing keys and the SAML cert are sealed in the database, not on disk. +.env +.env.* + +# Editor / OS junk. +.DS_Store +Thumbs.db +*.swp +*.swo +*~ +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..056dc47 --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# saltcorn-idp + +A Saltcorn plugin that turns a Saltcorn instance into a single sign-on (SSO) +**Identity Provider**. It speaks three protocols against one shared identity +model: + +- **OIDC / OAuth2** (authorization-code + PKCE) via `oidc-provider` +- **LDAP with groups** (LDAPS) via `ldapjs` +- **SAML 2.0** (SP-initiated, IdP-initiated, Single Logout) via `samlify` + +All three are multi-tenant aware: each Saltcorn tenant gets its own issuer, +asymmetric signing key, and SAML certificate, with the per-tenant private key +sealed at rest (AES-256-GCM) under a key derived from `SALTCORN_SESSION_SECRET`. + +- Package name: `saltcorn-idp` +- Version: `0.0.1` +- License: MIT +- Node: `>=20` +- Saltcorn plugin API version: `1` + +The plugin integrates through the Saltcorn `routes` export plus an `onLoad` +hook (it does not register a Saltcorn `authentication` method). See `index.js` +and `lib/routes.js`. + +--- + +## Feature / status matrix + +All three protocol surfaces are built and gated by end-to-end tests. Remaining +work is hardening, not new protocols. + +| Protocol | Library | Public base path | Status | +|----------|---------|------------------|--------| +| OIDC / OAuth2 (auth-code + PKCE) | `oidc-provider` `^9.8.4` | `/idp` | Built; covered by `test/e2e.js` and `test/mtGate.js` | +| LDAP with groups (LDAPS only) | `ldapjs` `3.0.7` | LDAPS listener (opt-in via env) | Built; covered by `test/ldapGate.js` and `test/ldapMultiTenantGate.js` | +| SAML 2.0 (SSO + SLO) | `samlify` `2.13.1` | `/idp/saml` | Built; covered by `test/samlGate.js` | +| Multi-tenant (per-tenant issuer/keys/cert) | n/a | per-tenant schema | Built; verified on Postgres | +| Admin UI (clients, groups, SAML SPs, LDAP service) | n/a | `/admin/idp` | Built (CSRF-protected, role-gated) | + +--- + +## Identity and groups: single source of truth + +Saltcorn's user table is the only account store. The IdP never provisions new +users; it authenticates existing Saltcorn users and projects their identity +into whichever protocol the relying party speaks. + +Group membership is computed once and reused across all three protocols. The +"effective groups" for a user are: + +- the user's Saltcorn **role**, exposed with a `role:` prefix (for example + `role:admin`), plus +- membership in custom IdP groups (tables `_idp_groups` and + `_idp_group_members`), exposed with a `group:` prefix (for example + `group:engineering`). + +That single list flows out as: + +- the OIDC `groups` claim (when the `groups` scope is granted), +- the LDAP `memberOf` attribute (and `groupOfNames` entries), and +- the SAML `groups` attribute. + +Because roles and custom groups carry distinct prefixes, a role and a group of +the same name never collide. + +--- + +## Quick start (development) + +These steps mirror the local dev setup. The plugin source lives at +`/home/scott/claude/saltcorn/idp` and is a sibling of the upstream Saltcorn +checkout. + +### 1. Install the plugin into the SQLite dev instances + +The shared `reinstallIdp.sh` script installs `saltcorn-idp` into both the MAIN +(`.dev-state`) and TEST (`.dev-state-test`) instances. Run it with the servers +stopped (install is centralized here because the plugins folder is shared and +`saltcorn install-plugin` needs an absolute `-d` path and clean +`node_modules` symlinks): + +```bash +cd /home/scott/claude/saltcorn +./reinstallIdp.sh +``` + +### 2. Run a dev instance + +```bash +cd /home/scott/claude/saltcorn +./startServer.sh # MAIN: SQLite, port 3000, LDAPS on 1636 +./startServerTest.sh # TEST: SQLite, port 3001, no LDAP +./startServerPg.sh # PG: Postgres multi-tenant, port 3002, LDAPS on 1637 +``` + +On first load per tenant, `onLoad` (see `index.js`) creates the `_idp_*` +tables, bootstraps the RSA signing key, ensures the SAML certificate, registers +the `/idp/` CSRF bypass, and starts the LDAP listener if +`SALTCORN_IDP_LDAP_PORT` is set. + +For the Postgres multi-tenant instance, register the plugin into each tenant +schema (tenants must already exist and the plugin must be installed into the PG +public schema once): + +```bash +cd /home/scott/claude/saltcorn +source .dev-state-pg/env.sh +saltcorn install-plugin -d ./idp # one-time, public schema +./idp/scripts/installIdpTenant.sh t1 t2 # per tenant (or '*' for all) +``` + +### 3. Run the gates + +```bash +cd /home/scott/claude/saltcorn/idp + +npm test # OIDC + groups end-to-end (test/e2e.js) -- needs :3000 and :3001 +npm run test:mt # multi-tenant OIDC (test/mtGate.js) -- needs :3002 (Postgres) + +node test/ldapGate.js # LDAP (needs :3000 and LDAPS :1636) +node test/samlGate.js # SAML 2.0 (needs :3000) +node test/ldapMultiTenantGate.js # multi-tenant LDAP (needs :3002 and LDAPS :1637; self-skips if port unreachable) +``` + +`npm test` and `npm run test:mt` are the only scripts defined in +`package.json`; the LDAP and SAML gates are invoked directly with `node`. + +--- + +## Configuration summary (key environment variables) + +| Variable | Required | Default | Purpose | +|----------|----------|---------|---------| +| `SALTCORN_SESSION_SECRET` | Yes | n/a (falls back to Saltcorn `session_secret` config) | Derives the at-rest KEK (AES-256-GCM) that seals private keys, SAML keys, and client/LDAP secrets, and the oidc-provider cookie keys. Rotating it invalidates all sealed data. | +| `SALTCORN_IDP_LDAP_PORT` | No | unset (LDAP disabled) | Enables the LDAPS listener on this port. Each dev instance uses its own port to avoid collisions. | +| `SALTCORN_IDP_LDAP_HOST` | No | `127.0.0.1` | Interface the LDAPS listener binds to. Loopback by default; set to `0.0.0.0` to expose LDAP on the network. | + +OIDC issuer derivation also honors Saltcorn's global `base_url` config. In any +multi-tenant or proxied deployment, set `base_url`; the request-host fallback is +vulnerable to Host-header injection. See `lib/oidc/discovery.js`. + +The signing algorithm is `RS256` over an RSA-2048 key per tenant; key lifecycle +states are `active`, `retiring`, and `retired` (see `lib/constants.js`). + +--- + +## Path namespaces + +| Base path | Scope | CSRF | +|-----------|-------|------| +| `/idp` | Public OIDC/OAuth2/SAML + machine endpoints | Exempt (registered in `disable_csrf_routes`) | +| `/idp/saml` | SAML metadata / SSO / SLO / IdP-init | Exempt | +| `/admin/idp` | Admin UI (browser) | Protected; gated to role_id 1 | + +LDAP is not an HTTP path; it is a separate LDAPS listener enabled by +`SALTCORN_IDP_LDAP_PORT`. + +--- + +## Documentation index + +Deep-dive docs live under [`./docs/`](./docs/). Each focuses on one subsystem; +this README is the entry point. + +| Document | Covers | +|----------|--------| +| [`./docs/architecture.md`](./docs/architecture.md) | Integration model, module layout, the public `/idp` vs admin `/admin/idp` routing split, identity/groups single source of truth, and a multi-tenancy overview | +| [`./docs/oidc.md`](./docs/oidc.md) | OIDC/OAuth2: discovery, JWKS, authorization-code + PKCE flow, the storage adapter, client registry, claims, and interaction (login/consent) handlers | +| [`./docs/ldap.md`](./docs/ldap.md) | LDAP server: LDAPS startup, directory tree, bind (user + service account), search, DoS guards, and DN escaping | +| [`./docs/saml.md`](./docs/saml.md) | SAML 2.0: metadata, SP-initiated and IdP-initiated SSO, SLO, the SP registry + ACS allow-list, AuthnRequest signature verification, and XML hardening | +| [`./docs/configuration.md`](./docs/configuration.md) | Configuration reference: environment variables, tunable constants, the `_idp_*` database tables, and the admin UI pages | +| [`./docs/operations.md`](./docs/operations.md) | Operations / dev environment: the three dev instances, starting and stopping, the plugin install workflow, multi-tenant host routing, and known issues | +| [`./docs/testing.md`](./docs/testing.md) | Test gates: scope, prerequisites, and what each gate asserts | +| [`./docs/security.md`](./docs/security.md) | Security posture: dependency pinning/overrides, XXE defenses, hardening guards, and attack-vector summary | + +See also [`VENDORING.md`](./VENDORING.md) for the dependency-ownership and +security rationale behind the pinned `ldapjs`, `samlify`, `xml-crypto`, and +`@xmldom/xmldom` versions. diff --git a/VENDORING.md b/VENDORING.md new file mode 100644 index 0000000..f5ef0ef --- /dev/null +++ b/VENDORING.md @@ -0,0 +1,94 @@ +# Dependency ownership and security posture + +This plugin embeds an LDAP server and a SAML stack. This document records how we +"own" those dependencies and where the security guards live, so the analysis is +not re-done every time someone reads a stale "TODO: vendor a fork" comment. + +## ldapjs: pinned dependency, NOT a source copy + +We adopt `ldapjs` 3.0.7 (MIT) as a PINNED dependency rather than copying its +source into the tree. A full copy would pull the `ldapjs` package plus 8 +`@ldapjs/*` sub-packages (asn1, attribute, change, controls, dn, filter, +messages, protocol) -- ~184 library files of ASN.1/BER protocol code we did not +write and cannot meaningfully re-audit on every upstream change. Copying it buys +nothing, because our security guards do not live inside the BER parser; they +live at the connection/handler layer, which is OUR code. + +Ownership is enforced through a single chokepoint: + +- `lib/ldap/vendor.js` is the ONLY file that `require("ldapjs")`. It re-exports + the error classes the handlers use and exposes `createHardenedServer()`, which + installs a per-connection inbound byte cap (`LDAP_MAX_MSG_BYTES`, default + 256 KiB) via the ldapjs `connectionRouter` hook -- the BER parser itself has + no message-size limit, so a declared multi-GB message would otherwise buffer + unbounded. This is the upgrade/audit point for the dependency. +- `lib/ldap/search.js` walks the parsed search filter and rejects nesting deeper + than `LDAP_MAX_FILTER_DEPTH` (default 32) before any DB work -- the filter + parser recurses without a depth bound. +- `lib/ldap/harden.js` does per-IP failed-bind lockout (the LDAP port is outside + Saltcorn's web-login throttling). +- The listener is loopback-bound (`127.0.0.1`) and LDAPS-only, and binds only in + the cluster primary process (`lib/ldap/server.js`). + +To verify the single-chokepoint invariant: + + grep -rn "require(['\"]ldapjs" lib # must match ONLY lib/ldap/vendor.js + +### Upgrading ldapjs / @ldapjs + +1. Bump the pinned `ldapjs` version in `package.json` and `npm install`. +2. Re-check that `connectionRouter`/`newConnection` still exist with the same + contract (server uses `options.connectionRouter` in place of `newConnection`; + the router must call `server.newConnection(conn)`). +3. Re-check the filter node shape used by the depth walk (`.clauses`, with + `.filters` as an alias on and/or/not). +4. Run `node test/ldapGate.js` (16 checks incl. the byte-cap and filter-depth + guards) against a running MAIN instance. + +## SAML XML stack: pinned + hardened config, NOT forked + +The SAML XML stack is `samlify` 2.13.1 + `@xmldom/xmldom` 0.8.13 + +`xml-crypto` 6.1.2 + `@authenio/samlify-node-xmllint` 2.0.0 (node-xmllint WASM +libxml2). We pin it and assert the safe defaults from our own code rather than +copying/forking it. + +XXE posture (verified in source, contradicting any "TODO" comment): + +- samlify ships an XXE-safe DOMParser by default: it builds the parser with + throwing `error`/`fatalError` handlers (`samlify` `api.js`). We RE-ASSERT this + from our code by calling `saml.setDOMParserOptions({})` next to + `saml.setSchemaValidator(...)` in `lib/saml/idp.js`, so a future samlify default + change cannot silently re-enable entity expansion. +- `@xmldom/xmldom` 0.8.x seeds the entity map only with the 5 predefined XML + entities and never resolves external/SYSTEM entities -- no classic + external-entity XXE surface. +- `node-xmllint` validates against the 4 BUNDLED SAML XSDs only; no external + schema fetch. +- Defence in depth: `lib/saml/routes.js` `decodeRequest()` rejects any inbound + SAML message containing a `= XML_CRYPTO_MIN` (6.1.2), + failing closed otherwise (the 2025 SAML-signature CVEs are patched there). +- Inbound AuthnRequests are signature-verified for SPs registered with a signing + cert + `want_authn_requests_signed` (per-SP, so conforming-but-unsigned SPs are + not locked out). samlify's signature-wrapping (XSW) defenses run on that path. + +## Pin / CVE log + +- `xml-crypto >= 6.1.2` (npm `overrides`): floor for the 2025 SAML signature + verification CVEs. A load-time assertion in `lib/saml/idp.js` enforces the same + floor at runtime. +- `@xmldom/xmldom >=0.8.13 <0.9` (npm `overrides`): the XXE-relevant parser; the + range avoids a hard pin breaking transitive resolution while keeping the + no-external-entity 0.8.x behavior. +- `ldapjs` 3.0.7, `samlify` 2.13.1, `@authenio/samlify-node-xmllint` 2.0.0: + pinned exact (dropped the caret) so the security-relevant chain cannot float. + +## Network exposure + +The LDAP listener binds `127.0.0.1` only and is LDAPS-only (no plaintext, no +StartTLS). OIDC/SAML are served over the host's HTTP(S) stack. The plugin makes +no outbound network calls during request handling. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c0afbd4 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,304 @@ +# 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). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..85ebb62 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,300 @@ +# 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=` 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. diff --git a/docs/ldap.md b/docs/ldap.md new file mode 100644 index 0000000..7734891 --- /dev/null +++ b/docs/ldap.md @@ -0,0 +1,351 @@ +# LDAP + +`saltcorn-idp` exposes Saltcorn's users and groups over an LDAPS directory +service, so applications that only speak LDAP can authenticate against, and +read group membership from, the same identity model that backs OIDC and SAML. +This document covers enabling the listener, the bind interface, the DN scheme +(including the multi-tenant tenant-in-DN convention), the bind and search +operations, the denial-of-service guards, the cluster single-binder behaviour, +multi-tenancy, and the vendoring chokepoint. + +For the other protocols this plugin implements see the sibling docs +[`./oidc.md`](./oidc.md) and [`./saml.md`](./saml.md). For the cross-cutting +security posture see [`./security.md`](./security.md), and for the rationale +behind pinning (not forking) `ldapjs` see [`../VENDORING.md`](../VENDORING.md). + +The implementation lives under `lib/ldap/`: + +``` +lib/ldap/server.js LDAPS listener, cluster gate, bind retry/backoff +lib/ldap/bind.js simple-bind handler (user + service account) +lib/ldap/search.js search handler (inetOrgPerson / groupOfNames) +lib/ldap/dn.js DN construction + RFC 4514 escaping +lib/ldap/tenant.js tenant-from-DN resolution + withTenant wrap +lib/ldap/serviceAccount.js sealed service-account credential storage +lib/ldap/vendor.js single ldapjs require point + per-message byte cap +lib/ldap/harden.js per-IP failed-bind lockout +``` + +## Enabling LDAP + +LDAP is off unless the port environment variable is set. The listener is +started from the plugin's `onLoad` hook (`startLdap` in `lib/ldap/server.js`), +and only when `SALTCORN_IDP_LDAP_PORT` holds a parseable integer. + +| Variable | Constant | Default | Purpose | +|----------|----------|---------|---------| +| `SALTCORN_IDP_LDAP_PORT` | `LDAP_PORT_ENV` | unset (disabled) | Port for the LDAPS listener. Unset or non-numeric means LDAP does not start. Each dev instance uses its own port so they do not collide on one. | +| `SALTCORN_IDP_LDAP_HOST` | `LDAP_HOST_ENV` | `127.0.0.1` (`LDAP_DEFAULT_HOST`) | Interface the listener binds to. An empty or whitespace-only value falls back to the default. | + +The transport is LDAPS only. `ldapjs` has no StartTLS support, so TLS is +required from the first byte; there is no plaintext LDAP on the wire. For +development the server generates a self-signed RSA-2048 / SHA-256 certificate at +startup (via `selfsigned`); a production deployment would supply its own +certificate. + +### Bind interface and network-exposure warning + +The default bind host is loopback (`127.0.0.1`), so LDAP is not reachable from +the network unless an operator explicitly opts in (for example by setting +`SALTCORN_IDP_LDAP_HOST=0.0.0.0`). On a successful listen the server logs: + +``` +[saltcorn-idp] LDAPS listening on ldaps://: base dc=saltcorn,dc=local (tenant = dc=,) +``` + +When the chosen host is not loopback (`server.js` `isLoopbackHost` checks for +`127.0.0.1`, `::1`, and `localhost`), it additionally emits a warning so a +network-exposed bind is never silent: + +``` +[saltcorn-idp] NOTE: LDAP is bound to (beyond loopback) -- it is reachable from the network; ensure this is intended and firewalled appropriately. +``` + +## Directory tree and DN scheme + +The directory mirrors Saltcorn's users and groups under a fixed base. The DN +helpers live in `lib/ldap/dn.js` and the base constants in `lib/constants.js`. + +| Constant | Value | +|----------|-------| +| `LDAP_BASE_DN` | `dc=saltcorn,dc=local` | +| `LDAP_PEOPLE_OU` | `ou=people,dc=saltcorn,dc=local` | +| `LDAP_GROUPS_OU` | `ou=groups,dc=saltcorn,dc=local` | + +Users are keyed by email (the `uid`), groups by name (the `cn`): + +``` +uid=,ou=people,dc=saltcorn,dc=local (a user) +cn=,ou=groups,dc=saltcorn,dc=local (a group) +``` + +### Tenant-in-DN + +In a multi-tenant deployment the tenant is encoded as one extra `dc` component +immediately before the base (`baseFor(tenant)` in `dn.js`): + +``` +uid=,ou=people,dc=,dc=saltcorn,dc=local +cn=,ou=groups,dc=,dc=saltcorn,dc=local +``` + +A falsy tenant yields the bare default base, so single-tenant DNs are +unchanged. See [Multi-tenancy](#multi-tenancy) below for how the tenant is +resolved and how cross-tenant access is denied. + +### RFC 4514 DN escaping + +A user email or group name is admin-controlled free text and may contain DN +special characters. `escapeDnValue` in `dn.js` applies RFC 4514 escaping when +building the result and `memberOf` DNs the server emits: + +- the characters `\ " , + ; < > =` are backslash-escaped anywhere in the value; +- a leading space or `#`, and a trailing space, are positionally escaped; +- a NUL byte becomes `\00`. + +This is escaping on output only. Inbound bind DNs are formatted by the client, +not by us. Without it, an unescaped special character would produce a malformed +DN, and `ldapjs`'s `DN.fromString()` would throw while `res.send()` builds the +`SearchEntry`, aborting the whole search. + +## Bind + +The simple-bind handler is `lib/ldap/bind.js`. It supports two principals: a +Saltcorn user, and an optional service account. Both authenticate against the +tenant resolved from the bind DN, with all lookups running inside +`withTenant` (which wraps `db.runWithTenant` for a non-default tenant). + +### User bind + +The email is extracted from the bind DN with `uidFromDn` (matches `uid=...`), +the user is loaded with Saltcorn's `User.findOne({ email })`, and the typed +password is checked with `user.checkPassword(creds)` -- Saltcorn's bcrypt +verification, which never stores or echoes plaintext. The bind fails if the +user does not exist, is `disabled`, or the password does not match. + +### Service account (search-then-bind) + +A configured service DN plus a sealed password lets an application bind as a +non-user principal, search for a user, then re-bind as that user to validate +the password -- the standard enterprise search-then-bind flow -- without +exposing a real user as the binder. When the bind DN matches the configured +service DN (compared case-insensitively after stripping whitespace via +`dnEquals`), the supplied credential is compared to the stored password with a +constant-time comparison (`crypto.timingSafeEqual`, in `constantTimeEqual`), so +no user lookup occurs and the match is not timing-leaky. + +The service credential is stored in the single-row, tenant-scoped +`_idp_ldap_service` table (`TABLE_LDAP_SERVICE`). The password is sealed at rest +with the same KEK pattern as client secrets (AES-256-GCM via `sealText` in +`lib/crypto.js`) and is never returned to the wire or echoed in the admin UI. It is configured +from the admin page at `/admin/idp/ldap`; see +[`./configuration.md`](./configuration.md). + +### Denials + +Every failure increments the per-IP counter (see +[Rate limiting](#rate-limiting-and-lockout)); a success clears it. + +| Condition | Result | +|-----------|--------| +| Source IP is currently locked out | `InvalidCredentialsError` (no auth attempted) | +| Anonymous bind: no DN, zero-length DN, or empty credentials | `InvalidCredentialsError` | +| Oversized credentials (length > 1024, `MAX_CRED_LEN`) | `InvalidCredentialsError` | +| Cross-tenant / unknown-tenant bind DN (`resolveTenant` returns `deny`) | `InvalidCredentialsError` | +| Service-account password mismatch (constant-time) | `InvalidCredentialsError` | +| User not found, disabled, or wrong password | `InvalidCredentialsError` | + +Anonymous bind is, in LDAP terms, accepted by the protocol but here treated as +an authentication failure: a bind with no DN or empty password is denied +outright, and the search handler separately refuses any anonymous-bound +connection (see below). + +## Search + +The search handler is `lib/ldap/search.js`. It requires an authenticated +(non-anonymous) bind: a connection whose bind DN equals `cn=anonymous` is +rejected with `InsufficientAccessRightsError` before any work. + +### Entries returned + +Users are returned as `inetOrgPerson` entries and the effective groups as +`groupOfNames` entries. Group membership reuses the same identity model as OIDC +(`groups.effectiveGroups` = role-as-group plus custom groups), so all three +protocols draw from one source of truth. + +A user entry (`userEntry`) carries: + +| Attribute | Value | +|-----------|-------| +| `objectclass` | `inetOrgPerson`, `organizationalPerson`, `person`, `top` | +| `uid` | user email | +| `cn` | user name (or email) | +| `sn` | user name (or email) | +| `mail` | user email | +| `displayname` | user name (or email) | +| `memberof` | array of the user's group DNs | + +A group entry carries `objectclass` `groupOfNames` plus `top`, the group `cn`, +and a `member` array of the member user DNs. Group entries are built from the +same membership data that fills `memberOf`, accumulated as the handler walks the +users. + +Entry attribute keys are emitted lowercase. The handler also normalizes the +requested attribute list to lowercase in place: `ldapjs`'s +`SearchResponse.send()` compares the lowercased entry key against the requested +list without lowercasing that list, so a mixed-case request such as `memberOf` +would otherwise drop our lowercase `memberof` key. This makes attribute +selection effectively case-insensitive. + +> Search strategy: a simple equality filter on `uid`/`mail` (the common +> search-then-bind "find this user" query) is answered with a targeted +> `User.findOne({ email })` lookup -- correct even for a directory larger than +> the result cap. Broader or complex filters load users into memory and filter +> with `req.filter.matches(...)`, capped at `LDAP_MAX_SEARCH_RESULTS` entries; +> exceeding the cap returns `sizeLimitExceeded` rather than exhausting the heap. +> A production build would translate arbitrary LDAP filters into targeted SQL. + +## Denial-of-service guards + +| Guard | Constant | Value | Where | +|-------|----------|-------|-------| +| Inbound byte cap, reset per parsed message | `LDAP_MAX_MSG_BYTES` | 262144 (256 KiB) | `lib/ldap/vendor.js` | +| Search-filter nesting depth | `LDAP_MAX_FILTER_DEPTH` | 32 | `lib/ldap/search.js` | +| Search result cap (then `sizeLimitExceeded`) | `LDAP_MAX_SEARCH_RESULTS` | 2000 | `lib/ldap/search.js` | +| Per-connection idle timeout (destroy on silence) | `LDAP_IDLE_TIMEOUT_MS` | 30000 (30 s) | `lib/ldap/vendor.js` | +| Per-IP failed-bind lockout | `MAX_FAILS` / `WINDOW_MS` | 10 fails / 5 min | `lib/ldap/harden.js` | + +### Per-connection-but-per-message byte cap + +The BER parser has no size limit, so a message that declares a multi-gigabyte +length would buffer unboundedly. `createHardenedServer` in `vendor.js` wraps +`ldapjs`'s `createServer` with a `connectionRouter` that counts inbound bytes +and calls `conn.destroy()` once the running total exceeds `LDAP_MAX_MSG_BYTES`. +The counter is reset on each parsed-message boundary (`conn.parser` emits +`message`). The cap is therefore per message, not a connection-lifetime quota: +the goal is to bound a single unbounded BER message, while still allowing a +legitimate long-lived connection to perform many sequential operations that each +fit the cap but would cumulatively exceed it. (The router must call +`server.newConnection(conn)` or `ldapjs` wires up no parser.) + +### Filter-depth cap + +`filterDepth` in `search.js` walks the parsed filter tree iteratively (a +stack-based walk, not recursion, following `.clauses` / `.filters`) and returns +the maximum nesting depth. If that exceeds `LDAP_MAX_FILTER_DEPTH` (32), the +search is rejected with `OperationsError` before any database work, guarding the +recursive filter parser against pathologically nested input. + +### Rate limiting and lockout + +The LDAP port is outside Saltcorn's web-login throttling, so `harden.js` +maintains an in-memory per-IP failure counter. After `MAX_FAILS` (10) failures +within a `WINDOW_MS` (5 minute) window, `isLocked(ip)` returns true and binds +from that IP are denied without an authentication attempt. A successful bind +clears the counter (`recordSuccess`); the window resets on expiry or success. +The map is in-memory and per-process: it is not replicated across a cluster and +is cleared on restart. + +## Cluster single-binder, retry, and the loud UNAVAILABLE warning + +Only the cluster primary binds the listener. `startLdap` returns silently in +forked workers (`isPrimary` check), so the workers do not race the port and spew +`EADDRINUSE`. A module-level `started` flag makes the start idempotent across the +per-tenant `onLoad` calls, so the listener is bound once per process. The +listener also sets `server.maxConnections = 256`. + +Binding goes through `listenWithRetry`. On a transient bind failure +(`EADDRINUSE` or `EACCES` -- for example the prior process's socket lingering +across a fast restart) it retries up to `LDAP_BIND_MAX_ATTEMPTS` (5) times with +a linear backoff of `LDAP_BIND_RETRY_BASE_MS * attempt` (500 ms times the +attempt number), logging each retry: + +``` +[saltcorn-idp] LDAP : not yet bindable (); retry /5 in ms +``` + +After the final attempt it fails loudly -- LDAP authentication being unavailable +is an operational problem, not a benign condition -- and resets `started` so a +later plugin reload can retry: + +``` +[saltcorn-idp] WARNING: LDAP enabled on : but could NOT bind after attempt(s) (): LDAP authentication is UNAVAILABLE +``` + +(`ldapjs`'s `Server.listen()` signals success only through the listen callback, +not a `listening` event on the wrapper, and that callback is re-registered on +each retry; a `settled` guard ensures the success and give-up effects each run +exactly once.) + +## Multi-tenancy + +The listener runs at process level in the default tenant context, so each bind +and search must establish its own tenant context. The tenant is taken from the +DN by `tenantFromDn` in `lib/ldap/tenant.js`, which captures the `dc=` +component immediately preceding the `dc=saltcorn,dc=local` base (lowercased). + +`resolveTenant(token)` then maps that token to a decision: + +- a falsy token, or one equal to the default schema, resolves to the default + context (no wrap); +- in a single-tenant deployment, any explicit tenant token is denied -- a + crafted multi-tenant DN cannot smuggle in a tenant; +- in a multi-tenant deployment, the token is validated against the live tenant + set (`getAllTenants()`); a known tenant runs inside + `runWithTenant()`, and an unknown tenant is denied so a crafted DN + cannot reach another tenant's schema. + +### Cross-tenant deny + +On bind, a `deny` decision yields `InvalidCredentialsError`. On search, the +handler resolves the tenant of the search base and the tenant of the bound +connection separately and requires them to match; if the bound connection's +tenant differs from the search base's tenant, the search is rejected with +`InsufficientAccessRightsError`. This prevents reading another tenant's +directory across a single bound connection. + +## Vendoring chokepoint + +`lib/ldap/vendor.js` is the single place that `require()`s `ldapjs`. Centralizing +the dependency there makes it the one upgrade, audit, and maintenance point, and +keeps the bind and search handlers off a direct `ldapjs` import (they use the +re-exported `InvalidCredentialsError`, `InsufficientAccessRightsError`, +`OperationsError`, and `ProtocolError`). All hardening lives in this plugin's own +code at layers it controls -- the per-message byte cap here, the per-IP lockout +in `harden.js`, and the filter-depth guard in `search.js` -- so no `ldapjs` fork +is needed. `ldapjs` is pinned (the caret dropped) rather than forked; see +[`../VENDORING.md`](../VENDORING.md) and [`./security.md`](./security.md). + +## Example: bind and search over loopback + +With `SALTCORN_IDP_LDAP_PORT=1636` set on a single-tenant instance (the default +host is loopback), a user can bind and search with OpenLDAP's client tools. The +listener is LDAPS only, so use the `ldaps://` scheme. The development +certificate is self-signed, so a client will need to disable certificate +verification (here via `LDAPTLS_REQCERT=never`). + +```sh +# Simple bind + subtree search for one user, requesting mail and memberOf. +LDAPTLS_REQCERT=never ldapsearch -H ldaps://127.0.0.1:1636 \ + -D "uid=admin@local,ou=people,dc=saltcorn,dc=local" \ + -w 'AdminP@ss1' \ + -b "ou=people,dc=saltcorn,dc=local" \ + "(uid=admin@local)" mail memberOf cn +``` + +A matching entry comes back as an `inetOrgPerson` with `mail` set to the user's +email and `memberOf` listing the user's group DNs (for example +`cn=role:admin,ou=groups,dc=saltcorn,dc=local`). Listing groups instead: + +```sh +LDAPTLS_REQCERT=never ldapsearch -H ldaps://127.0.0.1:1636 \ + -D "uid=admin@local,ou=people,dc=saltcorn,dc=local" \ + -w 'AdminP@ss1' \ + -b "ou=groups,dc=saltcorn,dc=local" \ + "(objectclass=groupOfNames)" cn member +``` + +On a multi-tenant Postgres instance the tenant goes in the DN; for tenant `t1` +the bind DN and base become +`uid=admin@t1.local,ou=people,dc=t1,dc=saltcorn,dc=local` and +`ou=people,dc=t1,dc=saltcorn,dc=local` respectively. diff --git a/docs/oidc.md b/docs/oidc.md new file mode 100644 index 0000000..13fb7e3 --- /dev/null +++ b/docs/oidc.md @@ -0,0 +1,440 @@ +# OIDC / OAuth2 + +Provider-side OpenID Connect (OIDC) and OAuth2 for the `saltcorn-idp` plugin. +This document covers the provider that turns Saltcorn into an OIDC Identity +Provider: the endpoints, the authorization-code + PKCE + consent flow, client +registration, token signing and key lifecycle, claims, and the integration +notes for the underlying [`oidc-provider`](https://www.npmjs.com/package/oidc-provider) +library. + +For the other protocols this plugin implements, see [`./ldap.md`](./ldap.md) +and [`./saml.md`](./saml.md). The admin pages used to register clients and +groups are documented in the "Admin UI pages" section of +[`./configuration.md`](./configuration.md#admin-ui-pages). Multi-tenant +behaviour is covered in [`./architecture.md`](./architecture.md) (overview) and +[`./operations.md`](./operations.md) (per-tenant install/routing); the OIDC +specifics are in "Per-tenant issuer and keys" below. + +All OIDC/OAuth2 work is built on `oidc-provider` v9 (an ESM-only module loaded +via dynamic `import()`). Saltcorn's own sessions provide the login; this plugin +mounts the provider under `/idp` and hosts only the login/consent interaction +screens itself. + +--- + +## Endpoint table + +All public endpoints live under `IDP_BASE_PATH` (`/idp`) and are registered with +`noCsrf: true` (machine-to-machine; `oidc-provider` manages its own CSRF/state). +Paths are defined in `lib/constants.js`; routing is in `lib/oidc/routes.js` +(delegated endpoints) and `lib/oidc/interactions.js` (the login/consent screens +this plugin hosts). + +| Endpoint | Path | Methods | Source / handler | Purpose | +|----------|------|---------|------------------|---------| +| Discovery | `/idp/.well-known/openid-configuration` | GET | `oidcRoutes` -> `delegate` | OIDC discovery document; `oidc-provider` advertises issuer, endpoint URIs, supported scopes/claims, and `jwks_uri`. | +| JWKS | `/idp/jwks` | GET | `oidcRoutes` -> `delegate` | Public signing keys (the public half of active + retiring keys). Advertised as `jwks_uri` in discovery. | +| Authorization | `/idp/auth` | GET, POST | `oidcRoutes` -> `delegate` | Authorization endpoint; starts the code flow, triggers login/consent interactions when needed. | +| Authorization resume | `/idp/auth/:uid` | GET, POST | `oidcRoutes` -> `delegate` | Resumes the authorization request after an interaction (login/consent) completes. | +| Token | `/idp/token` | POST | `oidcRoutes` -> `delegate` | Token endpoint; exchanges an authorization code (with PKCE verifier and/or client secret) for tokens. | +| Userinfo | `/idp/me` | GET, POST | `oidcRoutes` -> `delegate` | Userinfo endpoint; returns granted claims for the bearer access token. | +| Interaction | `/idp/interaction/:uid` | GET | `interactionRoutes` -> `interactionHandler` | Login or consent screen, hosted by this plugin. | +| Interaction confirm | `/idp/interaction/:uid/confirm` | POST | `interactionRoutes` -> `confirmHandler` | Consent submit (Allow / Deny). | + +Notes from the source: + +- The JWKS path is mount-relative `/jwks` (not under `/.well-known`); the + discovery document advertises it as `jwks_uri` (`lib/constants.js`). +- The discovery document and JWKS are generated and served by `oidc-provider` + itself; `lib/oidc/discovery.js` now only derives the issuer URL that feeds the + Provider (see comment at the bottom of that file). +- All endpoints except the two interaction routes are handled by `delegate`, + which forwards to `oidc-provider`'s callback (see "oidc-provider integration"). + +--- + +## Authorization-code + PKCE + consent flow + +End to end, an authorization-code flow with PKCE and an interactive consent +screen. Step references below are grounded in `lib/oidc/routes.js`, +`lib/oidc/interactions.js`, and `lib/oidc/provider.js`. + +1. Relying party redirects the browser to `/idp/auth` with the usual code-flow + parameters (`client_id`, `redirect_uri`, `response_type=code`, `scope`, + `state`, `nonce`, `code_challenge`, `code_challenge_method=S256`). The route + delegates to `oidc-provider`, which validates the client, redirect URI, + scopes, and PKCE challenge against the dynamically-loaded client metadata. + +2. `oidc-provider` determines whether it needs an interaction (login and/or + consent). The interaction URL is produced by the `interactions.url` callback + in `buildProvider` (`lib/oidc/provider.js`), which returns + `IDP_BASE_PATH + "/interaction/" + interaction.uid` (i.e. `/idp/interaction/:uid`). + +3. Login prompt -- handled by `interactionHandler` (`lib/oidc/interactions.js`): + - It calls `provider.interactionDetails(req, res)` to read the pending + interaction; an invalid/expired interaction returns HTTP 400 + `invalid or expired interaction`. + - If `req.user && req.user.id` (the user already has a Saltcorn session), it + finishes the login with + `provider.interactionFinished(req, res, { login: { accountId: String(req.user.id) } }, { mergeWithLastSubmission: false })`. + - Otherwise it redirects to Saltcorn's own login: + `/auth/login?dest=`. After the user logs in, + Saltcorn returns to the `dest`, the handler re-runs, now sees `req.user`, + and finishes the login. Only EXISTING Saltcorn users authenticate; there is + no auto-provisioning (see `findAccount` below). + +4. Consent prompt -- also handled by `interactionHandler`: + - When `details.prompt.name === "consent"`, it loads the client row via + `clients.getClient(details.params.client_id)`, splits the requested scope + string on spaces, and renders an HTML consent screen (`renderConsent`) + listing the client label/id and the scopes. All interpolated values are + escaped with `web.escapeHtml`. + - The form POSTs to `/idp/interaction/:uid/confirm`. + +5. Consent confirm -- handled by `confirmHandler` (`lib/oidc/interactions.js`): + - Deny: if `req.body.deny` is truthy, it finishes with + `interactionFinished(..., { error: "access_denied", error_description: "User denied the request" }, { mergeWithLastSubmission: false })`, + which redirects the browser back to the client with `error=access_denied`. + - Allow: it creates a grant with + `new provider.Grant({ accountId: details.session.accountId, clientId: details.params.client_id })`, + calls `grant.addOIDCScope(details.params.scope)` when a scope is present, + `await grant.save()` (persisted through the storage adapter), then + `interactionFinished(..., { consent: { grantId: grantId } }, { mergeWithLastSubmission: true })`. + +6. `oidc-provider` resumes the authorization request (via `/idp/auth/:uid`), + issues an authorization code, and redirects to the client's `redirect_uri` + with `code` and the echoed `state`. + +7. The client exchanges the code at `/idp/token` (POST). `oidc-provider` + validates the client credentials (PKCE `code_verifier` for public clients, or + the client secret for confidential clients), validates and consumes the code + via the adapter, calls `findAccount` + `claims`, signs the `id_token` with the + active RS256 key, and returns the token response + (`access_token`, `id_token`, `token_type`, `expires_in`). + +8. The client may call `/idp/me` (GET or POST) with + `Authorization: Bearer ` to fetch the granted claims again. + +--- + +## Client registration + +Relying parties are stored in the `_idp_clients` table and managed in +`lib/clients.js`. There are no static clients in the Provider config +(`clients: []` in `buildProvider`); every client is looked up dynamically +through the `Client` model in the storage adapter, so new registrations take +effect without rebuilding the provider. + +### Public (PKCE) vs confidential (sealed secret) + +`createClient(opts)` (`lib/clients.js`) keys behaviour off `opts.authMethod`, +defaulting to `"none"`: + +- Public client -- `authMethod === "none"`: no secret is generated; the + `secret_ciphertext` / `secret_iv` / `secret_tag` columns are left `null`. The + client must use PKCE. +- Confidential client -- any other `authMethod` (e.g. + `client_secret_basic`, `client_secret_post`): a random 32-byte secret + (`SECRET_BYTES = 32`) is generated as `base64url`, then sealed at rest with + `idpCrypto.sealText` (AES-256-GCM under the plugin KEK). The plaintext secret + is returned ONCE from `createClient` as `{ client_id, secret }` for display; + it is never stored in plaintext and never returned again. + +`createClient` always stores `grant_types` as `["authorization_code"]` and +`response_types` as `["code"]` (JSON arrays), plus `redirect_uris` (JSON array) +and an optional `scope` string. It inserts with `{ noid: true }`. A duplicate +`client_id` violates the primary key and throws. + +`toOidcMetadata(row)` renders a stored row into the metadata object +`oidc-provider` expects: + +```js +{ + client_id, + redirect_uris, // JSON.parse of the stored array + grant_types, // JSON.parse + response_types, // JSON.parse + token_endpoint_auth_method, // from token_auth_method + scope, // only if set + client_secret // only for confidential clients, unsealed via openText +} +``` + +### Redirect URIs + +`redirect_uris` is stored as a JSON array and passed straight through to +`oidc-provider`, which enforces an exact-string match of the request's +`redirect_uri` against this list (standard `oidc-provider` behaviour). The admin +UI splits a newline-separated textarea into the array (see the "Admin UI pages" +section of [`./configuration.md`](./configuration.md#admin-ui-pages)); it does +not validate that entries are well-formed URLs. + +### Scopes + +The Provider registers exactly these scopes (`buildProvider`): + +``` +openid email profile groups +``` + +Clients can only request from this set. A client's stored `scope` string (e.g. +the admin UI default `openid email profile groups`) is included in its metadata. +The `groups` scope is a plugin-specific scope that maps to the `groups` claim +(see "Claims" below). + +### `_idp_clients` columns (reference) + +Defined in `lib/schema.js`: + +| Column | Notes | +|--------|-------| +| `client_id` | TEXT PRIMARY KEY | +| `label` | TEXT, human label for the admin UI | +| `token_auth_method` | TEXT, default `none`; `none` = public/PKCE | +| `redirect_uris` | TEXT, JSON array | +| `grant_types` | TEXT, JSON array (always `["authorization_code"]`) | +| `response_types` | TEXT, JSON array (always `["code"]`) | +| `scope` | TEXT, space-separated scope string (nullable) | +| `secret_ciphertext` | TEXT hex; only for confidential clients | +| `secret_iv` | TEXT hex | +| `secret_tag` | TEXT hex | +| `created_at` | TEXT ISO timestamp | + +--- + +## Tokens and signing + +### Algorithm and key material + +- `id_token`s are signed with RS256 (`SIGNING_ALG = "RS256"` in + `lib/constants.js`), using a 2048-bit RSA key (`RSA_MODULUS_BITS = 2048`). +- The Provider is configured with `jwks: { keys: [privateJwk] }` in + `buildProvider`, where `privateJwk` comes from `keys.getActivePrivateJwk()`. + `oidc-provider` both signs with this private key and serves its PUBLIC half at + the JWKS endpoint. +- `conformIdTokenClaims: false` is set deliberately so scope-granted claims + (e.g. `email`, `groups`) appear in the `id_token` as well as at userinfo, so + relying parties can read them straight from the `id_token`. + +### JWKS, key status, and rotation + +Keys live in `_idp_keys` (one per tenant) with a `status` of `active`, +`retiring`, or `retired` (`KEY_STATUS` in `lib/constants.js`). Lifecycle is in +`lib/keys.js`: + +- `ensureActiveKey()` is idempotent: if an `active` key already exists for the + tenant it returns its metadata; otherwise it generates a new RSA keypair, + derives a `kid` (`crypto.newKid()`), stores the public JWK as JSON, seals the + private key PEM (AES-256-GCM), and inserts the row with `status = active`. It + returns only safe metadata (`kid`, `alg`, `status`, `created_at`) -- never the + sealed private material. Called from the plugin `onLoad`. +- `getJwks()` returns `{ keys: [...] }` containing the public JWKs of both + `active` and `retiring` keys. Publishing retiring keys lets relying parties + keep validating recently-issued tokens across a rotation. (Note: the actual + JWKS HTTP response is produced by `oidc-provider` from its configured `jwks`; + `getJwks()` is the plugin's own public-key reader, used e.g. by the admin + dashboard.) +- `getActiveKeyMeta()` reads `kid`/`alg`/`created_at` without decrypting -- safe + for the dashboard. +- `getActiveSigningKey()` decrypts the sealed private key PEM and imports it to a + key object; `getActivePrivateJwk()` exports that as a private JWK for the + Provider config. + +Each public/private JWK carries `kid`, `alg`, and `use: "sig"` (set in +`lib/crypto.js`). The private key is sealed under a KEK derived from +`SALTCORN_SESSION_SECRET`; rotating that secret invalidates all sealed material +(KEK derivation and the schema-qualified DDL are documented in +[`./configuration.md`](./configuration.md); the at-rest sealing rationale is in +[`./security.md`](./security.md)). + +The code does not include an automated rotation routine; the `retiring` / +`retire_after` machinery and `getJwks()` publishing retiring keys are the +building blocks for it. + +### `_idp_keys` columns (reference) + +| Column | Notes | +|--------|-------| +| `kid` | TEXT PRIMARY KEY | +| `alg` | TEXT (`RS256`) | +| `public_jwk` | TEXT, JSON-stringified public JWK | +| `private_ciphertext` | TEXT hex, sealed private key PEM | +| `private_iv` | TEXT hex | +| `private_tag` | TEXT hex | +| `status` | TEXT, default `active` | +| `created_at` | TEXT ISO timestamp | +| `retire_after` | TEXT (nullable) | + +--- + +## Claims + +Claims are produced by `oidcClaims(user, sub, grantedScopes)` in +`lib/claims.js`, called from the `findAccount` -> `claims` callback in +`buildProvider`. The granted scope string is split on spaces and each scope adds +its claims: + +| Scope | Claims | Source | +|-------|--------|--------| +| `openid` | `sub` | always present; `sub` is the Saltcorn user id (as a string) | +| `email` | `email`, `email_verified` | `user.email`; `email_verified = !!user.verified_on` | +| `profile` | `name` | `user._attributes.name`, falling back to `user.email` | +| `groups` | `groups` | `groups.effectiveGroups(user)` | + +### The `groups` claim and its prefixes + +`effectiveGroups(user)` in `lib/groups.js` is the single source of truth for +group identity (reused by LDAP and SAML). It returns an array combining: + +- the user's Saltcorn role, looked up in `_sc_roles` by `user.role_id`, emitted + as `role:` (`ROLE_PREFIX = "role:"`); and +- each custom group the user belongs to (via `_idp_group_members` -> + `_idp_groups`), emitted as `group:` (`GROUP_PREFIX = "group:"`). + +Example claim value: + +```json +{ "groups": ["role:admin", "group:engineering"] } +``` + +The `role:` / `group:` prefixes keep a role and a custom group of the same name +from colliding. Custom groups are managed in the admin UI (the +`/admin/idp/groups` page; see the "Admin UI pages" section of +[`./configuration.md`](./configuration.md#admin-ui-pages)). + +--- + +## oidc-provider integration notes + +The integration glue lives in `lib/oidc/provider.js` (Provider construction + +per-issuer cache), `lib/oidc/adapter.js` (storage), and `lib/oidc/routes.js` +(mounting + body re-streaming). + +### ESM import and per-issuer caching + +`oidc-provider` v9 is ESM-only, so it is loaded from this CommonJS plugin via a +cached dynamic import (`loadProviderClass` in `lib/oidc/provider.js`): + +```js +providerClassPromise = import("oidc-provider").then((m) => m.default); +``` + +Providers are built lazily and cached per issuer URL in a +`Map` (`providersByIssuer`). `getProviderEntry(req)` derives the issuer via +`issuerForReq(req)`, returns the cached `{ provider, handler }` if present, or +builds one with `buildProvider(issuer)` and stores +`{ provider, handler: provider.callback() }`. The Provider also sets +`provider.proxy = true` so it trusts `X-Forwarded-*` headers behind a proxy. + +Cookie-signing keys are derived deterministically from `SALTCORN_SESSION_SECRET` +(`crypto.deriveSecretHex(COOKIE_KEY_INFO, COOKIE_KEY_BYTES)`, where +`COOKIE_KEY_INFO = "saltcorn-idp:oidc-cookie-keys:v1"` and +`COOKIE_KEY_BYTES = 32`) so interaction sessions survive a restart. The `secure` +flag on the long/short cookies is set when the issuer is `https://`. + +`findAccount(ctx, sub)` parses `sub` as an integer and looks up the Saltcorn +user with `User.findOne({ id: uid })`; if the user does not exist it returns +`undefined` (existing users only, never auto-provisioned). When found it returns +`{ accountId: sub, claims: async (use, scope) => claims.oidcClaims(user, sub, scope) }`. + +### Callback mounting and the `delegate` handler + +OIDC endpoints are mounted under `/idp` and forwarded to `oidc-provider`'s +callback by `delegate` (`lib/oidc/routes.js`): + +- It fetches the per-issuer `entry` via `getProviderEntry(req)`. +- It computes `stripped = fullUrl.slice(IDP_BASE_PATH.length) || "/"` so the + mount-relative router inside `oidc-provider` matches (e.g. `/token` instead of + `/idp/token`), while keeping `originalUrl` so the provider derives the correct + mount. +- For GET/HEAD it just rewrites `req.url`; for other methods it builds a + re-streamed request (see below). +- It awaits `entry.handler(target, res)`. On error it logs and, if headers are + not yet sent, returns HTTP 500 `{ "error": "server_error" }`. + +### Body re-streaming (`restreamBody`) + +Saltcorn/Express has already drained and parsed the POST body, but +`oidc-provider` reads the raw request stream itself. `restreamBody` +(`lib/oidc/routes.js`) rebuilds a `Readable` carrying the body: + +- It prefers `req.rawBody` if present; otherwise it re-encodes `req.body` as JSON + (when content-type is `application/json`) or as URL-encoded form data; if there + is no body it uses an empty buffer. +- It copies the `IncomingMessage` properties Koa / raw-body rely on (`headers` + with a corrected `content-length`, `rawHeaders`, `method`, mount-relative + `url`, full `originalUrl`, HTTP version fields, `socket`, `connection`, + `complete = false`). + +### Storage adapter + +`SaltcornAdapter` (`lib/oidc/adapter.js`) implements the `oidc-provider` storage +interface against the single `_idp_oidc_store` table. `oidc-provider` +instantiates one adapter per model name (`new SaltcornAdapter(name)`); rows are +tenant-scoped because the `db.*` calls run in the request's tenant context. + +| Method | Behaviour | +|--------|-----------| +| `upsert(id, payload, expiresIn)` | Stores `model`, `id`, JSON `payload`, plus extracted `uid` / `grant_id` (`payload.grantId`) / `user_code` (`payload.userCode`) and `expires_at` (`epoch + expiresIn`, or null). Inserts with `{ noid: true }` or updates the existing `(model, id)` row. | +| `find(id)` | For model `Client`, returns `clients.toOidcMetadata(clients.getClient(id))` (dynamic client lookup); otherwise parses and returns the stored payload, or `undefined`. | +| `findByUid(uid)` | Looks up by `(model, uid)`. | +| `findByUserCode(userCode)` | Looks up by `(model, user_code)`. | +| `consume(id)` | Sets `payload.consumed = epoch()` and writes it back (single-use authorization codes). | +| `destroy(id)` | Deletes the `(model, id)` row. | +| `revokeByGrantId(grantId)` | Deletes every row with that `grant_id` (revokes all artifacts of a grant). | + +`oidc-provider` validates expiry / consumed itself from the payload, so `find()` +returns the stored payload as-is. + +### `_idp_oidc_store` columns (reference) + +Defined in `lib/schema.js`: + +| Column | Notes | +|--------|-------| +| `model` | TEXT; `oidc-provider` model name (e.g. `AuthorizationCode`, `Grant`, `RefreshToken`) | +| `id` | TEXT | +| `payload` | TEXT, JSON | +| `uid` | TEXT (interaction uid) | +| `grant_id` | TEXT | +| `user_code` | TEXT | +| `expires_at` | INTEGER, Unix epoch seconds | +| PRIMARY KEY | `(model, id)` | + +Indexes exist on `uid`, `grant_id`, and `user_code`. + +--- + +## Per-tenant issuer and keys + +The issuer is derived by `issuerForReq(req)` (`lib/oidc/discovery.js`): + +1. Prefer Saltcorn's configured `base_url` (`getState().getConfig("base_url", "")`). +2. If unset, fall back to `req.protocol + "://" + req.get("host")` and log a + warning. +3. Strip trailing slashes and append `IDP_BASE_PATH` (`/idp`). + +Example: with `base_url = https://example.com`, the issuer is +`https://example.com/idp`. + +Security: the request-host fallback is vulnerable to Host-header injection (a +forged `Host` could poison the advertised issuer/endpoints). `base_url` MUST be +set in any multi-tenant or proxied deployment; the fallback exists for +single-tenant localhost/dev. The issuer MUST exactly equal the URL prefix a +relying party used to fetch the discovery document. + +Because providers are cached per issuer and each tenant has its own `_idp_keys` +(its own active signing key) and its own `_idp_clients`, multi-tenant / multi-host +deployments get fully isolated issuers, JWKS, and client registries. See +[`./architecture.md`](./architecture.md) for the broader tenancy model and +[`./operations.md`](./operations.md) for per-tenant install and host routing. + +--- + +## Related environment variables + +| Variable | Used for | +|----------|----------| +| `SALTCORN_SESSION_SECRET` | Derives the at-rest KEK (sealing private keys and client secrets) and the deterministic `oidc-provider` cookie-signing keys. Required; see the "Environment variables" and "Crypto byte sizes" sections of [`./configuration.md`](./configuration.md). | + +Saltcorn's `base_url` config (not an env var) drives the issuer; see above. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..31fa5b8 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,266 @@ +# Operations & Deployment + +How to run, install, and operate the `saltcorn-idp` plugin in the local +development setup. For protocol/internal details see the sibling docs: +[OIDC](./oidc.md), [LDAP](./ldap.md), [SAML](./saml.md), +[architecture](./architecture.md), [configuration](./configuration.md). + +All commands below assume the project root: + +``` +/home/scott/claude/saltcorn +``` + +The upstream Saltcorn checkout lives in `saltcorn/` under that root; the plugin +source lives in `idp/`. Each `env.sh` prepends +`saltcorn/packages/saltcorn-cli/bin` to `PATH` so `saltcorn ...` resolves to the +in-tree CLI. + +## The three dev instances + +There are three parallel Saltcorn dev instances, each with its own start script, +state directory, and `env.sh`. They are intentionally isolated (distinct ports, +session stores, session secrets) so they can run at the same time. + +| Instance | Script | HTTP port | Backend | State dir | LDAPS port | +|----------|--------|-----------|---------|-----------|------------| +| MAIN | `startServer.sh` | 3000 (default) | SQLite | `.dev-state/` | 1636 | +| TEST | `startServerTest.sh` | 3001 (`SALTCORN_PORT`) | SQLite | `.dev-state-test/` | none | +| PG | `startServerPg.sh` | 3002 (`-p 3002`) | Postgres, multi-tenant | `.dev-state-pg/` | 1637 | + +Each start script `cd`s into its state directory before running +`saltcorn serve`. This is deliberate: on SQLite, Saltcorn's session store writes +`sessions.sqlite` at the process current working directory +(`packages/server/routes/utils.js`, the `db.isSQLite` branch), so running from +inside the state dir gives each SQLite instance its own `sessions.sqlite` +alongside its `saltcorn.sqlite`. (PG uses a Postgres-backed session store; see +below.) + +### What each env.sh sets + +Common to all three: `NVM_DIR` (sources nvm to pick up Node), and the `PATH` +prepend for the in-tree CLI. + +**MAIN -- `.dev-state/env.sh`** + +| Variable | Value | Purpose | +|----------|-------|---------| +| `SQLITE_FILEPATH` | `.dev-state/saltcorn.sqlite` | Forces the SQLite backend; DB file location | +| `SALTCORN_FILE_STORE` | `.dev-state/files` | Uploaded-file directory | +| `SALTCORN_SESSION_SECRET` | `32552b95...410151` | Session/cookie key; also the IdP KEK + oidc cookie-key source | +| `SALTCORN_IDP_LDAP_PORT` | `1636` | Enables the LDAPS listener on 1636. Only MAIN sets this | + +**TEST -- `.dev-state-test/env.sh`** + +| Variable | Value | Purpose | +|----------|-------|---------| +| `SQLITE_FILEPATH` | `.dev-state-test/saltcorn.sqlite` | TEST's own SQLite DB | +| `SALTCORN_FILE_STORE` | `.dev-state-test/files` | TEST's own file store | +| `SALTCORN_SESSION_SECRET` | `cde4d5ce...86265` | Different secret from MAIN | +| `SALTCORN_PORT` | `3001` | HTTP listen port (passed via `saltcorn serve -p "$SALTCORN_PORT"`) | + +TEST does **not** set `SALTCORN_IDP_LDAP_PORT`, so it runs no LDAP listener. + +**PG -- `.dev-state-pg/env.sh`** + +| Variable | Value | Purpose | +|----------|-------|---------| +| `PGHOST` | `/var/run/postgresql` | Unix socket; peer auth (no real password) | +| `PGUSER` | `scott` | OS user matched by peer auth | +| `PGDATABASE` | `saltcorn_idp` | DB name (must already exist) | +| `PGPASSWORD` | `peer` | Dummy value; peer auth ignores it, but Saltcorn only selects Postgres when user+password+database are all set (`connect.ts getConnectObject`) | +| `SALTCORN_MULTI_TENANT` | `true` | Enables schema-per-tenant (Postgres only) | +| `SALTCORN_IDP_LDAP_PORT` | `1637` | LDAPS listener on 1637 (distinct from MAIN's 1636) | +| `SALTCORN_FILE_STORE` | `.dev-state-pg/files` | File store | +| `SALTCORN_SESSION_SECRET` | `3ca4fab8...41a` | Different secret from MAIN/TEST | +| `SALTCORN_JWT_SECRET` | `d379db38...f158f` | JWT signing secret | + +PG deliberately does **not** set `SQLITE_FILEPATH`, so Saltcorn's +`getConnectObject()` selects Postgres. + +Note: on Postgres the session store is Postgres-backed, not SQLite. Saltcorn +only uses the `connect-sqlite3` `sessions.sqlite` store when `db.isSQLite` +(`packages/server/routes/utils.js`); on Postgres it uses `connect-pg-simple` +with a `_sc_session` table, so no `sessions.sqlite` is written for the PG +instance. + +## Starting and stopping + +Start each instance from the project root: + +```bash +./startServer.sh # MAIN -> http://localhost:3000 (LDAPS :1636) +./startServerTest.sh # TEST -> http://localhost:3001 (no LDAP) +./startServerPg.sh # PG -> http://localhost:3002 (LDAPS :1637) +``` + +On boot, MAIN and TEST run `saltcorn install-plugin -d ./dev-deploy` (the +separate metadata-migration plugin) before serving; that install is per-instance +safe because each uses its own source dir. Failures there are non-fatal -- the +previously installed version still loads. `startServerPg.sh` does not run any +install on boot. + +`saltcorn-idp` is **not** installed during boot of any instance -- see the next +section for why. Each instance loads it from a prior install. + +Stop an instance with Ctrl-C (the scripts `exec saltcorn serve`, so the foreground +process is the server). Stop all instances before re-installing the plugin (see +below). + +## Installing the plugin + +### MAIN + TEST: reinstallIdp.sh + +After editing `idp/` source, reinstall into both SQLite instances with the +dedicated script, then restart the servers: + +```bash +./reinstallIdp.sh +./startServer.sh & +./startServerTest.sh & +``` + +`reinstallIdp.sh` sources each instance's `env.sh` in a subshell and runs +`saltcorn install-plugin -d "$IDP_DIR"` once per instance, where +`IDP_DIR="$PWD/idp"`. + +### Why a separate script (not in startServer*.sh) + +Three reasons, all in the script's header comment and code: + +1. **Absolute `-d` path required.** `saltcorn install-plugin` `path.join()`s the + `-d` argument and then `require()`s it. A leading `./` gets collapsed and is + resolved as a node module instead of a filesystem path, so it fails. The + script uses the absolute `IDP_DIR="$PWD/idp"` instead of `./idp`. + +2. **EEXIST on existing node_modules symlinks.** `install-plugin` aborts with + `EEXIST` if the per-plugin-dir `node_modules` symlinks already exist (from a + prior install). `clearSymlinks()` removes them before each install so they can + be recreated cleanly: + + ```bash + clearSymlinks() { + find "$PLUGINS_ROOT" -maxdepth 2 -name node_modules -type l -delete 2>/dev/null || true + } + ``` + + `PLUGINS_ROOT` is `$HOME/.local/share/saltcorn-plugins`. + +3. **Shared plugins_folder race.** That `plugins_folder` is shared by both MAIN + and TEST. Doing the install in each start script would race on symlink + creation when both instances boot concurrently, so installs are centralized + here. Run it with the servers stopped. + +### The additive-copy-does-not-prune gotcha + +`saltcorn install-plugin` copies the `idp/` source into the shared +`plugins_folder` copy **additively**: new and modified files are copied, but +files you have **deleted** from `idp/` are **not** pruned from the copy. A stale +file can linger and still be loaded. `reinstallIdp.sh` clears the node_modules +symlinks but does not prune stale source files either. For a guaranteed clean +slate, delete the plugin's directory under +`~/.local/share/saltcorn-plugins/` before re-running the install. + +### PG (multi-tenant): per-tenant install + +The Postgres instance installs the plugin per tenant schema. Two steps: + +1. **One-time, into the public schema** (so the shared `plugins_folder` copy + exists): + + ```bash + source .dev-state-pg/env.sh + saltcorn install-plugin -d ./idp + ``` + +2. **Per tenant** (registers + enables the plugin in each tenant schema and runs + its `onLoad`): + + ```bash + ./idp/scripts/installIdpTenant.sh t1 t2 # named tenants + ./idp/scripts/installIdpTenant.sh '*' # all tenants + ``` + +Prerequisites (from the script header): the tenants must already exist +(`saltcorn create-tenant `), and the public-schema install above must have +run first. + +`installIdpTenant.sh` `cd`s to the project root, sources `.dev-state-pg/env.sh`, +and runs `idp/scripts/installIdpTenant.js` with the tenant arguments. + +What `installIdpTenant.js` does: + +- `Plugin.loadAllPlugins()`, then resolves the target tenant list from + `process.argv` (or all tenants via `getAllTenants()` when given `*` or no + args), mapping each to its `subdomain`. +- `init_multi_tenant(Plugin.loadAllPlugins, true, tenants)` -- initializes + per-tenant State (so `getState()` resolves inside `runWithTenant`) without + running migrations; this also re-runs existing plugins' idempotent `onLoad`. +- `getRootState().setConfig("tenants_unsafe_plugins", true)` -- a root-only + config that permits installing this **local** (`-d`) plugin into tenant + schemas. In this Saltcorn build, `loadAndSaveNewPlugin` skips any non-`npm` + plugin on a non-root tenant before its `allowUnsafe` argument is consulted, so + this config is the supported lever; the CLI's + `install-plugin -t -d ` cannot do it. It is intended for a + multi-tenant deployment that offers the IdP plugin to its tenants. +- For each tenant, `installInto(tenant)` runs inside + `db.runWithTenant(tenant, ...)` within a transaction: + - `db.deleteWhere("_sc_plugins", { name: "saltcorn-idp" })` first -- a + **delete-then-insert** that removes any prior rows (including the old manual + `_sc_plugins` SQL hack and earlier installs) so it converges on exactly one + `_sc_plugins` row (one source of truth). + - Creates a `new Plugin({ name: "saltcorn-idp", source: "local", location: IDP_DIR, configuration: {} })` + and calls `Plugin.loadAndSaveNewPlugin(plugin, true, false)`. + - Verifies by checking the `_sc_plugins` row exists and that + `_idp_ldap_service` exists in the tenant's schema + (`information_schema.tables`); throws if either is missing (meaning `onLoad` + did not run). + +On every subsequent boot of `startServerPg.sh`, per-tenant `onLoad` re-runs +automatically via `init_multi_tenant -> loadAllPlugins`; this is idempotent +(tables already exist, signing key already sealed, etc.), so no per-tenant +reinstall is needed on a normal restart -- only after editing the plugin source. + +## Multi-tenant host routing + +The PG instance is multi-tenant. The OIDC issuer is derived per request by +`issuerForReq()` (`lib/oidc/discovery.js`): it prefers the tenant's configured +`base_url` and falls back to `req.protocol + "://" + req.get("host")` when +`base_url` is unset. The SAML entity ID is that issuer plus `/saml` +(`lib/saml/idp.js`). See [architecture](./architecture.md) and [OIDC](./oidc.md). +In the dev/test setup the host conventions are: + +- **Subdomain selects the tenant.** Saltcorn's multi-tenant mode uses a + subdomain offset of 1 (`packages/server/app.js`), so the leading label of a + host like `t1.localhost.localdomain:3002` selects the tenant schema `t1`. +- **Issuer comes from `base_url` (or the request host).** There is no automatic + host transform: the issuer is exactly `base_url + "/idp"` when `base_url` is + set, otherwise `:///idp`. Whatever value results must + match exactly what a relying party used to fetch + `/.well-known/openid-configuration`, so set `base_url` per tenant. + +For LDAP, the tenant is encoded in the bind/search DN as an extra +`dc=` component immediately before the base DN, e.g. +`uid=admin@t1.local,ou=people,dc=t1,dc=saltcorn,dc=local`. Single-tenant +(SQLite) uses the base DN with no tenant component. See [LDAP](./ldap.md). + +## Known issues + +### Intermittent PG LDAP bind flake on :1637 + +On the Postgres multi-tenant instance, the cluster primary process occasionally +does **not** bind the LDAPS listener on `:1637` on a fresh boot. The listener is +simply absent; LDAP authentication is unavailable for that run. + +This is distinct from the handled `EADDRINUSE`/`EACCES` retry case. The LDAP +server start path (`lib/ldap/server.js`, `listenWithRetry`) already retries +transient bind failures up to `LDAP_BIND_MAX_ATTEMPTS` (5) with a linear backoff +of `LDAP_BIND_RETRY_BASE_MS` (500ms) x attempt, binding only in the cluster +primary (`isPrimary()`), and logs a loud `LDAP ... UNAVAILABLE` warning on final +failure. The flake observed here is not that path -- it is the primary not +binding `:1637` at all on a fresh boot. + +Workaround: restart the PG instance; the listener comes up on the next boot. +Root cause is **unresolved**. + +MAIN's LDAPS on `:1636` (SQLite, single-tenant) is not known to exhibit this. diff --git a/docs/saml.md b/docs/saml.md new file mode 100644 index 0000000..722cb15 --- /dev/null +++ b/docs/saml.md @@ -0,0 +1,478 @@ +# SAML 2.0 + +The `saltcorn-idp` plugin can act as a SAML 2.0 **Identity Provider (IdP)**. It +issues signed SAML Responses (assertions) to registered Service Providers (SPs), +built on the [samlify](https://www.npmjs.com/package/samlify) library and signing +with RSA-SHA256 from a per-tenant self-signed certificate. + +This document covers the SAML IdP only. For the OIDC/OAuth2 provider see +[`./oidc.md`](./oidc.md); for the LDAP directory see [`./ldap.md`](./ldap.md); +for the admin UI pages (including SP registration) see the "Admin UI pages" +section of [`./configuration.md`](./configuration.md#admin-ui-pages); for the +plugin overview and documentation index see the top-level +[`../README.md`](../README.md). + +Source files for everything below: + +``` +idp/lib/saml/idp.js IdP entity construction, cert management, samlify config +idp/lib/saml/routes.js HTTP endpoint handlers + message validation +idp/lib/saml/sps.js SP registry + ACS allow-list helpers +idp/lib/constants.js SAML route paths, size caps, URN constants +idp/lib/claims.js SAML AttributeStatement values (email + groups) +``` + +--- + +## Endpoints + +All SAML endpoints are mounted under `/idp/` (the `IDP_BASE_PATH` namespace) and +are **CSRF-exempt** (`noCsrf: true`), because SAML messages are not Saltcorn +forms. Route registration is in `idp/lib/saml/routes.js` (the `samlRoutes` +array); the path constants are in `idp/lib/constants.js`. + +| Path | Method(s) | Handler | Constant | Purpose | +|------|-----------|---------|----------|---------| +| `/idp/saml/metadata` | GET | `metadataHandler` | `SAML_METADATA_PATH` | IdP SAML metadata (XML) | +| `/idp/saml/sso` | GET, POST | `ssoHandler` | `SAML_SSO_PATH` | SP-initiated SSO | +| `/idp/saml/init` | GET | `initHandler` | `SAML_INIT_PATH` | IdP-initiated SSO | +| `/idp/saml/slo` | GET, POST | `sloHandler` | `SAML_SLO_PATH` | Single Logout | + +Notes: + +- The IdP **entityID** is `/saml`, where `` is derived per request + by `issuerForReq(req)` (`idp/lib/oidc/discovery.js`), the same derivation used + by OIDC. The published `` on every Response/LogoutResponse is also + `/saml` (`routes.js`, `routes.js`). +- `/idp/saml/sso` and `/idp/saml/slo` are each registered for **both** `get` and + `post` so they support the HTTP-Redirect binding (GET) and the HTTP-POST binding + (POST). `/idp/saml/metadata` and `/idp/saml/init` are GET only. +- The metadata document is served with `Content-Type: application/samlmetadata+xml` + (`routes.js`). It advertises both POST and Redirect bindings for the + SingleSignOnService and SingleLogoutService at `/saml/sso` and + `/saml/slo` respectively (`idp.js`), the signing certificate, + and the `emailAddress` NameID format. + +--- + +## Bindings + +The binding is chosen from the HTTP method by the handler, not from a +request-supplied binding value: + +``` +binding = (req.method === "POST") ? POST_BINDING : REDIRECT_BINDING +``` + +(`routes.js` for SSO, `routes.js` for SLO). + +- **HTTP-Redirect (GET):** the `SAMLRequest` query parameter is DEFLATE-compressed + (raw deflate) and base64-encoded. `decodeRequest` calls + `zlib.inflateRawSync(...)` (`routes.js`). +- **HTTP-POST (POST):** the `SAMLRequest` form field is base64-encoded but NOT + compressed; the decoded buffer is used directly (`routes.js`). + +samlify is called with the short binding names `"post"` and `"redirect"`, exported +as `POST_BINDING` / `REDIRECT_BINDING` from `idp.js`. The outbound login +Response and LogoutResponse are always delivered via the **HTTP-POST binding** +(an auto-submitting HTML form; see [Response delivery](#response-delivery)). + +--- + +## SP-initiated SSO + +Handler: `ssoHandler` (`routes.js`). Flow: + +1. Determine the binding from the HTTP method and read `SAMLRequest` plus optional + `RelayState` from the query (GET) or body (POST) (`routes.js`). Missing + `SAMLRequest` returns `400 missing SAMLRequest`. +2. Decode the request via `decodeRequest` (size caps, decompression, DTD/ENTITY + screen; see [Security screens](#security-screens)). A decode failure returns + `400 malformed SAML request`. +3. Extract the SP entityID from the XML `` element with a regex + (`matchIssuer`, `routes.js`). If absent: `400 could not parse SAML + AuthnRequest`. +4. **Registry gate:** look up the SP with `samlSps.getSp(spEntityId)`. An + unregistered SP returns `403 unknown SAML SP` (`routes.js`). This check + happens **before** the login bounce so an unknown SP cannot drive a login. +5. **ACS allow-list non-empty check:** `samlSps.acsUrls(spRow)`; an SP with no + registered ACS returns `403 SP has no registered ACS` (`routes.js`). +6. **Authentication:** if there is no `req.user.id`, redirect to + `/auth/login?dest=` (Saltcorn's own login), then the browser + returns to this endpoint (`routes.js`). +7. Select the IdP variant by the SP's signed-request flag (see + [AuthnRequest signature verification](#authnrequest-signature-verification)): + `getSignedIdp(issuer)` if `want_authn_requests_signed`, else `getIdp(issuer)` + (`routes.js`). +8. Build the SP entity from the **registry** entityID and the first allow-listed + ACS, passing the SP signing cert only when signed requests are required + (`routes.js`). +9. Parse the AuthnRequest with `idp.parseLoginRequest(sp, binding, parseReq)`. For + a signed redirect-binding request, the exact signed octet string is + reconstructed from the raw (still percent-encoded) query via + `rawQueryOctetString(req)` and passed as `parseReq.octetString` so the bytes + byte-match what the SP signed (`routes.js`). A parse/verification + failure returns `403 SAML request rejected`. +10. **Parser-differential guard:** the issuer parsed by samlify + (`requestInfo.extract.issuer`) must equal the entityID the SP was looked up by; + otherwise `403 issuer mismatch` (`routes.js`). +11. **ACS resolution:** prefer the parsed ACS + (`requestInfo.extract.request.assertionConsumerServiceUrl`), falling back to the + regex `matchAcs(xml)`. If the request named an ACS it MUST pass + `samlSps.acsAllowed(spRow, reqAcs)` (exact-string match) or the request is + rejected with `403 ACS not allowed`; if the request named no ACS, the first + allow-listed ACS is used (`routes.js`). +12. Load the Saltcorn user (`User.findOne({ id: req.user.id })`), compute the + attributes (`claims.samlAttributes(user)`), and send the signed Response to the + validated ACS (`routes.js`). + +Unhandled errors return `500 saml sso error`. + +--- + +## IdP-initiated SSO + +Handler: `initHandler` (`routes.js`). There is no AuthnRequest to trust; +the target SP and ACS are named by query parameters and validated against the +registry. + +Query parameters: + +| Param | Required | Meaning | +|-------|----------|---------| +| `sp` | yes | SP entityID (must be registered) | +| `acs` | no | Requested ACS URL (must be allow-listed if supplied) | +| `RelayState` | no | Opaque state echoed back to the SP | + +Flow: + +1. Read `sp`; missing returns `400 missing sp` (`routes.js`). +2. Registry gate: `samlSps.getSp(sp)`; unknown returns `403 unknown SAML SP` + (`routes.js`). +3. Non-empty ACS allow-list check; empty returns `403 SP has no registered ACS` + (`routes.js`). +4. Require an authenticated session; otherwise redirect to `/auth/login` + (`routes.js`). +5. ACS resolution: if `acs` is supplied it must pass `acsAllowed` (else + `403 ACS not allowed`); otherwise the first allow-listed ACS is used + (`routes.js`). +6. Build the SP (no signing cert is passed here), load the user, compute attributes, + and send the Response with `idpInitiated: true` (`routes.js`). + +Because the Response is unsolicited, `InResponseTo` is **dropped** from both the +`` and the `` elements +(`makeLoginResponseRenderer`, `routes.js`). + +--- + +## Single Logout (SLO) + +Handler: `sloHandler` in `lib/saml/routes.js`. SLO ends the CURRENT browser +session, so the handler is hardened against forged / cross-site LogoutRequests. +Flow: + +1. Determine binding from the method; read `SAMLRequest` + optional `RelayState`. + Missing `SAMLRequest` returns `400 missing SAMLRequest`. +2. Decode the request (same caps/screens as SSO). Failure returns + `400 malformed SAML request`. +3. Extract the SP entityID via `matchIssuer`; absent returns + `400 could not parse LogoutRequest`. +4. Registry gate: `samlSps.getSp(spEntityId)`; unknown returns + `403 unknown SAML SP`. +5. **Require an authenticated session.** If `req.user` is absent, return + `403 no authenticated session`. An unauthenticated request (or a forged + cross-site GET) has no session to end, so it cannot drive SLO. +6. Get the allow-listed endpoints. If the SP has **no registered ACS**, return + `403 SP has no registered ACS`. The LogoutResponse destination is the SP's + **first registered** endpoint (`sloAcs = allowed[0]`); the handler **never** + falls back to a URL parsed from the request, which would let a crafted + LogoutRequest steer the signed response to an attacker-chosen endpoint + (open redirect + RelayState leak). +7. **Signature (cert-registered SPs).** If the SP registered a signing cert, the + LogoutRequest signature is REQUIRED and verified (`getSignedIdp` sets + `wantLogoutRequestSigned`), mirroring the AuthnRequest path -- this blocks + forged / replayed LogoutRequests for opted-in SPs. (Residual: an SP with no + cert cannot be verified, so a cross-site request can at most force the + *current* user's own logout -- bounded by steps 5 and 9.) +8. Parse the LogoutRequest with `idp.parseLogoutRequest(...)`; failure returns + `403 SAML logout request rejected`. A parser-differential guard then requires + the authoritative parsed issuer to equal the looked-up entityID + (`403 issuer mismatch`). +9. **NameID must match the session.** The LogoutRequest `NameID` must equal the + session user's email (case-insensitive); a mismatch returns + `403 logout subject does not match the session`, so one SP cannot log out a + *different* user. +10. **Terminate the local Saltcorn session:** call `req.logout(...)` (guarded for + older synchronous passport) and `req.session.destroy(...)`, both best-effort. +11. Build and sign a LogoutResponse (`InResponseTo` set to the request id) and + auto-POST it to `sloAcs`. + +Unhandled errors return `500 saml slo error`. + +--- + +## SP registry and ACS allow-list + +Registered relying parties live in the `_idp_saml_sps` table. The IdP issues an +assertion **only** to a registered SP and **only** to one of its allow-listed ACS +URLs. A request-supplied ACS is never trusted on its own: it is accepted only when +it exactly matches an entry already in the allow-list. + +### `_idp_saml_sps` columns + +DDL: `idp/lib/schema.js` (`createIdpSamlSps`). All `CREATE TABLE` +statements are tenant-schema-qualified for multi-tenant Postgres. + +| Column | Type | Notes | +|--------|------|-------| +| `entity_id` | `TEXT PRIMARY KEY` | SAML entityID of the SP | +| `label` | `TEXT` | Human-readable label (admin UI) | +| `acs_urls` | `TEXT NOT NULL` | JSON array of allow-listed ACS URLs | +| `signing_cert` | `TEXT` | Public X.509 cert (PEM), nullable, **not sealed** | +| `want_authn_requests_signed` | `INTEGER NOT NULL DEFAULT 0` | 0/1; gates AuthnRequest signature enforcement | +| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp | + +### Registry helpers (`idp/lib/saml/sps.js`) + +| Function | Behavior | +|----------|----------| +| `getSp(entityId)` | `selectMaybeOne` by `entity_id`; returns the row or null | +| `listSps()` | All SPs ordered by `entity_id` | +| `createSp(opts)` | Insert `{ entityId, label, acsUrls, signingCert, wantSigned }`; `acs_urls` stored as JSON, `want_authn_requests_signed` stored as 1/0 | +| `deleteSp(entityId)` | `deleteWhere` by `entity_id` | +| `acsUrls(row)` | Parse the `acs_urls` JSON array; returns `[]` on parse error | +| `acsAllowed(row, acs)` | True only if `acs` is an **exact-string** member of the parsed list (no trailing-slash or scheme fuzzing) | +| `wantsSignedRequests(row)` | Coerces the stored INTEGER 0/1 (or pg boolean) to a real boolean | + +### Allow-list enforcement (no request-supplied ACS trusted) + +- **SSO:** an empty allow-list is rejected up front (`403 SP has no registered + ACS`); a request-named ACS must pass `acsAllowed` exactly, otherwise + `403 ACS not allowed`; only with no request ACS does the IdP fall back to the + first allow-listed entry (`routes.js`). +- **IdP-initiated:** same rules applied to the `acs` query parameter + (`routes.js`). +- **SLO:** stricter -- the LogoutResponse destination is always + `allowed[0]`; a request-parsed URL is never used at all (`routes.js`). +- **Defense in depth at registration:** the admin create handler rejects an SP + with an empty entityID or empty ACS list before it ever reaches the registry + (`adminUi.js`). + +--- + +## Signing certificate (per-tenant, sealed) + +The IdP signs assertions and LogoutResponses with RSA-SHA256 using a per-tenant +self-signed certificate stored in the `_idp_saml` table (singleton row, `id = +"default"`). + +### `_idp_saml` columns + +DDL: `idp/lib/schema.js` (`createIdpSaml`). + +| Column | Type | Notes | +|--------|------|-------| +| `id` | `TEXT PRIMARY KEY` | Always `"default"` | +| `cert` | `TEXT NOT NULL` | Self-signed X.509 cert (PEM), advertised in IdP metadata | +| `private_ciphertext` | `TEXT NOT NULL` | Sealed private key (hex) | +| `private_iv` | `TEXT NOT NULL` | AES-256-GCM IV (hex) | +| `private_tag` | `TEXT NOT NULL` | AES-256-GCM auth tag (hex) | +| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp | + +### Lifecycle + +- **Generation:** `ensureSamlCert()` (`idp.js`) runs from the plugin's + `onLoad` hook (per tenant). It is idempotent -- if the `id = "default"` row + exists it returns immediately. Otherwise it generates a 2048-bit RSA, SHA-256 + self-signed certificate via `selfsigned`, **seals the private key** with + `idpCrypto.sealText(pems.private)` (AES-256-GCM under a KEK derived from + `SALTCORN_SESSION_SECRET`; see [`./security.md`](./security.md)), and inserts the + row. +- **Retrieval:** `getSamlCert()` (`idp.js`) selects the row and unseals the + private key with `idpCrypto.openText(...)`, returning `{ cert, key }`. +- **Per-tenant isolation:** the constructed IdP entity is cached per issuer in an + in-process `Map` (`idpCache`, `idp.js`). `getIdp(issuer)` caches under the + issuer key; the signed variant caches under `issuer + "#signed"` (`idp.js`). + Distinct issuers (multi-tenant hosts) get distinct IdP entities and therefore + their own signing material. + +Note the SP `signing_cert` (used to verify inbound AuthnRequest signatures) is a +**public** cert and is stored unsealed in `_idp_saml_sps`; only the IdP's own +private key is sealed. + +--- + +## AuthnRequest signature verification + +The `want_authn_requests_signed` flag on an SP gates whether the IdP +cryptographically verifies inbound AuthnRequest signatures: + +``` +wantSigned = samlSps.wantsSignedRequests(spRow) // routes.js +idp = wantSigned ? await samlIdp.getSignedIdp(issuer) + : await samlIdp.getIdp(issuer) // routes.js +sp = samlIdp.buildSp(spEntityId, allowed[0], + { signingCert: wantSigned ? spRow.signing_cert : null }) // routes.js +``` + +- `getSignedIdp(issuer)` builds an IdP entity with `wantAuthnRequestsSigned: true` + (`idp.js`, `idp.js`); samlify then requires and verifies the + signature against the SP's `signing_cert` set on the SP entity + (`buildSp` sets `signingCert` + `authnRequestsSigned: true`, `idp.js`). +- For a **signed redirect-binding** request, the handler reconstructs the exact + signed octet string -- `SAMLRequest=&RelayState=&SigAlg=` (RelayState + omitted when absent) -- from the raw percent-encoded query, because `req.query` + is already decoded and would not byte-match what the SP signed + (`rawQueryOctetString`, `routes.js`; passed as `parseReq.octetString`, + `routes.js`). +- SPs **without** the flag use the default unsigned IdP variant + (`getIdp(issuer)`) and are not locked out; their requests are accepted unsigned. +- A signature failure surfaces as a `parseLoginRequest` throw and is answered with + `403 SAML request rejected` (`routes.js`). + +Required signature algorithm constant: `SAML_SIG_ALG = +"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"` (`constants.js`). + +The xml-crypto library is pinned to a minimum version and asserted at load: +`XML_CRYPTO_MIN = "6.1.2"` (`constants.js`), enforced by `assertXmlCryptoFloor()` +which throws at module load if xml-crypto is below the patched floor for the 2025 +SAML signature-verification CVEs (`idp.js`). + +--- + +## Security screens + +Both `ssoHandler` and `sloHandler` route inbound SAML messages through +`decodeRequest(samlReq, binding)` (`routes.js`) before any XML parsing. +These endpoints are fully unauthenticated (CSRF-exempt, no token gate), so the +work is bounded before allocation. + +### DTD / ENTITY (XXE) rejection + +A SAML protocol message never legitimately carries a DOCTYPE or ENTITY +declaration. After decoding (and decompression), the XML is matched against: + +``` +DTD_RE = /` and the SubjectConfirmationData use a 5-minute validity + window (`CONDITION_WINDOW_MS = 5 * 60 * 1000`, `routes.js`): `NotBefore` = + now, `NotOnOrAfter` = now + 5 min. +- The AuthnStatement carries `AuthnContextClassRef` = + `urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport` + (`SAML_AUTHN_CONTEXT`, `constants.js`), a server-generated `AuthnInstant`, and + a random hex `SessionIndex` (`buildAuthnStatement`, `routes.js`). +- Text token values are XML-attribute-escaped (`escapeXmlAttr`, `routes.js`); + the AuthnStatement is injected as raw markup after the escaping loop because it is + markup, not a text value. + +### Response delivery + +The signed Response is always delivered to the **validated ACS** via an +auto-submitting HTML form (HTTP-POST binding), never `response.entityEndpoint` +which could echo an unvalidated request value (`sendLoginResponse`, +`routes.js`). The form posts `SAMLResponse` (base64) plus optional +`RelayState` and includes a `