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

178 lines
7.9 KiB
Markdown

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