# SAML 2.0 SSO (/docs/auth/sso-saml)



Ductor speaks SAML 2.0 as a &#x2A;*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](/docs/auth/authentication#saml-browser-sessions).

## One router, every tenant [#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.

<Callout title="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.
</Callout>

## The SP never signs, and pins the IdP cert [#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
  `AuthnRequest`s — 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-acs-pipeline-replay-before-mint]

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

```mermaid
flowchart TD
    A["POST /saml/{tenant}/acs"] --> B["tenant = URLParam"]
    B -->|"empty"| E400["400 (canonical)"]
    B --> C["Body capped at 256KB<br/>(MaxBytesReader, before ParseForm)"]
    C --> D["SPBuilder.Build(tenant)"]
    D -->|"no IdP config"| E404["404 (canonical)"]
    D --> F["sp.ParseResponse(req, ids)"]
    F -->|"validation fails"| E401["401 (canonical)"]
    F --> G["NameID guard"]
    G -->|"empty NameID"| E401
    G --> H["ReplayStore.MarkAssertionOrFail<br/>(replay-before-mint)"]
    H -->|"any dup"| E401
    H --> I["deriveRoles + extractAttr<br/>(typed attributes)"]
    I --> J["MintSAMLSession → HS256 bearer"]
    J -->|"resolver / store outage"| E500["500"]
    J --> K["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).

<Callout title="crewjam validates the assertion; Ductor only projects it" type="info">
  `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.
</Callout>

## Attribute → role derivation (least privilege) [#attribute--role-derivation-least-privilege]

RBAC roles are derived from the assertion's `AttributeStatement`s 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](/docs/auth/authorization) layer
  reads it.

## Every failure looks identical on the wire [#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**:

```json
{"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.

<Callout title="Diagnostics live in the log, not the response" type="warn">
  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.
</Callout>

## Configuring a tenant's IdP [#configuring-a-tenants-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`.

```bash title="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](/docs/auth/authentication#saml-browser-sessions). When SAML is
disabled no `/saml` route is mounted at all.

## Where to go next [#where-to-go-next]

* [Authentication](/docs/auth/authentication#saml-browser-sessions) — the
  session-bearer lifecycle, TTL, and Single Logout revocation.
* [SCIM provisioning](/docs/auth/scim-provisioning) — automate user lifecycle
  and group→role mapping alongside SSO.
* [Hardening](/docs/auth/hardening) — session-secret rules and the production
  checklist.
