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
| Env | Required | Meaning |
|---|---|---|
YABANIN_SAML_IDP_METADATA_URL | yes | IdP metadata document URL, e.g. https://idp.example.com/saml/metadata |
YABANIN_SAML_IDP_METADATA_URL_FILE | no | Docker/K8s secret file holding the metadata URL (the direct variable wins) |
YABANIN_SAML_ENTITY_ID | no | SP entity ID; defaults to the ACS origin (https://admin.example.com) |
YABANIN_SAML_ACS_URL | no | Assertion Consumer Service; defaults to https://<admin_domain>/auth/saml/acs (from YABANIN_ADMIN_DOMAIN) |
YABANIN_SAML_ROLE_ATTR | no | SAML 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
| Route | Purpose |
|---|---|
GET /auth/saml/metadata | SP metadata XML for IdP-side import (entity ID + ACS) |
GET /auth/saml/login | Starts the flow: signed AuthnRequest, redirect to the IdP |
POST /auth/saml/acs | Assertion Consumer Service |
All three are public, unauthenticated routes and exist only when the metadata URL knob is set.
The flow
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.- The IdP authenticates the user and posts a SAML Response to the ACS.
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.- User mapping — fail-closed, identical to the OIDC flow: the NameID
must be an email address (format
emailAddressor 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, sameIsRoot, same 24hadminauth.SessionDuration). - A
loginaudit entry is written with the IdP entity, the mapped user and (when configured) the asserted role attribute.
Security contract
- No IdP-initiated login.
AllowIDPInitiatedis 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/xmlnever 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).