# 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's session secret (resolved from the installation -- env var, `~/.config/.saltcorn`, or DB config). - 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 (enabled from `/admin/idp/ldap`) | 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 in-process LDAP listener when it is enabled. The single, instance-global LDAP listener is configured from the admin panel at `/admin/idp/ldap` on the public (root) site (see `lib/ldap/settings.js`, `resolveRuntime`); `SALTCORN_IDP_LDAP_PORT` / `SALTCORN_IDP_LDAP_HOST` remain optional per-setting overrides (and the env port also still enables LDAP). 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) node test/keyRotationGate.js # key rotation invariants (8 checks; pins one worker via keep-alive) node test/jwksConsistencyGate.js # post-rotation JWKS convergence across all cluster workers ``` `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` | No | Resolved from the Saltcorn installation (connection object / `~/.config/.saltcorn` config file / DB 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. The plugin reads the secret from Saltcorn itself, so no separate env var is needed; setting it still works and takes priority. Rotating the secret invalidates all sealed data. | | `SALTCORN_IDP_LDAP_PORT` | No | from `/admin/idp/ldap` (LDAP disabled until enabled there) | Optional override for the LDAPS listener port; when set it also enables LDAP. The listener is normally configured from the admin panel; each dev instance can set its own port to avoid collisions. | | `SALTCORN_IDP_LDAP_HOST` | No | from `/admin/idp/ldap` (default `127.0.0.1`) | Optional override for the interface the LDAPS listener binds to. Loopback by default; set to `0.0.0.0` to expose LDAP on the network (the panel warns on a non-loopback host). | The in-process LDAPS listener is normally enabled and addressed from the admin panel at `/admin/idp/ldap` on the public (root) site -- root-tenant + admin only (tenant admins see it read-only). There is one listener per Saltcorn instance shared by all tenants, so its enable/host/port live in the root tenant's config (`idp_ldap_enabled` / `idp_ldap_host` / `idp_ldap_port`). The env vars above are optional per-setting overrides that win when present (env beats config); they are never required, and a plain install can enable LDAP from the panel alone. Listener changes apply on restart: the binding process records `idp_ldap_applied` and the panel shows a "restart Saltcorn to apply" reminder until the running listener matches the desired settings. `resolveRuntime` (`lib/ldap/settings.js`) applies this precedence; `startLdap` (`lib/ldap/server.js`) and the self-heal watchdog (`index.js`) consult it instead of reading `process.env` directly. 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 configured from `/admin/idp/ldap` (with `SALTCORN_IDP_LDAP_PORT` / `SALTCORN_IDP_LDAP_HOST` as optional overrides). --- ## 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.