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:
| Method | Route | Purpose |
|---|---|---|
GET | /saml/{tenant}/metadata | SP metadata document you hand to the IdP at config time |
GET | /saml/{tenant}/login | SP-initiated login — redirects the browser to the IdP SSO URL |
POST | /saml/{tenant}/acs | Assertion Consumer Service — the IdP POSTs the signed SAMLResponse here |
POST | /saml/{tenant}/slo | SP-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.ServiceProvideris built withKeyandCertificateset tonil. Ductor does not signAuthnRequests — 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 thatEntityDescriptor; crewjam'sParseResponsewalks 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:
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 itsFriendlyName(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.
# 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 acmeEnabling 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.
API Keys
Mint, list, and revoke DB-backed tenant API keys through the v2 API — roles, scopes, environment scope, expiry, storage, and rotation.
SCIM 2.0 provisioning
Automated user and group lifecycle from your IdP — the /scim/v2 router, the deprovision-revokes-key wire, group-to-role mapping, and bearer-token custody.