# 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.