SAML 2.0 Single Sign-On

veyla enterprise federates user authentication to a customer-managed SAML 2.0 Identity Provider (Okta, Microsoft Entra ID / Azure AD, ADFS, PingFederate, Keycloak, anything that emits signed AuthnResponses). SSO is configured per organization, so a multi-tenant install can federate different tenants to different identity stores.

This guide covers the install-time wiring — administrators arrive here once their cluster install is up and they want to plug it into the corporate IdP. For the underlying flow design (security posture, JIT provisioning rationale, attribute-mapping convention) see the host-side docs/security-hardening.md.


§0 — When to enable SAML

Situation Recommendation
Single internal team, on-prem evaluation Skip SAML — local password auth is enough
Production deployment with corporate IdP already in place Enable SAML — match how the rest of your tooling works
Multi-tenant install (one cluster, several customer orgs) Enable per-org SAML; each org gets its own slug + IdP
Air-gapped install with no IdP in the perimeter Skip SAML — local password is the only viable path

Site-admin accounts (the bootstrap admin created by the install Job and any account flagged admin site-wide) always retain the local-password sign-in path as a failsafe, even when SAML is enabled on their organization. This is by design — losing IdP connectivity shouldn't lock the operator out of the admin console.


§1 — Per-organization configuration model

Each organization has an OrganizationSamlConfig row with these fields, set on /admin/organizations/:id/edit:

Field What it is
slug Short, URL-safe identifier — appears in /auth/saml/<slug>/.... Lowercase letters, digits, hyphens. Default = the org's id, but a memorable slug (e.g. acme, corp-prod) is friendlier.
enabled Toggle to require an IdP cert (or fingerprint) before flipping on
idp_metadata_url The IdP's SAML metadata XML URL. Recommended — the host fetches it and auto-populates the SSO URL + signing cert
idp_sso_url Where AuthnRequests get redirected. Filled by metadata fetch, or set manually
idp_cert The IdP's signing certificate (PEM). Filled by metadata fetch, or pasted in
idp_cert_fingerprint SHA-1/256 fingerprint of the signing cert. Used as an alternative trust anchor when full PEM is unavailable
name_id_format Defaults to urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress. Most IdPs use this; Microsoft sometimes uses unspecified
attribute_mapping Hash mapping User fields (email, first_name, last_name) to the assertion attribute names the IdP emits

The form publishes the SP metadata URL inline so an operator can copy it directly into the IdP's "register a new SP" wizard.


§2 — Endpoints

Method Path Role
GET /auth/saml/init Slug picker — sign-in page form posts here
GET /auth/saml/<slug>/init Initiates SP-bound flow; redirects browser to the IdP
POST /auth/saml/<slug>/callback Assertion Consumer Service (ACS) — IdP POSTs the AuthnResponse here
GET /auth/saml/<slug>/metadata SP metadata XML — what the IdP wants when registering veyla as an SP

All four are anonymous endpoints — they have to be, since the whole point of the flow is establishing a session for a user who doesn't yet have one. (Earlier builds gated these behind authenticate_user!, which silently broke every SAML flow; if your install pre-dates the fix the SP metadata endpoint will 302 to /sign_in and IdPs will refuse to register the SP. Upgrade to a current image before continuing.)


§3 — Configuring the IdP (general procedure)

The wiring is the same shape for every IdP, with minor naming differences. Follow this sequence regardless of which IdP you're using.

  1. Capture the SP metadata URL from the org's admin form. The URL has the shape:

    https://<your-route-host>/auth/saml/<slug>/metadata
    

    The form shows it inline so you can copy it.

  2. Register veyla as a new SAML application in your IdP. Most IdPs ask for one of:

    • The SP metadata URL — they fetch it and auto-fill everything
    • The SP entity ID + ACS URL pasted in by hand:
      • Entity ID = the metadata URL itself (used as the SP's SAML identifier)
      • ACS URL = https://<your-route-host>/auth/saml/<slug>/callback
      • Name ID format = urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
    • Sign AuthnRequests = optional but recommended (the host supports request signing if you supply an SP signing key; see §6)
  3. Capture the IdP's metadata URL from the IdP-side application page. Most IdPs publish a "metadata" or "federation metadata URL". Examples:

| IdP | Metadata URL pattern | |-----|----------------------| | Okta | https://<tenant>.okta.com/app/<app-id>/sso/saml/metadata | | Microsoft Entra ID | https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml?appid=<app-id> | | ADFS | https://<adfs-host>/FederationMetadata/2007-06/FederationMetadata.xml | | Keycloak | https://<host>/realms/<realm>/protocol/saml/descriptor | | mocksaml.com (test) | https://mocksaml.com/api/saml/metadata |

  1. Paste the IdP metadata URL into the org's SAML config form and click Refresh metadata. The host fetches the XML, parses out the SSO URL and signing cert, and saves them. The form shows "Last fetched … ago — status: ok" on success.

If the host can't reach the IdP's metadata URL (egress-restricted cluster, firewall, expired TLS chain), the refresh fails loudly in the form with the underlying error. Two manual fallbacks:

  • Paste the cert in directly — copy <ds:X509Certificate> content from the IdP's metadata XML and paste into the form's IdP cert field (PEM format, with -----BEGIN/END CERTIFICATE----- wrappers).
  • Use the cert fingerprint — paste the SHA-256 hex digest into the fingerprint field instead. ruby-saml accepts either.
  1. Verify attribute mapping. Open the IdP-side application's attribute statements / claim rules and confirm at least the user's email is included. Default mapping the host expects:

| User field | Default assertion attribute | |------------|----------------------------| | email | email (or NameID, if no email attribute) | | first_name | givenName | | last_name | sn |

Common deviations: Microsoft Entra ID uses http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress for email; LDAP-backed IdPs sometimes use OIDs (urn:oid:0.9.2342.19200300.100.1.3 for mail). Adjust the form's attribute mapping fields to match what your IdP actually emits.

  1. Flip enabled to true on the org's SAML form and save.

§4 — Per-IdP quick references

Microsoft Entra ID (Azure AD)

  1. Enterprise Applications → New application → Create your own application → choose "Integrate any other application you don't find in the gallery."
  2. In the new app's Single sign-on blade, choose SAML.
  3. Basic SAML Configuration → set:
    • Identifier (Entity ID) = https://<route-host>/auth/saml/<slug>/metadata
    • Reply URL (ACS) = https://<route-host>/auth/saml/<slug>/callback
  4. User Attributes & Claims — the default claim set already emits the right attributes; verify givenname, surname, and the email claim. You may want to rename them to plain email, givenName, sn so they match the host's defaults.
  5. Copy the App Federation Metadata Url from the Setup section.
  6. Paste into the org's idp_metadata_url, refresh, enable.

Okta

  1. Applications → Create App Integration → SAML 2.0.
  2. General Settings → name + logo, then Next.
  3. Configure SAML:
    • Single sign-on URL = https://<route-host>/auth/saml/<slug>/callback
    • Audience URI (SP Entity ID) = https://<route-host>/auth/saml/<slug>/metadata
    • Name ID format = EmailAddress
  4. Attribute Statements — map email, givenName, sn to the corresponding Okta user attributes (user.email, user.firstName, user.lastName).
  5. Sign On tab → copy the Identity Provider metadata URL.
  6. Paste into the org's idp_metadata_url, refresh, enable.

Keycloak

  1. Clients → Create client → Client type SAML.
  2. Client ID = https://<route-host>/auth/saml/<slug>/metadata.
  3. Settings → Valid redirect URIs = https://<route-host>/auth/saml/<slug>/callback.
  4. Mappers — add User Property mappers for email, firstName, lastName as SAML attributes (match the host's expected names).
  5. Realm metadata URL: https://<host>/realms/<realm>/protocol/saml/descriptor.

mocksaml.com (test only)

Use only for verifying connectivity / shape during install, not for production. The IdP signs assertions with a published static cert.

  1. Set idp_metadata_url = https://mocksaml.com/api/saml/metadata.
  2. Refresh metadata.
  3. Enable.
  4. Sign in with any email/password at the IdP login form (mocksaml accepts anything and JIT-provisions an assertion).

samltest.id was the predecessor and is documented in many older guides; that service has since gone offline (returns HTTP 410 Gone). Use mocksaml.com instead.


§5 — Sign-in flow

Once the org's SAML config is enabled:

  1. The user lands on /sign_in. They click "Sign in with SSO" and the form posts the org slug to /auth/saml/init.
  2. The host redirects to /auth/saml/<slug>/init, builds an AuthnRequest, and redirects the browser to the IdP's SSO URL.
  3. The IdP authenticates the user and POSTs an AuthnResponse back to /auth/saml/<slug>/callback.
  4. The host validates the assertion's signature against the configured idp_cert (or fingerprint).
  5. JIT provisioning — if no User row exists for the email in the assertion, one is created with the mapped first/last name, added to the SAML org as a member, and an audit-log entry auth.saml.jit_provisioned is written.
  6. The user is redirected to the application root with a normal authenticated session — same cookie shape as a password-authenticated session, so logout / "remember me" / admin actions work identically downstream.

Failure modes (signature invalid, expired assertion, audience mismatch) emit auth.saml.callback.failure events with a classified failure_reason and surface the IdP error message in a flash on /sign_in.


§6 — Optional hardening

  • Sign AuthnRequests. When the IdP requires SP-signed AuthnRequests, supply an SP signing key (RSA 2048+, PKCS#8 PEM) via the chart's secrets.samlSpSigningKey value. The host re-emits the SP signing cert in its metadata XML so the IdP can verify.
  • Force authentication on every sign-in. Set OrganizationSamlConfig.force_authn = true (UI: "Re-prompt at IdP every sign-in") to require the user to re-enter credentials rather than reuse an SSO session. Useful for high-assurance environments; off by default.
  • Use cert fingerprint instead of full PEM. Smaller config surface and means you don't have to re-paste a cert when the IdP rotates intermediate chain. Trade-off: less defense against fingerprint-collision attacks (impractical in practice with SHA-256).
  • Disable local-password sign-in for non-admins. Site-wide admins keep local fallback regardless. Ordinary users on a SAML-enabled org default to SSO-only, but the local form remains as an opt-in. To remove it for a tenant, set the org's disable_local_password = true (form toggle).

§7 — Troubleshooting

Symptom Likely cause
/auth/saml/<slug>/metadata returns HTTP 302 to /sign_in You're on a build that pre-dates the controller fix landing the anonymous skip on the SAML flow. Upgrade.
IdP refuses to register the SP with "metadata schema invalid" Check the metadata URL with curl -i — if it's HTML, the controller-fix issue above is the cause
AuthnResponse rejected: "Signature verification failed" Cert mismatch. Refresh metadata, or paste the IdP's current cert directly. IdPs rotate signing certs on a schedule; if your idp_metadata_url is set, the host's "Refresh metadata" button + a metadata_stale? warning at 7 days catch this for you.
AuthnResponse rejected: "Audience mismatch" The IdP-side configured "Audience" doesn't match what the host expects. Audience is <your-route-host>/auth/saml/<slug>/metadata — copy from the SP metadata field on the admin form.
AuthnResponse rejected: "NotOnOrAfter is past" Clock skew between the host pod and the IdP. Most IdPs allow a 5-minute window; if your cluster's clock is off, fix NTP rather than tuning the SAML window.
JIT provisioning lands a user with no first name / last name Attribute mapping doesn't match what the IdP actually emits. Inspect the IdP-side application's attribute statements; pasting givenName when the IdP emits urn:oid:2.5.4.42 won't match. Adjust the org's attribute_mapping fields to the OIDs/names the IdP uses.
User signs in with one email, JIT creates a duplicate User The assertion's email attribute is case-mismatched with the existing User row. The host normalizes to lowercase before lookup; verify the IdP isn't emitting a mixed-case email that's getting compared against a stored email with different casing.

For deeper debugging, increase Rails logging on the SAML controller: oc set env deploy/<release> SAML_DEBUG=1. The host then logs the full inbound assertion (decrypted) at info level — useful for diagnosing attribute-name mismatches but obviously a sensitive log to leave on permanently.


§8 — Off-boarding a SAML config

To revert an organization to local-password sign-in:

  1. Untoggle enabled on the org's SAML form.
  2. Save. Existing JIT-provisioned users keep their local password (initialized at JIT time to a random secret), so they'll need to reset their password before they can sign in locally — site admins can trigger reset emails from the admin console.
  3. Optionally delete the SAML config entirely on the same form; the SP metadata endpoint stops responding for that slug.

The audit log retains all auth.saml.* events from before the change.