Initial commit.

This commit is contained in:
Scott Duensing 2026-06-01 16:40:54 -05:00
commit ad31f61fab
52 changed files with 10180 additions and 0 deletions

52
.gitattributes vendored Normal file
View file

@ -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

33
.gitignore vendored Normal file
View file

@ -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/

178
README.md Normal file
View file

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

94
VENDORING.md Normal file
View file

@ -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 `<!DOCTYPE` or `<!ENTITY` declaration (HTTP 400)
before it reaches the parser.
Signature posture:
- `lib/saml/idp.js` asserts at load that `xml-crypto >= 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.

304
docs/architecture.md Normal file
View file

@ -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:<role name>`); and
- their **custom group memberships**, stored in `_idp_group_members` joined to
`_idp_groups`, emitted with the `group:` prefix as `group:<group name>`.
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=<tenant>` 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).

300
docs/configuration.md Normal file
View file

@ -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=<tenant>` component before the base. |
| `LDAP_PEOPLE_OU` | `ou=people,dc=saltcorn,dc=local` | OU for user entries. |
| `LDAP_GROUPS_OU` | `ou=groups,dc=saltcorn,dc=local` | OU for group entries. |
| `LDAP_MAX_MSG_BYTES` | `262144` (256 * 1024) | DoS guard: cap on inbound bytes per LDAP message (the BER parser has no size limit). The byte counter is reset on each parsed-message boundary, so this is a per-message cap, not a connection-lifetime quota -- a per-connection counter would let an attacker kill a legitimate long-lived connection after a few normal operations. The connection is destroyed if a single message exceeds the cap. Enforced in `lib/ldap/vendor.js`; see [`./security.md`](./security.md) for the full rationale. |
| `LDAP_MAX_FILTER_DEPTH` | `32` | DoS guard: maximum search-filter nesting depth. |
| `LDAP_BIND_MAX_ATTEMPTS` | `5` | Max listener bind attempts on a transient failure (`EADDRINUSE`/`EACCES`). |
| `LDAP_BIND_RETRY_BASE_MS` | `500` | Bind retry backoff base; delay = base * attempt (linear). |
The LDAP simple-bind handler also enforces a credential length cap, defined
locally in `lib/ldap/bind.js` rather than in `lib/constants.js`:
| Constant | Value | Effect |
|----------|-------|--------|
| `MAX_CRED_LEN` (`lib/ldap/bind.js`) | `1024` | DoS/abuse guard on the bind handler: a bind with an empty DN, an empty password, or a password longer than this many characters is rejected as invalid credentials (and counts as a failure for the per-IP lockout) before any bcrypt or constant-time comparison runs. |
Additional LDAP runtime values set in `lib/ldap/server.js`:
| Setting | Value | Effect |
|---------|-------|--------|
| `server.maxConnections` | `256` | Maximum concurrent LDAP connections. |
| self-signed cert | RSA 2048-bit, SHA256 | Generated per startup for LDAPS (dev); production supplies a cert via config. |
### SAML
| Constant | Value | Effect |
|----------|-------|--------|
| `SAML_MAX_MSG_B64_BYTES` | `65536` (64 * 1024) | DoS guard: cap on the base64 `SAMLRequest`/`SAMLResponse` accepted before decoding. |
| `SAML_MAX_XML_BYTES` | `262144` (256 * 1024) | DoS guard: cap on inflated XML size (DEFLATE can expand roughly 1000:1; prevents a memory bomb). |
| `SAML_AUTHN_CONTEXT` | `urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport` | AuthnContext class advertised for password login. |
| `SAML_SIG_ALG` | `http://www.w3.org/2001/04/xmldsig-more#rsa-sha256` | Required signature algorithm (RSA-SHA256) on signed SAML messages. |
| `XML_CRYPTO_MIN` | `6.1.2` | Minimum acceptable `xml-crypto` version (2025 SAML-signature CVEs patched); asserted at load. |
### Plugin metadata and path namespaces
| Constant | Value |
|----------|-------|
| `PLUGIN_NAME` | `saltcorn-idp` |
| `PLUGIN_VERSION` | `0.0.1` |
| `IDP_BASE_PATH` | `/idp` |
| `ADMIN_BASE_PATH` | `/admin/idp` |
The admin role gate is `ADMIN_ROLE_ID = 1`, defined in `lib/adminUi.js` (admin
pages require `req.user.role_id === 1`).
---
## Database tables
All tables are prefixed `_idp_` and created idempotently during `onLoad` by
`createAllTables()` in `lib/schema.js`. Raw DDL is schema-qualified with
`db.getTenantSchemaPrefix()` so each Postgres tenant gets its own tables; on
SQLite the prefix is empty. Sealed key/secret material is stored as hex `TEXT`.
The auto-increment primary key on `_idp_groups` uses `integer` on SQLite and
`serial` on Postgres.
### `_idp_env`
Singleton per-tenant row tracking first-run bootstrap state and an instance
label.
| Column | Type | Notes |
|--------|------|-------|
| `env_id` | `TEXT PRIMARY KEY` | UUID assigned on first init (`crypto.randomUUID()`). |
| `env_label` | `TEXT` | Optional instance label; initialized to NULL. |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
| `bootstrapped_at` | `TEXT` | ISO 8601 timestamp set by `markBootstrapped`; NULL until first bootstrap. |
### `_idp_keys`
Per-tenant RSA signing keypairs and lifecycle. Index: `_idp_keys_status` on
`status`.
| Column | Type | Notes |
|--------|------|-------|
| `kid` | `TEXT PRIMARY KEY` | Key id (hex). |
| `alg` | `TEXT NOT NULL` | Signing algorithm (`RS256`). |
| `public_jwk` | `TEXT NOT NULL` | Public JWK, JSON-stringified. |
| `private_ciphertext` | `TEXT NOT NULL` | Sealed private key PEM (hex). |
| `private_iv` | `TEXT NOT NULL` | AES-GCM IV (hex). |
| `private_tag` | `TEXT NOT NULL` | AES-GCM auth tag (hex). |
| `status` | `TEXT NOT NULL DEFAULT 'active'` | One of `active`, `retiring`, `retired`. |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
| `retire_after` | `TEXT` | Optional retire-after timestamp. |
### `_idp_oidc_store`
Single table backing the oidc-provider storage adapter (all model types).
Indexes: `_idp_oidc_store_uid` on `uid`, `_idp_oidc_store_grant` on `grant_id`,
`_idp_oidc_store_usercode` on `user_code`.
| Column | Type | Notes |
|--------|------|-------|
| `model` | `TEXT NOT NULL` | oidc-provider model type. Part of PK. |
| `id` | `TEXT NOT NULL` | Instance id. Part of PK. |
| `payload` | `TEXT NOT NULL` | Serialized model state (JSON). |
| `uid` | `TEXT` | Interaction uid (nullable). |
| `grant_id` | `TEXT` | Grant reference (nullable). |
| `user_code` | `TEXT` | Device user code (nullable). |
| `expires_at` | `INTEGER` | Unix epoch seconds (nullable). |
| PRIMARY KEY | `(model, id)` | Composite. |
### `_idp_groups`
Custom groups (in addition to Saltcorn roles).
| Column | Type | Notes |
|--------|------|-------|
| `id` | `integer`/`serial` `PRIMARY KEY` | Auto-increment (SQLite `integer`, Postgres `serial`). |
| `name` | `TEXT NOT NULL UNIQUE` | Group name. |
| `description` | `TEXT` | Optional description. |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
### `_idp_group_members`
User-to-group junction. Index: `_idp_group_members_user` on `user_id`.
| Column | Type | Notes |
|--------|------|-------|
| `group_id` | `INTEGER NOT NULL` | Part of PK. |
| `user_id` | `INTEGER NOT NULL` | Saltcorn user id. Part of PK. |
| PRIMARY KEY | `(group_id, user_id)` | Composite. |
### `_idp_clients`
Registered OIDC relying parties. Confidential clients' secrets are sealed at
rest (hex columns); public clients (`token_auth_method = 'none'`) have none.
| Column | Type | Notes |
|--------|------|-------|
| `client_id` | `TEXT PRIMARY KEY` | Client identifier. |
| `label` | `TEXT` | Display name. |
| `token_auth_method` | `TEXT NOT NULL DEFAULT 'none'` | `none`, `client_secret_basic`, or `client_secret_post`. |
| `redirect_uris` | `TEXT NOT NULL` | JSON array. |
| `grant_types` | `TEXT NOT NULL` | JSON array. |
| `response_types` | `TEXT NOT NULL` | JSON array. |
| `scope` | `TEXT` | Space-separated scope string. |
| `secret_ciphertext` | `TEXT` | Sealed secret (hex); NULL for public clients. |
| `secret_iv` | `TEXT` | AES-GCM IV (hex); NULL for public clients. |
| `secret_tag` | `TEXT` | AES-GCM auth tag (hex); NULL for public clients. |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
### `_idp_saml`
Per-tenant SAML signing material (singleton row): a self-signed X.509 cert
advertised in IdP metadata plus its sealed private key used to sign assertions.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `TEXT PRIMARY KEY` | Fixed row id. |
| `cert` | `TEXT NOT NULL` | X.509 certificate (PEM, plaintext). |
| `private_ciphertext` | `TEXT NOT NULL` | Sealed private key (hex). |
| `private_iv` | `TEXT NOT NULL` | AES-GCM IV (hex). |
| `private_tag` | `TEXT NOT NULL` | AES-GCM auth tag (hex). |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
### `_idp_saml_sps`
Registered SAML service providers. The IdP only issues an assertion to a
registered SP and only to one of its allow-listed ACS URLs. The optional
`signing_cert` (public, not sealed) enables AuthnRequest signature
verification; `want_authn_requests_signed` gates enforcement.
| Column | Type | Notes |
|--------|------|-------|
| `entity_id` | `TEXT PRIMARY KEY` | SP entityID. |
| `label` | `TEXT` | Display name. |
| `acs_urls` | `TEXT NOT NULL` | JSON array of allow-listed ACS URLs. |
| `signing_cert` | `TEXT` | Public X.509 cert (PEM); nullable. |
| `want_authn_requests_signed` | `INTEGER NOT NULL DEFAULT 0` | 0/1 boolean; gates signature enforcement. |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
### `_idp_ldap_service`
LDAP service-account credentials for the search-then-bind flow (single row). The
password is sealed at rest (hex columns), like client secrets.
| Column | Type | Notes |
|--------|------|-------|
| `dn` | `TEXT` | Service bind DN. |
| `secret_ciphertext` | `TEXT` | Sealed password (hex). |
| `secret_iv` | `TEXT` | AES-GCM IV (hex). |
| `secret_tag` | `TEXT` | AES-GCM auth tag (hex). |
| `created_at` | `TEXT NOT NULL` | ISO 8601 timestamp. |
---
## Admin UI pages
The admin UI is defined in `lib/adminUi.js`. All pages live under
`/admin/idp` (constant `ADMIN_BASE_PATH`), are gated to `role_id = 1`, and use
CSRF-protected browser forms (a hidden `_csrf` field is rendered via
`req.csrfToken()`). Non-admins receive HTTP 403 `admin only`.
| Path | Method | Page / action |
|------|--------|---------------|
| `/admin/idp` | GET | Dashboard: env id, bootstrapped-at, issuer, active signing kid, signing alg, published JWKS key count, discovery and JWKS links. |
| `/admin/idp/` | GET | Dashboard (alias). |
| `/admin/idp/clients` | GET | List OIDC clients and the registration form. |
| `/admin/idp/clients/create` | POST | Register a new OIDC client (returns the one-time secret for confidential clients). |
| `/admin/idp/clients/delete` | POST | Delete an OIDC client by `client_id`. |
| `/admin/idp/groups` | GET | List custom groups with members and add/remove member controls. |
| `/admin/idp/groups/create` | POST | Create a custom group by `name`. |
| `/admin/idp/groups/delete` | POST | Delete a group by `id`. |
| `/admin/idp/groups/addmember` | POST | Add a user to a group by email. |
| `/admin/idp/groups/removemember` | POST | Remove a user from a group. |
| `/admin/idp/saml-sps` | GET | List SAML service providers and the registration form. |
| `/admin/idp/saml-sps/create` | POST | Register a SAML SP (rejects empty entityID or empty ACS list). |
| `/admin/idp/saml-sps/delete` | POST | Delete a SAML SP by `entity_id`. |
| `/admin/idp/ldap` | GET | Show the configured LDAP service DN and set/clear forms (password never displayed). |
| `/admin/idp/ldap/service` | POST | Set the LDAP service-account DN and password (password sealed at rest). |
| `/admin/idp/ldap/service/clear` | POST | Clear the LDAP service account. |
The shared navigation bar links: Dashboard, Clients, Groups, SAML SPs, LDAP.

351
docs/ldap.md Normal file
View file

@ -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://<host>:<port> base dc=saltcorn,dc=local (tenant = dc=<tenant>,<base>)
```
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 <host> (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=<email>,ou=people,dc=saltcorn,dc=local (a user)
cn=<group>,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=<email>,ou=people,dc=<tenant>,dc=saltcorn,dc=local
cn=<group>,ou=groups,dc=<tenant>,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 <host>:<port> not yet bindable (<code>); retry <n>/5 in <ms>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 <host>:<port> but could NOT bind after <n> attempt(s) (<code>): 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=<tenant>`
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(<tenant>)`, 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.

440
docs/oidc.md Normal file
View file

@ -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=<encoded /idp/interaction/:uid>`. 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 <access_token>` 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>` (`ROLE_PREFIX = "role:"`); and
- each custom group the user belongs to (via `_idp_group_members` ->
`_idp_groups`), emitted as `group:<name>` (`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.

266
docs/operations.md Normal file
View file

@ -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 <name>`), 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 <tenant> -d <dir>` 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 `<scheme>://<request-host>/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=<tenant>` 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.

478
docs/saml.md Normal file
View file

@ -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 `<issuer>/saml`, where `<issuer>` is derived per request
by `issuerForReq(req)` (`idp/lib/oidc/discovery.js`), the same derivation used
by OIDC. The published `<saml:Issuer>` on every Response/LogoutResponse is also
`<issuer>/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 `<issuer>/saml/sso` and
`<issuer>/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 `<Issuer>` 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=<originalUrl>` (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
`<samlp:Response>` and the `<saml:SubjectConfirmationData>` 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=<v>&RelayState=<v>&SigAlg=<v>` (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 = /<!DOCTYPE|<!ENTITY/i // routes.js
```
A match throws `saltcorn-idp: SAML message contains a DOCTYPE/ENTITY declaration`
(`routes.js`), which the handlers report as `400 malformed SAML request`.
This is defense in depth over samlify's XXE-safe parser: `idp.js` re-asserts
samlify's XXE-safe DOMParser options (`saml.setDOMParserOptions({})`) from the
plugin's own code so a future samlify default change cannot silently re-enable
entity expansion, and schema validation uses the bundled
`@authenio/samlify-node-xmllint` validator (`idp.js`, `idp.js`).
### Decompression-bomb / size caps
Two caps from `idp/lib/constants.js`:
| Constant | Value | Enforced where |
|----------|-------|----------------|
| `SAML_MAX_MSG_B64_BYTES` | `64 * 1024` (64 KiB) | `routes.js` -- reject the base64 blob before `Buffer.from` |
| `SAML_MAX_XML_BYTES` | `256 * 1024` (256 KiB) | `routes.js` (redirect) and `routes.js` (POST) -- cap inflated/decoded XML |
For the redirect binding the inflate output is capped directly via
`zlib.inflateRawSync(buf, { maxOutputLength: SAML_MAX_XML_BYTES })`, so a DEFLATE
payload that would expand roughly 1000:1 cannot exhaust memory. For the POST
binding the decoded buffer length is checked against the same cap. Exceeding either
cap throws `saltcorn-idp: SAML message too large`, reported as
`400 malformed SAML request`.
---
## Attributes in the assertion
The AttributeStatement values come from `claims.samlAttributes(user)`
(`idp/lib/claims.js`), which reuses the same `groups.effectiveGroups(user)`
source as the OIDC `groups` claim:
| Attribute | Value |
|-----------|-------|
| `email` | `user.email` |
| `groups` | Effective groups joined with `,` (e.g. `role:admin,group:engineering`) |
These map onto the `Email` / `Groups` value tags declared in the response template
(`idp.js`), filled by `makeLoginResponseRenderer` (`routes.js`). The
groups value is a single comma-joined string for the MVP (multi-valued
`AttributeValue` elements are noted as a future refinement).
Other Response details (all in `makeLoginResponseRenderer`, `routes.js`):
- `NameID` is the user email; NameID format
`urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` (`NAMEID_FORMAT_EMAIL`).
- Status code `urn:oasis:names:tc:SAML:2.0:status:Success`.
- `<saml:Conditions>` 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 `<noscript>` submit button (`autoPostForm`,
`routes.js`). All form values are HTML-escaped via `web.escapeHtml`.
---
## Registering an SP (admin)
SP registration is done through the admin UI under `/admin/idp` (admin-gated,
`role_id = 1`, CSRF-protected). Handlers are in `idp/lib/adminUi.js`. See the
"Admin UI pages" section of
[`./configuration.md`](./configuration.md#admin-ui-pages) for the full admin
surface.
| Path | Method | Handler | Purpose |
|------|--------|---------|---------|
| `/admin/idp/saml-sps` | GET | `samlSpsPage` | List SPs + registration form |
| `/admin/idp/saml-sps/create` | POST | `createSamlSpHandler` | Register an SP |
| `/admin/idp/saml-sps/delete` | POST | `deleteSamlSpHandler` | Delete an SP by entityID |
The registration form (`samlSpsPage`, `adminUi.js`) collects:
| Field | Required | Meaning |
|-------|----------|---------|
| `entity_id` | yes | SP entityID |
| `label` | no | Display label |
| `acs_urls` | yes | One ACS URL per line (parsed into a JSON array) |
| `signing_cert` | no | Public X.509 cert (PEM) for AuthnRequest signature verification |
| `want_signed` | no | Checkbox (`value="1"`); sets `want_authn_requests_signed` |
`createSamlSpHandler` (`adminUi.js`) trims the entityID, parses the ACS
textarea into a list (`parseUris` splits on newlines, trims, drops empties), and
**rejects** creation when the entityID or the ACS list is empty (`adminUi.js`).
A valid submission calls `samlSps.createSp(...)`; a duplicate `entity_id` (the
table's primary key) is caught and silently ignored, re-rendering the page. The SP
list page shows the entityID, label, ACS URLs, a "req signed" yes/no, and whether a
signing cert is present (`adminUi.js`).
---
## Constants reference
From `idp/lib/constants.js`:
| Constant | Value |
|----------|-------|
| `TABLE_SAML` | `_idp_saml` |
| `TABLE_SAML_SPS` | `_idp_saml_sps` |
| `SAML_METADATA_PATH` | `/idp/saml/metadata` |
| `SAML_SSO_PATH` | `/idp/saml/sso` |
| `SAML_SLO_PATH` | `/idp/saml/slo` |
| `SAML_INIT_PATH` | `/idp/saml/init` |
| `SAML_MAX_MSG_B64_BYTES` | `65536` (64 KiB) |
| `SAML_MAX_XML_BYTES` | `262144` (256 KiB) |
| `SAML_AUTHN_CONTEXT` | `urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport` |
| `SAML_SIG_ALG` | `http://www.w3.org/2001/04/xmldsig-more#rsa-sha256` |
| `XML_CRYPTO_MIN` | `6.1.2` |

396
docs/security.md Normal file
View file

@ -0,0 +1,396 @@
# Security Posture
This document describes the threat model and security posture of the
`saltcorn-idp` plugin: how secrets are sealed at rest, how CSRF is handled, the
asymmetric-signing model, the XML External Entity (XXE) posture, dependency
pinning, and the concrete hardening already in the code (each tied to a source
file and line). It also summarizes the adversarial review the protocol surfaces
went through.
For the XML dependency analysis in full, see [`../VENDORING.md`](../VENDORING.md).
Related docs: [`./architecture.md`](./architecture.md),
[`./configuration.md`](./configuration.md), [`./oidc.md`](./oidc.md),
[`./ldap.md`](./ldap.md).
## Threat model
The plugin turns Saltcorn into an SSO Identity Provider over three protocol
surfaces, each with a distinct trust boundary:
| Surface | Transport | Authentication of caller | Trust assumption |
|---------|-----------|--------------------------|------------------|
| OIDC / OAuth2 | Host HTTP(S) stack | Browser session (interactive) + per-client credentials/PKCE | Relying parties are explicitly registered; redirect URIs are allow-listed |
| SAML 2.0 | Host HTTP(S) stack | Browser session; per-SP registry + optional AuthnRequest signature | Service providers are explicitly registered; ACS URLs are allow-listed |
| LDAP(S) | Dedicated LDAPS listener | LDAP simple bind (user bcrypt or service account) | Listener is loopback-bound by default; outside Saltcorn's web-login throttling |
Adversary capabilities assumed in scope:
- An unauthenticated network client able to reach the public `/idp/*` endpoints
and (if exposed) the LDAPS port.
- A registered-but-malicious relying party / service provider attempting to
steer assertions, redirects, or tokens beyond its allow-listed endpoints.
- A client sending malformed, oversized, or maliciously structured protocol
messages (XML bombs, deeply nested LDAP filters, multi-GB BER messages,
DN-injection payloads, XXE).
Out of scope: compromise of the host running Saltcorn, compromise of
`SALTCORN_SESSION_SECRET`, and the security of the upstream Saltcorn auth stack
itself. The plugin makes no outbound network calls during request handling
(see [`../VENDORING.md`](../VENDORING.md)).
## At-rest secret sealing (KEK)
All long-lived secrets the plugin persists -- OIDC signing private keys, the
SAML signing private key, confidential OIDC client secrets, and the LDAP
service-account password -- are sealed before they touch the database.
Implementation: [`lib/crypto.js`](../lib/crypto.js).
| Aspect | Value | Source |
|--------|-------|--------|
| Cipher | AES-256-GCM | `crypto.js` (`GCM_ALGORITHM`) |
| KEK derivation | HKDF-SHA256 from `SALTCORN_SESSION_SECRET` | `crypto.js` |
| KEK info string | `saltcorn-idp:at-rest:aes-gcm-key:v1` | `crypto.js` |
| KEK salt | `saltcorn-idp:at-rest:aes-gcm-salt:v1` | `crypto.js` |
| IV | 12 random bytes per seal | `crypto.js`, `crypto.js` |
| KEK length | 32 bytes (256-bit) | `crypto.js` |
| GCM auth tag | stored alongside ciphertext + IV | `crypto.js`, `crypto.js` |
The info string is domain-separated so the IdP's KEK differs from any sibling
plugin's even though both derive from the same session secret
(`crypto.js`). Ciphertext, IV, and tag are stored as hex strings via
`sealText`/`openText` because Saltcorn's SQLite layer JSON-stringifies values
and cannot hold raw Buffers (`crypto.js`, `crypto.js`).
The KEK source resolves in this order (`crypto.js`): the
`SALTCORN_SESSION_SECRET` environment variable, then Saltcorn's `session_secret`
config, otherwise a hard error (`SALTCORN_SESSION_SECRET not available; cannot
derive KEK`). The KEK is cached per process and keyed by a SHA-256 hash of the
secret, so a rotated secret never keeps serving a stale KEK -- if the secret
changes the KEK is re-derived (`crypto.js`).
### Secret-rotation behavior
Rotating `SALTCORN_SESSION_SECRET` changes the KEK, which means all previously
sealed data (signing keys, SAML key, client secrets, LDAP service password) can
no longer be decrypted. This is documented, intentional behavior
(`crypto.js`). Plan rotation accordingly: rotating the session secret
requires re-bootstrapping the IdP's sealed material.
## CSRF model
| Namespace | CSRF | Mechanism |
|-----------|------|-----------|
| `/idp/*` (OIDC, JWKS, SAML, machine endpoints) | Exempt | `noCsrf: true` on each route; `/idp/` added to Saltcorn's `disable_csrf_routes` config at load |
| `/admin/idp/*` (browser admin UI) | Protected | Saltcorn's standard CSRF guard; every admin POST form embeds a `_csrf` token |
The `/idp/` namespace holds only machine/OIDC/SAML endpoints that are never
driven by Saltcorn browser forms: oidc-provider manages its own CSRF/state, and
SAML messages are XML payloads validated server-side against the registered-SP
allow-list rather than via form tokens. The bypass is registered once at startup
by `ensureCsrfBypass()`, which appends `IDP_BASE_PATH + "/"` to the global
(root-state) `disable_csrf_routes` config if not already present
([`index.js`](../index.js)). Each SAML route is additionally declared with
`noCsrf: true` ([`lib/saml/routes.js`](../lib/saml/routes.js)).
Admin pages under `/admin/idp` stay CSRF-protected because they are
browser-mediated state changes (client registration, SP registration, group and
LDAP service-account management).
## Asymmetric signing (RS256 / JWKS) vs Saltcorn HS256
Saltcorn's own session/JWT handling uses a symmetric secret (HS256-style).
Federated SSO tokens cannot be verified by relying parties that hold only a
shared secret, so the IdP issues asymmetrically signed tokens whose public half
is published for verification.
| Use | Algorithm | Key material |
|-----|-----------|--------------|
| OIDC `id_token` / JWT signing | RS256 (RSA-2048) | Per-tenant keypair in `_idp_keys`; private half sealed at rest, public half published at `/idp/jwks` |
| SAML assertion signing | RS256 (RSA-2048) | Per-tenant self-signed cert in `_idp_saml`; private key sealed at rest |
| oidc-provider session/auth cookies | Derived from `SALTCORN_SESSION_SECRET` | Deterministic HKDF (`deriveSecretHex`) so cookie keys survive restarts |
Constants: `SIGNING_ALG = "RS256"` and `RSA_MODULUS_BITS = 2048`
([`lib/constants.js`](../lib/constants.js)). Public keys are exported as
JWKs with `kid`, `alg`, and `use: "sig"`, and the exporter rejects anything that
is not a complete RSA public key ([`lib/crypto.js`](../lib/crypto.js)).
The cookie-signing keys use a distinct HKDF info string and are not the same key
as the at-rest KEK ([`lib/crypto.js`](../lib/crypto.js)).
The asymmetric model means a relying party only ever holds the public JWK: a
leaked verification key cannot mint tokens, in contrast to a shared-secret
HS256 deployment.
## XXE posture
Inbound SAML XML is defended in depth; see [`../VENDORING.md`](../VENDORING.md)
(section "SAML XML stack") for the full dependency analysis. Summary:
- samlify ships an XXE-safe DOMParser by default (throwing `error`/`fatalError`
handlers). The plugin re-asserts this from its own code via
`saml.setDOMParserOptions({})` so a future samlify default change cannot
silently re-enable entity expansion (`lib/saml/idp.js`; VENDORING.md).
- `@xmldom/xmldom` 0.8.x seeds only the 5 predefined XML entities and never
resolves external/SYSTEM entities (VENDORING.md).
- `node-xmllint` validates only against the 4 bundled SAML XSDs; no external
schema fetch (VENDORING.md).
- Defence in depth at the edge: `decodeRequest()` rejects any inbound SAML
message containing a `<!DOCTYPE` or `<!ENTITY` declaration before it reaches
the parser, returning HTTP 400. The screen is the regex
`DTD_RE = /<!DOCTYPE|<!ENTITY/i`
([`lib/saml/routes.js`](../lib/saml/routes.js), enforced at
`lib/saml/routes.js`).
## Dependency pinning and overrides
The security-relevant dependency chain is pinned exact (the caret was dropped)
and the XML parser/signature libraries are floored via npm `overrides`. Details
and the CVE log are in [`../VENDORING.md`](../VENDORING.md).
| Dependency | Constraint | Reason |
|------------|-----------|--------|
| `ldapjs` | `3.0.7` (exact) | BER/ASN.1 parser; guards live at the handler layer, not in the parser |
| `samlify` | `2.13.1` (exact) | SAML 2.0 processing; XXE-safe defaults re-asserted |
| `@authenio/samlify-node-xmllint` | `2.0.0` (exact) | SAML XSD validation (bundled libxml2) |
| `@xmldom/xmldom` | `>=0.8.13 <0.9` (npm `overrides`) | Keeps the no-external-entity 0.8.x behavior without a hard pin breaking transitive resolution |
| `xml-crypto` | `>=6.1.2` (npm `overrides` + runtime assertion) | Floor for the 2025 SAML signature-verification CVEs |
The `xml-crypto` floor (`XML_CRYPTO_MIN = "6.1.2"`,
[`lib/constants.js`](../lib/constants.js)) is additionally asserted at module
load in `lib/saml/idp.js`, failing closed if the resolved version is below the
floor (VENDORING.md).
The `ldapjs` dependency is governed by a single-chokepoint invariant: only
[`lib/ldap/vendor.js`](../lib/ldap/vendor.js) is allowed to `require("ldapjs")`.
Verify with:
```
grep -rn "require(['\"]ldapjs" lib # must match ONLY lib/ldap/vendor.js
```
## Hardening in code
Each item below is enforced in the plugin's own code (the layer it controls),
not delegated to a dependency.
### SAML decompression-bomb caps
DEFLATE can expand roughly 1000:1, so an unbounded inflate is a memory bomb that
would crash the worker. The SAML decode path bounds work before any allocation
([`lib/saml/routes.js`](../lib/saml/routes.js)):
| Guard | Constant | Value | Enforced |
|-------|----------|-------|----------|
| Base64 size cap (pre-decode) | `SAML_MAX_MSG_B64_BYTES` | 64 KiB | `routes.js` |
| Inflated XML size cap (redirect binding) | `SAML_MAX_XML_BYTES` | 256 KiB | `routes.js` (`inflateRawSync(..., { maxOutputLength })`) |
| Decoded size cap (POST binding) | `SAML_MAX_XML_BYTES` | 256 KiB | `routes.js` |
Constant values: [`lib/constants.js`](../lib/constants.js). These handlers
are fully unauthenticated (no CSRF, no API-token gate), so the bound is applied
before the work, not after (`routes.js`).
### SLO hardening: authenticated, subject-bound, no-request-ACS open-redirect fix
Ending a session via a GET is state-changing, and the handler signs a
LogoutResponse it then POSTs somewhere -- so SLO is hardened against forged /
cross-site LogoutRequests on several axes (`lib/saml/routes.js` `sloHandler`):
- **Authenticated session required.** If `req.user` is absent, return HTTP 403
-- an unauthenticated request (or a forged cross-site GET) has no session to
end, so it cannot drive SLO.
- **Subject must match the session.** The LogoutRequest `NameID` must equal the
session user's email (case-insensitive); a mismatch returns HTTP 403, so one
SP cannot log out a *different* user.
- **Signature required for cert-registered SPs.** An SP that registered a signing
cert must sign its LogoutRequest (`wantLogoutRequestSigned`), verified like the
AuthnRequest path. Residual: an SP with no registered cert cannot be verified,
so a cross-site request can at most force the current user's OWN logout
(bounded by the two checks above); we do not reject unsigned SLO outright
because some SPs legitimately do not sign LogoutRequests.
- **Registered-ACS-only destination.** Reject if the SP has no registered ACS
(HTTP 403), mirroring the SSO empty-list rejection; deliver only to the SP's
first registered ACS. The `matchAcs(xml)` request value is deliberately not
used as the destination -- taking the destination from the request would let a
crafted LogoutRequest steer the signed response (and any RelayState) to an
attacker-chosen endpoint (open redirect + leak).
For contrast, the SSO handler does allow a request-named ACS, but only after
validating it against the allow-list with `acsAllowed()`
([`lib/saml/sps.js`](../lib/saml/sps.js)); an unrecognized ACS is rejected
with HTTP 403 ([`lib/saml/routes.js`](../lib/saml/routes.js)). Allow-list
matching is exact-string (no trailing-slash or scheme fuzzing,
`lib/saml/sps.js`). Login Responses are always delivered to the validated
ACS, never to `response.entityEndpoint` (`lib/saml/routes.js`).
Two related SAML guards on the same path:
- Parser-differential guard: the issuer parsed authoritatively by samlify must
equal the entityID the SP was looked up by, else HTTP 403
([`lib/saml/routes.js`](../lib/saml/routes.js)).
- Per-SP AuthnRequest signature verification: SPs registered with
`want_authn_requests_signed` use the signature-verifying IdP variant and have
the request bound to the exact signed octet string for the redirect binding
([`lib/saml/routes.js`](../lib/saml/routes.js)); conforming-but-unsigned
SPs are not locked out.
### LDAP per-message byte cap
The BER parser has no message-size limit, so a declared multi-GB message would
buffer unbounded. `createHardenedServer()` wraps ldapjs with a
`connectionRouter` that counts inbound bytes and destroys the connection if a
single message exceeds the cap
([`lib/ldap/vendor.js`](../lib/ldap/vendor.js)):
- Cap: `LDAP_MAX_MSG_BYTES = 256 * 1024` (256 KiB,
[`lib/constants.js`](../lib/constants.js)), enforced at `vendor.js`.
- The counter resets on each parsed-message boundary
(`conn.parser.on("message")`, `vendor.js`). It is deliberately per-message,
not a connection-lifetime quota: a per-connection counter would let an attacker
kill a legitimate long-lived connection after a few normal operations
(`vendor.js`).
### RFC 4514 DN escaping
User emails and group names are admin-controlled free text that may contain DN
special characters. Without escaping, the concatenated DN would be malformed and
ldapjs's `DN.fromString()` would throw while building the SearchEntry, aborting
the whole search. `escapeDnValue()` escapes on output
([`lib/ldap/dn.js`](../lib/ldap/dn.js)):
- Special-anywhere characters `\ " , + ; < > =` (`dn.js`).
- Positionally special leading `#`/space and trailing space (`dn.js`).
- NUL byte to `\00` (`dn.js`).
Applied to the result and `memberOf` DNs we emit via `userDn`/`groupDn`
(`dn.js`); inbound bind DNs are formatted by the client, not by us
(`dn.js`).
### Filter-depth cap
The LDAP filter parser recurses without a depth bound. The search handler walks
the parsed filter iteratively (stack-based, not recursive) and rejects nesting
deeper than the cap before any DB work
([`lib/ldap/search.js`](../lib/ldap/search.js), guard at `search.js`):
- Cap: `LDAP_MAX_FILTER_DEPTH = 32` ([`lib/constants.js`](../lib/constants.js)).
- Over-depth returns an `OperationsError` (`search.js`).
### Per-IP bind lockout
The LDAP port is outside Saltcorn's web-login throttling, so the plugin
rate-limits failed binds per source IP
([`lib/ldap/harden.js`](../lib/ldap/harden.js)):
| Parameter | Value | Source |
|-----------|-------|--------|
| Max failures per IP | `MAX_FAILS = 10` | `harden.js` |
| Window | `WINDOW_MS = 5 * 60 * 1000` (5 min) | `harden.js` |
`isLocked(ip)` returns true once the count reaches 10 within the 5-minute window;
the window resets on expiry or on a successful bind (`harden.js`). The bind
handler checks the lock first and records a failure on every denial (invalid DN,
oversized/empty credentials, cross-tenant, wrong password) and a success on a
good bind ([`lib/ldap/bind.js`](../lib/ldap/bind.js)). The store is an
in-memory `Map`, per process, cleared on restart (`harden.js`), and is not
replicated across a cluster.
### LDAP loopback-default bind and exposure warning
The LDAPS listener binds loopback by default and is LDAPS-only (no plaintext, no
StartTLS); it binds only in the cluster primary process.
| Setting | Default | Env var | Source |
|---------|---------|---------|--------|
| Bind host | `127.0.0.1` | `SALTCORN_IDP_LDAP_HOST` | [`lib/constants.js`](../lib/constants.js) |
| Listener enabled / port | (disabled unless set) | `SALTCORN_IDP_LDAP_PORT` | [`lib/constants.js`](../lib/constants.js) |
If the operator binds to a non-loopback host, the server logs an explicit
exposure warning at startup
([`lib/ldap/server.js`](../lib/ldap/server.js)):
```
[saltcorn-idp] NOTE: LDAP is bound to <host> (beyond loopback) -- it is
reachable from the network; ensure this is intended and firewalled appropriately.
```
Loopback is treated as `127.0.0.1`, `::1`, or `localhost`
([`lib/ldap/server.js`](../lib/ldap/server.js)). The single-listener,
primary-only binding is documented in [`../VENDORING.md`](../VENDORING.md).
### Anonymous-bind / anonymous-operation deny
- Bind: anonymous binds (no DN, or empty password) are denied, as are absurdly
long credentials (`MAX_CRED_LEN = 1024`). The denial returns
`InvalidCredentialsError` and records a failure for lockout
([`lib/ldap/bind.js`, `bind.js`](../lib/ldap/bind.js)).
- Search: an anonymous-bound connection (`cn=anonymous`) is denied with
`InsufficientAccessRightsError` before any work
([`lib/ldap/search.js`](../lib/ldap/search.js)).
- Passwords are verified against Saltcorn's stored bcrypt hash via
`user.checkPassword()` and never stored or echoed; disabled users are rejected
(`lib/ldap/bind.js`). The service-account password is compared with a
constant-time comparison (`crypto.timingSafeEqual`) to avoid a timing oracle
(`lib/ldap/bind.js`, `bind.js`).
### Cross-tenant deny
The tenant is derived from the bind DN base (`dc=<tenant>,...`) and all lookups
run inside `runWithTenant`. Cross-tenant access is denied on both operations:
- Bind: the tenant resolved from the bind DN is rejected if it resolves to a deny
(unknown tenant, or an explicit tenant in single-tenant mode)
([`lib/ldap/bind.js`](../lib/ldap/bind.js)).
- Search: the tenant of the search base must equal the tenant of the bound
connection; a mismatch (or a deny on the base) returns
`InsufficientAccessRightsError`
([`lib/ldap/search.js`](../lib/ldap/search.js)).
Tenant resolution and the DN-encoding scheme are described in
[`./ldap.md`](./ldap.md).
## Adversarial review
The protocol surfaces were built under a security-first directive: enumerate
abuse cases and write adversarial tests before coding each module. The
public-facing SAML and LDAP handlers went through two rounds of adversarial
review, which surfaced four HIGH-severity and one MEDIUM-severity issues; all
five were fixed, and the fixes are visible in the current code:
- SLO open redirect: the LogoutResponse destination could fall back to a
request-supplied ACS. Fixed by requiring a registered ACS and never using the
request-parsed URL ([`lib/saml/routes.js`](../lib/saml/routes.js)).
- SAML decompression bomb: unbounded inflate of a DEFLATE-compressed
SAMLRequest. Fixed with the base64 and inflated-XML caps
([`lib/saml/routes.js`](../lib/saml/routes.js)).
- LDAP BER message bomb: no parser size limit. Fixed with the per-message byte
cap ([`lib/ldap/vendor.js`](../lib/ldap/vendor.js)).
- LDAP filter bomb: unbounded recursive filter parsing. Fixed with the
filter-depth cap ([`lib/ldap/search.js`](../lib/ldap/search.js)).
- DN injection aborting searches (MEDIUM): unescaped admin-controlled values in
emitted DNs. Fixed with RFC 4514 escaping on output
([`lib/ldap/dn.js`](../lib/ldap/dn.js)).
Items dismissed during review were those judged not exploitable in the plugin's
trust model -- notably the decision NOT to vendor a `ldapjs` fork (the security
guards live in the plugin's owned connection/handler layer, not in the BER
parser, so a fork buys nothing) and NOT to fork the SAML XML stack (the XXE-safe
defaults are asserted from plugin code and floored via npm `overrides`). Both
decisions are recorded in [`../VENDORING.md`](../VENDORING.md).
## Dual control: safe defaults
The plugin defaults to the least-exposed posture, requiring an explicit operator
opt-in to widen it:
- LDAP is disabled unless `SALTCORN_IDP_LDAP_PORT` is set, and binds only to
loopback (`127.0.0.1`) unless `SALTCORN_IDP_LDAP_HOST` is changed; a
non-loopback bind logs an explicit network-exposure warning
([`lib/ldap/server.js`](../lib/ldap/server.js)).
- LDAP is LDAPS-only -- no plaintext and no StartTLS (VENDORING.md).
- SAML and OIDC only issue assertions/tokens to explicitly registered SPs/clients
with allow-listed ACS/redirect URIs.
- All persisted secrets are sealed at rest under a KEK derived from the session
secret.
For configuration knobs (env vars, multi-tenant issuer derivation, base_url
trust), see [`./configuration.md`](./configuration.md).

138
docs/testing.md Normal file
View file

@ -0,0 +1,138 @@
# Testing
The `saltcorn-idp` test suite is a set of five end-to-end "gates", one per
protocol surface. Each gate is a standalone Node script under `test/` that
drives a live, already-running Saltcorn instance over the wire (HTTP, LDAPS,
or SAML) and asserts on real responses. There is no mocking: a gate is a black
box client, so a passing gate means the deployed plugin actually behaves.
The gates assume the local dev instances described in
[operations.md](./operations.md) (MAIN on `:3000`, TEST on `:3001`, PG on
`:3002`). See the [README](../README.md) for the plugin overview.
## Conventions shared by all gates
- Each gate prints `PASS`/`FAIL` per check and ends with a
`RESULT: <n> passed, <m> failed` line.
- Exit code: `0` when all checks pass (or a gate self-skips), `1` when any
check fails, `2` on an uncaught error (a thrown exception, e.g. a server that
is down and not handled by a self-skip).
- The admin credentials used throughout are `admin@local` /
`AdminP@ss1` (single-tenant gates) or `admin@<tenant>.local` / `AdminP@ss1`
(multi-tenant gates). The relying-party/SP redirect and callback targets are
fixed test values (`http://localhost:9099/...`) and do not need a real
listener there -- the gates only inspect the redirect/form, they never follow
the callback.
- Several gates register their own client/SP/group via the admin UI
(`/admin/idp/...`). Each does a delete-then-create at the start, so re-running
a gate is safe even if a prior run left an artifact behind. `e2e.js` and
`mtGate.js` also delete their clients again at the end; `samlGate.js` leaves
its registered SP in place (the next run's delete-then-create absorbs it).
## The five gates
| Gate file | Covers | Prerequisites (instance / port) | Backend | Self-skip when port unreachable | Run command |
|-----------|--------|---------------------------------|---------|---------------------------------|-------------|
| `test/e2e.js` | OIDC: discovery + JWKS on both instances; client registration via admin UI; authorization-code + PKCE + consent; token + `id_token` RS256 verify; userinfo; groups claim (`role:admin` + custom `group:` claim); confidential-client one-time secret | MAIN `:3000` and TEST `:3001` both up | SQLite | No -- throws (exit 2) if an instance is down | `node test/e2e.js` (or `npm test`) |
| `test/ldapGate.js` | LDAP (Phase 4): LDAPS simple bind vs bcrypt hash; authenticated user search (`mail`, `memberOf`); wrong-password + anonymous-search rejection; case-insensitive attribute selection; `groupOfNames` entries; filter-depth rejection; per-message inbound byte cap (DoS); configurable service account (search-then-bind); RFC 4514 DN escaping + `uidFromDn` unescape (unit) | MAIN `:3000` up AND its LDAPS listener on `:1636` | SQLite | No -- if LDAPS is down the bind checks fail (exit 1) rather than self-skip | `node test/ldapGate.js` |
| `test/samlGate.js` | SAML (Phase 5): IdP metadata; SP-initiated SSO (signed assertion + real `AuthnStatement`); SP registry + ACS allow-list (unregistered SP and out-of-allow-list ACS rejected, 403); DTD/ENTITY (XXE) rejection (400); decompression-bomb rejection (400); empty-ACS SP refused at registration; signed-SP AuthnRequest accepted + unsigned rejected (403) for a cert-registered SP; IdP-initiated SSO (no `InResponseTo`); Single Logout incl. no-session and wrong-NameID rejection (403) (LAST -- destroys the session) | MAIN `:3000` up | SQLite | No -- throws (exit 2) if MAIN is down | `node test/samlGate.js` |
| `test/mtGate.js` | Multi-tenant OIDC: distinct per-tenant issuers + signing keys; full auth-code + PKCE flow on `t1`; cross-tenant isolation -- a `t1` code, access token, and `id_token` are all REJECTED at `t2` (per-tenant store / Provider / key); positive control that the `t1` token still works at `t1` | PG `:3002` up, tenants `t1` + `t2` present with the plugin installed | Postgres | No explicit skip -- throws (exit 2) if PG/tenants are unavailable | `node test/mtGate.js` (or `npm run test:mt`) |
| `test/ldapMultiTenantGate.js` | Multi-tenant LDAP: tenant-in-DN binding (`dc=<tenant>,dc=saltcorn,dc=local`); correct-tenant bind + search; same uid under another tenant fails; cross-tenant search denied; unknown tenant denied; tenant-scoped `groupOfNames` member DNs. Bootstraps each tenant admin over HTTP first | PG `:3002` up AND its LDAPS listener on `:1637` | Postgres | Yes -- if `127.0.0.1:1637` is not reachable it prints `SKIP` and exits 0 (no-op on SQLite-only setups) | `node test/ldapMultiTenantGate.js` |
### Expected pass counts
When run against a correctly configured instance, each gate currently reports:
| Gate | Expected passes |
|------|-----------------|
| `test/e2e.js` | 28 |
| `test/ldapGate.js` | 22 |
| `test/samlGate.js` | 28 |
| `test/mtGate.js` | 15 |
| `test/ldapMultiTenantGate.js` | 8 |
A gate that self-skips (only `ldapMultiTenantGate.js` does this) reports
`0 passed, 0 failed (skipped)` and exits 0; that is not a failure, but it also
does not contribute its 8 passes.
### Ordering and shared state
Checks inside a gate run sequentially and share login/session state -- they are
not independent unit tests:
- `e2e.js` is organized as Phase 0..3 and reuses one admin session and cookie
jar across phases. The Phase 1 client registration is a prerequisite for the
Phase 1/2 auth-code flows.
- `samlGate.js` logs in once, registers the test SP, then runs SSO checks; the
Single Logout check is intentionally LAST because it destroys the session.
- `mtGate.js` keeps a separate cookie jar per tenant and depends on the
positive `t1` flow having produced a token before the cross-tenant rejection
checks run.
- `ldapMultiTenantGate.js` bootstraps both tenant admins over HTTP before any
LDAP bind.
Do not reorder checks within a gate.
## npm scripts
From `package.json`:
| Script | Command | Gate |
|--------|---------|------|
| `npm test` | `node test/e2e.js` | OIDC e2e gate (MAIN + TEST, SQLite) |
| `npm run test:mt` | `node test/mtGate.js` | multi-tenant OIDC gate (PG) |
The three remaining gates (`ldapGate.js`, `samlGate.js`,
`ldapMultiTenantGate.js`) have no npm script; run them directly with `node` as
shown in the table above.
## Running the full suite
The PG gates self-skip or fail fast when their backend is unreachable, so which
servers you start determines which gates can pass. See
[operations.md](./operations.md) for how to start each instance and install the
plugin.
1. **SQLite gates (`e2e`, `ldapGate`, `samlGate`).** Start MAIN and TEST:
```bash
./startServer.sh # MAIN -> :3000 (LDAPS :1636)
./startServerTest.sh # TEST -> :3001 (no LDAP)
```
- `e2e.js` needs both MAIN and TEST (Phase 0 checks discovery/JWKS on each).
- `ldapGate.js` needs MAIN plus its LDAPS listener on `:1636` (MAIN's
`env.sh` sets `SALTCORN_IDP_LDAP_PORT=1636`).
- `samlGate.js` needs MAIN only.
Then:
```bash
npm test # e2e
node test/ldapGate.js
node test/samlGate.js
```
2. **Postgres gates (`mtGate`, `ldapMultiTenantGate`).** Start PG and make sure
tenants `t1` and `t2` exist with the plugin installed per tenant (see
operations.md, "PG (multi-tenant): per-tenant install"):
```bash
./startServerPg.sh # PG -> :3002 (LDAPS :1637)
```
Then:
```bash
npm run test:mt # mtGate (HTTP :3002)
node test/ldapMultiTenantGate.js # PG LDAPS :1637
```
`ldapMultiTenantGate.js` probes `127.0.0.1:1637` first and self-skips
(exit 0) if the LDAPS listener is not up -- which is also the workaround
surface for the intermittent `:1637` bind flake documented in
operations.md. `mtGate.js` has no self-skip and will exit 2 if PG or the
tenants are not available, so only run it once PG is confirmed up.
If you do not need the multi-tenant coverage, running only the SQLite gates is
a valid quick pass.

161
index.js Normal file
View file

@ -0,0 +1,161 @@
// saltcorn-idp — turns Saltcorn into an SSO Identity Provider.
//
// Phase 0 (skeleton): create the _idp_* tables, bootstrap a per-tenant signing
// keypair, register the CSRF bypass for the public /idp/ endpoints, and serve
// the OIDC discovery + JWKS documents. Token issuance, LDAP, and SAML arrive in
// later phases. onLoad runs once per tenant schema in multi-tenant mode, so the
// tables and signing key are created per-tenant.
const cluster = require("cluster");
const net = require("net");
const { PLUGIN_NAME, PLUGIN_VERSION, IDP_BASE_PATH, LDAP_PORT_ENV, LDAP_HOST_ENV, LDAP_DEFAULT_HOST } = require("./lib/constants");
const { createAllTables } = require("./lib/schema");
const { initEnvIfMissing, markBootstrapped } = require("./lib/env");
const { ensureActiveKey } = require("./lib/keys");
const { routes } = require("./lib/routes");
const { startLdap, isListening } = require("./lib/ldap/server");
const { ensureSamlCert } = require("./lib/saml/idp");
// Self-heal delay for the intermittent unbound-LDAP heisenbug. ROOT CAUSE
// (captured 2026-06-01): on a flaky PG boot the cluster PRIMARY never runs
// onLoad/startLdap (every startLdap call is isPrimary=false), so nobody binds.
// So the watchdog arms in EVERY process (not just the primary), and on firing
// TCP-probes the port: if nothing is listening anywhere, it force-binds from
// THIS process. The per-worker stagger means the first worker binds and the
// rest probe-find-it-open and skip (no thundering herd). Local consts (not
// constants.js, which another module owns).
const LDAP_WATCHDOG_MS = 8000;
const LDAP_WATCHDOG_STAGGER_MS = 600;
const LDAP_PROBE_TIMEOUT_MS = 2000;
// Match server.js: the cluster API renamed isMaster -> isPrimary.
const isPrimary = () => {
return cluster.isPrimary !== undefined ? cluster.isPrimary : cluster.isMaster;
};
// Arm the LDAP self-heal watchdog at most once per process.
let ldapWatchdogArmed = false;
// Resolve true if something is already listening on host:port (so this process
// must NOT try to bind). Used cross-process: isListening() is per-process, but a
// TCP probe sees a listener bound by ANY process (e.g. the primary, or a peer
// worker that healed first).
const probeListening = (host, port) => {
return new Promise((resolve) => {
const sock = net.connect(port, host);
const done = (open) => { sock.destroy(); resolve(open); };
sock.setTimeout(LDAP_PROBE_TIMEOUT_MS);
sock.on("connect", () => done(true));
sock.on("timeout", () => done(false));
sock.on("error", () => done(false));
});
};
const log = (msg) => {
// eslint-disable-next-line no-console
console.log(`[${PLUGIN_NAME}] ${msg}`);
};
const ensureCsrfBypass = async () => {
// The /idp/ namespace holds only machine/OIDC endpoints (discovery, JWKS,
// and later authorize/token/userinfo) which are never driven by Saltcorn
// browser forms; oidc-provider manages its own CSRF/state. Admin pages live
// under /admin/idp and stay CSRF-protected. Bypass is a global (root-state)
// config evaluated at startup.
try {
const { getState } = require("@saltcorn/data/db/state");
const current = getState().getConfig("disable_csrf_routes", "");
const want = IDP_BASE_PATH + "/";
const entries = current.split(",").map((s) => s.trim()).filter(Boolean);
if (!entries.includes(want)) {
entries.push(want);
await getState().setConfig("disable_csrf_routes", entries.join(","));
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${PLUGIN_NAME}] failed to register csrf bypass:`, e);
}
};
const onLoad = async (cfg) => {
let bootstrapErr = null;
try {
await createAllTables();
const env = await initEnvIfMissing();
const key = await ensureActiveKey();
await ensureSamlCert();
if (!env.bootstrapped_at) {
await markBootstrapped(env.env_id);
log(`v${PLUGIN_VERSION} bootstrapped env_id=${env.env_id} signing kid=${key.kid}`);
} else {
log(`v${PLUGIN_VERSION} loaded env_id=${env.env_id} signing kid=${key.kid}`);
}
await ensureCsrfBypass();
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[${PLUGIN_NAME}] onLoad bootstrap failed:`, err);
bootstrapErr = err;
}
// Bind the LDAP listener INDEPENDENTLY of the bootstrap above. The listener
// needs none of the OIDC keys / SAML cert / CSRF setup, so a transient failure
// there (e.g. DB contention during concurrent multi-tenant boot) must not skip
// the bind -- that was the suspected cause of the intermittent unbound-:1637.
// Starts only if SALTCORN_IDP_LDAP_PORT is set; the module guard binds once per
// process despite per-tenant onLoad calls; retry + loud warning live in server.js.
try {
await startLdap();
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${PLUGIN_NAME}] startLdap failed:`, e);
}
// Self-heal watchdog for the intermittent silent no-bind. Armed in ANY process
// that has LDAP configured (NOT just the primary -- the captured root cause is
// that the primary never runs onLoad on the flaky boot). One-shot, staggered by
// worker id so the first worker binds and the rest see the port already up.
if (!ldapWatchdogArmed && process.env[LDAP_PORT_ENV]) {
ldapWatchdogArmed = true;
const port = parseInt(process.env[LDAP_PORT_ENV], 10);
const hostEnv = (process.env[LDAP_HOST_ENV] || "").trim();
// Probe loopback when bound to a wildcard/loopback interface.
const probeHost = (!hostEnv || hostEnv === "0.0.0.0" || hostEnv === "::") ? LDAP_DEFAULT_HOST : hostEnv;
const workerId = (cluster.worker && cluster.worker.id) || 0;
const delay = LDAP_WATCHDOG_MS + workerId * LDAP_WATCHDOG_STAGGER_MS;
const timer = setTimeout(async () => {
if (isListening()) {
return; // this process already bound it
}
if (Number.isFinite(port) && (await probeListening(probeHost, port))) {
return; // another process (primary, or a worker that healed first) bound it
}
// eslint-disable-next-line no-console
console.error(`[${PLUGIN_NAME}] LDAP WATCHDOG: nothing listening on ${probeHost}:${port} ${delay}ms after onLoad (pid=${process.pid} isPrimary=${isPrimary()}) -- the intermittent unbound-:1637 heisenbug; force-binding from this process`);
try {
await startLdap({ force: true });
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${PLUGIN_NAME}] LDAP WATCHDOG force-bind failed:`, e);
}
}, delay);
// Do not keep the event loop alive solely for this one-shot watchdog.
if (timer && typeof timer.unref === "function") {
timer.unref();
}
}
// Preserve the original contract: surface a bootstrap failure to Saltcorn.
if (bootstrapErr) {
throw bootstrapErr;
}
};
module.exports = {
sc_plugin_api_version: 1,
plugin_name: PLUGIN_NAME,
onLoad: onLoad,
routes: routes
};

547
lib/adminUi.js Normal file
View file

@ -0,0 +1,547 @@
// Admin UI for saltcorn-idp: a read-only dashboard plus group management.
// All pages are admin-gated (role_id 1) and live under /admin/idp (CSRF-protected
// browser forms — note these are NOT under the CSRF-exempt /idp namespace).
const crypto = require("crypto");
const constants = require("./constants");
const keys = require("./keys");
const groups = require("./groups");
const clients = require("./clients");
const samlSps = require("./saml/sps");
const serviceAccount = require("./ldap/serviceAccount");
const User = require("@saltcorn/data/models/user");
const { escapeHtml } = require("./web");
const { getEnv } = require("./env");
const { issuerForReq } = require("./oidc/discovery");
const ADMIN_ROLE_ID = 1;
const isAdmin = (req) => {
return !!(req && req.user && req.user.role_id === ADMIN_ROLE_ID);
};
// URL-scheme guards for admin-supplied URLs. A SAML ACS is always an http(s)
// web endpoint, so require that (a "javascript:" ACS would otherwise become a
// form action). OIDC redirect_uris may use native/custom schemes, so for those
// only reject the dangerous executable schemes.
const DANGEROUS_SCHEME = /^\s*(javascript|data|vbscript|file)\s*:/i;
const isHttpUrl = (u) => {
try {
const p = new URL(String(u)).protocol;
return p === "http:" || p === "https:";
} catch (e) {
return false;
}
};
// True if a PEM X.509 cert parses and is currently within its validity window.
// A null/empty cert is "ok" here (means "no cert supplied"); callers decide.
const certCurrentlyValid = (pem) => {
if (!pem) {
return true;
}
try {
const x = new crypto.X509Certificate(pem);
const now = Date.now();
const nb = Date.parse(x.validFrom);
const na = Date.parse(x.validTo);
return Number.isFinite(nb) && Number.isFinite(na) && now >= nb && now <= na;
} catch (e) {
return false;
}
};
// Lightweight per-session rate limit on admin MUTATIONS (defence-in-depth on top
// of the role gate). In-memory sliding window keyed by session id (falls back to
// IP). Generous enough never to trip normal admin use or the gates.
const adminHits = new Map();
const adminRateOk = (req) => {
const key = (req.sessionID || (req.session && req.session.id) || (req.connection && req.connection.remoteAddress) || "anon");
const now = Date.now();
const window = constants.ADMIN_RATE_WINDOW_MS;
const hits = (adminHits.get(key) || []).filter((t) => now - t < window);
if (hits.length >= constants.ADMIN_RATE_MAX) {
adminHits.set(key, hits);
return false;
}
hits.push(now);
adminHits.set(key, hits);
return true;
};
const csrfField = (req) => {
const token = req.csrfToken ? req.csrfToken() : "";
return `<input type="hidden" name="_csrf" value="${escapeHtml(token)}">`;
};
const layout = (title, body) => {
return `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<title>${escapeHtml(title)}</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 1.5rem; max-width: 900px; }
h1 { font-size: 1.4rem; }
h2 { font-size: 1.1rem; margin-top: 1.5rem; }
table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
th, td { border: 1px solid #ddd; padding: 0.35rem 0.6rem; text-align: left; vertical-align: top; }
th { background: #f5f5f5; }
code { font-family: ui-monospace, Menlo, Consolas, monospace; }
.muted { color: #888; }
nav { margin-bottom: 1rem; }
nav a { margin-right: 1rem; }
form.inline { display: inline; }
input[type=text] { padding: 0.2rem; }
button { padding: 0.2rem 0.5rem; cursor: pointer; }
</style>
</head><body>
<nav>
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}">Dashboard</a>
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients">Clients</a>
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups">Groups</a>
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/saml-sps">SAML SPs</a>
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap">LDAP</a>
</nav>
${body}
<p class="muted">${escapeHtml(constants.PLUGIN_NAME)} v${escapeHtml(constants.PLUGIN_VERSION)}</p>
</body></html>`;
};
const dashboard = async (req, res) => {
if (!isAdmin(req)) {
res.status(403).type("text/plain").send("admin only");
return;
}
try {
// Opportunistically sweep RETIRING keys whose grace window has elapsed to
// RETIRED (drops them from JWKS) so admins don't need a separate cron.
await keys.retireExpiredKeys();
const env = await getEnv();
const jwks = await keys.getJwks();
const active = await keys.getActiveKeyMeta();
const issuer = issuerForReq(req);
const body = `
<table>
<tr><th>Env ID</th><td><code>${escapeHtml(env ? env.env_id : "?")}</code></td></tr>
<tr><th>Bootstrapped at</th><td>${escapeHtml(env ? env.bootstrapped_at : "")}</td></tr>
<tr><th>Issuer</th><td><code>${escapeHtml(issuer)}</code></td></tr>
<tr><th>Active signing key (kid)</th><td><code>${escapeHtml(active ? active.kid : "(none)")}</code></td></tr>
<tr><th>Active key status</th><td>${escapeHtml(active ? constants.KEY_STATUS.ACTIVE : "(none)")}</td></tr>
<tr><th>Active key created at</th><td>${escapeHtml(active ? active.created_at : "")}</td></tr>
<tr><th>Signing alg</th><td>${escapeHtml(active ? active.alg : "")}</td></tr>
<tr><th>Published JWKS keys</th><td>${escapeHtml(jwks.keys.length)}</td></tr>
<tr><th>Discovery</th><td><a href="${escapeHtml(constants.WELL_KNOWN_OPENID)}">${escapeHtml(constants.WELL_KNOWN_OPENID)}</a></td></tr>
<tr><th>JWKS</th><td><a href="${escapeHtml(constants.JWKS_PATH)}">${escapeHtml(constants.JWKS_PATH)}</a></td></tr>
</table>
<h2>Signing key rotation</h2>
<p class="muted">Generates a new active signing key (new kid). The previous key keeps verifying issued tokens (stays in JWKS as <code>retiring</code>) until its grace window elapses, then drops out.</p>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/rotate-key">${csrfField(req)}<button>rotate signing key</button></form>`;
res.type("text/html").send(layout("saltcorn-idp dashboard", body));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] dashboard failed:`, e);
res.status(500).type("text/plain").send("dashboard unavailable");
}
};
const rotateKeyHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
try {
await keys.rotateActiveKey();
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] key rotation failed:`, e);
}
res.redirect(constants.ADMIN_BASE_PATH);
};
const groupsPage = async (req, res) => {
if (!isAdmin(req)) {
res.status(403).type("text/plain").send("admin only");
return;
}
try {
const all = await groups.listGroups();
let rows = "";
for (const g of all) {
const members = await groups.membersOf(g.id);
let memberHtml = "";
for (const member of members) {
const u = await User.findOne({ id: member.user_id });
const label = u ? u.email : ("user#" + member.user_id);
memberHtml += `<form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/removemember">${csrfField(req)}<input type="hidden" name="group_id" value="${escapeHtml(g.id)}"><input type="hidden" name="user_id" value="${escapeHtml(member.user_id)}"><code>${escapeHtml(label)}</code> <button>x</button></form><br>`;
}
rows += `<tr>
<td><code>${escapeHtml(g.name)}</code></td>
<td>${memberHtml || '<span class="muted">(none)</span>'}
<form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/addmember">${csrfField(req)}<input type="hidden" name="group_id" value="${escapeHtml(g.id)}"><input type="text" name="email" placeholder="user email"> <button>add</button></form>
</td>
<td><form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/delete">${csrfField(req)}<input type="hidden" name="id" value="${escapeHtml(g.id)}"><button>delete</button></form></td>
</tr>`;
}
const body = `
<h1>Groups</h1>
<p class="muted">The OIDC <code>groups</code> claim = each user's Saltcorn role (as <code>role:&lt;name&gt;</code>) plus these custom groups (as <code>group:&lt;name&gt;</code>).</p>
<table><tr><th>Group</th><th>Members</th><th></th></tr>${rows || '<tr><td colspan="3" class="muted">no groups yet</td></tr>'}</table>
<h2>Create group</h2>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/create">${csrfField(req)}
<input type="text" name="name" placeholder="group name" required> <button>create</button>
</form>`;
res.type("text/html").send(layout("saltcorn-idp groups", body));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] groups page failed:`, e);
res.status(500).type("text/plain").send("groups unavailable");
}
};
const requireAdmin = (req, res) => {
if (!isAdmin(req)) {
res.status(403).type("text/plain").send("admin only");
return false;
}
// requireAdmin gates the mutating handlers; throttle POSTs per session.
if (req.method === "POST" && !adminRateOk(req)) {
res.status(429).type("text/plain").send("too many requests");
return false;
}
return true;
};
const createGroupHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const name = String((req.body && req.body.name) || "").trim();
if (name) {
try {
await groups.createGroup(name);
} catch (e) {
// unique-name violation or similar; ignore and re-render
}
}
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
};
const deleteGroupHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const id = parseInt(req.body && req.body.id, 10);
if (Number.isFinite(id)) {
await groups.deleteGroup(id);
}
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
};
const addMemberHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const groupId = parseInt(req.body && req.body.group_id, 10);
const email = String((req.body && req.body.email) || "").trim();
if (Number.isFinite(groupId) && email) {
const u = await User.findOne({ email: email });
if (u) {
await groups.addMember(groupId, u.id);
}
}
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
};
const removeMemberHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const groupId = parseInt(req.body && req.body.group_id, 10);
const userId = parseInt(req.body && req.body.user_id, 10);
if (Number.isFinite(groupId) && Number.isFinite(userId)) {
await groups.removeMember(groupId, userId);
}
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
};
const clientsPage = async (req, res) => {
if (!isAdmin(req)) {
res.status(403).type("text/plain").send("admin only");
return;
}
try {
const all = await clients.listClients();
let rows = "";
for (const c of all) {
const uris = JSON.parse(c.redirect_uris).map((u) => `<code>${escapeHtml(u)}</code>`).join("<br>");
rows += `<tr>
<td><code>${escapeHtml(c.client_id)}</code></td>
<td>${escapeHtml(c.label || "")}</td>
<td>${escapeHtml(c.token_auth_method)}</td>
<td>${uris}</td>
<td>${escapeHtml(c.scope || "")}</td>
<td><form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients/delete">${csrfField(req)}<input type="hidden" name="client_id" value="${escapeHtml(c.client_id)}"><button>delete</button></form></td>
</tr>`;
}
const body = `
<h1>Clients (relying parties)</h1>
<table><tr><th>client_id</th><th>label</th><th>auth</th><th>redirect URIs</th><th>scope</th><th></th></tr>${rows || '<tr><td colspan="6" class="muted">no clients yet</td></tr>'}</table>
<h2>Register client</h2>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients/create">${csrfField(req)}
<p>client_id <input type="text" name="client_id" required></p>
<p>label <input type="text" name="label"></p>
<p>redirect URIs (one per line)<br><textarea name="redirect_uris" rows="3" cols="50"></textarea></p>
<p>auth method
<select name="auth_method">
<option value="none">none (public + PKCE)</option>
<option value="client_secret_basic">client_secret_basic (confidential)</option>
<option value="client_secret_post">client_secret_post (confidential)</option>
</select>
</p>
<p>scope <input type="text" name="scope" value="openid email profile groups"></p>
<button>register</button>
</form>`;
res.type("text/html").send(layout("saltcorn-idp clients", body));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] clients page failed:`, e);
res.status(500).type("text/plain").send("clients unavailable");
}
};
const parseUris = (text) => {
return String(text || "").split(/[\r\n]+/).map((s) => s.trim()).filter(Boolean);
};
const createClientHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const clientId = String((req.body && req.body.client_id) || "").trim();
const redirectUris = parseUris(req.body && req.body.redirect_uris);
// Reject executable-scheme redirect URIs (javascript:/data:/...). Custom
// native-app schemes + loopback are allowed (oidc-provider validates the rest).
if (!clientId || redirectUris.some((u) => DANGEROUS_SCHEME.test(u))) {
res.redirect(constants.ADMIN_BASE_PATH + "/clients");
return;
}
let created = null;
try {
created = await clients.createClient({
clientId: clientId,
label: String((req.body && req.body.label) || "").trim(),
redirectUris: redirectUris,
authMethod: String((req.body && req.body.auth_method) || "none"),
scope: String((req.body && req.body.scope) || "").trim()
});
} catch (e) {
res.redirect(constants.ADMIN_BASE_PATH + "/clients");
return;
}
if (created && created.secret) {
const body = `
<h1>Client registered</h1>
<p>client_id: <code>${escapeHtml(created.client_id)}</code></p>
<p>Client secret (shown once - copy it now):</p>
<p><code>${escapeHtml(created.secret)}</code></p>
<p><a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients">Back to clients</a></p>`;
res.type("text/html").send(layout("client secret", body));
} else {
res.redirect(constants.ADMIN_BASE_PATH + "/clients");
}
};
const deleteClientHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const clientId = String((req.body && req.body.client_id) || "").trim();
if (clientId) {
await clients.deleteClient(clientId);
}
res.redirect(constants.ADMIN_BASE_PATH + "/clients");
};
const samlSpsPage = async (req, res) => {
if (!isAdmin(req)) {
res.status(403).type("text/plain").send("admin only");
return;
}
try {
const all = await samlSps.listSps();
let rows = "";
for (const s of all) {
const urls = samlSps.acsUrls(s).map((u) => `<code>${escapeHtml(u)}</code>`).join("<br>");
rows += `<tr>
<td><code>${escapeHtml(s.entity_id)}</code></td>
<td>${escapeHtml(s.label || "")}</td>
<td>${urls}</td>
<td>${s.want_authn_requests_signed ? "yes" : "no"}</td>
<td>${s.signing_cert ? "yes" : "no"}</td>
<td><form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/saml-sps/delete">${csrfField(req)}<input type="hidden" name="entity_id" value="${escapeHtml(s.entity_id)}"><button>delete</button></form></td>
</tr>`;
}
const body = `
<h1>SAML service providers</h1>
<p class="muted">Only registered SPs receive assertions, and only at an allow-listed ACS URL. A signing cert enables (and "require signed" enforces) AuthnRequest signature verification.</p>
<table><tr><th>entityID</th><th>label</th><th>ACS URLs</th><th>req signed</th><th>cert</th><th></th></tr>${rows || '<tr><td colspan="6" class="muted">no SPs yet</td></tr>'}</table>
<h2>Register SP</h2>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/saml-sps/create">${csrfField(req)}
<p>entityID <input type="text" name="entity_id" required></p>
<p>label <input type="text" name="label"></p>
<p>ACS URLs (one per line)<br><textarea name="acs_urls" rows="3" cols="60"></textarea></p>
<p>signing cert (PEM, optional)<br><textarea name="signing_cert" rows="4" cols="60"></textarea></p>
<p><label><input type="checkbox" name="want_signed" value="1"> require signed AuthnRequests</label></p>
<button>register</button>
</form>`;
res.type("text/html").send(layout("saltcorn-idp saml sps", body));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml sps page failed:`, e);
res.status(500).type("text/plain").send("saml sps unavailable");
}
};
const createSamlSpHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const entityId = String((req.body && req.body.entity_id) || "").trim();
const acsUrls = parseUris(req.body && req.body.acs_urls);
// An SP with no ACS URL is unusable and a hazard: the SSO/SLO handlers would
// have nothing to validate a request-supplied ACS against. Reject it here so
// a no-ACS SP never reaches the registry (defence-in-depth over the request
// handlers, which also reject an empty allow-list).
const signingCert = String((req.body && req.body.signing_cert) || "").trim() || null;
// Reject: no entityId, no/empty ACS list, any ACS that is not an http(s) URL
// (a SAML ACS is always a web endpoint; this blocks a "javascript:" ACS from
// becoming the auto-POST form action), or an expired/unparseable signing cert.
if (!entityId || acsUrls.length === 0 || !acsUrls.every(isHttpUrl) || !certCurrentlyValid(signingCert)) {
res.redirect(constants.ADMIN_BASE_PATH + "/saml-sps");
return;
}
try {
await samlSps.createSp({
entityId: entityId,
label: String((req.body && req.body.label) || "").trim(),
acsUrls: acsUrls,
signingCert: signingCert,
wantSigned: !!(req.body && req.body.want_signed)
});
} catch (e) {
// duplicate entity_id or similar; fall through to re-render
}
res.redirect(constants.ADMIN_BASE_PATH + "/saml-sps");
};
const deleteSamlSpHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const entityId = String((req.body && req.body.entity_id) || "").trim();
if (entityId) {
await samlSps.deleteSp(entityId);
}
res.redirect(constants.ADMIN_BASE_PATH + "/saml-sps");
};
const ldapServicePage = async (req, res) => {
if (!isAdmin(req)) {
res.status(403).type("text/plain").send("admin only");
return;
}
try {
const dn = await serviceAccount.getServiceDn();
const body = `
<h1>LDAP service account</h1>
<p class="muted">A service DN + password for the search-then-bind flow (an application binds as this DN, searches for a user, then re-binds as that user to validate the password). The password is sealed at rest and never displayed.</p>
<table><tr><th>Configured service DN</th><td>${dn ? `<code>${escapeHtml(dn)}</code>` : '<span class="muted">(none)</span>'}</td></tr></table>
<h2>Set service account</h2>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap/service">${csrfField(req)}
<p>service DN <input type="text" name="dn" placeholder="cn=svc,ou=people,dc=saltcorn,dc=local" size="55"></p>
<p>password <input type="password" name="password"></p>
<button>save</button>
</form>
<h2>Clear</h2>
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap/service/clear">${csrfField(req)}<button>clear service account</button></form>`;
res.type("text/html").send(layout("saltcorn-idp ldap", body));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] ldap service page failed:`, e);
res.status(500).type("text/plain").send("ldap admin unavailable");
}
};
const setLdapServiceHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const dn = String((req.body && req.body.dn) || "").trim();
const password = String((req.body && req.body.password) || "");
if (dn && password) {
await serviceAccount.setServiceAccount(dn, password);
}
res.redirect(constants.ADMIN_BASE_PATH + "/ldap");
};
const clearLdapServiceHandler = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
await serviceAccount.setServiceAccount(null, null);
res.redirect(constants.ADMIN_BASE_PATH + "/ldap");
};
const adminRoutes = [
{ url: constants.ADMIN_BASE_PATH, method: "get", callback: dashboard },
{ url: constants.ADMIN_BASE_PATH + "/", method: "get", callback: dashboard },
{ url: constants.ADMIN_BASE_PATH + "/rotate-key", method: "post", callback: rotateKeyHandler },
{ url: constants.ADMIN_BASE_PATH + "/clients", method: "get", callback: clientsPage },
{ url: constants.ADMIN_BASE_PATH + "/clients/create", method: "post", callback: createClientHandler },
{ url: constants.ADMIN_BASE_PATH + "/clients/delete", method: "post", callback: deleteClientHandler },
{ url: constants.ADMIN_BASE_PATH + "/groups", method: "get", callback: groupsPage },
{ url: constants.ADMIN_BASE_PATH + "/groups/create", method: "post", callback: createGroupHandler },
{ url: constants.ADMIN_BASE_PATH + "/groups/delete", method: "post", callback: deleteGroupHandler },
{ url: constants.ADMIN_BASE_PATH + "/groups/addmember", method: "post", callback: addMemberHandler },
{ url: constants.ADMIN_BASE_PATH + "/groups/removemember", method: "post", callback: removeMemberHandler },
{ url: constants.ADMIN_BASE_PATH + "/saml-sps", method: "get", callback: samlSpsPage },
{ url: constants.ADMIN_BASE_PATH + "/saml-sps/create", method: "post", callback: createSamlSpHandler },
{ url: constants.ADMIN_BASE_PATH + "/saml-sps/delete", method: "post", callback: deleteSamlSpHandler },
{ url: constants.ADMIN_BASE_PATH + "/ldap", method: "get", callback: ldapServicePage },
{ url: constants.ADMIN_BASE_PATH + "/ldap/service", method: "post", callback: setLdapServiceHandler },
{ url: constants.ADMIN_BASE_PATH + "/ldap/service/clear", method: "post", callback: clearLdapServiceHandler }
];
module.exports = {
adminRoutes,
isAdmin,
escapeHtml
};

41
lib/claims.js Normal file
View file

@ -0,0 +1,41 @@
// Central claim mapper: a Saltcorn user -> OIDC claims, gated by granted scopes.
// This is the single place identity is rendered for relying parties; the LDAP
// and SAML renderers in later phases reuse the same group/identity source.
const groups = require("./groups");
const oidcClaims = async (user, sub, grantedScopes) => {
const scopes = String(grantedScopes || "").split(" ").filter(Boolean);
const out = { sub: sub };
if (scopes.includes("email")) {
out.email = user.email;
out.email_verified = !!user.verified_on;
}
if (scopes.includes("profile")) {
out.name = (user._attributes && user._attributes.name) || user.email;
}
if (scopes.includes("groups")) {
out.groups = await groups.effectiveGroups(user);
}
return out;
};
// SAML AttributeStatement values for a user: email + groups. Groups is the
// effective-groups ARRAY (one <saml:AttributeValue> per element is emitted by
// the renderer, matching the OIDC/LDAP array form). Reuses the same
// effectiveGroups source as OIDC.
const samlAttributes = async (user) => {
const effective = await groups.effectiveGroups(user);
return {
email: user.email,
groups: effective
};
};
module.exports = {
oidcClaims,
samlAttributes
};

95
lib/clients.js Normal file
View file

@ -0,0 +1,95 @@
// Relying-party (OAuth/OIDC client) registry. Backed by _idp_clients and exposed
// to oidc-provider via the Client model in the storage adapter (so new clients
// are picked up without rebuilding the provider). Confidential clients get a
// random secret, sealed at rest; public clients (token_auth_method='none') use
// PKCE and have no secret.
const nodeCrypto = require("crypto");
const db = require("@saltcorn/data/db");
const idpCrypto = require("./crypto");
const { TABLE_CLIENTS } = require("./constants");
const SECRET_BYTES = 32;
const AUTH_NONE = "none";
const listClients = async () => {
return await db.select(TABLE_CLIENTS, {}, { orderBy: "client_id" });
};
const getClient = async (clientId) => {
return await db.selectMaybeOne(TABLE_CLIENTS, { client_id: clientId });
};
const deleteClient = async (clientId) => {
await db.deleteWhere(TABLE_CLIENTS, { client_id: clientId });
};
// Creates a client. Returns { client_id, secret } where secret is the plaintext
// to display ONCE (null for public clients). Throws if the client_id exists.
const createClient = async (opts) => {
const authMethod = opts.authMethod || AUTH_NONE;
let cipher = null;
let iv = null;
let tag = null;
let secret = null;
if (authMethod !== AUTH_NONE) {
secret = nodeCrypto.randomBytes(SECRET_BYTES).toString("base64url");
const sealed = idpCrypto.sealText(secret);
cipher = sealed.ciphertext;
iv = sealed.iv;
tag = sealed.tag;
}
await db.insert(TABLE_CLIENTS, {
client_id: opts.clientId,
label: opts.label || null,
token_auth_method: authMethod,
redirect_uris: JSON.stringify(opts.redirectUris || []),
grant_types: JSON.stringify(["authorization_code"]),
response_types: JSON.stringify(["code"]),
scope: opts.scope || null,
secret_ciphertext: cipher,
secret_iv: iv,
secret_tag: tag,
created_at: new Date().toISOString()
}, { noid: true });
return { client_id: opts.clientId, secret: secret };
};
// Render a stored client row into the metadata object oidc-provider expects.
// The (unsealed) secret is only included for confidential clients.
const toOidcMetadata = (row) => {
const meta = {
client_id: row.client_id,
redirect_uris: JSON.parse(row.redirect_uris),
grant_types: JSON.parse(row.grant_types),
response_types: JSON.parse(row.response_types),
token_endpoint_auth_method: row.token_auth_method
};
if (row.scope) {
meta.scope = row.scope;
}
if (row.token_auth_method !== AUTH_NONE && row.secret_ciphertext) {
meta.client_secret = idpCrypto.openText({
ciphertext: row.secret_ciphertext,
iv: row.secret_iv,
tag: row.secret_tag
}).toString("utf8");
}
return meta;
};
module.exports = {
listClients,
getClient,
deleteClient,
createClient,
toOidcMetadata
};

169
lib/constants.js Normal file
View file

@ -0,0 +1,169 @@
// Constants for the saltcorn-idp plugin.
//
// One source of truth for plugin metadata, route base paths, table names, and
// signing parameters shared across the lib/ modules. Crypto byte-sizes live in
// crypto.js; protocol/policy values live here.
const PLUGIN_NAME = "saltcorn-idp";
const PLUGIN_VERSION = "0.0.1";
// Public OIDC/OAuth2 + machine endpoints live under this path and are
// CSRF-exempt. Admin (browser, CSRF-protected) pages live under ADMIN_BASE_PATH.
const IDP_BASE_PATH = "/idp";
const ADMIN_BASE_PATH = "/admin/idp";
// Defence-in-depth rate limit on admin mutation POSTs (on top of the role gate):
// a generous per-session sliding window -- normal admin use / the gates never
// approach it, but it caps automated abuse of a compromised admin session.
const ADMIN_RATE_MAX = 200;
const ADMIN_RATE_WINDOW_MS = 60 * 1000;
// Well-known discovery + JWKS endpoints (relative to the server root).
const WELL_KNOWN_OPENID = IDP_BASE_PATH + "/.well-known/openid-configuration";
// oidc-provider serves JWKS at the mount-relative /jwks (not under /.well-known);
// the discovery document advertises this as jwks_uri.
const JWKS_PATH = IDP_BASE_PATH + "/jwks";
// OIDC flow endpoints (served by oidc-provider via delegation) + the interaction
// (login/consent) endpoint we host ourselves.
const AUTH_PATH = IDP_BASE_PATH + "/auth";
const AUTH_RESUME_PATH = IDP_BASE_PATH + "/auth/:uid";
const TOKEN_PATH = IDP_BASE_PATH + "/token";
const USERINFO_PATH = IDP_BASE_PATH + "/me";
const INTERACTION_PATH = IDP_BASE_PATH + "/interaction/:uid";
const INTERACTION_CONFIRM_PATH = IDP_BASE_PATH + "/interaction/:uid/confirm";
// Plugin tables (all prefixed _idp_, created idempotently in onLoad).
const TABLE_ENV = "_idp_env";
const TABLE_KEYS = "_idp_keys";
const TABLE_OIDC_STORE = "_idp_oidc_store";
const TABLE_GROUPS = "_idp_groups";
const TABLE_GROUP_MEMBERS = "_idp_group_members";
const TABLE_CLIENTS = "_idp_clients";
// Signing.
const SIGNING_ALG = "RS256";
const RSA_MODULUS_BITS = 2048;
// Key lifecycle states.
const KEY_STATUS = {
ACTIVE: "active",
RETIRING: "retiring",
RETIRED: "retired"
};
// Signing-key rotation grace window. On rotation the prior ACTIVE key is marked
// RETIRING with retire_after = now + this window; it stays in JWKS (so tokens it
// signed still verify) until the window elapses, after which retireExpiredKeys()
// flips it RETIRED (dropped from JWKS). Sized to comfortably exceed the longest
// id_token lifetime so no live token is ever orphaned by rotation.
const KEY_RETIRE_GRACE_MS = 24 * 60 * 60 * 1000;
// LDAP (Phase 4). An instance enables LDAP by setting the port env var (so the
// dev instances don't collide on one port); the directory tree mirrors Saltcorn
// users (ou=people) and groups (ou=groups).
const LDAP_BASE_DN = "dc=saltcorn,dc=local";
const LDAP_PEOPLE_OU = "ou=people," + LDAP_BASE_DN;
const LDAP_GROUPS_OU = "ou=groups," + LDAP_BASE_DN;
const LDAP_PORT_ENV = "SALTCORN_IDP_LDAP_PORT";
// Interface the LDAPS listener binds to. Defaults to loopback so LDAP is NOT
// network-exposed unless an operator explicitly opts in (e.g. "0.0.0.0" to serve
// LDAP clients on other hosts).
const LDAP_HOST_ENV = "SALTCORN_IDP_LDAP_HOST";
const LDAP_DEFAULT_HOST = "127.0.0.1";
// LDAP DoS guards: cap total inbound bytes per connection (the BER parser has no
// size limit) and the search-filter nesting depth (the filter parser recurses
// without a depth bound). A multi-tenant deployment encodes the tenant as an
// extra dc component (dc=<tenant>,dc=saltcorn,dc=local); the bare base = default.
const LDAP_MAX_MSG_BYTES = 256 * 1024;
const LDAP_MAX_FILTER_DEPTH = 32;
const TABLE_LDAP_SERVICE = "_idp_ldap_service";
// LDAP bind-retry: only the cluster primary binds the listener, so a transient
// EADDRINUSE (e.g. the prior process's socket lingering across a fast restart)
// would otherwise leave LDAP silently down with no fallback. Retry a bounded
// number of times with linear backoff, then warn LOUDLY. Delay = base * attempt.
const LDAP_BIND_MAX_ATTEMPTS = 5;
const LDAP_BIND_RETRY_BASE_MS = 500;
// Per-connection idle timeout: a connection that sends no data for this long is
// destroyed, so an attacker cannot hoard the connection pool with idle/slow-loris
// sockets (works with LDAP_MAX_MSG_BYTES, which bounds a never-completing message).
const LDAP_IDLE_TIMEOUT_MS = 30 * 1000;
// Cap the number of directory entries a single search loads/returns, bounding
// memory; past this the search returns sizeLimitExceeded. (MVP loads users into
// memory and filters there; a production build would push the filter to SQL.)
const LDAP_MAX_SEARCH_RESULTS = 2000;
// SAML (Phase 5). The IdP entityID is <issuer>/saml; SSO + metadata under /idp/saml.
const TABLE_SAML = "_idp_saml";
const TABLE_SAML_SPS = "_idp_saml_sps";
const SAML_METADATA_PATH = IDP_BASE_PATH + "/saml/metadata";
const SAML_SSO_PATH = IDP_BASE_PATH + "/saml/sso";
const SAML_SLO_PATH = IDP_BASE_PATH + "/saml/slo";
const SAML_INIT_PATH = IDP_BASE_PATH + "/saml/init";
// SAML DoS guards: cap the base64 SAMLRequest/SAMLResponse we accept (before
// decoding) and the inflated XML size (deflate can expand ~1000:1, so an
// unbounded inflateRawSync on an unauthenticated endpoint is a memory bomb).
const SAML_MAX_MSG_B64_BYTES = 64 * 1024;
const SAML_MAX_XML_BYTES = 256 * 1024;
// AuthnContext class for password login + the signature algorithm we require on
// signed SAML messages (RSA-SHA256). One source of truth for these URNs.
const SAML_AUTHN_CONTEXT = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport";
const SAML_SIG_ALG = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
// Minimum acceptable xml-crypto version (2025 SAML-signature CVEs patched).
const XML_CRYPTO_MIN = "6.1.2";
module.exports = {
PLUGIN_NAME,
PLUGIN_VERSION,
IDP_BASE_PATH,
ADMIN_BASE_PATH,
ADMIN_RATE_MAX,
ADMIN_RATE_WINDOW_MS,
WELL_KNOWN_OPENID,
JWKS_PATH,
AUTH_PATH,
AUTH_RESUME_PATH,
TOKEN_PATH,
USERINFO_PATH,
INTERACTION_PATH,
INTERACTION_CONFIRM_PATH,
TABLE_ENV,
TABLE_KEYS,
TABLE_OIDC_STORE,
TABLE_GROUPS,
TABLE_GROUP_MEMBERS,
TABLE_CLIENTS,
SIGNING_ALG,
RSA_MODULUS_BITS,
KEY_STATUS,
KEY_RETIRE_GRACE_MS,
LDAP_BASE_DN,
LDAP_PEOPLE_OU,
LDAP_GROUPS_OU,
LDAP_PORT_ENV,
LDAP_HOST_ENV,
LDAP_DEFAULT_HOST,
LDAP_MAX_MSG_BYTES,
LDAP_MAX_FILTER_DEPTH,
TABLE_LDAP_SERVICE,
LDAP_BIND_MAX_ATTEMPTS,
LDAP_BIND_RETRY_BASE_MS,
LDAP_IDLE_TIMEOUT_MS,
LDAP_MAX_SEARCH_RESULTS,
TABLE_SAML,
TABLE_SAML_SPS,
SAML_METADATA_PATH,
SAML_SSO_PATH,
SAML_SLO_PATH,
SAML_INIT_PATH,
SAML_MAX_MSG_B64_BYTES,
SAML_MAX_XML_BYTES,
SAML_AUTHN_CONTEXT,
SAML_SIG_ALG,
XML_CRYPTO_MIN
};

168
lib/crypto.js Normal file
View file

@ -0,0 +1,168 @@
// Crypto primitives for the saltcorn-idp plugin.
//
// seal/open — AES-256-GCM at-rest encryption. The 32-byte KEK is
// derived once per process via HKDF-SHA256 from
// SALTCORN_SESSION_SECRET.
// sealText/openText — same, but with hex-string fields for DB storage
// (Saltcorn's sqlite layer JSON-stringifies values, so
// raw Buffers must not be stored directly).
// generateSigningKeyPair / publicKeyToJwk / exportPrivatePem / importPrivatePem
// — asymmetric signing keys for OIDC id_token signing and
// the public JWKS.
//
// The KEK info string is domain-separated from other plugins so the IdP's KEK
// differs from dev-deploy's even though both derive from the same session
// secret. Rotating SALTCORN_SESSION_SECRET invalidates all sealed data (the KEK
// changes, so existing ciphertexts no longer decrypt). Documented behavior.
const crypto = require("crypto");
const KEK_INFO = "saltcorn-idp:at-rest:aes-gcm-key:v1";
const KEK_SALT = "saltcorn-idp:at-rest:aes-gcm-salt:v1";
const HKDF_HASH = "sha256";
const GCM_ALGORITHM = "aes-256-gcm";
const IV_BYTES = 12;
const KEK_BYTES = 32;
const KID_BYTES = 16;
let cachedKek = null;
let cachedSecretHash = null;
const getSessionSecret = () => {
const fromEnv = process.env.SALTCORN_SESSION_SECRET;
if (fromEnv && fromEnv.length > 0) {
return fromEnv;
}
try {
const { getState } = require("@saltcorn/data/db/state");
const v = getState().getConfig("session_secret");
if (v) {
return v;
}
} catch (e) {
// getState not available (e.g. outside a request context); fall through
}
throw new Error("saltcorn-idp: SALTCORN_SESSION_SECRET not available; cannot derive KEK");
};
const getKek = () => {
// Re-derive if the session secret changes so a rotated secret never keeps
// serving a stale KEK. The KEK is global (Saltcorn's session_secret is
// global), so this cache is shared safely across tenants.
const sessionSecret = getSessionSecret();
const secretHash = crypto.createHash(HKDF_HASH).update(sessionSecret, "utf8").digest("hex");
if (cachedKek && cachedSecretHash === secretHash) {
return cachedKek;
}
const ikm = Buffer.from(sessionSecret, "utf8");
const salt = Buffer.from(KEK_SALT, "utf8");
const info = Buffer.from(KEK_INFO, "utf8");
cachedKek = Buffer.from(crypto.hkdfSync(HKDF_HASH, ikm, salt, info, KEK_BYTES));
cachedSecretHash = secretHash;
return cachedKek;
};
const seal = (plaintext) => {
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv(GCM_ALGORITHM, getKek(), iv);
const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
const tag = cipher.getAuthTag();
return { ciphertext: ct, iv: iv, tag: tag };
};
const open = (sealed) => {
const decipher = crypto.createDecipheriv(GCM_ALGORITHM, getKek(), sealed.iv);
decipher.setAuthTag(sealed.tag);
return Buffer.concat([decipher.update(sealed.ciphertext), decipher.final()]);
};
const sealText = (plaintext) => {
const sealed = seal(plaintext);
return {
ciphertext: sealed.ciphertext.toString("hex"),
iv: sealed.iv.toString("hex"),
tag: sealed.tag.toString("hex")
};
};
const openText = (sealedHex) => {
const sealed = {
ciphertext: Buffer.from(sealedHex.ciphertext, "hex"),
iv: Buffer.from(sealedHex.iv, "hex"),
tag: Buffer.from(sealedHex.tag, "hex")
};
return open(sealed);
};
const generateSigningKeyPair = (modulusBits) => {
return crypto.generateKeyPairSync("rsa", { modulusLength: modulusBits });
};
const publicKeyToJwk = (publicKey, kid, alg) => {
const jwk = publicKey.export({ format: "jwk" });
if (jwk.kty !== "RSA" || !jwk.n || !jwk.e) {
throw new Error("saltcorn-idp: exported JWK is not a complete RSA public key");
}
jwk.kid = kid;
jwk.alg = alg;
jwk.use = "sig";
return jwk;
};
const exportPrivatePem = (privateKey) => {
return privateKey.export({ type: "pkcs8", format: "pem" });
};
const importPrivatePem = (pem) => {
return crypto.createPrivateKey(pem);
};
const newKid = () => {
return crypto.randomBytes(KID_BYTES).toString("hex");
};
const privateKeyToJwk = (privateKey, kid, alg) => {
const jwk = privateKey.export({ format: "jwk" });
jwk.kid = kid;
jwk.alg = alg;
jwk.use = "sig";
return jwk;
};
// Deterministic secret derived from SALTCORN_SESSION_SECRET (distinct info per
// use). Used for oidc-provider cookie-signing keys so they survive restarts.
const deriveSecretHex = (info, bytes) => {
const ikm = Buffer.from(getSessionSecret(), "utf8");
const salt = Buffer.from(info, "utf8");
const out = crypto.hkdfSync(HKDF_HASH, ikm, salt, Buffer.from(info, "utf8"), bytes);
return Buffer.from(out).toString("hex");
};
module.exports = {
seal,
open,
sealText,
openText,
generateSigningKeyPair,
publicKeyToJwk,
privateKeyToJwk,
exportPrivatePem,
importPrivatePem,
newKid,
deriveSecretHex
};

47
lib/env.js Normal file
View file

@ -0,0 +1,47 @@
// Singleton per-instance (per-tenant) environment row for saltcorn-idp. Tracks
// first-run bootstrap state and an instance label for the admin UI.
//
// No module-level cache: in multi-tenant mode a single un-keyed cache would
// serve one tenant's row to another. Reads are infrequent (onLoad + dashboard),
// so we query each time within the caller's tenant context.
const crypto = require("crypto");
const db = require("@saltcorn/data/db");
const { TABLE_ENV } = require("./constants");
const getEnv = async () => {
const rows = await db.select(TABLE_ENV, {});
return rows.length > 0 ? rows[0] : null;
};
const initEnvIfMissing = async () => {
const existing = await getEnv();
if (existing) {
return existing;
}
const now = new Date().toISOString();
const row = {
env_id: crypto.randomUUID(),
env_label: null,
created_at: now,
bootstrapped_at: null
};
await db.insert(TABLE_ENV, row, { noid: true });
return row;
};
const markBootstrapped = async (envId) => {
const now = new Date().toISOString();
await db.updateWhere(TABLE_ENV, { bootstrapped_at: now }, { env_id: envId });
};
module.exports = {
getEnv,
initEnvIfMissing,
markBootstrapped
};

79
lib/groups.js Normal file
View file

@ -0,0 +1,79 @@
// Group membership model: the single source of truth for the OIDC `groups` claim
// (and, in a later phase, LDAP groups). A user's effective groups = their
// Saltcorn role exposed as a group (role:<name>) UNION their custom group
// memberships (group:<name>), so a role and a custom group with the same name
// never collide.
const db = require("@saltcorn/data/db");
const { TABLE_GROUPS, TABLE_GROUP_MEMBERS } = require("./constants");
const ROLE_PREFIX = "role:";
const GROUP_PREFIX = "group:";
const listGroups = async () => {
return await db.select(TABLE_GROUPS, {}, { orderBy: "name" });
};
const createGroup = async (name, description) => {
return await db.insert(TABLE_GROUPS, {
name: name,
description: description || null,
created_at: new Date().toISOString()
});
};
const deleteGroup = async (id) => {
await db.deleteWhere(TABLE_GROUP_MEMBERS, { group_id: id });
await db.deleteWhere(TABLE_GROUPS, { id: id });
};
const membersOf = async (groupId) => {
return await db.select(TABLE_GROUP_MEMBERS, { group_id: groupId });
};
const addMember = async (groupId, userId) => {
const existing = await db.selectMaybeOne(TABLE_GROUP_MEMBERS, { group_id: groupId, user_id: userId });
if (existing) {
return;
}
await db.insert(TABLE_GROUP_MEMBERS, { group_id: groupId, user_id: userId }, { noid: true });
};
const removeMember = async (groupId, userId) => {
await db.deleteWhere(TABLE_GROUP_MEMBERS, { group_id: groupId, user_id: userId });
};
const effectiveGroups = async (user) => {
const out = [];
const role = await db.selectMaybeOne("_sc_roles", { id: user.role_id });
if (role && role.role) {
out.push(ROLE_PREFIX + role.role);
}
const members = await db.select(TABLE_GROUP_MEMBERS, { user_id: user.id });
for (const member of members) {
const group = await db.selectMaybeOne(TABLE_GROUPS, { id: member.group_id });
if (group && group.name) {
out.push(GROUP_PREFIX + group.name);
}
}
return out;
};
module.exports = {
listGroups,
createGroup,
deleteGroup,
membersOf,
addMember,
removeMember,
effectiveGroups
};

168
lib/keys.js Normal file
View file

@ -0,0 +1,168 @@
// Signing-key lifecycle for the saltcorn-idp OIDC provider.
//
// Keys are per-tenant (stored in this tenant's _idp_keys). The private key is
// sealed at rest (AES-256-GCM under the plugin KEK); only the PUBLIC JWK is
// ever exposed, via JWKS. ensureActiveKey() is idempotent: it creates an active
// key only if none exists for the current tenant. getActiveKeyMeta() never
// touches private material so the dashboard can render without decrypting.
const db = require("@saltcorn/data/db");
const crypto = require("./crypto");
const constants = require("./constants");
// Generate + seal a fresh RSA signing keypair and insert it as the new ACTIVE
// key. Shared by ensureActiveKey (first key for a tenant) and rotateActiveKey
// (replacement key). Returns only safe metadata; the sealed private material is
// never handed back.
const insertActiveKey = async () => {
const kid = crypto.newKid();
const pair = crypto.generateSigningKeyPair(constants.RSA_MODULUS_BITS);
const jwk = crypto.publicKeyToJwk(pair.publicKey, kid, constants.SIGNING_ALG);
const pem = crypto.exportPrivatePem(pair.privateKey);
const sealed = crypto.sealText(pem);
const now = new Date().toISOString();
const row = {
kid: kid,
alg: constants.SIGNING_ALG,
public_jwk: JSON.stringify(jwk),
private_ciphertext: sealed.ciphertext,
private_iv: sealed.iv,
private_tag: sealed.tag,
status: constants.KEY_STATUS.ACTIVE,
created_at: now,
retire_after: null
};
await db.insert(constants.TABLE_KEYS, row, { noid: true });
return { kid: kid, alg: constants.SIGNING_ALG, status: constants.KEY_STATUS.ACTIVE, created_at: now };
};
const ensureActiveKey = async () => {
const existing = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE });
if (existing) {
return { kid: existing.kid, alg: existing.alg, status: existing.status, created_at: existing.created_at };
}
return await insertActiveKey();
};
const getJwks = async () => {
const active = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE });
const retiring = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.RETIRING });
const keys = active.concat(retiring).map((r) => JSON.parse(r.public_jwk));
return { keys: keys };
};
const getActiveKeyMeta = async () => {
const row = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE });
if (!row) {
return null;
}
return { kid: row.kid, alg: row.alg, created_at: row.created_at };
};
// Phase 1: decrypts and returns the private signing key for id_token signing.
const getActiveSigningKey = async () => {
const row = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE });
if (!row) {
return null;
}
const pem = crypto.openText({
ciphertext: row.private_ciphertext,
iv: row.private_iv,
tag: row.private_tag
}).toString("utf8");
return {
kid: row.kid,
alg: row.alg,
privateKey: crypto.importPrivatePem(pem)
};
};
// Phase 1: the active private signing key as a JWK, for oidc-provider's jwks
// config (it signs id_tokens with it and serves the public half via JWKS).
const getActivePrivateJwk = async () => {
const key = await getActiveSigningKey();
if (!key) {
return null;
}
return crypto.privateKeyToJwk(key.privateKey, key.kid, key.alg);
};
// All non-retired private signing keys as JWKs for oidc-provider's jwks config:
// the ACTIVE key FIRST (oidc-provider signs new tokens with the first key that
// matches the requested alg) followed by any RETIRING keys, so id_tokens issued
// BEFORE a rotation still verify against JWKS during the grace window. RETIRED
// keys are excluded. This is what actually keeps a rotated-out key in /idp/jwks
// (the provider builds JWKS from this set, not from keys.getJwks()).
const getSigningPrivateJwks = async () => {
const active = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE });
const retiring = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.RETIRING });
const jwks = [];
for (const row of active.concat(retiring)) {
const pem = crypto.openText({
ciphertext: row.private_ciphertext,
iv: row.private_iv,
tag: row.private_tag
}).toString("utf8");
jwks.push(crypto.privateKeyToJwk(crypto.importPrivatePem(pem), row.kid, row.alg));
}
return jwks;
};
// Flip any RETIRING keys whose grace window has elapsed to RETIRED, dropping
// them from JWKS. Idempotent and cheap; called opportunistically on the admin
// dashboard GET and at the end of a rotation. NULL retire_after never matches
// (SQL "<=" of NULL is NULL, not true), so a key without a deadline is safe.
const retireExpiredKeys = async () => {
const now = new Date().toISOString();
await db.updateWhere(
constants.TABLE_KEYS,
{ status: constants.KEY_STATUS.RETIRED },
{ status: constants.KEY_STATUS.RETIRING, retire_after: { lt: now, equal: true } }
);
};
// Admin-triggered rotation. Generates+seals a NEW ACTIVE keypair (new kid) and
// demotes the prior ACTIVE key to RETIRING with retire_after = now + the grace
// window, so id_tokens signed by the old key keep verifying (it stays in JWKS)
// until the window elapses. The OIDC Provider is built per-issuer with the
// active private JWK baked in, so its cache is cleared here to force a rebuild
// against the new key. Also sweeps any already-expired RETIRING keys to RETIRED.
const rotateActiveKey = async () => {
const prior = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE });
if (prior) {
const retireAfter = new Date(Date.now() + constants.KEY_RETIRE_GRACE_MS).toISOString();
await db.updateWhere(
constants.TABLE_KEYS,
{ status: constants.KEY_STATUS.RETIRING, retire_after: retireAfter },
{ kid: prior.kid }
);
}
const created = await insertActiveKey();
await retireExpiredKeys();
// Lazy-require to avoid a require cycle (provider.js requires this module).
const { clearProviderCache } = require("./oidc/provider");
clearProviderCache();
return created;
};
module.exports = {
ensureActiveKey,
getJwks,
getActiveKeyMeta,
getActiveSigningKey,
getActivePrivateJwk,
getSigningPrivateJwks,
rotateActiveKey,
retireExpiredKeys
};

91
lib/ldap/bind.js Normal file
View file

@ -0,0 +1,91 @@
// LDAP simple-bind handler. Verifies the typed password against the user's
// stored bcrypt hash via Saltcorn's User.checkPassword (never stores/echoes
// plaintext), or against the configured service account (constant-time compare,
// for the search-then-bind flow). Denies anonymous, oversized, and cross-tenant
// binds, and rate-limits failures per IP. The tenant is derived from the bind
// DN base (dc=<tenant>,...) and all lookups run inside runWithTenant.
const nodeCrypto = require("crypto");
const User = require("@saltcorn/data/models/user");
const vendor = require("./vendor");
const harden = require("./harden");
const tenant = require("./tenant");
const serviceAccount = require("./serviceAccount");
const { uidFromDn } = require("./dn");
const MAX_CRED_LEN = 1024;
const ipOf = (req) => {
return (req.connection && req.connection.remoteAddress) || "unknown";
};
const dnEquals = (a, b) => {
return String(a).replace(/\s+/g, "").toLowerCase() === String(b).replace(/\s+/g, "").toLowerCase();
};
const constantTimeEqual = (a, b) => {
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
if (ba.length !== bb.length) {
return false;
}
return nodeCrypto.timingSafeEqual(ba, bb);
};
const handler = async (req, res, next) => {
const ip = ipOf(req);
if (harden.isLocked(ip)) {
return next(new vendor.InvalidCredentialsError());
}
try {
const creds = req.credentials || "";
// Deny anonymous (no DN / empty password) and absurdly long credentials.
if (!req.dn || req.dn.length === 0 || creds === "" || creds.length > MAX_CRED_LEN) {
harden.recordFail(ip);
return next(new vendor.InvalidCredentialsError());
}
const t = tenant.resolveTenant(tenant.tenantFromDn(req.dn));
if (t.deny) {
harden.recordFail(ip);
return next(new vendor.InvalidCredentialsError());
}
const dnStr = req.dn.toString();
const authed = await tenant.withTenant(t.tenant, async () => {
// Service-account bind (search-then-bind binder).
const svc = await serviceAccount.getServiceAccount();
if (svc && svc.dn && dnEquals(dnStr, svc.dn)) {
return constantTimeEqual(creds, svc.password);
}
// User bind.
const email = uidFromDn(req.dn);
const user = email ? await User.findOne({ email: email }) : null;
if (!user || user.disabled || !user.checkPassword(creds)) {
return false;
}
return true;
});
if (!authed) {
harden.recordFail(ip);
return next(new vendor.InvalidCredentialsError());
}
harden.recordSuccess(ip);
res.end();
return next();
} catch (e) {
// eslint-disable-next-line no-console
console.error("[saltcorn-idp] ldap bind error:", e);
harden.recordFail(ip);
return next(new vendor.InvalidCredentialsError());
}
};
module.exports = {
handler
};

85
lib/ldap/dn.js Normal file
View file

@ -0,0 +1,85 @@
// LDAP distinguished-name helpers. Users are uid=<email>,ou=people,<base>;
// groups are cn=<group>,ou=groups,<base>. In a multi-tenant deployment the
// tenant is an extra dc component: <base> becomes dc=<tenant>,dc=saltcorn,dc=local.
// A falsy tenant yields the bare default base, so single-tenant DNs are unchanged.
const constants = require("../constants");
const baseFor = (tenant) => {
return tenant ? ("dc=" + tenant + "," + constants.LDAP_BASE_DN) : constants.LDAP_BASE_DN;
};
// RFC 4514 escaping for an RDN attribute value. A user email or group name
// (admin-controlled free text) may contain DN special characters (comma, plus,
// quote, ...); without escaping the concatenated DN is malformed and ldapjs's
// DN.fromString() throws when res.send() builds the SearchEntry, aborting the
// WHOLE search. We escape on output (the result/memberOf DNs we emit); inbound
// bind DNs are formatted by the client, not by us.
const escapeDnValue = (value) => {
let s = String(value === null || value === undefined ? "" : value);
// Characters that are special anywhere in the value.
s = s.replace(/([\\",+;<>=])/g, "\\$1");
// A leading '#' or space, and a trailing space, are positionally special.
s = s.replace(/^([ #])/, "\\$1").replace(/ $/, "\\ ");
// NUL byte.
s = s.replace(/\0/g, "\\00");
return s;
};
const userDn = (email, tenant) => {
return "uid=" + escapeDnValue(email) + ",ou=people," + baseFor(tenant);
};
const groupDn = (group, tenant) => {
return "cn=" + escapeDnValue(group) + ",ou=groups," + baseFor(tenant);
};
// Reverse of escapeDnValue: turn RFC 4514 escapes back into the literal value.
// `\XX` (two hex digits) -> that byte; `\<char>` -> <char>.
const unescapeDnValue = (v) => {
let out = "";
for (let i = 0; i < v.length; i++) {
if (v[i] === "\\" && i + 1 < v.length) {
const hex = v.slice(i + 1, i + 3);
if (/^[0-9a-fA-F]{2}$/.test(hex)) {
out += String.fromCharCode(parseInt(hex, 16));
i += 2;
} else {
out += v[i + 1];
i += 1;
}
} else {
out += v[i];
}
}
return out;
};
// Extract the uid (the user's email) from a bind DN (ldapjs DN object or string).
// ldapjs re-escapes RFC 4514 special chars in toString() (e.g. a comma becomes
// "\2c" or "\,"), so we capture the full RDN value -- up to the first UNESCAPED
// comma, treating "\<x>" as one unit -- then unescape it. A naive /uid=([^,]+)/
// truncated at escaped commas and returned the escaped form, so users whose email
// contains a comma/plus/etc. could never bind.
const uidFromDn = (dn) => {
const s = typeof dn === "string" ? dn : (dn ? dn.toString() : "");
const m = s.match(/uid=((?:\\.|[^,\\])*)/i);
if (!m) {
return null;
}
const v = unescapeDnValue(m[1]).trim();
return v || null;
};
module.exports = {
userDn,
groupDn,
uidFromDn
};

47
lib/ldap/harden.js Normal file
View file

@ -0,0 +1,47 @@
// Application-level hardening for the LDAP server: per-IP failed-bind rate
// limiting / lockout (the LDAP port is outside Saltcorn's web-login throttling).
// The parser-level guards (max inbound bytes per connection, filter-nesting
// depth) now live in our owned layer -- lib/ldap/vendor.js (byte cap via the
// connectionRouter) and lib/ldap/search.js (filter-depth walk) -- so no ldapjs
// fork is needed.
const WINDOW_MS = 5 * 60 * 1000;
const MAX_FAILS = 10;
const fails = new Map();
const isLocked = (ip) => {
const e = fails.get(ip);
if (!e) {
return false;
}
if (Date.now() - e.first > WINDOW_MS) {
fails.delete(ip);
return false;
}
return e.count >= MAX_FAILS;
};
const recordFail = (ip) => {
const e = fails.get(ip);
if (!e || Date.now() - e.first > WINDOW_MS) {
fails.set(ip, { count: 1, first: Date.now() });
} else {
e.count = e.count + 1;
}
};
const recordSuccess = (ip) => {
fails.delete(ip);
};
module.exports = {
isLocked,
recordFail,
recordSuccess,
MAX_FAILS
};

192
lib/ldap/search.js Normal file
View file

@ -0,0 +1,192 @@
// LDAP search handler. Returns Saltcorn users as inetOrgPerson entries (with
// mail + memberOf) and the effective groups as groupOfNames entries, where the
// membership reuses the SAME identity model as OIDC (groups.effectiveGroups =
// role-as-group + custom groups). Requires an authenticated (non-anonymous)
// bind, enforces a filter-nesting-depth cap, selects requested attributes
// case-insensitively, resolves the tenant from the search base, and DENIES a
// search whose base tenant differs from the bound connection's tenant.
//
// MVP: loads all users and filters via req.filter.matches (fine for dev-scale
// directories). A production build would translate the LDAP filter into a
// targeted query.
const User = require("@saltcorn/data/models/user");
const groups = require("../groups");
const vendor = require("./vendor");
const tenant = require("./tenant");
const constants = require("../constants");
const { userDn, groupDn } = require("./dn");
// Iterative (non-recursive) walk of the filter tree to measure nesting depth.
// and/or/not expose children via .clauses (with .filters as an alias).
const filterDepth = (root) => {
let max = 0;
const stack = [[root, 1]];
while (stack.length > 0) {
const item = stack.pop();
const node = item[0];
const depth = item[1];
if (depth > max) {
max = depth;
}
const kids = (node && (node.clauses || node.filters)) || [];
for (const kid of kids) {
stack.push([kid, depth + 1]);
}
}
return max;
};
const userEntry = (user, effective, tenantName) => {
const name = (user._attributes && user._attributes.name) || user.email;
// Lowercase attribute names match the case-insensitive request normalization
// below (and ldapjs lowercases entry keys when filtering requested attrs).
return {
dn: userDn(user.email, tenantName),
attributes: {
objectclass: ["inetOrgPerson", "organizationalPerson", "person", "top"],
uid: [user.email],
cn: [name],
sn: [name],
mail: [user.email],
displayname: [name],
memberof: effective.map((g) => groupDn(g, tenantName))
}
};
};
const handler = async (req, res, next) => {
try {
if (req.connection.ldap.bindDN.equals("cn=anonymous")) {
return next(new vendor.InsufficientAccessRightsError());
}
// DoS guard: reject pathologically nested filters before any DB work.
if (req.filter && filterDepth(req.filter) > constants.LDAP_MAX_FILTER_DEPTH) {
return next(new vendor.OperationsError());
}
// Case-insensitive attribute selection: SearchResponse.send() compares the
// lowercased entry key against the REQUESTED list (res.attributes, copied
// at response construction BEFORE this handler) WITHOUT lowercasing it, so
// a mixed-case request (memberOf) drops our lowercase keys. Lowercase the
// response's attribute list in place (mutating req.attributes is too late).
if (Array.isArray(res.attributes)) {
for (let i = 0; i < res.attributes.length; i++) {
res.attributes[i] = String(res.attributes[i]).toLowerCase();
}
}
// Tenant: the search base names a tenant; the bound connection is in a
// tenant; they must match (no cross-tenant reads).
const baseT = tenant.resolveTenant(tenant.tenantFromDn(req.dn));
if (baseT.deny) {
return next(new vendor.InsufficientAccessRightsError());
}
const boundT = tenant.resolveTenant(tenant.tenantFromDn(req.connection.ldap.bindDN));
if ((boundT.tenant || null) !== (baseT.tenant || null)) {
return next(new vendor.InsufficientAccessRightsError());
}
let truncated = false;
await tenant.withTenant(baseT.tenant, async () => {
// Fast path: a simple equality on uid/mail (the common search-then-bind
// "find me this user" query) is answered with a targeted DB lookup --
// no full-directory load, correct even for directories larger than the
// cap. A presence filter like (uid=*) is NOT equality (no .value) and
// falls through to the capped scan.
const f = req.filter;
const attr = f && f.attribute ? String(f.attribute).toLowerCase() : null;
// A leaf equality on uid/mail (same structural test as the existing
// single-equality fast path). Reused to classify OR children below.
const isUidMailEquality = (node) => {
const a = node && node.attribute ? String(node.attribute).toLowerCase() : null;
return !!node && (a === "uid" || a === "mail") && node.value !== undefined && node.value !== null && !node.filters && !node.clauses;
};
// OR children expose siblings via .filters (ldapjs) or .clauses (alias).
const orChildren = (node) => {
if (!node) {
return null;
}
const t = String(node.type || "").toLowerCase();
if (t !== "or" && t !== "orfilter") {
return null;
}
const kids = node.filters || node.clauses;
return Array.isArray(kids) ? kids : null;
};
let users;
if (f && (attr === "uid" || attr === "mail") && f.value !== undefined && f.value !== null && !f.filters && !f.clauses) {
const one = await User.findOne({ email: String(f.value) });
users = one ? [one] : [];
} else if (orChildren(f) && orChildren(f).length > 0 && orChildren(f).every(isUidMailEquality)) {
// Fast path extension: an OR of uid/mail equalities (e.g.
// (|(uid=a)(mail=b))) resolves each child via findOne and unions
// the hits, deduped by email. Still targeted (no full scan).
// Full LDAP-filter-to-SQL translation remains out of scope.
const byEmail = new Map();
for (const child of orChildren(f)) {
const one = await User.findOne({ email: String(child.value) });
if (one && !byEmail.has(one.email)) {
byEmail.set(one.email, one);
}
}
users = Array.from(byEmail.values());
} else {
// Broad/complex filter: load up to the cap and filter in memory.
// Past the cap we report sizeLimitExceeded rather than letting a
// broad filter against a huge directory exhaust the heap.
const max = constants.LDAP_MAX_SEARCH_RESULTS;
users = await User.find({}, { limit: max + 1 });
if (users.length > max) {
users.length = max;
truncated = true;
}
}
const groupMap = {};
for (const user of users) {
const effective = await groups.effectiveGroups(user);
const entry = userEntry(user, effective, baseT.tenant);
if (req.filter.matches(entry.attributes)) {
res.send(entry);
}
for (const g of effective) {
if (!groupMap[g]) {
groupMap[g] = [];
}
groupMap[g].push(userDn(user.email, baseT.tenant));
}
}
// Emit groupOfNames entries (one per non-empty effective group),
// built from the SAME membership source as memberOf above.
for (const gname of Object.keys(groupMap)) {
const gentry = {
dn: groupDn(gname, baseT.tenant),
attributes: {
objectclass: ["groupOfNames", "top"],
cn: [gname],
member: groupMap[gname]
}
};
if (req.filter.matches(gentry.attributes)) {
res.send(gentry);
}
}
});
if (truncated) {
return next(new vendor.SizeLimitExceededError());
}
res.end();
return next();
} catch (e) {
// eslint-disable-next-line no-console
console.error("[saltcorn-idp] ldap search error:", e);
return next(new vendor.OperationsError());
}
};
module.exports = {
handler
};

177
lib/ldap/server.js Normal file
View file

@ -0,0 +1,177 @@
// LDAPS server: one listener, bound only in the cluster PRIMARY process (so the
// forked workers don't race the port and spew EADDRINUSE), and started from
// onLoad ONLY when the port env var (SALTCORN_IDP_LDAP_PORT) is set (so the three
// dev instances don't collide). The bind interface is SALTCORN_IDP_LDAP_HOST,
// defaulting to loopback so LDAP is not network-exposed unless explicitly opted
// in. LDAPS-only (ldapjs has no StartTLS); for dev we generate a self-signed
// cert, production would supply one via config. Bind + search reuse the Saltcorn
// user/group model and resolve their own tenant context.
//
// The ldapjs dependency is owned through lib/ldap/vendor.js (the single
// require point); the byte-cap / filter-depth / per-IP guards live in our code
// (vendor.js, search.js, harden.js). Multi-tenancy is via tenant-in-DN +
// runWithTenant (lib/ldap/tenant.js).
const cluster = require("cluster");
const selfsigned = require("selfsigned");
const constants = require("../constants");
const vendor = require("./vendor");
const bind = require("./bind");
const search = require("./search");
let started = false;
let listening = false;
const noopLog = {
trace() {},
debug() {},
info() {},
warn() {},
error() {},
fatal() {},
child() { return noopLog; }
};
const isPrimary = () => {
return cluster.isPrimary !== undefined ? cluster.isPrimary : cluster.isMaster;
};
const isLoopbackHost = (host) => {
const h = String(host).toLowerCase();
return h === "127.0.0.1" || h === "::1" || h === "localhost";
};
// Bind the listener, retrying a bounded number of times on a transient bind
// failure (EADDRINUSE/EACCES) -- e.g. the prior process's socket lingering across
// a fast restart. After the last attempt, fail LOUDLY (LDAP auth is unavailable,
// not benign) and reset `started` so a later plugin reload can retry.
//
// ldapjs's Server.listen() fires the SUCCESS path via the callback only -- it does
// NOT emit a 'listening' event on the wrapper (only 'error' is emitted there), so
// success must be detected by the listen callback. But listen() is re-called on
// every retry, and each call leaves its callback registered as a one-shot
// 'listening' listener on the inner net server; when a later attempt finally
// binds, ALL the accumulated callbacks fire. The `settled` guard makes the
// success (and give-up) effect run exactly once regardless.
const listenWithRetry = (server, host, port) => {
let settled = false;
let attempt = 1;
const onListening = () => {
if (settled) {
return;
}
settled = true;
listening = true;
server.removeListener("error", onError);
server.on("error", (e) => {
// eslint-disable-next-line no-console
console.error("[saltcorn-idp] ldap server runtime error:", e && e.message);
});
// eslint-disable-next-line no-console
console.log("[saltcorn-idp] LDAPS listening on ldaps://" + host + ":" + port + " base " + constants.LDAP_BASE_DN + " (tenant = dc=<tenant>,<base>)");
if (!isLoopbackHost(host)) {
// eslint-disable-next-line no-console
console.warn("[saltcorn-idp] NOTE: LDAP is bound to " + host + " (beyond loopback) -- it is reachable from the network; ensure this is intended and firewalled appropriately.");
}
};
const onError = (e) => {
if (settled) {
return;
}
const code = e && e.code;
if ((code === "EADDRINUSE" || code === "EACCES") && attempt < constants.LDAP_BIND_MAX_ATTEMPTS) {
const delay = constants.LDAP_BIND_RETRY_BASE_MS * attempt;
// eslint-disable-next-line no-console
console.error("[saltcorn-idp] LDAP " + host + ":" + port + " not yet bindable (" + code + "); retry " + (attempt + 1) + "/" + constants.LDAP_BIND_MAX_ATTEMPTS + " in " + delay + "ms");
attempt++;
setTimeout(() => {
if (!settled) {
server.listen(port, host, onListening);
}
}, delay);
return;
}
settled = true;
server.removeListener("error", onError);
started = false;
listening = false;
// eslint-disable-next-line no-console
console.error("[saltcorn-idp] WARNING: LDAP enabled on " + host + ":" + port + " but could NOT bind after " + attempt + " attempt(s) (" + code + "): LDAP authentication is UNAVAILABLE");
};
// One persistent error listener drives all retries; the listen callback (not a
// 'listening' event) signals success.
server.on("error", onError);
server.listen(port, host, onListening);
};
const startLdap = async (opts) => {
const force = !!(opts && opts.force);
// Diagnostic line for the intermittent unbound-:1637 heisenbug: capture pid,
// cluster role, the once-per-process started/listening flags, and the port env
// at a normal log level so the next occurrence is recorded (the symptom is a
// silent no-bind: no "LDAPS listening" line, no EADDRINUSE/retry).
// ROOT CAUSE (captured 2026-06-01): on a flaky PG boot EVERY startLdap call is
// isPrimary=false -- the cluster PRIMARY never runs onLoad/startLdap, so nobody
// binds. The self-heal watchdog (index.js) therefore force-binds from whatever
// process notices the port is unbound; force=true bypasses the isPrimary gate.
// eslint-disable-next-line no-console
console.log("[saltcorn-idp] startLdap: pid=" + process.pid + " isPrimary=" + isPrimary() + " force=" + force + " started=" + started + " listening=" + listening + " " + constants.LDAP_PORT_ENV + "=" + (process.env[constants.LDAP_PORT_ENV] || ""));
if (started) {
return;
}
const portStr = process.env[constants.LDAP_PORT_ENV];
if (!portStr) {
return;
}
const port = parseInt(portStr, 10);
if (!Number.isFinite(port)) {
return;
}
// Bind interface is configurable; default to loopback so LDAP is not exposed
// unless an operator opts in. An empty/whitespace value falls back to default.
const hostEnv = (process.env[constants.LDAP_HOST_ENV] || "").trim();
const host = hostEnv || constants.LDAP_DEFAULT_HOST;
// Bind only in the cluster primary; forked workers return silently -- UNLESS
// forced by the watchdog (the primary never bound, so a worker heals it; the
// EADDRINUSE retry in listenWithRetry arbitrates if two workers race).
if (!isPrimary() && !force) {
return;
}
started = true;
let server;
try {
const pems = await selfsigned.generate(
[{ name: "commonName", value: "saltcorn-idp-ldap" }],
{ keyType: "rsa", keySize: 2048, algorithm: "sha256" }
);
server = vendor.createHardenedServer({ certificate: pems.cert, key: pems.private, log: noopLog });
server.maxConnections = 256;
server.bind("", bind.handler);
server.search(constants.LDAP_BASE_DN, search.handler);
} catch (e) {
started = false;
// eslint-disable-next-line no-console
console.error("[saltcorn-idp] failed to initialize LDAP server:", e);
return;
}
listenWithRetry(server, host, port);
};
// True once the listener is actually bound (the onListening callback fired);
// false before bind and after a give-up. Lets index.js arm a self-heal watchdog.
const isListening = () => {
return listening;
};
module.exports = {
startLdap,
isListening
};

View file

@ -0,0 +1,56 @@
// LDAP service account (search-then-bind binder). A configured service DN +
// sealed password lets an application bind as a non-user principal, search for a
// user by mail, then re-bind as that user DN to validate the password -- the
// standard enterprise flow -- without exposing a real user as the binder. The
// password is sealed at rest with the same KEK pattern as client secrets and is
// never returned to the wire or echoed in the admin UI. Single-row table,
// tenant-scoped (all db.* calls resolve the current tenant schema).
const db = require("@saltcorn/data/db");
const idpCrypto = require("../crypto");
const { TABLE_LDAP_SERVICE } = require("../constants");
const getServiceAccount = async () => {
const row = await db.selectMaybeOne(TABLE_LDAP_SERVICE, {});
if (!row || !row.dn) {
return null;
}
const password = idpCrypto.openText({
ciphertext: row.secret_ciphertext,
iv: row.secret_iv,
tag: row.secret_tag
}).toString("utf8");
return { dn: row.dn, password: password };
};
const getServiceDn = async () => {
const row = await db.selectMaybeOne(TABLE_LDAP_SERVICE, {});
return row && row.dn ? row.dn : null;
};
const setServiceAccount = async (dn, plaintext) => {
await db.deleteWhere(TABLE_LDAP_SERVICE, {});
if (!dn || !plaintext) {
return;
}
const sealed = idpCrypto.sealText(plaintext);
await db.insert(TABLE_LDAP_SERVICE, {
dn: dn,
secret_ciphertext: sealed.ciphertext,
secret_iv: sealed.iv,
secret_tag: sealed.tag,
created_at: new Date().toISOString()
}, { noid: true });
};
module.exports = {
getServiceAccount,
getServiceDn,
setServiceAccount
};

59
lib/ldap/tenant.js Normal file
View file

@ -0,0 +1,59 @@
// Multi-tenant LDAP helpers. The LDAP listener is process-level and runs in the
// process's default tenant context, so each bind/search must establish its own
// tenant context. The tenant is encoded as an extra dc component in the DN
// (uid=<email>,ou=people,dc=<tenant>,dc=saltcorn,dc=local); the bare base
// (dc=saltcorn,dc=local) means the default tenant (no wrap). resolveTenant
// validates the token against the live tenant set and DENIES unknown tenants so
// a crafted DN cannot reach another tenant's schema.
const db = require("@saltcorn/data/db");
// Capture the dc=<tenant> immediately preceding the dc=saltcorn,dc=local base.
const TENANT_RE = /dc=([^,]+),\s*dc=saltcorn,\s*dc=local\s*$/i;
const tenantFromDn = (dn) => {
const s = typeof dn === "string" ? dn : (dn ? dn.toString() : "");
const m = s.match(TENANT_RE);
return m ? m[1].trim().toLowerCase() : null;
};
// Resolve a parsed tenant token to a context decision:
// { tenant: null } -> run in the default context (no wrap)
// { tenant: "t1" } -> run inside runWithTenant("t1")
// { deny: true } -> reject (unknown tenant / illegal on single-tenant)
const resolveTenant = (token) => {
const def = String((db.connectObj && db.connectObj.default_schema) || "public").toLowerCase();
if (!token || token === def) {
return { tenant: null };
}
if (!db.is_it_multi_tenant || !db.is_it_multi_tenant()) {
return { deny: true };
}
let known;
try {
// getAllTenants is a MODULE-level export (returns the live {subdomain:State}
// map), NOT a method on the State instance.
const { getAllTenants } = require("@saltcorn/data/db/state");
known = new Set(Object.keys(getAllTenants() || {}).map((t) => String(t).toLowerCase()));
} catch (e) {
known = new Set();
}
if (known.has(token)) {
return { tenant: token };
}
return { deny: true };
};
const withTenant = (tenant, fn) => {
return tenant ? db.runWithTenant(tenant, fn) : fn();
};
module.exports = {
tenantFromDn,
resolveTenant,
withTenant
};

62
lib/ldap/vendor.js Normal file
View file

@ -0,0 +1,62 @@
// Single-require chokepoint + hardening WRAPPER for the PINNED ldapjs dependency
// (an npm package, NOT a vendored source copy/fork -- see VENDORING.md). This is
// the ONLY file that require()s ldapjs, so it is the one upgrade/audit point.
// Our hardening lives in OUR code at a layer we control: a
// per-connection inbound byte cap here (the BER parser has no size limit, so a
// declared multi-GB message would buffer unbounded), the per-IP bind lockout in
// harden.js, and the filter-nesting-depth guard in search.js. Re-exporting the
// error classes keeps the handlers off a direct ldapjs import.
const ldap = require("ldapjs");
const constants = require("../constants");
// createServer with a connectionRouter that caps inbound bytes PER LDAP MESSAGE
// and then hands off to ldapjs's own connection setup. ldapjs uses
// options.connectionRouter in place of its internal newConnection, so the router
// MUST call server.newConnection(conn) or no parser is wired up. The cap is reset
// on each parsed-message boundary (conn.parser emits 'message'): the goal is to
// bound a single unbounded BER message (the parser has no size limit), NOT to
// impose a connection-lifetime quota -- a per-connection counter would let an
// attacker kill a legitimate long-lived connection after a few normal operations
// (sequential binds/searches that each fit the cap but cumulatively exceed it).
const createHardenedServer = (opts) => {
let server;
const options = Object.assign({}, opts, {
connectionRouter: (conn) => {
server.newConnection(conn);
// Idle timeout: drop a connection that goes silent so slow-loris /
// idle sockets cannot hoard the (capped) connection pool. ldapjs never
// calls setTimeout itself, so without this connections live forever.
if (typeof conn.setTimeout === "function") {
conn.setTimeout(constants.LDAP_IDLE_TIMEOUT_MS);
conn.on("timeout", () => conn.destroy());
}
let total = 0;
if (conn.parser && typeof conn.parser.on === "function") {
conn.parser.on("message", () => {
total = 0;
});
}
conn.on("data", (chunk) => {
total += chunk.length;
if (total > constants.LDAP_MAX_MSG_BYTES) {
conn.destroy();
}
});
}
});
server = ldap.createServer(options);
return server;
};
module.exports = {
createHardenedServer,
InvalidCredentialsError: ldap.InvalidCredentialsError,
InsufficientAccessRightsError: ldap.InsufficientAccessRightsError,
OperationsError: ldap.OperationsError,
ProtocolError: ldap.ProtocolError,
SizeLimitExceededError: ldap.SizeLimitExceededError
};

88
lib/oidc/adapter.js Normal file
View file

@ -0,0 +1,88 @@
// Single-table storage adapter for oidc-provider, backed by _idp_oidc_store.
// oidc-provider instantiates one adapter per model (`new SaltcornAdapter(name)`).
// Rows are tenant-scoped because db.* runs in the request's tenant context.
// oidc-provider validates expiry/consumed itself from the payload, so find()
// returns the stored payload as-is; consume() records `consumed` inside it.
const db = require("@saltcorn/data/db");
const clients = require("../clients");
const { TABLE_OIDC_STORE } = require("../constants");
const epoch = () => {
return Math.floor(Date.now() / 1000);
};
class SaltcornAdapter {
constructor(name) {
this.name = name;
}
async upsert(id, payload, expiresIn) {
const row = {
model: this.name,
id: id,
payload: JSON.stringify(payload),
uid: payload.uid || null,
grant_id: payload.grantId || null,
user_code: payload.userCode || null,
expires_at: expiresIn ? epoch() + expiresIn : null
};
const existing = await db.selectMaybeOne(TABLE_OIDC_STORE, { model: this.name, id: id });
if (existing) {
await db.updateWhere(TABLE_OIDC_STORE, row, { model: this.name, id: id });
} else {
await db.insert(TABLE_OIDC_STORE, row, { noid: true });
}
}
async find(id) {
if (this.name === "Client") {
const row = await clients.getClient(id);
return row ? clients.toOidcMetadata(row) : undefined;
}
const row = await db.selectMaybeOne(TABLE_OIDC_STORE, { model: this.name, id: id });
return row ? JSON.parse(row.payload) : undefined;
}
async findByUid(uid) {
const row = await db.selectMaybeOne(TABLE_OIDC_STORE, { model: this.name, uid: uid });
return row ? JSON.parse(row.payload) : undefined;
}
async findByUserCode(userCode) {
const row = await db.selectMaybeOne(TABLE_OIDC_STORE, { model: this.name, user_code: userCode });
return row ? JSON.parse(row.payload) : undefined;
}
async consume(id) {
const row = await db.selectMaybeOne(TABLE_OIDC_STORE, { model: this.name, id: id });
if (!row) {
return;
}
const payload = JSON.parse(row.payload);
payload.consumed = epoch();
await db.updateWhere(TABLE_OIDC_STORE, { payload: JSON.stringify(payload) }, { model: this.name, id: id });
}
async destroy(id) {
await db.deleteWhere(TABLE_OIDC_STORE, { model: this.name, id: id });
}
async revokeByGrantId(grantId) {
await db.deleteWhere(TABLE_OIDC_STORE, { grant_id: grantId });
}
}
module.exports = SaltcornAdapter;

43
lib/oidc/discovery.js Normal file
View file

@ -0,0 +1,43 @@
// OIDC discovery document + issuer derivation.
//
// The issuer MUST exactly equal the URL prefix a relying party used to fetch
// /.well-known/openid-configuration. We prefer the tenant's configured base_url
// (the trustworthy source); otherwise we fall back to the request scheme+host.
//
// SECURITY: the request-host fallback is vulnerable to Host-header injection
// (an attacker forging Host could poison the advertised issuer/endpoints).
// base_url should be set in any multi-tenant or proxied deployment; the
// fallback exists for single-tenant localhost/dev. This is revisited in the
// multi-tenancy phase (validate host against the tenant's known domains).
const constants = require("../constants");
const issuerForReq = (req) => {
let base = "";
try {
const { getState } = require("@saltcorn/data/db/state");
const configured = getState().getConfig("base_url", "");
if (configured) {
base = configured;
}
} catch (e) {
// getState unavailable; fall back to request-derived host
}
if (!base) {
base = req.protocol + "://" + req.get("host");
// eslint-disable-next-line no-console
console.warn(`[${constants.PLUGIN_NAME}] base_url not set; deriving issuer from request Host (${base}). Set base_url to prevent Host-header issuer poisoning.`);
}
base = base.replace(/\/+$/, "");
return base + constants.IDP_BASE_PATH;
};
// NOTE: the discovery document and JWKS are now generated and served by
// oidc-provider itself (see oidc/provider.js + oidc/routes.js); we only keep the
// issuer derivation here, which feeds the Provider's issuer.
module.exports = {
issuerForReq
};

110
lib/oidc/interactions.js Normal file
View file

@ -0,0 +1,110 @@
// Login + consent interaction handlers. oidc-provider redirects the browser here
// (interactions.url) when it needs the user to authenticate or grant consent.
// login - reuse Saltcorn's session; if not logged in, bounce to /auth/login
// (returning via ?dest=). Authenticate EXISTING users only.
// consent - render a consent screen listing the client + requested scopes;
// the Allow/Deny form posts to .../confirm.
const constants = require("../constants");
const web = require("../web");
const clients = require("../clients");
const { getProviderEntry } = require("./provider");
const renderConsent = (uid, clientLabel, clientId, scopes) => {
const items = scopes.map((s) => `<li><code>${web.escapeHtml(s)}</code></li>`).join("");
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Authorize</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 2rem; max-width: 480px; }
h1 { font-size: 1.3rem; }
code { font-family: ui-monospace, Menlo, Consolas, monospace; }
button { padding: 0.4rem 0.9rem; cursor: pointer; margin-right: 0.5rem; }
</style>
</head><body>
<h1>Authorize ${web.escapeHtml(clientLabel || clientId)}</h1>
<p><code>${web.escapeHtml(clientId)}</code> is requesting access to your account:</p>
<ul>${items}</ul>
<form method="post" action="${web.escapeHtml(constants.IDP_BASE_PATH)}/interaction/${web.escapeHtml(uid)}/confirm">
<button name="allow" value="1">Allow</button>
<button name="deny" value="1">Deny</button>
</form>
</body></html>`;
};
const interactionHandler = async (req, res) => {
const entry = await getProviderEntry(req);
const provider = entry.provider;
let details;
try {
details = await provider.interactionDetails(req, res);
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] interactionDetails failed:`, e);
res.status(400).type("text/plain").send("invalid or expired interaction");
return;
}
const promptName = details.prompt && details.prompt.name;
if (promptName === "login") {
if (req.user && req.user.id) {
await provider.interactionFinished(req, res, { login: { accountId: String(req.user.id) } }, { mergeWithLastSubmission: false });
} else {
const back = constants.IDP_BASE_PATH + "/interaction/" + details.uid;
res.redirect("/auth/login?dest=" + encodeURIComponent(back));
}
return;
}
if (promptName === "consent") {
const client = await clients.getClient(details.params.client_id);
const scopes = String(details.params.scope || "").split(" ").filter(Boolean);
res.type("text/html").send(renderConsent(details.uid, client && client.label, details.params.client_id, scopes));
return;
}
res.status(400).type("text/plain").send("unsupported interaction prompt: " + promptName);
};
const confirmHandler = async (req, res) => {
const entry = await getProviderEntry(req);
const provider = entry.provider;
let details;
try {
details = await provider.interactionDetails(req, res);
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] interactionDetails (confirm) failed:`, e);
res.status(400).type("text/plain").send("invalid or expired interaction");
return;
}
if (req.body && req.body.deny) {
await provider.interactionFinished(req, res, { error: "access_denied", error_description: "User denied the request" }, { mergeWithLastSubmission: false });
return;
}
const grant = new provider.Grant({ accountId: details.session.accountId, clientId: details.params.client_id });
if (details.params.scope) {
grant.addOIDCScope(details.params.scope);
}
const grantId = await grant.save();
await provider.interactionFinished(req, res, { consent: { grantId: grantId } }, { mergeWithLastSubmission: true });
};
const interactionRoutes = [
{ url: constants.INTERACTION_PATH, method: "get", callback: interactionHandler, noCsrf: true },
{ url: constants.INTERACTION_CONFIRM_PATH, method: "post", callback: confirmHandler, noCsrf: true }
];
module.exports = {
interactionRoutes
};

124
lib/oidc/provider.js Normal file
View file

@ -0,0 +1,124 @@
// Per-issuer oidc-provider instances.
//
// oidc-provider v9 is ESM-only, so it is loaded via dynamic import() from this
// CommonJS module. One Provider is built lazily and cached per issuer URL (the
// issuer is the tenant hostname + IDP_BASE_PATH), so multi-tenant / multi-host
// deployments each get their own issuer, signing key, and clients.
//
// The Provider is fed our active signing key as a PRIVATE JWK (it both signs
// id_tokens and serves the public half at the JWKS endpoint). Cookie-signing
// keys are derived deterministically from SALTCORN_SESSION_SECRET so interaction
// sessions survive a restart.
const constants = require("../constants");
const crypto = require("../crypto");
const keys = require("../keys");
const claims = require("../claims");
const User = require("@saltcorn/data/models/user");
const SaltcornAdapter = require("./adapter");
const { issuerForReq } = require("./discovery");
const COOKIE_KEY_INFO = "saltcorn-idp:oidc-cookie-keys:v1";
const COOKIE_KEY_BYTES = 32;
let providerClassPromise = null;
const providersByIssuer = new Map();
const loadProviderClass = async () => {
if (!providerClassPromise) {
providerClassPromise = import("oidc-provider").then((m) => m.default);
}
return providerClassPromise;
};
const buildProvider = async (issuer) => {
const Provider = await loadProviderClass();
// Feed the FULL signing set (active first, then any RETIRING keys) so that
// after a key rotation the rotated-out key remains in JWKS and id_tokens it
// signed keep verifying through the grace window; oidc-provider signs new
// tokens with the first key matching the alg (the active one).
const signingJwks = await keys.getSigningPrivateJwks();
if (!signingJwks.length) {
throw new Error("saltcorn-idp: no active signing key for issuer " + issuer);
}
const cookieKey = crypto.deriveSecretHex(COOKIE_KEY_INFO, COOKIE_KEY_BYTES);
const secure = issuer.startsWith("https://");
const provider = new Provider(issuer, {
adapter: SaltcornAdapter,
jwks: { keys: signingJwks },
cookies: {
keys: [cookieKey],
long: { secure: secure, signed: true },
short: { secure: secure, signed: true }
},
// No static clients: all relying parties are looked up dynamically from
// the _idp_clients registry via the Client model in SaltcornAdapter.
clients: [],
// Put scope-granted claims in the id_token too (not only userinfo), so
// relying parties can read groups/email straight from the id_token.
conformIdTokenClaims: false,
claims: {
openid: ["sub"],
email: ["email", "email_verified"],
profile: ["name"],
groups: ["groups"]
},
scopes: ["openid", "email", "profile", "groups"],
features: {
devInteractions: { enabled: false }
},
interactions: {
url: async (ctx, interaction) => {
return constants.IDP_BASE_PATH + "/interaction/" + interaction.uid;
}
},
// Authenticate EXISTING Saltcorn users only; never auto-provision.
findAccount: async (ctx, sub) => {
const uid = parseInt(sub, 10);
const user = Number.isFinite(uid) ? await User.findOne({ id: uid }) : null;
if (!user) {
return undefined;
}
return {
accountId: sub,
claims: async (use, scope) => {
return claims.oidcClaims(user, sub, scope);
}
};
}
});
provider.proxy = true;
return provider;
};
const getProviderEntry = async (req) => {
const issuer = issuerForReq(req);
let entry = providersByIssuer.get(issuer);
if (!entry) {
const provider = await buildProvider(issuer);
entry = { provider: provider, handler: provider.callback() };
providersByIssuer.set(issuer, entry);
}
return entry;
};
// Drop the cached Provider(s) so the next request rebuilds with the current
// active signing key. Each Provider is constructed with the active private JWK
// baked in (jwks config), so after a key rotation the cached instances would
// keep signing id_tokens with the OLD key and serving the OLD JWKS; clearing the
// cache forces a rebuild. The key material itself lives in the DB, so a rebuilt
// Provider picks up the freshly-rotated key. Called by keys.rotateActiveKey().
const clearProviderCache = () => {
providersByIssuer.clear();
};
module.exports = {
getProviderEntry,
clearProviderCache
};

89
lib/oidc/routes.js Normal file
View file

@ -0,0 +1,89 @@
// OIDC endpoints served by oidc-provider, mounted under IDP_BASE_PATH via
// delegation. We strip the /idp prefix so oidc-provider's mount-relative router
// matches, keep req.originalUrl so it derives the mount path, and (for POST)
// re-stream the body that Saltcorn/Express already parsed, since oidc-provider
// reads the raw request stream.
const { Readable } = require("stream");
const constants = require("../constants");
const { getProviderEntry } = require("./provider");
// Saltcorn's body parser drains the POST stream, so rebuild a readable carrying
// the body (the exact rawBody if available, else re-encoded req.body) for
// oidc-provider's own parser. Copies the IncomingMessage props Koa/raw-body use,
// with the mount-relative url and a corrected content-length.
const restreamBody = (req, strippedUrl, fullUrl) => {
let buf;
let contentLength;
if (req.rawBody && req.rawBody.length) {
buf = req.rawBody;
contentLength = String(req.headers["content-length"] || buf.length);
} else if (req.body && typeof req.body === "object" && Object.keys(req.body).length) {
const ct = String(req.headers["content-type"] || "");
buf = ct.includes("application/json")
? Buffer.from(JSON.stringify(req.body))
: Buffer.from(new URLSearchParams(req.body).toString());
contentLength = String(buf.length);
} else {
buf = Buffer.alloc(0);
contentLength = "0";
}
const stream = new Readable({ read() {} });
stream.push(buf);
stream.push(null);
stream.headers = Object.assign({}, req.headers, { "content-length": contentLength });
stream.rawHeaders = req.rawHeaders;
stream.method = req.method;
stream.url = strippedUrl;
stream.originalUrl = fullUrl;
stream.httpVersion = req.httpVersion;
stream.httpVersionMajor = req.httpVersionMajor;
stream.httpVersionMinor = req.httpVersionMinor;
stream.socket = req.socket;
stream.connection = req.connection;
stream.complete = false;
return stream;
};
const delegate = async (req, res) => {
try {
const entry = await getProviderEntry(req);
const fullUrl = req.originalUrl || req.url;
const stripped = fullUrl.slice(constants.IDP_BASE_PATH.length) || "/";
let target = req;
if (req.method === "GET" || req.method === "HEAD") {
req.url = stripped;
} else {
target = restreamBody(req, stripped, fullUrl);
}
await entry.handler(target, res);
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] oidc delegate failed:`, e);
if (!res.headersSent) {
res.status(500).json({ error: "server_error" });
}
}
};
const oidcRoutes = [
{ url: constants.WELL_KNOWN_OPENID, method: "get", callback: delegate, noCsrf: true },
{ url: constants.JWKS_PATH, method: "get", callback: delegate, noCsrf: true },
{ url: constants.AUTH_PATH, method: "get", callback: delegate, noCsrf: true },
{ url: constants.AUTH_PATH, method: "post", callback: delegate, noCsrf: true },
{ url: constants.AUTH_RESUME_PATH, method: "get", callback: delegate, noCsrf: true },
{ url: constants.AUTH_RESUME_PATH, method: "post", callback: delegate, noCsrf: true },
{ url: constants.TOKEN_PATH, method: "post", callback: delegate, noCsrf: true },
{ url: constants.USERINFO_PATH, method: "get", callback: delegate, noCsrf: true },
{ url: constants.USERINFO_PATH, method: "post", callback: delegate, noCsrf: true }
];
module.exports = {
oidcRoutes
};

16
lib/routes.js Normal file
View file

@ -0,0 +1,16 @@
// Aggregated plugin routes: OIDC endpoints (oidc-provider) + interaction
// (login/consent) endpoint + admin pages. Exported as the plugin's `routes`
// array via index.js.
const { oidcRoutes } = require("./oidc/routes");
const { interactionRoutes } = require("./oidc/interactions");
const { samlRoutes } = require("./saml/routes");
const { adminRoutes } = require("./adminUi");
const routes = [].concat(oidcRoutes, interactionRoutes, samlRoutes, adminRoutes);
module.exports = {
routes
};

217
lib/saml/idp.js Normal file
View file

@ -0,0 +1,217 @@
// SAML 2.0 Identity Provider built on samlify (CommonJS). Signs assertions with
// RSA-SHA256 using a per-tenant self-signed cert (persisted in _idp_saml, sealed
// key). One IdP entity is cached per issuer (host-derived), mirroring the OIDC
// provider cache, so multi-tenant deployments get per-tenant signing material.
// The AttributeStatement (email + groups) reuses claims.samlAttributes.
//
// Supports SP-initiated SSO, IdP-initiated SSO, and Single Logout. Relying-party
// SPs are registered + validated against an ACS allow-list (lib/saml/sps.js), so
// an arbitrary request-supplied ACS is never trusted. xml-crypto is pinned
// >=6.1.2 (2025 SAML signature-verification CVEs patched) and asserted at load.
const saml = require("samlify");
const validator = require("@authenio/samlify-node-xmllint");
const db = require("@saltcorn/data/db");
const selfsigned = require("selfsigned");
const idpCrypto = require("../crypto");
const constants = require("../constants");
// Fail closed at load if xml-crypto is below the patched floor (2025 SAML
// signature-verification CVEs). One source of truth: constants.XML_CRYPTO_MIN.
const assertXmlCryptoFloor = () => {
const min = constants.XML_CRYPTO_MIN.split(".").map((n) => parseInt(n, 10));
let cur;
try {
cur = String(require("xml-crypto/package.json").version).split(".").map((n) => parseInt(n, 10));
} catch (e) {
throw new Error("saltcorn-idp: cannot resolve xml-crypto version");
}
for (let i = 0; i < min.length; i++) {
const c = cur[i] || 0;
if (c > min[i]) {
return;
}
if (c < min[i]) {
throw new Error("saltcorn-idp: xml-crypto " + cur.join(".") + " < required " + constants.XML_CRYPTO_MIN);
}
}
};
assertXmlCryptoFloor();
saml.setSchemaValidator(validator);
// Re-assert samlify's XXE-safe DOMParser options from our own code so a future
// samlify default change cannot silently re-enable entity expansion. Passing {}
// merges in the throwing error/fatalError handlers (samlify api.js).
if (typeof saml.setDOMParserOptions === "function") {
saml.setDOMParserOptions({});
}
const idpCache = new Map();
// Response template carrying an AttributeStatement; samlify fills the standard
// tokens, the attributes[] generate the AttributeStatement with {Email}/{Groups}
// value tags, and createLoginResponse's customTagReplacement fills those.
const LOGIN_RESPONSE_TEMPLATE = {
context: '<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}" InResponseTo="{InResponseTo}"><saml:Issuer>{Issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="{StatusCode}"/></samlp:Status><saml:Assertion xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="{AssertionID}" Version="2.0" IssueInstant="{IssueInstant}"><saml:Issuer>{Issuer}</saml:Issuer><saml:Subject><saml:NameID Format="{NameIDFormat}">{NameID}</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData NotOnOrAfter="{SubjectConfirmationDataNotOnOrAfter}" Recipient="{SubjectRecipient}" InResponseTo="{InResponseTo}"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="{ConditionsNotBefore}" NotOnOrAfter="{ConditionsNotOnOrAfter}"><saml:AudienceRestriction><saml:Audience>{Audience}</saml:Audience></saml:AudienceRestriction></saml:Conditions>{AuthnStatement}{AttributeStatement}</saml:Assertion></samlp:Response>',
attributes: [
{ name: "email", nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", valueXsiType: "xs:string", valueTag: "Email" },
{ name: "groups", nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", valueXsiType: "xs:string", valueTag: "Groups" }
]
};
const ensureSamlCert = async () => {
const existing = await db.selectMaybeOne(constants.TABLE_SAML, { id: "default" });
if (existing) {
return;
}
const pems = await selfsigned.generate(
[{ name: "commonName", value: "saltcorn-idp-saml" }],
{ keyType: "rsa", keySize: 2048, algorithm: "sha256" }
);
const sealed = idpCrypto.sealText(pems.private);
await db.insert(constants.TABLE_SAML, {
id: "default",
cert: pems.cert,
private_ciphertext: sealed.ciphertext,
private_iv: sealed.iv,
private_tag: sealed.tag,
created_at: new Date().toISOString()
}, { noid: true });
};
const getSamlCert = async () => {
const row = await db.selectMaybeOne(constants.TABLE_SAML, { id: "default" });
if (!row) {
return null;
}
const key = idpCrypto.openText({
ciphertext: row.private_ciphertext,
iv: row.private_iv,
tag: row.private_tag
}).toString("utf8");
return { cert: row.cert, key: key };
};
// Construct an IdP entity for an issuer. The signed flag drives whether samlify
// cryptographically verifies inbound AuthnRequest AND LogoutRequest signatures
// (only used for SPs that registered a signing cert). singleLogoutService
// advertises SLO in the published metadata.
const buildIdp = (issuer, c, signed) => {
return saml.IdentityProvider({
entityID: issuer + "/saml",
privateKey: c.key,
signingCert: c.cert,
wantAuthnRequestsSigned: !!signed,
wantLogoutRequestSigned: !!signed,
singleSignOnService: [
{ Binding: saml.Constants.namespace.binding.post, Location: issuer + "/saml/sso" },
{ Binding: saml.Constants.namespace.binding.redirect, Location: issuer + "/saml/sso" }
],
singleLogoutService: [
{ Binding: saml.Constants.namespace.binding.post, Location: issuer + "/saml/slo" },
{ Binding: saml.Constants.namespace.binding.redirect, Location: issuer + "/saml/slo" }
],
nameIDFormat: [saml.Constants.namespace.format.emailAddress],
loginResponseTemplate: LOGIN_RESPONSE_TEMPLATE
});
};
const getIdp = async (issuer) => {
if (idpCache.has(issuer)) {
return idpCache.get(issuer);
}
const c = await getSamlCert();
if (!c) {
throw new Error("saltcorn-idp: no SAML signing cert for issuer " + issuer);
}
const idp = buildIdp(issuer, c, false);
idpCache.set(issuer, idp);
return idp;
};
// IdP variant that REQUIRES + verifies AuthnRequest signatures. Used per-request
// for SPs registered with want_authn_requests_signed; cached under a distinct
// key so the default (unsigned) IdP is unaffected.
const getSignedIdp = async (issuer) => {
const cacheKey = issuer + "#signed";
if (idpCache.has(cacheKey)) {
return idpCache.get(cacheKey);
}
const c = await getSamlCert();
if (!c) {
throw new Error("saltcorn-idp: no SAML signing cert for issuer " + issuer);
}
const idp = buildIdp(issuer, c, true);
idpCache.set(cacheKey, idp);
return idp;
};
const getMetadata = async (issuer) => {
const idp = await getIdp(issuer);
return idp.getMetadata();
};
// An SP entity for a REGISTERED relying party. entityID + acsUrl come from the
// registry (validated against the SP's allow-list), never trusted verbatim from
// the request. When opts.signingCert is supplied the SP's signing cert is set so
// samlify can verify the AuthnRequest signature against it.
const buildSp = (entityID, acsUrl, opts) => {
opts = opts || {};
const settings = {
entityID: entityID,
assertionConsumerService: [
{ Binding: saml.Constants.namespace.binding.post, Location: acsUrl }
]
};
if (opts.signingCert) {
settings.signingCert = opts.signingCert;
settings.authnRequestsSigned = true;
}
return saml.ServiceProvider(settings);
};
// SP entity used when building a LogoutResponse: wantLogoutResponseSigned makes
// samlify sign the outbound LogoutResponse (the target/SP setting governs this).
const buildSpForLogout = (entityID, sloUrl, opts) => {
opts = opts || {};
const settings = {
entityID: entityID,
assertionConsumerService: [
{ Binding: saml.Constants.namespace.binding.post, Location: sloUrl }
],
singleLogoutService: [
{ Binding: saml.Constants.namespace.binding.post, Location: sloUrl },
{ Binding: saml.Constants.namespace.binding.redirect, Location: sloUrl }
],
wantLogoutResponseSigned: true
};
if (opts.signingCert) {
settings.signingCert = opts.signingCert;
}
return saml.ServiceProvider(settings);
};
module.exports = {
// samlify's flow methods (parseLoginRequest/createLoginResponse) take the
// SHORT binding names; the full URNs are only for the metadata settings above.
POST_BINDING: "post",
REDIRECT_BINDING: "redirect",
ensureSamlCert,
getIdp,
getSignedIdp,
getMetadata,
buildSp,
buildSpForLogout
};

545
lib/saml/routes.js Normal file
View file

@ -0,0 +1,545 @@
// SAML IdP HTTP endpoints: metadata, SP-initiated SSO, IdP-initiated SSO, and
// Single Logout (SLO). All SAML messages are validated against a registered-SP
// allow-list (lib/saml/sps.js): the IdP only issues an assertion to a registered
// SP and only to one of its allow-listed ACS URLs, so a forged AuthnRequest
// cannot redirect a signed assertion to an attacker-chosen Destination. Inbound
// XML is DTD/ENTITY-screened (defence-in-depth over samlify's XXE-safe parser)
// and, for SPs registered with a signing cert, the AuthnRequest signature is
// verified. Mounted under /idp/ (CSRF-exempt; SAML messages aren't Saltcorn forms).
const zlib = require("zlib");
const crypto = require("crypto");
const User = require("@saltcorn/data/models/user");
const constants = require("../constants");
const claims = require("../claims");
const web = require("../web");
const samlIdp = require("./idp");
const samlSps = require("./sps");
const { issuerForReq } = require("../oidc/discovery");
// A SAML protocol message never legitimately carries a DOCTYPE or ENTITY
// declaration; reject any that does before it reaches the XML parser.
const DTD_RE = /<!DOCTYPE|<!ENTITY/i;
const CONDITION_WINDOW_MS = 5 * 60 * 1000;
const STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
const NAMEID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
const newId = () => {
return "_" + crypto.randomBytes(16).toString("hex");
};
const decodeRequest = (samlReq, binding) => {
// These handlers are fully unauthenticated (noCsrf, no apiToken gate), so
// bound the work before any allocation: reject an oversized base64 blob, then
// cap the inflate output (DEFLATE can expand ~1000:1 -- an unbounded
// inflateRawSync is a memory bomb that crashes the worker).
if (typeof samlReq !== "string" || samlReq.length > constants.SAML_MAX_MSG_B64_BYTES) {
throw new Error("saltcorn-idp: SAML message too large");
}
const buf = Buffer.from(samlReq, "base64");
let xml;
if (binding === samlIdp.REDIRECT_BINDING) {
xml = zlib.inflateRawSync(buf, { maxOutputLength: constants.SAML_MAX_XML_BYTES }).toString("utf8");
} else {
if (buf.length > constants.SAML_MAX_XML_BYTES) {
throw new Error("saltcorn-idp: SAML message too large");
}
xml = buf.toString("utf8");
}
if (DTD_RE.test(xml)) {
throw new Error("saltcorn-idp: SAML message contains a DOCTYPE/ENTITY declaration");
}
return xml;
};
const matchIssuer = (xml) => {
const m = xml.match(/<(?:[\w]+:)?Issuer[^>]*>([^<]+)<\/(?:[\w]+:)?Issuer>/);
return m ? m[1].trim() : null;
};
const matchAcs = (xml) => {
const m = xml.match(/AssertionConsumerServiceURL="([^"]+)"/);
return m ? m[1] : null;
};
const escapeXmlAttr = (s) => {
return String(s === null || s === undefined ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
};
// Reconstruct the exact octet string the SP signed for a redirect-binding
// request: SAMLRequest=<v>&RelayState=<v>&SigAlg=<v> (RelayState omitted when
// absent), using the RAW (still percent-encoded) values from req.originalUrl so
// the bytes byte-match what the SP signed (req.query is already decoded).
const rawQueryOctetString = (req) => {
const url = req.originalUrl || "";
const qpos = url.indexOf("?");
const q = qpos >= 0 ? url.slice(qpos + 1) : "";
const raw = {};
for (const part of q.split("&")) {
const eq = part.indexOf("=");
if (eq > 0) {
raw[part.slice(0, eq)] = part.slice(eq + 1);
}
}
let s = "SAMLRequest=" + (raw.SAMLRequest || "");
if (raw.RelayState !== undefined) {
s += "&RelayState=" + raw.RelayState;
}
s += "&SigAlg=" + (raw.SigAlg || "");
return s;
};
// A real AuthnStatement fragment (raw XML markup). AuthnInstant + SessionIndex
// are server-generated (ISO time / hex), the class ref is a fixed URN, so none
// need XML escaping; this is injected verbatim, not through the value loop.
const buildAuthnStatement = (nowIso, sessionIndex) => {
return '<saml:AuthnStatement AuthnInstant="' + nowIso + '" SessionIndex="' + sessionIndex + '">'
+ "<saml:AuthnContext><saml:AuthnContextClassRef>"
+ constants.SAML_AUTHN_CONTEXT
+ "</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement>";
};
// Expand the single Groups <saml:AttributeValue>{attrGroups}</saml:AttributeValue>
// (samlify bakes ONE AttributeValue per attribute into the template) into ONE
// element per group, so SAML emits multi-valued group attributes matching the
// OIDC/LDAP array form. We capture the AttributeValue's own attributes from the
// template so the xmlns/xsi:type markup is reproduced verbatim (keeps the
// assertion schema-valid + signable); each group text is XML-escaped. An empty
// group list collapses to a single empty AttributeValue (valid, no membership).
const expandGroupValues = (template, groupsArr) => {
const re = /<saml:AttributeValue\b([^>]*)>\{attrGroups\}<\/saml:AttributeValue>/;
const m = template.match(re);
if (!m) {
return template;
}
const attrs = m[1];
const list = Array.isArray(groupsArr) ? groupsArr : (groupsArr === null || groupsArr === undefined ? [] : [groupsArr]);
let values = "";
if (list.length === 0) {
values = "<saml:AttributeValue" + attrs + "></saml:AttributeValue>";
} else {
for (const g of list) {
values += "<saml:AttributeValue" + attrs + ">" + escapeXmlAttr(g) + "</saml:AttributeValue>";
}
}
return template.replace(re, values);
};
// Builds the customTagReplacement callback for a login Response. samlify hands
// us the (already AttributeStatement-expanded) template and uses our returned
// context verbatim, so we fill the FULL token set ourselves: standard tokens +
// the attribute-value tokens ({attrEmail}) and the multi-valued group elements.
// The AuthnStatement is injected after the escaping loop because it is markup,
// not a text value.
const makeLoginResponseRenderer = (opts) => {
return (template) => {
const nowIso = new Date().toISOString();
const laterIso = new Date(Date.now() + CONDITION_WINDOW_MS).toISOString();
const respId = newId();
const sessionIndex = newId();
const tvalue = {
ID: respId,
AssertionID: newId(),
Destination: opts.acs,
Audience: opts.spEntityId,
SubjectRecipient: opts.acs,
Issuer: opts.issuer + "/saml",
IssueInstant: nowIso,
StatusCode: STATUS_SUCCESS,
ConditionsNotBefore: nowIso,
ConditionsNotOnOrAfter: laterIso,
SubjectConfirmationDataNotOnOrAfter: laterIso,
NameIDFormat: NAMEID_FORMAT_EMAIL,
NameID: opts.attrs.email,
attrEmail: opts.attrs.email
};
// Multi-value the Groups attribute BEFORE the token loop so the per-group
// text goes through the same XML escaping as every other value.
let work = expandGroupValues(template, opts.attrs.groups);
if (opts.idpInitiated) {
// An unsolicited Response must NOT carry InResponseTo (on the Response
// or the SubjectConfirmationData) -- drop both attributes.
work = work.replace(/\s+InResponseTo="\{InResponseTo\}"/g, "");
} else {
tvalue.InResponseTo = (opts.requestInfo && opts.requestInfo.extract && opts.requestInfo.extract.request && opts.requestInfo.extract.request.id) || "";
}
for (const k of Object.keys(tvalue)) {
work = work.replace(new RegExp("\\{" + k + "\\}", "g"), escapeXmlAttr(tvalue[k]));
}
work = work.replace(/\{AuthnStatement\}/g, buildAuthnStatement(nowIso, sessionIndex));
return { id: respId, context: work };
};
};
// customTagReplacement for a LogoutResponse (fills the default samlify logout
// template tokens: ID/IssueInstant/Destination/InResponseTo/Issuer/StatusCode).
const makeLogoutResponseRenderer = (opts) => {
return (template) => {
const respId = newId();
const tvalue = {
ID: respId,
IssueInstant: new Date().toISOString(),
Destination: opts.destination || "",
InResponseTo: opts.requestId || "",
Issuer: opts.issuer + "/saml",
StatusCode: STATUS_SUCCESS
};
let work = template;
for (const k of Object.keys(tvalue)) {
work = work.replace(new RegExp("\\{" + k + "\\}", "g"), escapeXmlAttr(tvalue[k]));
}
return { id: respId, context: work };
};
};
const autoPostForm = (acs, samlResponse, relayState) => {
const rs = relayState
? `<input type="hidden" name="RelayState" value="${web.escapeHtml(relayState)}">`
: "";
return `<!doctype html>
<html><body onload="document.forms[0].submit()">
<form method="post" action="${web.escapeHtml(acs)}">
<input type="hidden" name="SAMLResponse" value="${web.escapeHtml(samlResponse)}">
${rs}
<noscript><button type="submit">Continue</button></noscript>
</form></body></html>`;
};
// Builds + signs the login Response and auto-POSTs it to the (validated) ACS.
const sendLoginResponse = async (res, opts) => {
const customTagReplacement = makeLoginResponseRenderer({
issuer: opts.issuer,
spEntityId: opts.spEntityId,
acs: opts.acs,
requestInfo: opts.requestInfo,
idpInitiated: opts.idpInitiated,
attrs: opts.attrs
});
const response = await opts.idp.createLoginResponse(
opts.sp,
opts.requestInfo,
samlIdp.POST_BINDING,
{ email: opts.attrs.email, groups: opts.attrs.groups },
{ relayState: opts.relayState, customTagReplacement: customTagReplacement }
);
// Always deliver to the validated ACS (never response.entityEndpoint, which
// could echo an unvalidated request value).
res.type("text/html").send(autoPostForm(opts.acs, response.context, opts.relayState));
};
const metadataHandler = async (req, res) => {
try {
const xml = await samlIdp.getMetadata(issuerForReq(req));
res.set("Content-Type", "application/samlmetadata+xml").send(xml);
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml metadata failed:`, e);
res.status(503).type("text/plain").send("saml metadata unavailable");
}
};
// SP-initiated SSO. Validates the SP registration + ACS allow-list BEFORE any
// assertion is issued (and verifies the AuthnRequest signature for SPs that
// registered a signing cert).
const ssoHandler = async (req, res) => {
try {
const binding = req.method === "POST" ? samlIdp.POST_BINDING : samlIdp.REDIRECT_BINDING;
const samlReq = (req.body && req.body.SAMLRequest) || (req.query && req.query.SAMLRequest);
const relayState = (req.body && req.body.RelayState) || (req.query && req.query.RelayState) || "";
if (!samlReq) {
res.status(400).type("text/plain").send("missing SAMLRequest");
return;
}
let xml;
try {
xml = decodeRequest(samlReq, binding);
} catch (e) {
res.status(400).type("text/plain").send("malformed SAML request");
return;
}
const spEntityId = matchIssuer(xml);
if (!spEntityId) {
res.status(400).type("text/plain").send("could not parse SAML AuthnRequest");
return;
}
// Registry gate: only registered SPs get assertions. Checked before the
// login bounce so an attacker cannot use an unknown SP to drive a login.
const spRow = await samlSps.getSp(spEntityId);
if (!spRow) {
res.status(403).type("text/plain").send("unknown SAML SP");
return;
}
const allowed = samlSps.acsUrls(spRow);
if (allowed.length === 0) {
res.status(403).type("text/plain").send("SP has no registered ACS");
return;
}
if (!(req.user && req.user.id)) {
res.redirect("/auth/login?dest=" + encodeURIComponent(req.originalUrl));
return;
}
const issuer = issuerForReq(req);
const wantSigned = samlSps.wantsSignedRequests(spRow);
const idp = wantSigned ? await samlIdp.getSignedIdp(issuer) : await samlIdp.getIdp(issuer);
const sp = samlIdp.buildSp(spEntityId, allowed[0], { signingCert: wantSigned ? spRow.signing_cert : null });
const parseReq = { query: req.query, body: req.body };
if (wantSigned && binding === samlIdp.REDIRECT_BINDING) {
parseReq.octetString = rawQueryOctetString(req);
}
let requestInfo;
try {
requestInfo = await idp.parseLoginRequest(sp, binding, parseReq);
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml AuthnRequest rejected:`, e && e.message);
res.status(403).type("text/plain").send("SAML request rejected");
return;
}
// Parser-differential guard: the authoritative (parsed) issuer must match
// the entityID we looked the SP up by.
const parsedIssuer = (requestInfo.extract && requestInfo.extract.issuer) || spEntityId;
if (parsedIssuer !== spEntityId) {
res.status(403).type("text/plain").send("issuer mismatch");
return;
}
// ACS allow-list: prefer the parsed ACS, fall back to the regex; if the
// request named an ACS it MUST be allow-listed, else use the first one.
const reqAcs = (requestInfo.extract && requestInfo.extract.request && requestInfo.extract.request.assertionConsumerServiceUrl) || matchAcs(xml);
let acs;
if (reqAcs) {
if (!samlSps.acsAllowed(spRow, reqAcs)) {
res.status(403).type("text/plain").send("ACS not allowed");
return;
}
acs = reqAcs;
} else {
acs = allowed[0];
}
const user = await User.findOne({ id: req.user.id });
if (!user) {
res.status(403).type("text/plain").send("no such user");
return;
}
const attrs = await claims.samlAttributes(user);
await sendLoginResponse(res, { idp, sp, requestInfo, issuer, spEntityId, acs, attrs, relayState, idpInitiated: false });
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml sso failed:`, e);
if (!res.headersSent) {
res.status(500).type("text/plain").send("saml sso error");
}
}
};
// IdP-initiated SSO: an unsolicited Response for a registered SP named by query
// param (?sp=<entityID>&acs=<acsUrl>). The target SP + ACS are still validated
// against the registry/allow-list (no AuthnRequest to trust).
const initHandler = async (req, res) => {
try {
const spEntityId = String((req.query && req.query.sp) || "");
if (!spEntityId) {
res.status(400).type("text/plain").send("missing sp");
return;
}
const spRow = await samlSps.getSp(spEntityId);
if (!spRow) {
res.status(403).type("text/plain").send("unknown SAML SP");
return;
}
const allowed = samlSps.acsUrls(spRow);
if (allowed.length === 0) {
res.status(403).type("text/plain").send("SP has no registered ACS");
return;
}
if (!(req.user && req.user.id)) {
res.redirect("/auth/login?dest=" + encodeURIComponent(req.originalUrl));
return;
}
const reqAcs = String((req.query && req.query.acs) || "");
let acs;
if (reqAcs) {
if (!samlSps.acsAllowed(spRow, reqAcs)) {
res.status(403).type("text/plain").send("ACS not allowed");
return;
}
acs = reqAcs;
} else {
acs = allowed[0];
}
const issuer = issuerForReq(req);
const idp = await samlIdp.getIdp(issuer);
const sp = samlIdp.buildSp(spEntityId, acs);
const user = await User.findOne({ id: req.user.id });
if (!user) {
res.status(403).type("text/plain").send("no such user");
return;
}
const attrs = await claims.samlAttributes(user);
const relayState = String((req.query && req.query.RelayState) || "");
await sendLoginResponse(res, { idp, sp, requestInfo: {}, issuer, spEntityId, acs, attrs, relayState, idpInitiated: true });
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml idp-initiated sso failed:`, e);
if (!res.headersSent) {
res.status(500).type("text/plain").send("saml init error");
}
}
};
// Single Logout: parse a LogoutRequest from a registered SP, terminate the local
// Saltcorn session, and return a signed LogoutResponse.
const sloHandler = async (req, res) => {
try {
const binding = req.method === "POST" ? samlIdp.POST_BINDING : samlIdp.REDIRECT_BINDING;
const samlReq = (req.body && req.body.SAMLRequest) || (req.query && req.query.SAMLRequest);
const relayState = (req.body && req.body.RelayState) || (req.query && req.query.RelayState) || "";
if (!samlReq) {
res.status(400).type("text/plain").send("missing SAMLRequest");
return;
}
let xml;
try {
xml = decodeRequest(samlReq, binding);
} catch (e) {
res.status(400).type("text/plain").send("malformed SAML request");
return;
}
const spEntityId = matchIssuer(xml);
if (!spEntityId) {
res.status(400).type("text/plain").send("could not parse LogoutRequest");
return;
}
const spRow = await samlSps.getSp(spEntityId);
if (!spRow) {
res.status(403).type("text/plain").send("unknown SAML SP");
return;
}
// SLO terminates THE CURRENT user's session. An unauthenticated request
// has no session to end and must not reach the logout below -- this is
// what stops a forged LogoutRequest from destroying a session.
if (!(req.user && req.user.id)) {
res.status(403).type("text/plain").send("no authenticated session");
return;
}
const issuer = issuerForReq(req);
const allowed = samlSps.acsUrls(spRow);
// The LogoutResponse destination MUST be a registered URL. Mirror the SSO
// handler's empty-list rejection and NEVER fall back to a URL parsed from
// the request: matchAcs(xml) would let an SP registered with no ACS (or a
// crafted LogoutRequest) steer the signed LogoutResponse to an
// attacker-chosen endpoint (open redirect + assertion/RelayState leak).
if (allowed.length === 0) {
res.status(403).type("text/plain").send("SP has no registered ACS");
return;
}
// MVP: deliver the LogoutResponse to the SP's first registered endpoint.
const sloAcs = allowed[0];
// For an SP that registered a signing cert, REQUIRE + verify the
// LogoutRequest signature (getSignedIdp sets wantLogoutRequestSigned),
// mirroring the AuthnRequest path -- this blocks forged/replayed
// LogoutRequests for opted-in SPs. RESIDUAL: an SP WITHOUT a registered
// cert can't have its requests verified, so a cross-site GET could still
// force the CURRENT user to log out THEMSELVES (the req.user + NameID==
// session checks above bound it to self-logout). Register the SP's signing
// cert to close it fully; we don't reject unsigned SLO outright because
// that would break SPs that legitimately don't sign LogoutRequests.
// The signed redirect binding needs the
// raw octet string reconstructed exactly as the SP signed it.
const wantSigned = samlSps.wantsSignedRequests(spRow);
const idp = wantSigned ? await samlIdp.getSignedIdp(issuer) : await samlIdp.getIdp(issuer);
const sp = samlIdp.buildSpForLogout(spEntityId, sloAcs, { signingCert: spRow.signing_cert });
const parseReq = { query: req.query, body: req.body };
if (wantSigned && binding === samlIdp.REDIRECT_BINDING) {
parseReq.octetString = rawQueryOctetString(req);
}
let requestInfo;
try {
requestInfo = await idp.parseLogoutRequest(sp, binding, parseReq);
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml LogoutRequest rejected:`, e && e.message);
res.status(403).type("text/plain").send("SAML logout request rejected");
return;
}
// Parser-differential guard (mirror the SSO handler): the authoritative
// parsed issuer must equal the entityID we looked the SP up by.
const parsedIssuer = (requestInfo.extract && requestInfo.extract.issuer) || spEntityId;
if (parsedIssuer !== spEntityId) {
res.status(403).type("text/plain").send("issuer mismatch");
return;
}
// The LogoutRequest MUST name the currently authenticated user; never end
// a session the request does not pertain to.
const reqNameId = String((requestInfo.extract && requestInfo.extract.nameID) || "").trim().toLowerCase();
const sessionEmail = String((req.user && req.user.email) || "").trim().toLowerCase();
if (!reqNameId || !sessionEmail || reqNameId !== sessionEmail) {
res.status(403).type("text/plain").send("logout subject does not match the session");
return;
}
// The actual logout: end the local Saltcorn session.
if (typeof req.logout === "function") {
try {
req.logout(() => {});
} catch (e) {
// older passport: req.logout() is synchronous
}
}
if (req.session && typeof req.session.destroy === "function") {
try {
req.session.destroy(() => {});
} catch (e) {
// best effort
}
}
const requestId = (requestInfo.extract && requestInfo.extract.request && requestInfo.extract.request.id) || "";
const customTagReplacement = makeLogoutResponseRenderer({ issuer, destination: sloAcs, requestId });
const response = await idp.createLogoutResponse(sp, requestInfo, samlIdp.POST_BINDING, { relayState: relayState, customTagReplacement: customTagReplacement });
res.type("text/html").send(autoPostForm(sloAcs, response.context, relayState));
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${constants.PLUGIN_NAME}] saml slo failed:`, e);
if (!res.headersSent) {
res.status(500).type("text/plain").send("saml slo error");
}
}
};
const samlRoutes = [
{ url: constants.SAML_METADATA_PATH, method: "get", callback: metadataHandler, noCsrf: true },
{ url: constants.SAML_SSO_PATH, method: "get", callback: ssoHandler, noCsrf: true },
{ url: constants.SAML_SSO_PATH, method: "post", callback: ssoHandler, noCsrf: true },
{ url: constants.SAML_INIT_PATH, method: "get", callback: initHandler, noCsrf: true },
{ url: constants.SAML_SLO_PATH, method: "get", callback: sloHandler, noCsrf: true },
{ url: constants.SAML_SLO_PATH, method: "post", callback: sloHandler, noCsrf: true }
];
module.exports = {
samlRoutes
};

82
lib/saml/sps.js Normal file
View file

@ -0,0 +1,82 @@
// SAML service-provider (relying-party) registry. Backed by _idp_saml_sps. The
// SSO endpoint only issues an assertion to a registered SP and only to one of
// its allow-listed ACS URLs -- this is what prevents a forged AuthnRequest from
// having a signed assertion delivered to an attacker-chosen Destination. The
// signing_cert is a PUBLIC X.509 cert (no sealing, unlike client secrets); when
// present and want_authn_requests_signed is set, the SSO endpoint verifies the
// AuthnRequest signature against it.
const db = require("@saltcorn/data/db");
const { TABLE_SAML_SPS } = require("../constants");
// True if the requested ACS exactly matches one of the SP's allow-listed URLs.
// Exact-string match is the secure default (no trailing-slash/scheme fuzzing).
const acsAllowed = (row, acs) => {
if (!row || !acs) {
return false;
}
let list = [];
try {
list = JSON.parse(row.acs_urls);
} catch (e) {
return false;
}
return Array.isArray(list) && list.includes(acs);
};
const acsUrls = (row) => {
try {
const list = JSON.parse(row.acs_urls);
return Array.isArray(list) ? list : [];
} catch (e) {
return [];
}
};
const createSp = async (opts) => {
await db.insert(TABLE_SAML_SPS, {
entity_id: opts.entityId,
label: opts.label || null,
acs_urls: JSON.stringify(opts.acsUrls || []),
signing_cert: opts.signingCert || null,
want_authn_requests_signed: opts.wantSigned ? 1 : 0,
created_at: new Date().toISOString()
}, { noid: true });
return { entity_id: opts.entityId };
};
const deleteSp = async (entityId) => {
await db.deleteWhere(TABLE_SAML_SPS, { entity_id: entityId });
};
const getSp = async (entityId) => {
return await db.selectMaybeOne(TABLE_SAML_SPS, { entity_id: entityId });
};
const listSps = async () => {
return await db.select(TABLE_SAML_SPS, {}, { orderBy: "entity_id" });
};
// Coerce the stored INTEGER 0/1 (sqlite) or boolean (pg driver) to a real bool.
const wantsSignedRequests = (row) => {
return !!(row && row.want_authn_requests_signed);
};
module.exports = {
acsAllowed,
acsUrls,
createSp,
deleteSp,
getSp,
listSps,
wantsSignedRequests
};

190
lib/schema.js Normal file
View file

@ -0,0 +1,190 @@
// Idempotent DDL for the saltcorn-idp plugin tables. Portable subset: TEXT for
// strings/JSON/timestamps, INTEGER for booleans; no JSONB. Sealed key/secret
// material is stored as hex TEXT (see crypto.sealText). Table names come from
// constants so there is one source of truth.
//
// MULTI-TENANCY: raw CREATE statements must be schema-qualified with
// db.getTenantSchemaPrefix() (e.g. "t1".) so tables land in the current tenant's
// Postgres schema -- otherwise unqualified DDL hits the wrong schema while
// Saltcorn's db.select reads the tenant-qualified one. On SQLite the prefix is
// "" (single schema), so the dev MAIN/TEST instances are unaffected.
const db = require("@saltcorn/data/db");
const { TABLE_ENV, TABLE_KEYS, TABLE_OIDC_STORE, TABLE_GROUPS, TABLE_GROUP_MEMBERS, TABLE_CLIENTS, TABLE_SAML, TABLE_SAML_SPS, TABLE_LDAP_SERVICE } = require("./constants");
const createIdpEnv = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_ENV} (
env_id TEXT PRIMARY KEY,
env_label TEXT,
created_at TEXT NOT NULL,
bootstrapped_at TEXT
)
`);
};
const createIdpKeys = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_KEYS} (
kid TEXT PRIMARY KEY,
alg TEXT NOT NULL,
public_jwk TEXT NOT NULL,
private_ciphertext TEXT NOT NULL,
private_iv TEXT NOT NULL,
private_tag TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
retire_after TEXT
)
`);
await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_KEYS}_status ON ${pfx}${TABLE_KEYS} (status)`);
};
// Single table backing the oidc-provider storage adapter (all model types).
const createIdpOidcStore = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_OIDC_STORE} (
model TEXT NOT NULL,
id TEXT NOT NULL,
payload TEXT NOT NULL,
uid TEXT,
grant_id TEXT,
user_code TEXT,
expires_at INTEGER,
PRIMARY KEY (model, id)
)
`);
await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_OIDC_STORE}_uid ON ${pfx}${TABLE_OIDC_STORE} (uid)`);
await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_OIDC_STORE}_grant ON ${pfx}${TABLE_OIDC_STORE} (grant_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_OIDC_STORE}_usercode ON ${pfx}${TABLE_OIDC_STORE} (user_code)`);
};
// Custom groups + the user<->group junction ("meet me" table).
const createIdpGroups = async () => {
const pfx = db.getTenantSchemaPrefix();
// Portable auto-increment PK: sqlite "integer primary key" auto-assigns
// rowids; postgres uses "serial". (AUTOINCREMENT is sqlite-only syntax.)
const serial = db.isSQLite ? "integer" : "serial";
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_GROUPS} (
id ${serial} PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL
)
`);
};
const createIdpGroupMembers = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_GROUP_MEMBERS} (
group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
PRIMARY KEY (group_id, user_id)
)
`);
await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_GROUP_MEMBERS}_user ON ${pfx}${TABLE_GROUP_MEMBERS} (user_id)`);
};
// Registered relying parties. Confidential clients' secrets are sealed at rest
// (hex columns); public clients (token_auth_method='none') have none.
const createIdpClients = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_CLIENTS} (
client_id TEXT PRIMARY KEY,
label TEXT,
token_auth_method TEXT NOT NULL DEFAULT 'none',
redirect_uris TEXT NOT NULL,
grant_types TEXT NOT NULL,
response_types TEXT NOT NULL,
scope TEXT,
secret_ciphertext TEXT,
secret_iv TEXT,
secret_tag TEXT,
created_at TEXT NOT NULL
)
`);
};
// Per-tenant SAML signing material: a self-signed X.509 cert (advertised in IdP
// metadata) + its sealed private key (used to sign assertions). Singleton row.
const createIdpSaml = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_SAML} (
id TEXT PRIMARY KEY,
cert TEXT NOT NULL,
private_ciphertext TEXT NOT NULL,
private_iv TEXT NOT NULL,
private_tag TEXT NOT NULL,
created_at TEXT NOT NULL
)
`);
};
// Registered SAML relying parties (service providers). The IdP only issues an
// assertion to a registered SP and only to one of its allow-listed ACS URLs, so
// a forged AuthnRequest cannot redirect a signed assertion to an attacker. The
// optional signing_cert (public, not sealed) enables AuthnRequest signature
// verification; want_authn_requests_signed (INTEGER 0/1) gates enforcement.
const createIdpSamlSps = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_SAML_SPS} (
entity_id TEXT PRIMARY KEY,
label TEXT,
acs_urls TEXT NOT NULL,
signing_cert TEXT,
want_authn_requests_signed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
`);
};
// LDAP service-account credentials (search-then-bind binder). Single row; the
// password is sealed at rest (hex columns), like client secrets.
const createIdpLdapService = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_LDAP_SERVICE} (
dn TEXT,
secret_ciphertext TEXT,
secret_iv TEXT,
secret_tag TEXT,
created_at TEXT NOT NULL
)
`);
};
const createAllTables = async () => {
await createIdpEnv();
await createIdpKeys();
await createIdpOidcStore();
await createIdpGroups();
await createIdpGroupMembers();
await createIdpClients();
await createIdpSaml();
await createIdpSamlSps();
await createIdpLdapService();
};
module.exports = {
createAllTables
};

16
lib/web.js Normal file
View file

@ -0,0 +1,16 @@
// Small shared web helpers for the plugin's HTML responses.
const escapeHtml = (value) => {
const s = value === null || value === undefined ? "" : String(value);
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
};
module.exports = {
escapeHtml
};

1252
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "saltcorn-idp",
"version": "0.0.1",
"description": "Saltcorn plugin: turns Saltcorn into an SSO Identity Provider (OIDC/OAuth2, LDAP with groups, and SAML 2.0). Per-tenant asymmetric signing keys sealed at rest; multi-tenant. See VENDORING.md for the dependency-ownership/security posture.",
"main": "index.js",
"scripts": {
"test": "node test/e2e.js",
"test:mt": "node test/mtGate.js"
},
"keywords": [
"saltcorn",
"sso",
"identity-provider",
"oidc",
"oauth2",
"ldap",
"saml"
],
"engines": {
"node": ">=20"
},
"author": "Scott Duensing",
"license": "MIT",
"dependencies": {
"@authenio/samlify-node-xmllint": "2.0.0",
"ldapjs": "3.0.7",
"oidc-provider": "^9.8.4",
"samlify": "2.13.1",
"selfsigned": "^5.5.0"
},
"overrides": {
"xml-crypto": ">=6.1.2",
"@xmldom/xmldom": ">=0.8.13 <0.9"
}
}

View file

@ -0,0 +1,99 @@
// Clean per-tenant installer for saltcorn-idp on the Postgres multi-tenant
// instance. Registers + enables the plugin in each named tenant schema and runs
// its onLoad (creating the _idp_* tables + bootstrapping keys), using Saltcorn's
// supported Plugin.loadAndSaveNewPlugin API inside runWithTenant -- replacing the
// old manual "INSERT INTO <tenant>._sc_plugins" SQL hack.
//
// The CLI `install-plugin -t <tenant> -d <dir>` cannot do this: a local (-d)
// plugin is "unsafe" on a non-root tenant, so loadAndSaveNewPlugin returns
// before the upsert unless allowUnsafeOnTenantsWithoutConfigSetting (its 5th arg)
// is set, and the CLI never passes it. This script passes it.
//
// Usage (from project root, PG env sourced -- see installIdpTenant.sh):
// node idp/scripts/installIdpTenant.js t1 t2 (or '*' for all tenants)
const { createRequire } = require("node:module");
const path = require("node:path");
// Re-root @saltcorn/* against the Saltcorn checkout's node_modules. This file
// lives at idp/scripts/, so up 2 = project root, then saltcorn/packages/...
const scRequire = createRequire(path.join(__dirname, "..", "..", "saltcorn", "packages", "saltcorn-data", "package.json"));
const Plugin = scRequire("@saltcorn/data/models/plugin");
const db = scRequire("@saltcorn/data/db");
const { init_multi_tenant, getRootState } = scRequire("@saltcorn/data/db/state");
const { getAllTenants } = scRequire("@saltcorn/admin-models/models/tenant");
const PLUGIN_NAME = "saltcorn-idp";
const IDP_DIR = path.resolve(__dirname, "..");
const installInto = async (tenant) => {
await db.runWithTenant(tenant, async () => {
await db.withTransaction(async () => {
// Remove any prior rows (including the old manual-hack row and earlier
// installs) so we converge on exactly one _sc_plugins row -- no
// duplicate source of truth.
await db.deleteWhere("_sc_plugins", { name: PLUGIN_NAME });
const plugin = new Plugin({ name: PLUGIN_NAME, source: "local", location: IDP_DIR, configuration: {} });
await Plugin.loadAndSaveNewPlugin(plugin, true, false);
});
// Verify against the NEWEST table so a stale Phase-3 row can't pass.
const row = await db.selectMaybeOne("_sc_plugins", { name: PLUGIN_NAME });
const svc = await db.query(
"SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = '_idp_ldap_service'",
[db.getTenantSchema()]
);
const hasSvc = svc && svc.rows && svc.rows.length > 0;
// eslint-disable-next-line no-console
console.log(`[installIdpTenant] ${tenant}: _sc_plugins=${row ? "yes" : "NO"} _idp_ldap_service=${hasSvc ? "yes" : "NO"}`);
if (!row || !hasSvc) {
throw new Error("install verification failed for tenant " + tenant + " (onLoad did not run)");
}
});
};
const main = async () => {
await Plugin.loadAllPlugins();
// Resolve the target tenants (from the public _sc_tenants list).
let tenants = process.argv.slice(2);
if (tenants.length === 0 || (tenants.length === 1 && tenants[0] === "*")) {
const all = await db.runWithTenant(db.connectObj.default_schema, getAllTenants);
tenants = (all || []).map((t) => (typeof t === "string" ? t : t.subdomain)).filter(Boolean);
}
if (!tenants || tenants.length === 0) {
// eslint-disable-next-line no-console
console.error("[installIdpTenant] no tenants to install into");
process.exit(1);
}
// Initialize per-tenant State (so getState() resolves inside runWithTenant)
// without running migrations. This also runs each tenant's existing plugins'
// onLoad, which is itself idempotent.
await init_multi_tenant(Plugin.loadAllPlugins, true, tenants);
// Permit installing this LOCAL plugin into tenant schemas. In this Saltcorn
// build loadAndSaveNewPlugin skips any non-"npm" plugin on a non-root tenant
// BEFORE the allowUnsafe arg is consulted; the supported lever is this
// root-only config -- the intended setting for a multi-tenant deployment that
// offers the IdP plugin to its tenants.
await getRootState().setConfig("tenants_unsafe_plugins", true);
// eslint-disable-next-line no-console
console.log("[installIdpTenant] installing " + PLUGIN_NAME + " into: " + tenants.join(", "));
for (const t of tenants) {
await installInto(t);
}
// eslint-disable-next-line no-console
console.log("[installIdpTenant] done");
process.exit(0);
};
main().catch((e) => {
// eslint-disable-next-line no-console
console.error("[installIdpTenant] ERROR:", e && (e.stack || e.message || e));
process.exit(1);
});

18
scripts/installIdpTenant.sh Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Clean per-tenant installer for saltcorn-idp on the Postgres MULTI-TENANT
# instance (.dev-state-pg). Replaces the manual "INSERT INTO <t>._sc_plugins"
# SQL hack: registers + enables the plugin in each tenant schema and runs its
# onLoad (creating the _idp_* tables + bootstrapping keys). After this, per-tenant
# onLoad re-runs automatically on every boot via init_multi_tenant->loadAllPlugins.
#
# Prerequisites: the tenants must already exist (saltcorn create-tenant <name>),
# and the plugin must be installed into the PG public schema once (the normal
# install-plugin -d ./idp path) so the shared plugins_folder copy exists.
#
# Usage: ./idp/scripts/installIdpTenant.sh t1 t2 (or '*' for all tenants)
set -e
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
# shellcheck disable=SC1091
source .dev-state-pg/env.sh
node "$ROOT/idp/scripts/installIdpTenant.js" "$@"

180
test/adminGate.js Normal file
View file

@ -0,0 +1,180 @@
// Admin defense-in-depth gate (MAIN :3000). Exercises the adminUi.js hardening
// that the role gate sits on top of -- none of which was gate-covered before:
// 1. URL-scheme validation: a javascript:/data: redirect_uri (OIDC client) or
// ACS URL (SAML SP) is rejected (the entity is never created).
// 2. SP signing-cert expiry: an SP registered with an EXPIRED signing cert is
// rejected; a valid http ACS + no cert is accepted (positive control).
// 3. Admin-mutation rate limit: > ADMIN_RATE_MAX admin POSTs in the window
// eventually return HTTP 429 (run LAST -- it throttles the session).
// Run: node idp/test/adminGate.js (MAIN up; logs in as admin@local).
const http = require("http");
const PORT = 3000;
const ADMIN_EMAIL = "admin@local";
const ADMIN_PW = "AdminP@ss1";
const RUN = Date.now();
// A real self-signed cert whose validity window is entirely in 2020 (expired).
const EXPIRED_CERT = [
"-----BEGIN CERTIFICATE-----",
"MIICwDCCAaigAwIBAgIUCTQ9FTQodMj9cA3nEeBD+garqVEwDQYJKoZIhvcNAQEL",
"BQAwGjEYMBYGA1UEAwwPZXhwaXJlZC1zcC10ZXN0MB4XDTIwMDEwMTAwMDAwMFoX",
"DTIwMDEwMjAwMDAwMFowGjEYMBYGA1UEAwwPZXhwaXJlZC1zcC10ZXN0MIIBIjAN",
"BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt4i8DXM2wEj5DEu4wo+i9xD9dydq",
"dmDcqYKu6v8wy6Tm1f704JEJ4gOvsMpBgJfFwT0xxa5MVhvahozdWj8gmQulBsfa",
"mCk8KXWAjHvWc5+gu1nnFVsDU3mUuNWHIRZtdSZkAL9EjjHJ3reZKmQjDWl8Prfe",
"x+jJUk8+CPZLxxAUK6vpERZ2pEOhLe1gjXADGsgRB6OU618hFYb9WodBtm1SaM7c",
"d65HG8jmfM1U3frCBktk00d3Dk0rP/Qz/iWO2lel451H6TpvtQAZq6hjseotLpFa",
"YY2fcFOgPzYg8A8OzrlymjkXmAas11u7XJI8PgUsPvbKyd7WZCtKsLdGOQIDAQAB",
"MA0GCSqGSIb3DQEBCwUAA4IBAQBBHXmy9f38uEJpNVqP4njoYF+NHDF8YHVHpnPF",
"YlLBGFoLa1jaKhrC/CcueHTiZmSxPPQhStosGWhzAFK3aWCSFaj74+T5nxbcHvxj",
"NYjMKUv1f4eczl5GJXOXgYgu1m4t8XI69qesRoj/ZTT1t+q0DdYdvyP/RWwas3Si",
"4qneOomj74OYHf64qrObj9jJ8ii0oTKfLPiQz8Z82fM95gRLq+iUcE4PizN6eBPb",
"URZXCIt2PCvZOI9vr5aFLmcuwSE7QjqNEm7jaMRonhFW0vQ8SprCNmb8meineq6r",
"Lz68ydyzrUeFbIZZwl5Liwaq7Dd3Uk4MCdL2ub/gOzs44CwW",
"-----END CERTIFICATE-----"
].join("\n");
const jar = {};
let pass = 0;
let fail = 0;
const ok = (cond, msg) => {
if (cond) {
pass++;
console.log(" PASS " + msg);
} else {
fail++;
console.log(" FAIL " + msg);
}
};
const storeCookies = (headers) => {
const sc = headers["set-cookie"];
if (!sc) {
return;
}
for (const line of sc) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
if (eq > 0) {
jar[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
}
}
};
const request = (method, path, opts) => {
const options = opts || {};
return new Promise((resolve, reject) => {
const headers = Object.assign({}, options.headers || {});
if (Object.keys(jar).length > 0) {
headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; ");
}
let data = null;
if (options.body) {
data = new URLSearchParams(options.body).toString();
headers["Content-Type"] = "application/x-www-form-urlencoded";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request({ host: "localhost", port: PORT, method: method, path: path, headers: headers }, (resp) => {
storeCookies(resp.headers);
let body = "";
resp.on("data", (c) => { body += c; });
resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: body }));
});
r.on("error", reject);
if (data !== null) {
r.write(data);
}
r.end();
});
};
const csrfOf = (html) => (html.match(/name="_csrf" value="([^"]+)"/) || [])[1] || "";
const adminCsrf = async (path) => csrfOf((await request("GET", path)).body);
const run = async () => {
// Login.
const lp = await request("GET", "/auth/login");
await request("POST", "/auth/login", { body: { email: ADMIN_EMAIL, password: ADMIN_PW, _csrf: csrfOf(lp.body) } });
ok(/true/.test((await request("GET", "/auth/authenticated")).body), "admin session authenticated");
// --- 1. URL-scheme validation (OIDC client redirect_uris) ---
const dangerClient = "adminGate_js_" + RUN;
await request("POST", "/admin/idp/clients/create", {
body: { client_id: dangerClient, label: "danger", redirect_uris: "javascript:alert(document.cookie)", auth_method: "none", _csrf: await adminCsrf("/admin/idp/clients") }
});
const clientList1 = (await request("GET", "/admin/idp/clients")).body;
ok(clientList1.indexOf(dangerClient) < 0, "OIDC client with javascript: redirect_uri REJECTED (not created)");
// Positive control: a valid https redirect IS accepted.
const okClient = "adminGate_ok_" + RUN;
await request("POST", "/admin/idp/clients/create", {
body: { client_id: okClient, label: "ok", redirect_uris: "https://example.com/cb", auth_method: "none", _csrf: await adminCsrf("/admin/idp/clients") }
});
const clientList2 = (await request("GET", "/admin/idp/clients")).body;
ok(clientList2.indexOf(okClient) >= 0, "OIDC client with https redirect_uri accepted (control)");
await request("POST", "/admin/idp/clients/delete", { body: { client_id: okClient, _csrf: await adminCsrf("/admin/idp/clients") } });
// --- 1b. URL-scheme validation (SAML ACS) ---
const dangerSp = "http://localhost:9098/adminGateDanger_" + RUN;
await request("POST", "/admin/idp/saml-sps/create", {
body: { entity_id: dangerSp, label: "danger", acs_urls: "data:text/html;base64,PHNjcmlwdD4=", _csrf: await adminCsrf("/admin/idp/saml-sps") }
});
const spList1 = (await request("GET", "/admin/idp/saml-sps")).body;
ok(spList1.indexOf(dangerSp) < 0, "SAML SP with data: ACS URL REJECTED (not created)");
// --- 2. SP signing-cert expiry ---
const expiredSp = "http://localhost:9098/adminGateExpired_" + RUN;
await request("POST", "/admin/idp/saml-sps/create", {
body: { entity_id: expiredSp, label: "expired", acs_urls: "https://example.com/acs", signing_cert: EXPIRED_CERT, _csrf: await adminCsrf("/admin/idp/saml-sps") }
});
const spList2 = (await request("GET", "/admin/idp/saml-sps")).body;
ok(spList2.indexOf(expiredSp) < 0, "SAML SP with EXPIRED signing cert REJECTED (not created)");
// Positive control: valid http ACS, no cert, IS accepted.
const okSp = "http://localhost:9098/adminGateOk_" + RUN;
await request("POST", "/admin/idp/saml-sps/create", {
body: { entity_id: okSp, label: "ok", acs_urls: "https://example.com/acs", _csrf: await adminCsrf("/admin/idp/saml-sps") }
});
const spList3 = (await request("GET", "/admin/idp/saml-sps")).body;
ok(spList3.indexOf(okSp) >= 0, "SAML SP with valid ACS + no cert accepted (control)");
await request("POST", "/admin/idp/saml-sps/delete", { body: { entity_id: okSp, _csrf: await adminCsrf("/admin/idp/saml-sps") } });
// --- 3. Admin-mutation rate limit (LAST -- throttles the session) ---
// Reuse one CSRF token across a burst of harmless no-op deletes; requireAdmin
// throttles every admin POST, so we should hit HTTP 429 within the window.
const csrf = await adminCsrf("/admin/idp/saml-sps");
let saw429 = false;
let count = 0;
for (let i = 0; i < 260 && !saw429; i++) {
const r = await request("POST", "/admin/idp/saml-sps/delete", { body: { entity_id: "__adminGate_probe__", _csrf: csrf } });
count++;
if (r.status === 429) {
saw429 = true;
}
}
ok(saw429, "admin-mutation rate limit returned 429 after " + count + " POSTs");
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
};
const main = async () => {
try {
await run();
} catch (e) {
console.error("ADMIN GATE ERROR:", e);
process.exit(2);
}
};
main();

299
test/e2e.js Normal file
View file

@ -0,0 +1,299 @@
// End-to-end test suite for saltcorn-idp. Self-contained (node:http + node:crypto,
// no deps). Run with both instances up: node test/e2e.js (or: npm test)
//
// Phase 0 - discovery + JWKS on both instances (MAIN :3000, TEST :3001)
// Phase 1 - register a client (admin UI), authorization-code + PKCE flow with a
// consent screen, token, id_token verify, userinfo
// Phase 2 - groups claim (custom group via admin UI + role-as-group)
// Phase 3 - clients registry (confidential client secret issuance)
//
// Tests run in order and share login/session state.
const http = require("http");
const crypto = require("crypto");
const MAIN = 3000;
const TEST = 3001;
const ADMIN_EMAIL = "admin@local";
const ADMIN_PW = "AdminP@ss1";
const CLIENT_ID = "test-rp";
const REDIRECT_URI = "http://localhost:9099/cb";
const ADMIN_BASE = "/admin/idp";
const jar = {};
let pass = 0;
let fail = 0;
const ok = (cond, msg) => {
if (cond) {
pass++;
console.log(" PASS " + msg);
} else {
fail++;
console.log(" FAIL " + msg);
}
};
const section = (name) => {
console.log("\n== " + name + " ==");
};
const storeCookies = (headers) => {
const sc = headers["set-cookie"];
if (!sc) {
return;
}
for (const line of sc) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
if (eq > 0) {
jar[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
}
}
};
const cookieHeader = () => {
return Object.keys(jar).map((k) => k + "=" + jar[k]).join("; ");
};
const request = (port, method, path, opts) => {
const options = opts || {};
return new Promise((resolve, reject) => {
const headers = Object.assign({}, options.headers || {});
if (port === MAIN && Object.keys(jar).length > 0) {
headers["Cookie"] = cookieHeader();
}
let data = null;
if (options.body) {
data = typeof options.body === "string" ? options.body : new URLSearchParams(options.body).toString();
headers["Content-Type"] = headers["Content-Type"] || "application/x-www-form-urlencoded";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request({ host: "localhost", port: port, method: method, path: path, headers: headers }, (resp) => {
if (port === MAIN) {
storeCookies(resp.headers);
}
let body = "";
resp.on("data", (c) => { body += c; });
resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: body }));
});
r.on("error", reject);
if (data !== null) {
r.write(data);
}
r.end();
});
};
const getJson = async (port, path) => {
const r = await request(port, "GET", path);
try {
return JSON.parse(r.body);
} catch (e) {
return { __status: r.status, __raw: r.body };
}
};
const csrfOf = (html) => {
const m = html.match(/name="_csrf" value="([^"]+)"/);
return m ? m[1] : "";
};
const resolvePath = (loc) => {
const abs = loc.indexOf("http") === 0 ? new URL(loc) : new URL(loc, "http://localhost:" + MAIN);
return abs.pathname + abs.search;
};
const login = async () => {
const page = await request(MAIN, "GET", "/auth/login");
const csrf = csrfOf(page.body);
const resp = await request(MAIN, "POST", "/auth/login", { body: { email: ADMIN_EMAIL, password: ADMIN_PW, _csrf: csrf } });
return resp.status;
};
const adminCsrf = async (page) => {
return csrfOf((await request(MAIN, "GET", ADMIN_BASE + page)).body);
};
const findGroupId = async (name) => {
const html = (await request(MAIN, "GET", ADMIN_BASE + "/groups")).body;
const m = html.match(new RegExp("<code>" + name + "</code>[\\s\\S]*?name=\"group_id\" value=\"(\\d+)\""));
return m ? parseInt(m[1], 10) : null;
};
const clientExists = async (clientId) => {
const html = (await request(MAIN, "GET", ADMIN_BASE + "/clients")).body;
return html.indexOf("<code>" + clientId + "</code>") >= 0;
};
// Runs the authorization-code + PKCE flow, following interaction redirects and
// submitting the consent screen when it appears. Returns tokens + decoded id_token.
const authCodeFlow = async (scope) => {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest().toString("base64url");
const state = crypto.randomBytes(8).toString("base64url");
const nonce = crypto.randomBytes(8).toString("base64url");
const q = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: scope,
state: state,
nonce: nonce,
code_challenge: challenge,
code_challenge_method: "S256"
});
let path = "/idp/auth?" + q.toString();
let code = null;
let hops = 0;
while (hops < 15) {
hops++;
const r = await request(MAIN, "GET", path);
if (r.status >= 300 && r.status < 400 && r.headers.location) {
if (r.headers.location.indexOf(REDIRECT_URI) === 0) {
code = new URL(r.headers.location).searchParams.get("code");
break;
}
path = resolvePath(r.headers.location);
} else if (r.status === 200 && /\/confirm"/.test(r.body)) {
const m = r.body.match(/action="([^"]*\/confirm)"/);
if (!m) {
throw new Error("consent page without a confirm action");
}
const pr = await request(MAIN, "POST", m[1], { body: { allow: "1" } });
if (pr.status >= 300 && pr.status < 400 && pr.headers.location) {
path = resolvePath(pr.headers.location);
} else {
throw new Error("consent confirm did not redirect: HTTP " + pr.status);
}
} else {
throw new Error("auth flow stalled: HTTP " + r.status + " " + r.body.slice(0, 150));
}
}
const tok = await request(MAIN, "POST", "/idp/token", { body: {
grant_type: "authorization_code",
code: code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier
} });
let tokens = {};
try {
tokens = JSON.parse(tok.body);
} catch (e) {
/* leave empty */
}
let header = {};
let payload = {};
if (tokens.id_token) {
tokens.__parts = tokens.id_token.split(".");
header = JSON.parse(Buffer.from(tokens.__parts[0], "base64url").toString());
payload = JSON.parse(Buffer.from(tokens.__parts[1], "base64url").toString());
}
return { tokenStatus: tok.status, nonce: nonce, tokens: tokens, header: header, payload: payload };
};
const userInfo = async (accessToken) => {
const r = await request(MAIN, "GET", "/idp/me", { headers: { Authorization: "Bearer " + accessToken } });
let body = {};
try {
body = JSON.parse(r.body);
} catch (e) {
/* leave empty */
}
return { status: r.status, body: body };
};
const run = async () => {
section("Phase 0: discovery + JWKS (both instances)");
for (const pair of [["MAIN", MAIN], ["TEST", TEST]]) {
const label = pair[0];
const port = pair[1];
const disc = await getJson(port, "/idp/.well-known/openid-configuration");
ok(disc.issuer === "http://localhost:" + port + "/idp", label + " issuer = " + disc.issuer);
const jwks = await getJson(port, "/idp/jwks");
const key = jwks.keys && jwks.keys[0];
ok(jwks.keys && jwks.keys.length === 1 && key.kty === "RSA", label + " JWKS: one RSA key");
ok(key && !key.d && !key.p && !key.q, label + " JWKS public-only");
}
section("Phase 1: client registration + auth-code + PKCE + consent");
const loginStatus = await login();
ok(loginStatus === 302 || loginStatus === 303, "Saltcorn login redirected (HTTP " + loginStatus + ")");
ok(/true/.test((await request(MAIN, "GET", "/auth/authenticated")).body), "Saltcorn session authenticated");
// register the test relying party via the clients admin UI (idempotent)
await request(MAIN, "POST", ADMIN_BASE + "/clients/delete", { body: { client_id: CLIENT_ID, _csrf: await adminCsrf("/clients") } });
await request(MAIN, "POST", ADMIN_BASE + "/clients/create", { body: {
client_id: CLIENT_ID, label: "E2E Test RP", redirect_uris: REDIRECT_URI,
auth_method: "none", scope: "openid email profile groups", _csrf: await adminCsrf("/clients")
} });
ok(await clientExists(CLIENT_ID), "test-rp registered via clients admin UI");
const r1 = await authCodeFlow("openid email profile");
ok(r1.tokenStatus === 200, "token endpoint HTTP 200");
ok(!!r1.tokens.access_token && !!r1.tokens.id_token, "received access_token + id_token");
const jwks = await getJson(MAIN, "/idp/jwks");
const jwk = (jwks.keys || []).find((k) => k.kid === r1.header.kid) || (jwks.keys || [])[0];
const pub = crypto.createPublicKey({ key: jwk, format: "jwk" });
const sigOk = crypto.verify("sha256", Buffer.from(r1.tokens.__parts[0] + "." + r1.tokens.__parts[1]), pub, Buffer.from(r1.tokens.__parts[2], "base64url"));
ok(sigOk, "id_token RS256 signature verifies (kid=" + r1.header.kid + ")");
ok(r1.payload.iss === "http://localhost:3000/idp", "id_token iss = " + r1.payload.iss);
ok(r1.payload.aud === CLIENT_ID, "id_token aud = " + r1.payload.aud);
ok(r1.payload.nonce === r1.nonce, "id_token nonce matches request");
ok(!!r1.payload.sub, "id_token sub = " + r1.payload.sub);
const ui1 = await userInfo(r1.tokens.access_token);
ok(ui1.status === 200 && ui1.body.sub === r1.payload.sub, "userinfo sub matches id_token");
ok(ui1.body.email === ADMIN_EMAIL, "userinfo email = " + ui1.body.email);
section("Phase 2: groups claim (custom group + role-as-group)");
const grp = "e2e-grp-" + crypto.randomBytes(4).toString("hex");
await request(MAIN, "POST", ADMIN_BASE + "/groups/create", { body: { name: grp, _csrf: await adminCsrf("/groups") } });
const gid = await findGroupId(grp);
ok(!!gid, "group '" + grp + "' created via admin UI (id=" + gid + ")");
const addResp = await request(MAIN, "POST", ADMIN_BASE + "/groups/addmember", { body: { group_id: gid, email: ADMIN_EMAIL, _csrf: await adminCsrf("/groups") } });
ok(addResp.status === 302 || addResp.status === 303, "added admin to group via admin UI");
const r2 = await authCodeFlow("openid email profile groups");
ok(r2.tokenStatus === 200, "token endpoint HTTP 200 (groups scope)");
const idGroups = r2.payload.groups || [];
ok(Array.isArray(idGroups) && idGroups.indexOf("role:admin") >= 0, "id_token groups includes role:admin (" + JSON.stringify(idGroups) + ")");
ok(idGroups.indexOf("group:" + grp) >= 0, "id_token groups includes group:" + grp);
const ui2 = await userInfo(r2.tokens.access_token);
ok(Array.isArray(ui2.body.groups) && ui2.body.groups.indexOf("group:" + grp) >= 0, "userinfo groups includes group:" + grp);
await request(MAIN, "POST", ADMIN_BASE + "/groups/delete", { body: { id: gid, _csrf: await adminCsrf("/groups") } });
ok((await findGroupId(grp)) === null, "test group removed (cleanup)");
section("Phase 3: clients registry (confidential secret)");
const confResp = await request(MAIN, "POST", ADMIN_BASE + "/clients/create", { body: {
client_id: "e2e-conf", label: "Conf", redirect_uris: REDIRECT_URI,
auth_method: "client_secret_basic", scope: "openid", _csrf: await adminCsrf("/clients")
} });
ok(confResp.status === 200 && /Client secret/.test(confResp.body), "confidential client create shows a one-time secret");
await request(MAIN, "POST", ADMIN_BASE + "/clients/delete", { body: { client_id: "e2e-conf", _csrf: await adminCsrf("/clients") } });
ok(!(await clientExists("e2e-conf")), "confidential client deleted");
await request(MAIN, "POST", ADMIN_BASE + "/clients/delete", { body: { client_id: CLIENT_ID, _csrf: await adminCsrf("/clients") } });
ok(!(await clientExists(CLIENT_ID)), "test-rp cleaned up");
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
};
run().catch((e) => {
console.error("E2E ERROR:", e);
process.exit(2);
});

127
test/keyRotationGate.js Normal file
View file

@ -0,0 +1,127 @@
// Key-rotation gate (MAIN :3000). Validates the now-wired signing-key rotation
// lifecycle: an admin rotation mints a NEW active key while KEEPING the prior
// key in JWKS (status RETIRING) so id_tokens it signed keep verifying during the
// grace window. Also checks the rotate endpoint is admin-gated.
// Run: node idp/test/keyRotationGate.js (MAIN up; logs in as admin@local).
const http = require("http");
const PORT = 3000;
const ADMIN_EMAIL = "admin@local";
const ADMIN_PW = "AdminP@ss1";
const jar = {};
let pass = 0;
let fail = 0;
const ok = (cond, msg) => {
if (cond) {
pass++;
console.log(" PASS " + msg);
} else {
fail++;
console.log(" FAIL " + msg);
}
};
const storeCookies = (headers) => {
const sc = headers["set-cookie"];
if (!sc) {
return;
}
for (const line of sc) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
if (eq > 0) {
jar[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
}
}
};
const request = (method, path, opts) => {
const options = opts || {};
return new Promise((resolve, reject) => {
const headers = Object.assign({}, options.headers || {});
if (!options.noCookies && Object.keys(jar).length > 0) {
headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; ");
}
let data = null;
if (options.body) {
data = new URLSearchParams(options.body).toString();
headers["Content-Type"] = "application/x-www-form-urlencoded";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request({ host: "localhost", port: PORT, method: method, path: path, headers: headers }, (resp) => {
storeCookies(resp.headers);
let body = "";
resp.on("data", (c) => { body += c; });
resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: body }));
});
r.on("error", reject);
if (data !== null) {
r.write(data);
}
r.end();
});
};
const csrfOf = (h) => (h.match(/name="_csrf" value="([^"]+)"/) || [])[1] || "";
const jwksKids = async () => {
const r = await request("GET", "/idp/jwks");
let keys = [];
try {
keys = (JSON.parse(r.body).keys) || [];
} catch (e) {
keys = [];
}
return keys.map((k) => k.kid).filter(Boolean);
};
const run = async () => {
// rotate endpoint must be admin-gated.
const anon = await request("POST", "/admin/idp/rotate-key", { noCookies: true, body: { _csrf: "x" } });
ok(anon.status === 403 || anon.status === 302 || anon.status === 401,
"unauthenticated rotate-key is rejected (HTTP " + anon.status + ")");
const lp = await request("GET", "/auth/login");
await request("POST", "/auth/login", { body: { email: ADMIN_EMAIL, password: ADMIN_PW, _csrf: csrfOf(lp.body) } });
ok(/true/.test((await request("GET", "/auth/authenticated")).body), "admin session authenticated");
const before = await jwksKids();
ok(before.length >= 1, "JWKS serves at least one signing key before rotation (" + before.length + ")");
const csrf = csrfOf((await request("GET", "/admin/idp")).body);
const rot = await request("POST", "/admin/idp/rotate-key", { body: { _csrf: csrf } });
ok(rot.status === 302, "admin rotate-key returned 302 (HTTP " + rot.status + ")");
const after = await jwksKids();
const beforeSet = new Set(before);
const afterSet = new Set(after);
const newKids = after.filter((k) => !beforeSet.has(k));
const retained = before.every((k) => afterSet.has(k));
ok(retained, "ALL pre-rotation kids remain in JWKS (rotated-out key kept as RETIRING for the grace window)");
ok(newKids.length === 1, "exactly ONE new kid appeared in JWKS after rotation (" + newKids.join(",") + ")");
ok(after.length === before.length + 1, "JWKS grew by exactly one key (" + before.length + " -> " + after.length + ")");
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
};
const main = async () => {
try {
await run();
} catch (e) {
console.error("KEY ROTATION GATE ERROR:", e);
process.exit(2);
}
};
main();

354
test/ldapGate.js Normal file
View file

@ -0,0 +1,354 @@
// Phase 4 gate: drive the LDAPS server (MAIN, port 1636) as an LDAP client.
// Verifies simple bind against the bcrypt hash, user search (mail + memberOf,
// the same role-as-group + custom-group model as OIDC), wrong-password and
// anonymous-search rejection, plus the hardening additions: case-insensitive
// attribute selection, groupOfNames entries, filter-nesting-depth rejection,
// the per-connection inbound byte cap (DoS), and the configurable service
// account (search-then-bind). Uses the ldapjs client + a little HTTP for the
// service-account config. Run: node idp/test/ldapGate.js (MAIN must be up).
const ldap = require("ldapjs");
const tls = require("tls");
const http = require("http");
const HOST = "127.0.0.1";
const LDAP_PORT = 1636;
const URL = "ldaps://127.0.0.1:1636";
const BASE = "dc=saltcorn,dc=local";
const PEOPLE = "ou=people," + BASE;
const ADMIN_DN = "uid=admin@local," + PEOPLE;
const ADMIN_PW = "AdminP@ss1";
const MAIN_PORT = 3000;
const SVC_DN = "cn=svc,ou=people," + BASE;
const SVC_PW = "svcSecret123!";
const jar = {};
let pass = 0;
let fail = 0;
const ok = (cond, msg) => {
if (cond) {
pass++;
console.log(" PASS " + msg);
} else {
fail++;
console.log(" FAIL " + msg);
}
};
const newClient = () => {
return ldap.createClient({
url: URL,
tlsOptions: { rejectUnauthorized: false },
connectTimeout: 5000,
timeout: 8000
});
};
const doBind = (client, dn, pw) => {
return new Promise((resolve) => {
client.bind(dn, pw, (err) => resolve(err));
});
};
const doSearch = (client, base, opts) => {
return new Promise((resolve, reject) => {
client.search(base, opts, (err, res) => {
if (err) {
reject(err);
return;
}
const entries = [];
res.on("searchEntry", (e) => entries.push(e.pojo));
res.on("error", (e) => reject(e));
res.on("end", (r) => resolve({ status: r ? r.status : -1, entries: entries }));
});
});
};
const attrMap = (entry) => {
const out = {};
if (entry && entry.attributes) {
for (const a of entry.attributes) {
out[a.type.toLowerCase()] = a.values;
}
}
return out;
};
// --- minimal HTTP (to configure the LDAP service account via the admin UI) ---
const storeCookies = (headers) => {
const sc = headers["set-cookie"];
if (!sc) {
return;
}
for (const line of sc) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
if (eq > 0) {
jar[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
}
}
};
const httpReq = (method, path, opts) => {
const options = opts || {};
return new Promise((resolve, reject) => {
const headers = {};
if (Object.keys(jar).length > 0) {
headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; ");
}
let data = null;
if (options.body) {
data = new URLSearchParams(options.body).toString();
headers["Content-Type"] = "application/x-www-form-urlencoded";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request({ host: "localhost", port: MAIN_PORT, method: method, path: path, headers: headers }, (resp) => {
storeCookies(resp.headers);
let body = "";
resp.on("data", (c) => { body += c; });
resp.on("end", () => resolve({ status: resp.statusCode, body: body }));
});
r.on("error", reject);
if (data !== null) {
r.write(data);
}
r.end();
});
};
const configureServiceAccount = async () => {
try {
const lp = await httpReq("GET", "/auth/login");
const csrf = (lp.body.match(/name="_csrf" value="([^"]+)"/) || [])[1] || "";
await httpReq("POST", "/auth/login", { body: { email: "admin@local", password: ADMIN_PW, _csrf: csrf } });
const sp = await httpReq("GET", "/admin/idp/ldap");
const csrf2 = (sp.body.match(/name="_csrf" value="([^"]+)"/) || [])[1] || "";
const r = await httpReq("POST", "/admin/idp/ldap/service", { body: { dn: SVC_DN, password: SVC_PW, _csrf: csrf2 } });
return r.status === 302 || r.status === 303;
} catch (e) {
return false;
}
};
// Send a BER header declaring a huge length, then dribble bytes; the server's
// per-connection byte cap must destroy the connection well before the declared
// length is reached.
const oversizedMessageClosed = () => {
return new Promise((resolve) => {
const sock = tls.connect({ host: HOST, port: LDAP_PORT, rejectUnauthorized: false }, () => {
sock.write(Buffer.from([0x30, 0x84, 0x7f, 0xff, 0xff, 0xff]));
const chunk = Buffer.alloc(64 * 1024, 0x00);
let written = 0;
const pump = () => {
while (written < 400 * 1024) {
written += chunk.length;
if (!sock.write(chunk)) {
sock.once("drain", pump);
return;
}
}
};
pump();
});
let done = false;
const finish = (v) => {
if (!done) {
done = true;
try { sock.destroy(); } catch (e) { /* ignore */ }
resolve(v);
}
};
sock.on("close", () => finish(true));
sock.on("error", () => { /* expected when the server destroys us */ });
setTimeout(() => finish(false), 7000);
});
};
const run = async () => {
// 0. uidFromDn RFC 4514 unescaping (unit): a bind DN whose uid contains an
// escaped special char (e.g. a comma, rendered \2c or \,) must decode back to
// the literal email, or users with such emails could never bind.
const { uidFromDn } = require("../lib/ldap/dn");
ok(uidFromDn("uid=admin@local,ou=people,dc=saltcorn,dc=local") === "admin@local", "uidFromDn plain email");
ok(uidFromDn("uid=alice\\2cbob@x.com,ou=people,dc=saltcorn,dc=local") === "alice,bob@x.com", "uidFromDn unescapes hex \\2c -> comma");
ok(uidFromDn("uid=a\\+b@x.com,ou=people,dc=saltcorn,dc=local") === "a+b@x.com", "uidFromDn unescapes \\+ -> plus");
// 1. correct bind
let c = newClient();
let err = await doBind(c, ADMIN_DN, ADMIN_PW);
ok(!err, "simple bind as admin@local succeeds" + (err ? " (" + err.name + ")" : ""));
// 2. authenticated search for the user
if (!err) {
const r = await doSearch(c, PEOPLE, { scope: "sub", filter: "(uid=admin@local)", attributes: ["mail", "memberof", "cn"] });
ok(r.entries.length === 1, "search returns exactly one entry for admin@local");
const a = attrMap(r.entries[0]);
ok(a.mail && a.mail[0] === "admin@local", "entry mail = " + (a.mail && a.mail[0]));
ok(Array.isArray(a.memberof) && a.memberof.some((g) => g.indexOf("role:admin") >= 0), "memberOf includes role:admin (" + JSON.stringify(a.memberof) + ")");
const priv = a.userpassword || a.password || a.private_ciphertext;
ok(!priv, "no password/secret attributes exposed");
}
c.unbind(() => {});
// 3. wrong password rejected
c = newClient();
err = await doBind(c, ADMIN_DN, "wrong-password");
ok(!!err && /InvalidCredentials/i.test(err.name || ""), "wrong password rejected (" + (err && err.name) + ")");
c.unbind(() => {});
// 4. anonymous bind is accepted (standard LDAP), but anonymous SEARCH is denied
c = newClient();
err = await doBind(c, "", "");
let anonSearchErr = null;
if (!err) {
try {
await doSearch(c, PEOPLE, { scope: "sub", filter: "(uid=admin@local)", attributes: ["mail"] });
} catch (e) {
anonSearchErr = e;
}
}
ok(!!anonSearchErr && /InsufficientAccessRights/i.test(anonSearchErr.name || ""), "anonymous search denied (" + (anonSearchErr && anonSearchErr.name) + ")");
c.unbind(() => {});
// 5. mixed-case attribute request still returns the attributes
c = newClient();
err = await doBind(c, ADMIN_DN, ADMIN_PW);
if (!err) {
const r = await doSearch(c, PEOPLE, { scope: "sub", filter: "(uid=admin@local)", attributes: ["Mail", "MemberOf", "CN"] });
const a = attrMap(r.entries[0] || {});
ok(a.mail && a.mail[0] === "admin@local" && Array.isArray(a.memberof), "mixed-case attribute request returns mail + memberOf");
}
c.unbind(() => {});
// 6. groupOfNames entries
c = newClient();
err = await doBind(c, ADMIN_DN, ADMIN_PW);
if (!err) {
const r = await doSearch(c, BASE, { scope: "sub", filter: "(objectclass=groupOfNames)", attributes: ["cn", "member"] });
const groupsFound = r.entries.map(attrMap);
const adminGroup = groupsFound.find((g) => Array.isArray(g.member) && g.member.some((m) => m.indexOf("uid=admin@local") >= 0));
ok(r.entries.length >= 1 && !!adminGroup, "groupOfNames entries returned with admin@local as member (" + r.entries.length + " groups)");
}
c.unbind(() => {});
// 7. deeply-nested filter rejected (depth > LDAP_MAX_FILTER_DEPTH)
c = newClient();
err = await doBind(c, ADMIN_DN, ADMIN_PW);
let deepErr = null;
if (!err) {
const deepFilter = "(!".repeat(40) + "(uid=admin@local)" + ")".repeat(40);
try {
await doSearch(c, PEOPLE, { scope: "sub", filter: deepFilter, attributes: ["mail"] });
} catch (e) {
deepErr = e;
}
}
ok(!!deepErr, "deeply-nested (40) filter rejected (" + (deepErr && deepErr.name) + ")");
c.unbind(() => {});
// 8. oversized inbound message -> server destroys the connection, survives
const closed = await oversizedMessageClosed();
ok(closed, "oversized inbound message: connection destroyed by byte cap");
c = newClient();
err = await doBind(c, ADMIN_DN, ADMIN_PW);
ok(!err, "server survived the oversized message (fresh bind still works)");
c.unbind(() => {});
// 9. configurable service account (search-then-bind binder)
const configured = await configureServiceAccount();
ok(configured, "configured LDAP service account via admin UI");
if (configured) {
c = newClient();
err = await doBind(c, SVC_DN, SVC_PW);
ok(!err, "service-account bind succeeds" + (err ? " (" + err.name + ")" : ""));
if (!err) {
const r = await doSearch(c, PEOPLE, { scope: "sub", filter: "(uid=admin@local)", attributes: ["mail"] });
ok(r.entries.length === 1, "service account can search (search-then-bind)");
}
c.unbind(() => {});
c = newClient();
err = await doBind(c, SVC_DN, "wrong-service-pw");
ok(!!err && /InvalidCredentials/i.test(err.name || ""), "service-account wrong password rejected (" + (err && err.name) + ")");
c.unbind(() => {});
}
// 10. per-message byte cap (NOT a connection-lifetime quota): a single
// connection issuing many sequential sub-cap searches whose CUMULATIVE bytes
// exceed LDAP_MAX_MSG_BYTES (256 KiB) must keep working. A cumulative counter
// would destroy the connection partway through; the per-message reset must not.
c = newClient();
err = await doBind(c, ADMIN_DN, ADMIN_PW);
let allSucceeded = !err;
if (!err) {
// ~26 KiB flat-OR filter (depth 2, under the depth cap); 16 iterations
// is ~416 KiB cumulative, comfortably past the 256 KiB single-message cap.
const bigFilter = "(|" + "(uid=nobody@local)".repeat(1400) + ")";
for (let i = 0; i < 16 && allSucceeded; i++) {
try {
await doSearch(c, PEOPLE, { scope: "sub", filter: bigFilter, attributes: ["mail"] });
} catch (e) {
allSucceeded = false;
}
}
}
ok(allSucceeded, "16 sequential sub-cap searches (>256 KiB cumulative) all succeed on one connection (per-message reset, not lifetime quota)");
c.unbind(() => {});
// 11. RFC 4514 DN escaping: a group whose NAME contains a DN special char
// (comma) must not abort the search. Without escaping, groupDn() emits a
// malformed "cn=group:ev,il,ou=groups,..." that ldapjs DN.fromString() rejects
// when building the SearchEntry, failing the whole search (OperationsError).
const grpCsrf = async () => ((await httpReq("GET", "/admin/idp/groups")).body.match(/name="_csrf" value="([^"]+)"/) || [])[1] || "";
let commaGid = null;
try {
await httpReq("POST", "/admin/idp/groups/create", { body: { name: "ev,il", _csrf: await grpCsrf() } });
const gp = await httpReq("GET", "/admin/idp/groups");
commaGid = (gp.body.match(/ev,il<\/code>[\s\S]*?name="id" value="(\d+)"/) || [])[1] || null;
if (commaGid) {
await httpReq("POST", "/admin/idp/groups/addmember", { body: { group_id: commaGid, email: "admin@local", _csrf: await grpCsrf() } });
}
} catch (e) {
/* leave commaGid null; assertion below will flag the setup */
}
c = newClient();
err = await doBind(c, ADMIN_DN, ADMIN_PW);
let commaSearchOk = false;
let escapedDnSeen = false;
if (!err) {
try {
const r = await doSearch(c, PEOPLE, { scope: "sub", filter: "(uid=admin@local)", attributes: ["memberof"] });
commaSearchOk = r.entries.length === 1;
const a = attrMap(r.entries[0] || {});
escapedDnSeen = Array.isArray(a.memberof) && a.memberof.some((d) => d.indexOf("group:ev") >= 0 && d.indexOf("il") >= 0);
} catch (e) {
commaSearchOk = false;
}
}
ok(!!commaGid && commaSearchOk, "search succeeds with a comma-containing group (DN value RFC 4514 escaped, no parse abort)");
ok(escapedDnSeen, "memberOf still carries the comma group's DN");
c.unbind(() => {});
if (commaGid) {
await httpReq("POST", "/admin/idp/groups/delete", { body: { id: commaGid, _csrf: await grpCsrf() } });
}
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
};
run().catch((e) => {
console.error("LDAP GATE ERROR:", e);
process.exit(2);
});

231
test/ldapMultiTenantGate.js Normal file
View file

@ -0,0 +1,231 @@
// Multi-tenant LDAP gate against the Postgres instance (LDAPS :1637). Verifies
// tenant-in-DN binding + cross-tenant denial: a tenant user binds under
// dc=<tenant>,dc=saltcorn,dc=local and resolves only that tenant's directory; the
// same credentials under a different tenant fail; a user bound under one tenant
// cannot search another tenant's subtree; an unknown tenant is denied. Bootstraps
// each tenant admin over HTTP (Host header) first. Self-skips (exit 0) when the
// PG LDAP port is not reachable, so it is a no-op on SQLite-only setups.
// Run: node idp/test/ldapMultiTenantGate.js (PG :3002 up with SALTCORN_IDP_LDAP_PORT).
const ldap = require("ldapjs");
const http = require("http");
const net = require("net");
const PG_PORT = 3002;
const LDAP_PORT = 1637;
const URL = "ldaps://127.0.0.1:" + LDAP_PORT;
const TENANTS = ["t1", "t2"];
const ADMIN_PW = "AdminP@ss1";
const jars = { t1: {}, t2: {} };
let pass = 0;
let fail = 0;
const ok = (cond, msg) => {
if (cond) {
pass++;
console.log(" PASS " + msg);
} else {
fail++;
console.log(" FAIL " + msg);
}
};
const HOST = (t) => t + ".localhost.localdomain:" + PG_PORT;
const ADMIN = (t) => "admin@" + t + ".local";
const userDn = (email, tenant) => "uid=" + email + ",ou=people,dc=" + tenant + ",dc=saltcorn,dc=local";
const peopleBase = (tenant) => "ou=people,dc=" + tenant + ",dc=saltcorn,dc=local";
// --- LDAP helpers ---
const newClient = () => {
return ldap.createClient({ url: URL, tlsOptions: { rejectUnauthorized: false }, connectTimeout: 5000, timeout: 8000 });
};
const doBind = (client, dn, pw) => {
return new Promise((resolve) => {
client.bind(dn, pw, (err) => resolve(err));
});
};
const doSearch = (client, base, opts) => {
return new Promise((resolve, reject) => {
client.search(base, opts, (err, res) => {
if (err) {
reject(err);
return;
}
const entries = [];
res.on("searchEntry", (e) => entries.push(e.pojo));
res.on("error", (e) => reject(e));
res.on("end", (r) => resolve({ status: r ? r.status : -1, entries: entries }));
});
});
};
// --- minimal HTTP (Host-routed) to bootstrap each tenant admin ---
const storeCookies = (t, headers) => {
const sc = headers["set-cookie"];
if (!sc) {
return;
}
for (const line of sc) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
if (eq > 0) {
jars[t][pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
}
}
};
const httpReq = (t, method, path, opts) => {
const options = opts || {};
return new Promise((resolve, reject) => {
const headers = { Host: HOST(t) };
const jar = jars[t];
if (Object.keys(jar).length > 0) {
headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; ");
}
let data = null;
if (options.body) {
data = new URLSearchParams(options.body).toString();
headers["Content-Type"] = "application/x-www-form-urlencoded";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request({ host: "127.0.0.1", port: PG_PORT, method: method, path: path, headers: headers }, (resp) => {
storeCookies(t, resp.headers);
let body = "";
resp.on("data", (c) => { body += c; });
resp.on("end", () => resolve({ status: resp.statusCode, body: body }));
});
r.on("error", reject);
if (data !== null) {
r.write(data);
}
r.end();
});
};
const csrfOf = (html) => {
const m = html.match(/name="_csrf" value="([^"]+)"/);
return m ? m[1] : "";
};
const authed = async (t) => {
return /true/.test((await httpReq(t, "GET", "/auth/authenticated")).body);
};
const bootstrapTenant = async (t) => {
const lp = await httpReq(t, "GET", "/auth/login");
await httpReq(t, "POST", "/auth/login", { body: { email: ADMIN(t), password: ADMIN_PW, _csrf: csrfOf(lp.body) } });
if (await authed(t)) {
return true;
}
const cp = await httpReq(t, "GET", "/auth/create_first_user");
const cc = csrfOf(cp.body);
if (cc) {
await httpReq(t, "POST", "/auth/create_first_user", { body: { email: ADMIN(t), password: ADMIN_PW, default_language: "en", _csrf: cc } });
}
return await authed(t);
};
const portOpen = (host, port) => {
return new Promise((resolve) => {
const s = net.connect({ host: host, port: port });
s.setTimeout(2500);
s.on("connect", () => { s.destroy(); resolve(true); });
s.on("error", () => resolve(false));
s.on("timeout", () => { s.destroy(); resolve(false); });
});
};
const attrMap = (entry) => {
const out = {};
if (entry && entry.attributes) {
for (const a of entry.attributes) {
out[a.type.toLowerCase()] = a.values;
}
}
return out;
};
const run = async () => {
if (!(await portOpen("127.0.0.1", LDAP_PORT))) {
console.log(" SKIP PG LDAPS :" + LDAP_PORT + " not reachable -- multi-tenant LDAP gate skipped");
console.log("\nRESULT: " + pass + " passed, " + fail + " failed (skipped)");
process.exit(0);
}
// Ensure both tenant admins exist (created over HTTP if missing).
for (const t of TENANTS) {
ok(await bootstrapTenant(t), t + " admin bootstrapped (" + ADMIN(t) + ")");
}
// 1. bind under the correct tenant succeeds + resolves that tenant's user
let c = newClient();
let err = await doBind(c, userDn(ADMIN("t1"), "t1"), ADMIN_PW);
ok(!err, "bind admin@t1.local under dc=t1 succeeds" + (err ? " (" + err.name + ")" : ""));
if (!err) {
const r = await doSearch(c, peopleBase("t1"), { scope: "sub", filter: "(uid=" + ADMIN("t1") + ")", attributes: ["mail", "memberof"] });
const a = attrMap(r.entries[0] || {});
ok(r.entries.length === 1 && a.mail && a.mail[0] === ADMIN("t1"), "dc=t1 search resolves t1's user only");
}
c.unbind(() => {});
// 2. same uid under the OTHER tenant fails (user not present in t2)
c = newClient();
err = await doBind(c, userDn(ADMIN("t1"), "t2"), ADMIN_PW);
ok(!!err && /InvalidCredentials/i.test(err.name || ""), "admin@t1.local under dc=t2 rejected (" + (err && err.name) + ")");
c.unbind(() => {});
// 3. cross-tenant search denied (bound under dc=t1, search base dc=t2)
c = newClient();
err = await doBind(c, userDn(ADMIN("t1"), "t1"), ADMIN_PW);
let crossErr = null;
if (!err) {
try {
await doSearch(c, peopleBase("t2"), { scope: "sub", filter: "(uid=" + ADMIN("t2") + ")", attributes: ["mail"] });
} catch (e) {
crossErr = e;
}
}
ok(!!crossErr && /InsufficientAccessRights/i.test(crossErr.name || ""), "cross-tenant search (bound t1, base t2) denied (" + (crossErr && crossErr.name) + ")");
c.unbind(() => {});
// 4. unknown tenant denied
c = newClient();
err = await doBind(c, userDn(ADMIN("t1"), "bogus"), ADMIN_PW);
ok(!!err && /InvalidCredentials/i.test(err.name || ""), "bind under unknown tenant dc=bogus denied (" + (err && err.name) + ")");
c.unbind(() => {});
// 5. groupOfNames within a tenant
c = newClient();
err = await doBind(c, userDn(ADMIN("t1"), "t1"), ADMIN_PW);
if (!err) {
const r = await doSearch(c, "dc=t1,dc=saltcorn,dc=local", { scope: "sub", filter: "(objectclass=groupOfNames)", attributes: ["cn", "member"] });
const grp = r.entries.map(attrMap).find((g) => Array.isArray(g.member) && g.member.some((m) => m.indexOf("dc=t1,dc=saltcorn,dc=local") >= 0));
ok(r.entries.length >= 1 && !!grp, "dc=t1 groupOfNames entries carry tenant-scoped member DNs");
}
c.unbind(() => {});
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
};
run().catch((e) => {
console.error("LDAP MT GATE ERROR:", e);
process.exit(2);
});

295
test/mtGate.js Normal file
View file

@ -0,0 +1,295 @@
// Multi-tenant OIDC gate against the Postgres instance (:3002). Drives a full
// authorization-code + PKCE flow on tenant t1's issuer and asserts per-tenant
// isolation: an authorization code, access token, and id_token minted by t1 are
// REJECTED at t2 (per-tenant store + per-issuer Provider + per-tenant signing
// key). Tenants are addressed by Host header (tNN.localhost.localdomain:3002 ->
// tenant tNN; the token issuer is the 2-label base_url http://tNN.localhost:3002/idp).
// Each tenant uses a separate cookie jar. Run: node test/mtGate.js
// (PG :3002 up, tenants t1/t2 present with the plugin installed).
const http = require("http");
const crypto = require("crypto");
const PG_PORT = 3002;
const TENANTS = ["t1", "t2"];
const CLIENT_ID = "mt-rp";
const REDIRECT_URI = "http://localhost:9099/cb";
const ADMIN_PW = "AdminP@ss1";
const jars = { t1: {}, t2: {} };
let pass = 0;
let fail = 0;
const ok = (cond, msg) => {
if (cond) {
pass++;
console.log(" PASS " + msg);
} else {
fail++;
console.log(" FAIL " + msg);
}
};
const HOST = (t) => t + ".localhost.localdomain:" + PG_PORT;
const ISSUER = (t) => "http://" + t + ".localhost:" + PG_PORT + "/idp";
const ADMIN = (t) => "admin@" + t + ".local";
const storeCookies = (t, headers) => {
const sc = headers["set-cookie"];
if (!sc) {
return;
}
for (const line of sc) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
if (eq > 0) {
jars[t][pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
}
}
};
const request = (t, method, path, opts) => {
const options = opts || {};
return new Promise((resolve, reject) => {
const headers = Object.assign({ Host: HOST(t) }, options.headers || {});
const jar = jars[t];
if (Object.keys(jar).length > 0) {
headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; ");
}
let data = null;
if (options.body) {
data = new URLSearchParams(options.body).toString();
headers["Content-Type"] = "application/x-www-form-urlencoded";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request({ host: "127.0.0.1", port: PG_PORT, method: method, path: path, headers: headers }, (resp) => {
storeCookies(t, resp.headers);
let body = "";
resp.on("data", (c) => { body += c; });
resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: body }));
});
r.on("error", reject);
if (data !== null) {
r.write(data);
}
r.end();
});
};
const csrfOf = (html) => {
const m = html.match(/name="_csrf" value="([^"]+)"/);
return m ? m[1] : "";
};
const getJson = async (t, path) => {
const r = await request(t, "GET", path);
try {
return JSON.parse(r.body);
} catch (e) {
return {};
}
};
const authed = async (t) => {
return /true/.test((await request(t, "GET", "/auth/authenticated")).body);
};
const resolvePath = (loc) => {
const u = loc.indexOf("http") === 0 ? new URL(loc) : new URL(loc, "http://x");
return u.pathname + u.search;
};
// Ensure a tenant admin exists and the jar holds an authenticated session.
const bootstrapTenant = async (t) => {
const lp = await request(t, "GET", "/auth/login");
await request(t, "POST", "/auth/login", { body: { email: ADMIN(t), password: ADMIN_PW, _csrf: csrfOf(lp.body) } });
if (await authed(t)) {
return true;
}
const cp = await request(t, "GET", "/auth/create_first_user");
const cc = csrfOf(cp.body);
if (cc) {
await request(t, "POST", "/auth/create_first_user", { body: { email: ADMIN(t), password: ADMIN_PW, default_language: "en", _csrf: cc } });
}
if (await authed(t)) {
return true;
}
const lp2 = await request(t, "GET", "/auth/login");
await request(t, "POST", "/auth/login", { body: { email: ADMIN(t), password: ADMIN_PW, _csrf: csrfOf(lp2.body) } });
return await authed(t);
};
const clientExists = async (t, clientId) => {
const html = (await request(t, "GET", "/admin/idp/clients")).body;
return html.indexOf("<code>" + clientId + "</code>") >= 0;
};
const registerRp = async (t) => {
const c1 = csrfOf((await request(t, "GET", "/admin/idp/clients")).body);
await request(t, "POST", "/admin/idp/clients/delete", { body: { client_id: CLIENT_ID, _csrf: c1 } });
const c2 = csrfOf((await request(t, "GET", "/admin/idp/clients")).body);
await request(t, "POST", "/admin/idp/clients/create", { body: {
client_id: CLIENT_ID, label: "MT RP", redirect_uris: REDIRECT_URI,
auth_method: "none", scope: "openid email profile groups", _csrf: c2
} });
return clientExists(t, CLIENT_ID);
};
// Run the auth-code + PKCE flow up to obtaining the authorization code (does NOT
// exchange it), so the caller can replay an unconsumed code cross-tenant.
const getAuthCode = async (t) => {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest().toString("base64url");
const q = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: "openid email profile groups",
state: crypto.randomBytes(8).toString("base64url"),
nonce: crypto.randomBytes(8).toString("base64url"),
code_challenge: challenge,
code_challenge_method: "S256"
});
let path = "/idp/auth?" + q.toString();
let code = null;
let hops = 0;
while (hops < 15) {
hops++;
const r = await request(t, "GET", path);
if (r.status >= 300 && r.status < 400 && r.headers.location) {
if (r.headers.location.indexOf(REDIRECT_URI) === 0) {
code = new URL(r.headers.location).searchParams.get("code");
break;
}
path = resolvePath(r.headers.location);
} else if (r.status === 200 && /\/confirm"/.test(r.body)) {
const m = r.body.match(/action="([^"]*\/confirm)"/);
if (!m) {
throw new Error("consent page without a confirm action");
}
const pr = await request(t, "POST", resolvePath(m[1]), { body: { allow: "1" } });
if (pr.status >= 300 && pr.status < 400 && pr.headers.location) {
path = resolvePath(pr.headers.location);
} else {
throw new Error("consent confirm did not redirect: HTTP " + pr.status);
}
} else {
throw new Error("auth flow stalled on " + t + ": HTTP " + r.status + " " + r.body.slice(0, 120));
}
}
return { code: code, verifier: verifier };
};
const exchangeToken = async (t, code, verifier) => {
const r = await request(t, "POST", "/idp/token", { body: {
grant_type: "authorization_code",
code: code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier
} });
let tokens = {};
try {
tokens = JSON.parse(r.body);
} catch (e) {
/* leave empty */
}
let header = {};
let payload = {};
if (tokens.id_token) {
tokens.__parts = tokens.id_token.split(".");
header = JSON.parse(Buffer.from(tokens.__parts[0], "base64url").toString());
payload = JSON.parse(Buffer.from(tokens.__parts[1], "base64url").toString());
}
return { status: r.status, body: r.body, tokens: tokens, header: header, payload: payload };
};
const verifyIdToken = async (t, tok) => {
const jwks = await getJson(t, "/idp/jwks");
const jwk = (jwks.keys || []).find((k) => k.kid === tok.header.kid) || (jwks.keys || [])[0];
if (!jwk) {
return false;
}
const pub = crypto.createPublicKey({ key: jwk, format: "jwk" });
return crypto.verify("sha256", Buffer.from(tok.tokens.__parts[0] + "." + tok.tokens.__parts[1]), pub, Buffer.from(tok.tokens.__parts[2], "base64url"));
};
const run = async () => {
// Phase 0: distinct issuers + distinct keys per tenant
const d1 = await getJson("t1", "/idp/.well-known/openid-configuration");
const d2 = await getJson("t2", "/idp/.well-known/openid-configuration");
ok(d1.issuer === ISSUER("t1") && d2.issuer === ISSUER("t2"), "distinct per-tenant issuers (" + d1.issuer + " / " + d2.issuer + ")");
const k1 = await getJson("t1", "/idp/jwks");
const k2 = await getJson("t2", "/idp/jwks");
const kid1 = k1.keys && k1.keys[0] && k1.keys[0].kid;
const kid2 = k2.keys && k2.keys[0] && k2.keys[0].kid;
ok(kid1 && kid2 && kid1 !== kid2, "distinct per-tenant signing keys (" + kid1 + " / " + kid2 + ")");
// Bootstrap admins + register the RP in each tenant
for (const t of TENANTS) {
ok(await bootstrapTenant(t), t + " admin session established");
ok(await registerRp(t), t + " RP registered (" + CLIENT_ID + ")");
}
// Positive: full flow on t1
const c1 = await getAuthCode("t1");
ok(!!c1.code, "t1 issued an authorization code");
const tok = await exchangeToken("t1", c1.code, c1.verifier);
ok(tok.status === 200 && !!tok.tokens.access_token && !!tok.tokens.id_token, "t1 token endpoint issued access_token + id_token");
ok(await verifyIdToken("t1", tok), "t1 id_token verifies against t1 JWKS");
ok(tok.payload.iss === ISSUER("t1"), "t1 id_token iss = " + tok.payload.iss);
// Cross-tenant (A): an unconsumed t1 code is rejected at t2's token endpoint
const c1b = await getAuthCode("t1");
const xTok = await exchangeToken("t2", c1b.code, c1b.verifier);
let xErr = {};
try {
xErr = JSON.parse(xTok.body);
} catch (e) {
/* leave empty */
}
ok(xTok.status >= 400 && !xTok.tokens.access_token, "t1 authorization code REJECTED at t2 token endpoint (HTTP " + xTok.status + " " + (xErr.error || "") + ")");
// Cross-tenant (B): a t1 access token is rejected at t2 userinfo
const meT2 = await request("t2", "GET", "/idp/me", { headers: { Authorization: "Bearer " + tok.tokens.access_token } });
ok(meT2.status === 401, "t1 access_token REJECTED at t2 userinfo (HTTP " + meT2.status + ")");
// Cross-tenant (C): a t1 id_token does NOT verify against t2's JWKS
const sigVsT2 = await verifyIdToken("t2", tok);
ok(sigVsT2 === false, "t1 id_token signature does NOT verify against t2 JWKS");
ok(tok.payload.iss !== ISSUER("t2"), "t1 id_token iss is not t2's issuer");
// Positive control: the same t1 access token still works at t1 userinfo
const meT1 = await request("t1", "GET", "/idp/me", { headers: { Authorization: "Bearer " + tok.tokens.access_token } });
ok(meT1.status === 200, "t1 access_token still valid at t1 userinfo (rejection is tenant-specific, not blanket)");
// Cleanup
for (const t of TENANTS) {
const cc = csrfOf((await request(t, "GET", "/admin/idp/clients")).body);
await request(t, "POST", "/admin/idp/clients/delete", { body: { client_id: CLIENT_ID, _csrf: cc } });
}
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
};
run().catch((e) => {
console.error("MT GATE ERROR:", e);
process.exit(2);
});

279
test/samlGate.js Normal file
View file

@ -0,0 +1,279 @@
// Phase 5 gate: drive SAML against the IdP (MAIN :3000) as a samlify SP.
// Covers SP-initiated SSO (signed assertion + real AuthnStatement), the SP
// registry + ACS allow-list (unregistered SP and out-of-allow-list ACS are
// rejected), DTD/ENTITY (XXE) rejection, IdP-initiated SSO (no InResponseTo),
// and Single Logout. Tests run in order and share state (login + a registered
// SP); the SLO test is LAST because it destroys the session. Run:
// node idp/test/samlGate.js (MAIN up, SP registered automatically).
const saml = require("samlify");
const validator = require("@authenio/samlify-node-xmllint");
const http = require("http");
const zlib = require("zlib");
saml.setSchemaValidator(validator);
const PORT = 3000;
const ADMIN_EMAIL = "admin@local";
const ADMIN_PW = "AdminP@ss1";
const SP_ENTITY = "http://localhost:9099/saml/sp";
const SP_ACS = "http://localhost:9099/saml/acs";
const jar = {};
let pass = 0;
let fail = 0;
const ok = (cond, msg) => {
if (cond) {
pass++;
console.log(" PASS " + msg);
} else {
fail++;
console.log(" FAIL " + msg);
}
};
const storeCookies = (headers) => {
const sc = headers["set-cookie"];
if (!sc) {
return;
}
for (const line of sc) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
if (eq > 0) {
jar[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
}
}
};
const request = (method, path, opts) => {
const options = opts || {};
return new Promise((resolve, reject) => {
const headers = Object.assign({}, options.headers || {});
if (!options.noCookies && Object.keys(jar).length > 0) {
headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; ");
}
let data = null;
if (options.body) {
data = new URLSearchParams(options.body).toString();
headers["Content-Type"] = "application/x-www-form-urlencoded";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request({ host: "localhost", port: PORT, method: method, path: path, headers: headers }, (resp) => {
storeCookies(resp.headers);
let body = "";
resp.on("data", (c) => { body += c; });
resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: body }));
});
r.on("error", reject);
if (data !== null) {
r.write(data);
}
r.end();
});
};
const adminCsrf = async (path) => {
const p = await request("GET", path);
return (p.body.match(/name="_csrf" value="([^"]+)"/) || [])[1] || "";
};
const samlResponseOf = (body) => {
const m = body.match(/name="SAMLResponse" value="([^"]+)"/);
return m ? m[1] : null;
};
// Build a redirect-binding SAMLRequest query value from raw XML (deflate+base64).
const deflateReq = (xml) => {
return zlib.deflateRawSync(Buffer.from(xml, "utf8")).toString("base64");
};
const run = async () => {
// 1. Saltcorn login
const lp = await request("GET", "/auth/login");
const csrf = (lp.body.match(/name="_csrf" value="([^"]+)"/) || [])[1] || "";
const lr = await request("POST", "/auth/login", { body: { email: ADMIN_EMAIL, password: ADMIN_PW, _csrf: csrf } });
ok(lr.status === 302 || lr.status === 303, "Saltcorn login redirected");
ok(/true/.test((await request("GET", "/auth/authenticated")).body), "session authenticated");
// 2. Register the test SP (delete+create for a deterministic ACS allow-list)
await request("POST", "/admin/idp/saml-sps/delete", { body: { entity_id: SP_ENTITY, _csrf: await adminCsrf("/admin/idp/saml-sps") } });
const reg = await request("POST", "/admin/idp/saml-sps/create", { body: { entity_id: SP_ENTITY, label: "gate sp", acs_urls: SP_ACS, _csrf: await adminCsrf("/admin/idp/saml-sps") } });
ok(reg.status === 302 || reg.status === 303, "registered test SP via admin UI");
// 3. IdP metadata -> build idp + sp
const md = await request("GET", "/idp/saml/metadata");
ok(md.status === 200 && /EntityDescriptor/.test(md.body), "IdP metadata served (HTTP " + md.status + ")");
ok(/SingleLogoutService/.test(md.body), "IdP metadata advertises SingleLogoutService");
const idp = saml.IdentityProvider({ metadata: md.body });
const sp = saml.ServiceProvider({
entityID: SP_ENTITY,
assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: SP_ACS }],
wantAssertionsSigned: true,
wantLogoutResponseSigned: true
});
// 4. SP-initiated AuthnRequest (redirect binding) -> hit IdP SSO with cookie
const loginReq = sp.createLoginRequest(idp, "redirect");
const url = new URL(loginReq.context);
ok(!!url.searchParams.get("SAMLRequest"), "SP created AuthnRequest");
const ssoResp = await request("GET", url.pathname + url.search);
ok(ssoResp.status === 200 && /SAMLResponse/.test(ssoResp.body), "IdP SSO returned a Response form (HTTP " + ssoResp.status + ")");
// 5. Verify the SAMLResponse as the SP
const samlResponse = samlResponseOf(ssoResp.body);
ok(!!samlResponse, "extracted SAMLResponse from auto-POST form");
if (samlResponse) {
const parsed = await sp.parseLoginResponse(idp, "post", { body: { SAMLResponse: samlResponse } });
const ex = parsed.extract;
ok(ex.nameID === ADMIN_EMAIL, "assertion NameID = " + ex.nameID + " (signature verified vs IdP cert)");
const attrs = ex.attributes || {};
const emailVal = Array.isArray(attrs.email) ? attrs.email[0] : attrs.email;
ok(emailVal === ADMIN_EMAIL, "email attribute = " + JSON.stringify(attrs.email));
const grp = attrs.groups;
const grpStr = Array.isArray(grp) ? grp.join(",") : String(grp || "");
ok(grpStr.indexOf("role:admin") >= 0, "groups attribute includes role:admin (" + JSON.stringify(grp) + ")");
const decoded = Buffer.from(samlResponse, "base64").toString("utf8");
ok(/AuthnStatement/.test(decoded) && /PasswordProtectedTransport/.test(decoded), "assertion carries a real AuthnStatement");
}
// 6. Adversarial: an UNREGISTERED SP entityID is rejected
const evilSp = saml.ServiceProvider({
entityID: "http://localhost:9099/saml/UNREGISTERED",
assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: SP_ACS }]
});
const evilReq = evilSp.createLoginRequest(idp, "redirect");
const evilUrl = new URL(evilReq.context);
const evilResp = await request("GET", evilUrl.pathname + evilUrl.search);
ok(evilResp.status === 403 && !/SAMLResponse/.test(evilResp.body), "unregistered SP rejected (403, no assertion)");
// 7. Adversarial: registered SP requesting an ACS NOT in its allow-list
const badAcsSp = saml.ServiceProvider({
entityID: SP_ENTITY,
assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: "http://evil.example/acs" }]
});
const badReq = badAcsSp.createLoginRequest(idp, "redirect");
const badUrl = new URL(badReq.context);
const badResp = await request("GET", badUrl.pathname + badUrl.search);
ok(badResp.status === 403 && !/SAMLResponse/.test(badResp.body), "out-of-allow-list ACS rejected (403, no assertion)");
// 8. Adversarial: a DTD/ENTITY (XXE) AuthnRequest is rejected before parse
const xxeXml = `<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>`
+ `<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"`
+ ` ID="_x" Version="2.0" IssueInstant="2026-01-01T00:00:00Z" AssertionConsumerServiceURL="${SP_ACS}">`
+ `<saml:Issuer>${SP_ENTITY}</saml:Issuer></samlp:AuthnRequest>`;
const xxeResp = await request("GET", "/idp/saml/sso?SAMLRequest=" + encodeURIComponent(deflateReq(xxeXml)));
ok(xxeResp.status === 400 && !/SAMLResponse/.test(xxeResp.body), "DTD/ENTITY AuthnRequest rejected (400, no XXE)");
// 9. IdP-initiated SSO (unsolicited Response, no InResponseTo)
const initResp = await request("GET", "/idp/saml/init?sp=" + encodeURIComponent(SP_ENTITY) + "&acs=" + encodeURIComponent(SP_ACS));
ok(initResp.status === 200 && /SAMLResponse/.test(initResp.body), "IdP-initiated SSO returned a Response (HTTP " + initResp.status + ")");
const initSaml = samlResponseOf(initResp.body);
if (initSaml) {
const iparsed = await sp.parseLoginResponse(idp, "post", { body: { SAMLResponse: initSaml } });
ok(iparsed.extract.nameID === ADMIN_EMAIL, "IdP-initiated NameID = admin@local");
const idecoded = Buffer.from(initSaml, "base64").toString("utf8");
ok(!/InResponseTo=/.test(idecoded), "IdP-initiated Response has no InResponseTo");
}
// 9a2. Signed-SP path: register an SP WITH a signing cert + want_signed, then
// a correctly SIGNED AuthnRequest must be accepted (signature verified against
// the registered cert) and an UNSIGNED one rejected. Exercises getSignedIdp +
// the redirect-binding octetString reconstruction.
const selfsigned = require("selfsigned");
const spPems = await selfsigned.generate([{ name: "commonName", value: "gate-signed-sp" }], { keyType: "rsa", keySize: 2048, algorithm: "sha256" });
const SIGNED_SP = "http://localhost:9099/saml/signed-sp";
await request("POST", "/admin/idp/saml-sps/delete", { body: { entity_id: SIGNED_SP, _csrf: await adminCsrf("/admin/idp/saml-sps") } });
await request("POST", "/admin/idp/saml-sps/create", { body: { entity_id: SIGNED_SP, label: "signed sp", acs_urls: SP_ACS, signing_cert: spPems.cert, want_signed: "1", _csrf: await adminCsrf("/admin/idp/saml-sps") } });
const signedSp = saml.ServiceProvider({
entityID: SIGNED_SP,
assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: SP_ACS }],
signingCert: spPems.cert,
privateKey: spPems.private,
authnRequestsSigned: true
});
// Client-side IdP view with WantAuthnRequestsSigned=true so samlify lets a
// signing SP build the request (built directly rather than from metadata,
// whose advertised flag is false and would override the constructor; the
// SERVER still decides verification via the SP's registered want_signed flag).
const ISSUER = "http://localhost:" + PORT + "/idp";
const signedIdp = saml.IdentityProvider({
entityID: ISSUER + "/saml",
singleSignOnService: [
{ Binding: saml.Constants.namespace.binding.redirect, Location: ISSUER + "/saml/sso" },
{ Binding: saml.Constants.namespace.binding.post, Location: ISSUER + "/saml/sso" }
],
wantAuthnRequestsSigned: true
});
const sgReq = signedSp.createLoginRequest(signedIdp, "redirect");
const sgUrl = new URL(sgReq.context);
const sgResp = await request("GET", sgUrl.pathname + sgUrl.search);
ok(sgResp.status === 200 && /SAMLResponse/.test(sgResp.body), "signed-SP: signed AuthnRequest accepted (signature verified, HTTP " + sgResp.status + ")");
const unsignedSp = saml.ServiceProvider({ entityID: SIGNED_SP, assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: SP_ACS }] });
const usReq = unsignedSp.createLoginRequest(idp, "redirect");
const usUrl = new URL(usReq.context);
const usResp = await request("GET", usUrl.pathname + usUrl.search);
ok(usResp.status === 403 && !/SAMLResponse/.test(usResp.body), "signed-SP: UNSIGNED AuthnRequest rejected (403)");
await request("POST", "/admin/idp/saml-sps/delete", { body: { entity_id: SIGNED_SP, _csrf: await adminCsrf("/admin/idp/saml-sps") } });
// 9b. Adversarial DoS: a deflate "zip bomb" (10 MiB inflating from a tiny
// base64 blob) must be rejected (400) by the inflate output cap, NOT crash
// the worker. The server must still serve afterwards.
const bomb = zlib.deflateRawSync(Buffer.alloc(10 * 1024 * 1024, 0x41)).toString("base64");
const bombResp = await request("GET", "/idp/saml/sso?SAMLRequest=" + encodeURIComponent(bomb));
ok(bombResp.status === 400 && !/SAMLResponse/.test(bombResp.body), "decompression-bomb SAMLRequest rejected (400, output cap)");
ok((await request("GET", "/idp/saml/metadata")).status === 200, "server survived the decompression bomb (metadata still served)");
// 9c. Adversarial: an SP registered with NO ACS URL must be refused, so the
// SLO/SSO handlers can never fall back to an attacker-supplied destination.
const noAcsSp = "http://localhost:9099/saml/NOACS";
await request("POST", "/admin/idp/saml-sps/delete", { body: { entity_id: noAcsSp, _csrf: await adminCsrf("/admin/idp/saml-sps") } });
await request("POST", "/admin/idp/saml-sps/create", { body: { entity_id: noAcsSp, label: "no acs", acs_urls: "", _csrf: await adminCsrf("/admin/idp/saml-sps") } });
const spsList = (await request("GET", "/admin/idp/saml-sps")).body;
ok(spsList.indexOf(noAcsSp) < 0, "SP with empty ACS list refused at registration (defence-in-depth)");
// 9d. Adversarial SLO: a LogoutRequest with NO authenticated session must be
// rejected (an unauthenticated forgery cannot destroy a session).
const forgedReq = sp.createLogoutRequest(idp, "redirect", { logoutNameID: ADMIN_EMAIL });
const fUrl = new URL(forgedReq.context);
const noSessSlo = await request("GET", fUrl.pathname + fUrl.search, { noCookies: true });
ok(noSessSlo.status === 403 && !/SAMLResponse/.test(noSessSlo.body), "SLO without a session rejected (403, no forced logout)");
// 9e. Adversarial SLO: a LogoutRequest naming a DIFFERENT subject than the
// session user must be rejected (cannot log out a session it does not name).
const wrongReq = sp.createLogoutRequest(idp, "redirect", { logoutNameID: "intruder@local" });
const wUrl = new URL(wrongReq.context);
const wrongSlo = await request("GET", wUrl.pathname + wUrl.search);
ok(wrongSlo.status === 403 && !/SAMLResponse/.test(wrongSlo.body), "SLO with a mismatched NameID rejected (403)");
// 10. Single Logout (LAST: destroys the session) -- authenticated, correct NameID.
const logoutReq = sp.createLogoutRequest(idp, "redirect", { logoutNameID: ADMIN_EMAIL });
const lurl = new URL(logoutReq.context);
const sloResp = await request("GET", lurl.pathname + lurl.search);
ok(sloResp.status === 200 && /SAMLResponse/.test(sloResp.body), "SLO returned a LogoutResponse form (HTTP " + sloResp.status + ")");
const sloSaml = samlResponseOf(sloResp.body);
if (sloSaml) {
const lparsed = await sp.parseLogoutResponse(idp, "post", { body: { SAMLResponse: sloSaml } });
ok(!!(lparsed && lparsed.extract), "LogoutResponse verified (signature vs IdP cert)");
const ir = lparsed.extract.response ? lparsed.extract.response.inResponseTo : null;
ok(ir === logoutReq.id, "LogoutResponse InResponseTo matches the LogoutRequest id");
}
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
};
run().catch((e) => {
console.error("SAML GATE ERROR:", e);
process.exit(2);
});

217
test/samlMultiTenantGate.js Normal file
View file

@ -0,0 +1,217 @@
// Multi-tenant SAML ISOLATION gate (Postgres :3002). mtGate proves OIDC
// cross-tenant isolation and ldapMultiTenantGate proves LDAP; this proves SAML:
// each tenant signs assertions with its OWN per-tenant certificate, and a
// Response issued by t1 cannot be validated as t2's.
// 1. t1 and t2 serve DISTINCT SAML signing certs + DISTINCT issuers/entityIDs
// in their IdP metadata.
// 2. An SP-initiated SSO Response from t1 embeds t1's signing cert (not t2's).
// 3. The Response validates against t1's metadata-derived IdP (happy path) but
// is REJECTED against t2's (t1's signature does not verify with t2's cert).
// Tenants are addressed by Host header (tNN.localhost.localdomain:3002). Run:
// node idp/test/samlMultiTenantGate.js (PG :3002 up, idp installed per-tenant).
// Self-skips (exit 0) if :3002 is unreachable.
const saml = require("samlify");
const validator = require("@authenio/samlify-node-xmllint");
const http = require("http");
const net = require("net");
saml.setSchemaValidator(validator);
const PG_PORT = 3002;
const TENANTS = ["t1", "t2"];
const ADMIN_PW = "AdminP@ss1";
const SP_ENTITY = "http://localhost:9097/mtsaml/sp";
const SP_ACS = "http://localhost:9097/mtsaml/acs";
const jars = { t1: {}, t2: {} };
let pass = 0;
let fail = 0;
const ok = (cond, msg) => {
if (cond) {
pass++;
console.log(" PASS " + msg);
} else {
fail++;
console.log(" FAIL " + msg);
}
};
const HOST = (t) => t + ".localhost.localdomain:" + PG_PORT;
const ADMIN = (t) => "admin@" + t + ".local";
const portOpen = (port) => {
return new Promise((resolve) => {
const s = net.connect(port, "127.0.0.1");
const done = (up) => { s.destroy(); resolve(up); };
s.setTimeout(1000);
s.on("connect", () => done(true));
s.on("timeout", () => done(false));
s.on("error", () => done(false));
});
};
const storeCookies = (t, headers) => {
const sc = headers["set-cookie"];
if (!sc) {
return;
}
for (const line of sc) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
if (eq > 0) {
jars[t][pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
}
}
};
const request = (t, method, path, opts) => {
const options = opts || {};
return new Promise((resolve, reject) => {
const headers = Object.assign({ Host: HOST(t) }, options.headers || {});
const jar = jars[t];
if (Object.keys(jar).length > 0) {
headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; ");
}
let data = null;
if (options.body) {
data = new URLSearchParams(options.body).toString();
headers["Content-Type"] = "application/x-www-form-urlencoded";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request({ host: "127.0.0.1", port: PG_PORT, method: method, path: path, headers: headers }, (resp) => {
storeCookies(t, resp.headers);
let body = "";
resp.on("data", (c) => { body += c; });
resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: body }));
});
r.on("error", reject);
if (data !== null) {
r.write(data);
}
r.end();
});
};
const csrfOf = (h) => (h.match(/name="_csrf" value="([^"]+)"/) || [])[1] || "";
const adminCsrf = async (t, path) => csrfOf((await request(t, "GET", path)).body);
const authed = async (t) => /true/.test((await request(t, "GET", "/auth/authenticated")).body);
// First <X509Certificate> in an XML doc (metadata signing cert / signature cert),
// whitespace-stripped for stable comparison.
const x509Of = (xml) => {
const m = xml.match(/<(?:[A-Za-z0-9]+:)?X509Certificate>([\s\S]*?)<\/(?:[A-Za-z0-9]+:)?X509Certificate>/);
return m ? m[1].replace(/\s+/g, "") : null;
};
const entityIdOf = (xml) => (xml.match(/entityID="([^"]+)"/) || [])[1] || null;
const samlResponseOf = (body) => (body.match(/name="SAMLResponse" value="([^"]+)"/) || [])[1] || null;
const bootstrapTenant = async (t) => {
const lp = await request(t, "GET", "/auth/login");
await request(t, "POST", "/auth/login", { body: { email: ADMIN(t), password: ADMIN_PW, _csrf: csrfOf(lp.body) } });
if (await authed(t)) return true;
const cp = await request(t, "GET", "/auth/create_first_user");
const cc = csrfOf(cp.body);
if (cc) {
await request(t, "POST", "/auth/create_first_user", { body: { email: ADMIN(t), password: ADMIN_PW, default_language: "en", _csrf: cc } });
}
if (await authed(t)) return true;
const lp2 = await request(t, "GET", "/auth/login");
await request(t, "POST", "/auth/login", { body: { email: ADMIN(t), password: ADMIN_PW, _csrf: csrfOf(lp2.body) } });
return await authed(t);
};
const registerSp = async (t) => {
await request(t, "POST", "/admin/idp/saml-sps/delete", { body: { entity_id: SP_ENTITY, _csrf: await adminCsrf(t, "/admin/idp/saml-sps") } });
await request(t, "POST", "/admin/idp/saml-sps/create", { body: { entity_id: SP_ENTITY, label: "mtsaml", acs_urls: SP_ACS, _csrf: await adminCsrf(t, "/admin/idp/saml-sps") } });
};
const run = async () => {
for (const t of TENANTS) {
ok(await bootstrapTenant(t), t + " admin session established");
}
// 1. Per-tenant metadata: distinct signing certs + distinct issuers.
const md1 = (await request("t1", "GET", "/idp/saml/metadata")).body;
const md2 = (await request("t2", "GET", "/idp/saml/metadata")).body;
const c1 = x509Of(md1);
const c2 = x509Of(md2);
ok(c1 && c2, "both tenants serve a SAML signing cert in metadata");
ok(c1 && c2 && c1 !== c2, "t1 and t2 have DISTINCT SAML signing certs");
const e1 = entityIdOf(md1);
const e2 = entityIdOf(md2);
ok(e1 && e2 && e1 !== e2, "t1 and t2 have DISTINCT IdP entityIDs (" + e1 + " / " + e2 + ")");
// 2. Drive SP-initiated SSO on t1 -> Response embeds t1's cert, not t2's.
await registerSp("t1");
const idp1 = saml.IdentityProvider({ metadata: md1 });
const idp2 = saml.IdentityProvider({ metadata: md2 });
const sp = saml.ServiceProvider({
entityID: SP_ENTITY,
assertionConsumerService: [{ Binding: saml.Constants.namespace.binding.post, Location: SP_ACS }],
wantAssertionsSigned: true
});
const loginReq = sp.createLoginRequest(idp1, "redirect");
const u = new URL(loginReq.context);
const ssoResp = await request("t1", "GET", u.pathname + u.search);
const samlResp = samlResponseOf(ssoResp.body);
ok(ssoResp.status === 200 && !!samlResp, "t1 SP-initiated SSO returned a signed Response (HTTP " + ssoResp.status + ")");
if (samlResp) {
const respXml = Buffer.from(samlResp, "base64").toString("utf8");
const respCert = x509Of(respXml);
ok(respCert === c1, "t1 Response is signed with t1's cert (embedded cert matches t1 metadata)");
ok(respCert !== c2, "t1 Response is NOT signed with t2's cert (cross-tenant isolation)");
// 3. Validates against t1's IdP; rejected against t2's.
let t1ok = false;
try {
const info = await sp.parseLoginResponse(idp1, "post", { body: { SAMLResponse: samlResp } });
t1ok = !!(info && info.extract);
} catch (e) {
t1ok = false;
}
ok(t1ok, "t1 Response validates against t1's IdP (happy path)");
let t2rejected = false;
try {
await sp.parseLoginResponse(idp2, "post", { body: { SAMLResponse: samlResp } });
t2rejected = false;
} catch (e) {
t2rejected = true;
}
ok(t2rejected, "t1 Response REJECTED against t2's IdP (signature fails with t2's cert)");
}
// Cleanup.
await request("t1", "POST", "/admin/idp/saml-sps/delete", { body: { entity_id: SP_ENTITY, _csrf: await adminCsrf("t1", "/admin/idp/saml-sps") } });
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
};
const main = async () => {
if (!(await portOpen(PG_PORT))) {
console.log("SKIP: Postgres multi-tenant instance not reachable on 127.0.0.1:" + PG_PORT);
process.exit(0);
}
try {
await run();
} catch (e) {
console.error("SAML MT GATE ERROR:", e);
process.exit(2);
}
};
main();