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 byissuerForReq(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/ssoand/idp/saml/sloare each registered for bothgetandpostso they support the HTTP-Redirect binding (GET) and the HTTP-POST binding (POST)./idp/saml/metadataand/idp/saml/initare 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/ssoand<issuer>/saml/slorespectively (idp.js), the signing certificate, and theemailAddressNameID 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
SAMLRequestquery parameter is DEFLATE-compressed (raw deflate) and base64-encoded.decodeRequestcallszlib.inflateRawSync(...)(routes.js). - HTTP-POST (POST): the
SAMLRequestform 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:
- Determine the binding from the HTTP method and read
SAMLRequestplus optionalRelayStatefrom the query (GET) or body (POST) (routes.js). MissingSAMLRequestreturns400 missing SAMLRequest. - Decode the request via
decodeRequest(size caps, decompression, DTD/ENTITY screen; see Security screens). A decode failure returns400 malformed SAML request. - Extract the SP entityID from the XML
<Issuer>element with a regex (matchIssuer,routes.js). If absent:400 could not parse SAML AuthnRequest. - Registry gate: look up the SP with
samlSps.getSp(spEntityId). An unregistered SP returns403 unknown SAML SP(routes.js). This check happens before the login bounce so an unknown SP cannot drive a login. - ACS allow-list non-empty check:
samlSps.acsUrls(spRow); an SP with no registered ACS returns403 SP has no registered ACS(routes.js). - 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). - Select the IdP variant by the SP's signed-request flag (see
AuthnRequest signature verification):
getSignedIdp(issuer)ifwant_authn_requests_signed, elsegetIdp(issuer)(routes.js). - 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). - 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 viarawQueryOctetString(req)and passed asparseReq.octetStringso the bytes byte-match what the SP signed (routes.js). A parse/verification failure returns403 SAML request rejected. - Parser-differential guard: the issuer parsed by samlify
(
requestInfo.extract.issuer) must equal the entityID the SP was looked up by; otherwise403 issuer mismatch(routes.js). - ACS resolution: prefer the parsed ACS
(
requestInfo.extract.request.assertionConsumerServiceUrl), falling back to the regexmatchAcs(xml). If the request named an ACS it MUST passsamlSps.acsAllowed(spRow, reqAcs)(exact-string match) or the request is rejected with403 ACS not allowed; if the request named no ACS, the first allow-listed ACS is used (routes.js). - 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:
- Read
sp; missing returns400 missing sp(routes.js). - Registry gate:
samlSps.getSp(sp); unknown returns403 unknown SAML SP(routes.js). - Non-empty ACS allow-list check; empty returns
403 SP has no registered ACS(routes.js). - Require an authenticated session; otherwise redirect to
/auth/login(routes.js). - ACS resolution: if
acsis supplied it must passacsAllowed(else403 ACS not allowed); otherwise the first allow-listed ACS is used (routes.js). - 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:
- Determine binding from the method; read
SAMLRequest+ optionalRelayState. MissingSAMLRequestreturns400 missing SAMLRequest. - Decode the request (same caps/screens as SSO). Failure returns
400 malformed SAML request. - Extract the SP entityID via
matchIssuer; absent returns400 could not parse LogoutRequest. - Registry gate:
samlSps.getSp(spEntityId); unknown returns403 unknown SAML SP. - Require an authenticated session. If
req.useris absent, return403 no authenticated session. An unauthenticated request (or a forged cross-site GET) has no session to end, so it cannot drive SLO. - 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). - Signature (cert-registered SPs). If the SP registered a signing cert, the
LogoutRequest signature is REQUIRED and verified (
getSignedIdpsetswantLogoutRequestSigned), 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.) - Parse the LogoutRequest with
idp.parseLogoutRequest(...); failure returns403 SAML logout request rejected. A parser-differential guard then requires the authoritative parsed issuer to equal the looked-up entityID (403 issuer mismatch). - NameID must match the session. The LogoutRequest
NameIDmust equal the session user's email (case-insensitive); a mismatch returns403 logout subject does not match the session, so one SP cannot log out a different user. - Terminate the local Saltcorn session: call
req.logout(...)(guarded for older synchronous passport) andreq.session.destroy(...), both best-effort. - Build and sign a LogoutResponse (
InResponseToset to the request id) and auto-POST it tosloAcs.
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 passacsAllowedexactly, otherwise403 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
acsquery 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'sonLoadhook (per tenant). It is idempotent -- if theid = "default"row exists it returns immediately. Otherwise it generates a 2048-bit RSA, SHA-256 self-signed certificate viaselfsigned, seals the private key withidpCrypto.sealText(pems.private)(AES-256-GCM under a KEK derived fromSALTCORN_SESSION_SECRET; see./security.md), and inserts the row. - Retrieval:
getSamlCert()(idp.js) selects the row and unseals the private key withidpCrypto.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 underissuer + "#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 withwantAuthnRequestsSigned: true(idp.js,idp.js); samlify then requires and verifies the signature against the SP'ssigning_certset on the SP entity (buildSpsetssigningCert+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, becausereq.queryis already decoded and would not byte-match what the SP signed (rawQueryOctetString,routes.js; passed asparseReq.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
parseLoginRequestthrow and is answered with403 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):
NameIDis the user email; NameID formaturn: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-generatedAuthnInstant, and a random hexSessionIndex(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 |