# 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 `/saml`, where `` is derived per request by `issuerForReq(req)` (`idp/lib/oidc/discovery.js`), the same derivation used by OIDC. The published `` on every Response/LogoutResponse is also `/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 `/saml/sso` and `/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 `` 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=` (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 `` and the `` 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=&RelayState=&SigAlg=` (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 = /` 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 `