Skip to main content

Admin SSO (SAML 2.0)

The admin console also supports single sign-on via any SAML 2.0 identity provider (Okta, Entra ID, OneLogin, Shibboleth, …). The implementation is the github.com/crewjam/saml service provider (OP-14). Like the OIDC flow, the feature is opt-in: with no configuration the SAML routes are not mounted (they answer 404) and the console stays on the password flow, unchanged.

Enabling​

EnvRequiredMeaning
YABANIN_SAML_IDP_METADATA_URLyesIdP metadata document URL, e.g. https://idp.example.com/saml/metadata
YABANIN_SAML_IDP_METADATA_URL_FILEnoDocker/K8s secret file holding the metadata URL (the direct variable wins)
YABANIN_SAML_ENTITY_IDnoSP entity ID; defaults to the ACS origin (https://admin.example.com)
YABANIN_SAML_ACS_URLnoAssertion Consumer Service; defaults to https://<admin_domain>/auth/saml/acs (from YABANIN_ADMIN_DOMAIN)
YABANIN_SAML_ROLE_ATTRnoSAML attribute recorded in the login audit entry as asserted_role (informational — it never grants anything)

The metadata URL is fetched once at startup; any failure logs loudly and keeps the console on the password flow — never a half-mounted SSO. The SP signing key pair is ephemeral per process (the IdP validates assertions, not our requests); AuthnRequests are signed with it where the binding requires.

Endpoints​

RoutePurpose
GET /auth/saml/metadataSP metadata XML for IdP-side import (entity ID + ACS)
GET /auth/saml/loginStarts the flow: signed AuthnRequest, redirect to the IdP
POST /auth/saml/acsAssertion Consumer Service

All three are public, unauthenticated routes and exist only when the metadata URL knob is set.

The flow​

  1. GET /auth/saml/login — builds the AuthnRequest (HTTP-Redirect binding, signed HTTP-POST fallback) and seals the request ID in a one-time 10-minute HttpOnly+Secure cookie scoped to /auth/saml.
  2. The IdP authenticates the user and posts a SAML Response to the ACS.
  3. POST /auth/saml/acs — crewjam verifies the signature against the certificates published in the IdP metadata, the issuer, the audience (SP entity ID), the ACS recipient, every timestamp (NotBefore/NotOnOrAfter/IssueInstant, bounded clock skew) and the InResponseTo against the flow cookie. On top of that the assertion ID is deduplicated: a byte-for-byte replay of a still-valid response is refused.
  4. User mapping — fail-closed, identical to the OIDC flow: the NameID must be an email address (format emailAddress or unspecified) matching an existing active admin user. SSO never provisions accounts, never widens roles; the issued session is byte-for-byte the password-flow session (same HMAC cookie, same IsRoot, same 24h adminauth.SessionDuration).
  5. A login audit entry is written with the IdP entity, the mapped user and (when configured) the asserted role attribute.

Security contract​

  • No IdP-initiated login. AllowIDPInitiated is false: an assertion with no matching AuthnRequest of ours is refused outright.
  • Replay-proof. crewjam validates InResponseTo; the assertion ID cache refuses the second presentation of the same assertion.
  • XML hardening. Responses are round-trip validated before parsing; Go's encoding/xml never resolves external or parameter entities, and the metadata ingestion refuses DTD/entity tricks (tested).
  • Unknown emails get the same refusal as disabled accounts — no user enumeration, no signup-by-SSO. The email local part never reaches the logs; only the domain is recorded on a mapping refusal.
  • No open redirects. Success always lands on /.
  • The asserted role attribute is audit metadata only; authority always comes from the database user record (and the guarded-session middleware re-validates it per request).