478 lines
22 KiB
Markdown
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` |
|