Initial commit.
This commit is contained in:
commit
ad31f61fab
52 changed files with 10180 additions and 0 deletions
52
.gitattributes
vendored
Normal file
52
.gitattributes
vendored
Normal 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
33
.gitignore
vendored
Normal 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
178
README.md
Normal 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
94
VENDORING.md
Normal 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
304
docs/architecture.md
Normal 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
300
docs/configuration.md
Normal 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
351
docs/ldap.md
Normal 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
440
docs/oidc.md
Normal 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
266
docs/operations.md
Normal 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
478
docs/saml.md
Normal 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
396
docs/security.md
Normal 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
138
docs/testing.md
Normal 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
161
index.js
Normal 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
547
lib/adminUi.js
Normal 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:<name></code>) plus these custom groups (as <code>group:<name></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
41
lib/claims.js
Normal 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
95
lib/clients.js
Normal 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
169
lib/constants.js
Normal 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
168
lib/crypto.js
Normal 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
47
lib/env.js
Normal 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
79
lib/groups.js
Normal 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
168
lib/keys.js
Normal 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
91
lib/ldap/bind.js
Normal 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
85
lib/ldap/dn.js
Normal 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
47
lib/ldap/harden.js
Normal 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
192
lib/ldap/search.js
Normal 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
177
lib/ldap/server.js
Normal 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
|
||||||
|
};
|
||||||
56
lib/ldap/serviceAccount.js
Normal file
56
lib/ldap/serviceAccount.js
Normal 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
59
lib/ldap/tenant.js
Normal 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
62
lib/ldap/vendor.js
Normal 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
88
lib/oidc/adapter.js
Normal 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
43
lib/oidc/discovery.js
Normal 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
110
lib/oidc/interactions.js
Normal 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
124
lib/oidc/provider.js
Normal 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
89
lib/oidc/routes.js
Normal 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
16
lib/routes.js
Normal 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
217
lib/saml/idp.js
Normal 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
545
lib/saml/routes.js
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 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
82
lib/saml/sps.js
Normal 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
190
lib/schema.js
Normal 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
16
lib/web.js
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
escapeHtml
|
||||||
|
};
|
||||||
1252
package-lock.json
generated
Normal file
1252
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
package.json
Normal file
35
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
99
scripts/installIdpTenant.js
Normal file
99
scripts/installIdpTenant.js
Normal 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
18
scripts/installIdpTenant.sh
Executable 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
180
test/adminGate.js
Normal 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
299
test/e2e.js
Normal 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
127
test/keyRotationGate.js
Normal 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
354
test/ldapGate.js
Normal 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
231
test/ldapMultiTenantGate.js
Normal 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
295
test/mtGate.js
Normal 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
279
test/samlGate.js
Normal 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
217
test/samlMultiTenantGate.js
Normal 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();
|
||||||
Loading…
Add table
Reference in a new issue