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

478 lines
22 KiB
Markdown

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