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

22 KiB

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 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; for the LDAP directory see ./ldap.md; for the admin UI pages (including SP registration) see the "Admin UI pages" section of ./configuration.md; for the plugin overview and documentation index see the top-level ../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).


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). 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): 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), 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 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