Auth & Security

SAML 2.0 SSO

Ductor's receive-only SAML Service Provider — the per-tenant /saml router, replay-before-mint ACS pipeline, attribute-to-role derivation, and its oracle-defense posture.

Ductor speaks SAML 2.0 as a receive-only Service Provider (SP). Your identity provider (Okta, Azure AD, Auth0, …) asserts a user's identity; Ductor validates that assertion, mints a short-lived session bearer, and hands the browser back to your app. This page is the reference for how that SP is wired and why it is built the way it is. For turning SAML on and the session-bearer lifecycle, start with Authentication → SAML browser sessions.

One router, every tenant

The SP is a hand-rolled chi sub-router mounted under /saml/{tenant}. A single deployment serves every tenant through path scoping — there is no per-tenant listener. Four routes exist:

MethodRoutePurpose
GET/saml/{tenant}/metadataSP metadata document you hand to the IdP at config time
GET/saml/{tenant}/loginSP-initiated login — redirects the browser to the IdP SSO URL
POST/saml/{tenant}/acsAssertion Consumer Service — the IdP POSTs the signed SAMLResponse here
POST/saml/{tenant}/sloSP-initiated Single Logout (mounted only when SLO is wired)

These routes sit in front of the bearer auth chain — they must, because the browser hitting /login and the IdP POSTing /acs are both unauthenticated in the ordinary sense. The "authentication" on /acs is the XML-dsig signature on the SAMLResponse, validated inside the application service, not a bearer token.

The tenant is bound from the URL path — never a header

The {tenant} path segment is baked into the SP's crewjam-signed Destination (the ACS URL …/saml/{tenant}/acs) at SP-build time. Swapping the path segment makes ParseResponse reject the assertion, because the IdP-signed Destination no longer matches. That is what makes the path-bound tenant the trust anchor — no request header could provide the same guarantee, so none is consulted.

The SP never signs, and pins the IdP cert

Two properties define the SP's trust model:

  • Receive-only by default. The per-tenant crewjam.ServiceProvider is built with Key and Certificate set to nil. Ductor does not sign AuthnRequests — it uses the HTTP-Redirect binding, which does not sign by default. (An optional pod-level SP keypair can be wired to enable signed requests and encrypted assertions; when absent — the default — behavior is byte-identical receive-only.)
  • Pinned IdP signing cert. Each tenant's SP is built fresh from the stored saml_idp_config.metadata_xml. The IdP's signing certificate is pinned inside that EntityDescriptor; crewjam's ParseResponse walks it directly. There is no trust-on-first-use and no dynamic metadata fetch on the hot path — the metadata you persist via the CLI is the trust root.

Built SPs are cached per tenant for five minutes so the ACS hot path does not re-parse XML on every request. The CLI additionally invalidates the cache on every set/disable, so a cert rotation takes effect on the next request rather than waiting out the TTL.

The ACS pipeline: replay-before-mint

The order of operations at /acs is the security-critical part. Each step gates the next:

empty no IdP config validation fails empty NameID any dup resolver / store outage POST /saml/{tenant}/acs tenant = URLParam 400 (canonical) Body capped at 256KB(MaxBytesReader, before ParseForm) SPBuilder.Build(tenant) 404 (canonical) sp.ParseResponse(req, ids) 401 (canonical) NameID guard ReplayStore.MarkAssertionOrFail(replay-before-mint) deriveRoles + extractAttr(typed attributes) MintSAMLSession → HS256 bearer 500 HTML handoff page → RelayState

The assertion ID is recorded in the replay store with an atomic INSERT-or-fail before any session bearer is minted. This ordering is deliberate: a session can never precede deduplication, so a captured SAMLResponse cannot be replayed into a second valid session even under a race. Attributes are read only from the typed *saml.Assertion crewjam returns — never from raw XML — which is the defense against XML Signature Wrapping (XSW).

crewjam validates the assertion; Ductor only projects it

sp.ParseResponse is where the signature, audience, destination, condition window, and InResponseTo are checked. Everything after it operates on a trusted, typed assertion. IdP-initiated SSO is rejected unless the tenant's config sets AllowIDPInitiated — an empty InResponseTo is refused both by crewjam and by an explicit belt-and-braces guard so ops can see idp_initiated_not_allowed as a distinct audit reason.

Attribute → role derivation (least privilege)

RBAC roles are derived from the assertion's AttributeStatements through a per-tenant mapping. The rules:

  • Case-insensitive name matching against either the attribute's canonical Name (e.g. urn:oid:1.3.6.1.4.1.5923.1.1.1.1) or its FriendlyName (e.g. eduPersonAffiliation). Different IdPs project one side or the other; matching both lets a single Ductor mapping work across Okta, Azure AD, and Auth0 without per-tenant tweaking.
  • Unknown values are dropped. A role is granted only when the operator explicitly bound an IdP attribute value to a Ductor role in the tenant's RolesMap. An unmapped value contributes nothing — this is the least-privilege interpretation, so a new IdP group never silently becomes a Ductor role.
  • The derived role set is deduplicated and carried into the minted session bearer, from which the normal authorization layer reads it.

Every failure looks identical on the wire

The SP is a public, unauthenticated surface, so it is built not to leak why a request was rejected. A single writeSAMLError helper writes every failure branch across every handler, producing a byte-identical body and headers:

{"error":"saml_request_invalid"}

with a fixed WWW-Authenticate header. The HTTP status code is the only signal the wire carries — 400 (malformed request), 401 (assertion rejected), 404 (no IdP configured), 500 (internal mint/build failure). The actual diagnostic reason — a bad signature, a replayed assertion, an empty NameID — is written to slog only, never to the response body. This defeats an attacker probing the rejection cause via response length, structure, or content.

Diagnostics live in the log, not the response

When you are debugging a failing SAML login, the response body tells you nothing beyond the status code — that is by design. Look at the server logs for the saml.acs.* events, which carry a normalized reject_reason (signature_invalid, assertion_replayed, empty_name_id, idp_initiated_not_allowed, no_idp_config). The underlying crewjam PrivateErr text is logged there and nowhere else.

Configuring a tenant's IdP

Per-tenant IdP config is managed out of band through the CLI — it is not a runtime API surface. The metadata XML you supply is validated (parsed) before it is persisted; invalid metadata is refused fail-fast rather than stored and failing later at /acs.

Manage a tenant's SAML IdP
# Set (or replace) a tenant's IdP from its metadata document, with an
# optional attribute→role mapping file.
ductor saml-idp set --tenant acme \
  --metadata-file ./acme-idp-metadata.xml \
  --attribute-mapping ./acme-role-map.json

# Inspect the stored config for a tenant.
ductor saml-idp get --tenant acme

# Disable SAML for a tenant (the SP stops resolving; /acs returns 404).
ductor saml-idp disable --tenant acme

Enabling the SP itself (mounting the routes) is a server config flag — identity.saml.enabled — documented under Authentication. When SAML is disabled no /saml route is mounted at all.

Where to go next

  • Authentication — the session-bearer lifecycle, TTL, and Single Logout revocation.
  • SCIM provisioning — automate user lifecycle and group→role mapping alongside SSO.
  • Hardening — session-secret rules and the production checklist.