Skip to content

SSO / SAML Setup Enterprise

Configure SAML 2.0 Single Sign-On so your team authenticates to Controlinfra through your identity provider (IdP).

Overview

With SAML SSO enabled:

  • Users from your domain log in via your IdP — no Controlinfra password needed.
  • New users are auto-created in the org on their first successful login.
  • Combined with SCIM Provisioning, groups in your IdP map to roles in Controlinfra.

Controlinfra supports any SAML 2.0-compliant IdP. The setup steps below use Okta as the worked example; collapsible sections cover the differences for Azure AD (Entra ID) and Google Workspace.

Prerequisites

  • Enterprise plan — SSO is gated on the Enterprise plan.
  • Owner or Admin role in the Controlinfra org.
  • Admin access to your IdP.

Endpoints you'll need

These come from Controlinfra:

FieldValue
ACS URL (Reply URL / Single Sign-On URL)https://api.controlinfra.com/api/sso/saml/callback
SP Entity ID (Audience URI)The Issuer you set when creating the IdP app — copy it back into Controlinfra in Step 2.
Sign-on URL (where users start)https://app.controlinfra.com/login — users click "Continue with SSO" and enter their email.

The same ACS URL is used for every Controlinfra org. The org is identified at runtime via RelayState (carried in the SAML AuthnRequest), so you do not need a per-org metadata or ACS endpoint.

Step 1 — Create a SAML app in your IdP

Okta
  1. Open your Okta admin console → ApplicationsCreate App Integration.

  2. Choose SAML 2.0, click Next.

  3. General Settings: give it a name (e.g. "Controlinfra"), upload a logo if you like, Next.

  4. Configure SAML:

    • Single sign-on URL: https://api.controlinfra.com/api/sso/saml/callback
    • Audience URI (SP Entity ID): any unique identifier, e.g. controlinfra:<your-org-slug>. You'll paste this back into Controlinfra in Step 2.
    • Name ID format: EmailAddress
    • Application username: Email
  5. Attribute Statementsrequired for SSO sign-in:

    NameFormatValue
    emailUnspecifieduser.profile.email
    displayNameUnspecifieduser.firstName + " " + user.lastName

    Okta Identity Engine syntax

    New Okta tenants run on Okta Identity Engine (OIE), where the expression syntax is user.profile.<property>. The older Classic Okta syntax user.email / user.login is rejected by OIE with "Invalid property". Use user.profile.email everywhere unless you're on Classic Okta.

Without an email attribute the SAML callback will fail with "SAML response is missing the email attribute" because Controlinfra refuses to authenticate on NameID alone (NameID isn't guaranteed to be an email address, so falling back to it would let a misconfigured IdP project identity from arbitrary fields). 6. Group Attribute Statements (only if you want group-based role routing — see SCIM Provisioning):

Two equivalent ways to express this, depending on your Okta tier:

Expression-based (recommended — works on all Okta tiers including OIE):

NameExpression
groupsuser.getGroups({'group.profile.name': '.*'})

Regex-filter-based (Classic Okta — may be rejected on OIE):

NameFilterValue
groupsMatches regex .*(no value field)
  1. Click Next, then Finish.
  2. On the application's Sign On tab, click View SAML setup instructions (or the IdP metadata link). From there, copy:
    • Identity Provider Single Sign-On URL (the IdP login URL)
    • Identity Provider Issuer (the IdP entity ID)
    • X.509 Certificate — download the .cert or .pem file.

:::

Azure AD (Entra ID)
  1. Enterprise ApplicationsNew ApplicationCreate your own application → "Integrate any other application (Non-gallery)".
  2. Under the new app: Single sign-onSAML.
  3. Basic SAML ConfigurationEdit:
    • Identifier (Entity ID): any unique identifier, e.g. controlinfra:<your-org-slug>.
    • Reply URL (ACS): https://api.controlinfra.com/api/sso/saml/callback
  4. Attributes & ClaimsEdit:
    • Set the Unique User Identifier (Name ID) claim to user.mail with format Email address.
    • Add a claim named email mapped to user.mail.
    • (Optional) Add a displayName claim mapped to user.displayname.
  5. SAML Signing Certificate → download Certificate (Base64).
  6. Copy the Login URL (IdP SSO URL) and Microsoft Entra Identifier (IdP issuer) from the Set up section near the bottom of the SAML page.
Google Workspace
  1. Admin ConsoleAppsWeb and mobile appsAdd custom SAML app.
  2. Note the SSO URL and Entity ID Google shows you, and download the Certificate.
  3. Click Continue:
    • ACS URL: https://api.controlinfra.com/api/sso/saml/callback
    • Entity ID: any unique identifier, e.g. controlinfra:<your-org-slug>.
    • Name ID format: EMAIL, mapped to Basic Information > Primary email.
  4. Attribute mapping: add emailBasic Information > Primary email.
  5. Finish and assign the app to the relevant organizational units / groups.

Step 2 — Configure Controlinfra

  1. In Controlinfra, open SettingsSecuritySSO.
  2. Pick the matching Provider (Okta, Azure AD, Google, or Custom).
  3. Fill in:
    • IdP SSO URL — the IdP login URL from Step 1.
    • Issuer (SP Entity ID) — the same value you put in your IdP's "Audience URI / Identifier" field.
    • X.509 Certificate — paste the PEM, or use Upload .pem / .crt to load it from disk.
    • Enforced Domains — comma-separated list of email domains that must use SSO (e.g. acme.com, contoso.com). Users whose email matches one of these are auto-redirected to SSO at login.
    • Default Role — fallback role for users who don't match any SCIM group mapping. See Role assignment on login below.
    • Enabled — toggle on once you're ready to use SSO.
  4. Click Save. The certificate is masked as **** in the form on subsequent loads — leave it as-is to keep the stored cert; paste a new PEM to replace it.

Step 3 — Test the connection

Click Test Connection on the SSO card. This runs server-side checks without redirecting to your IdP:

CheckWhat it verifies
Required fieldsentryPoint, issuer, and cert are all set.
Entry point URLParses as http(s)://....
Provider host hintThe host roughly matches the selected provider (informational; doesn't fail).
CertificateParses as a valid X.509 PEM and isn't expired.
SAML strategyConstructs without throwing.
IdP reachabilityHEAD request reaches the entry point within 5s (HTTPS only; private/loopback addresses are refused for SSRF reasons).

A red mark on Certificate is the most common Step 2 mistake — re-download the PEM and paste it again.

WARNING

The test does NOT exercise an actual SAML round-trip. The first time you log in via SSO is the real end-to-end test. Until that succeeds, keep at least one Owner account with email/password as a fallback.

Step 4 — Log in via SSO

  1. From the marketing/login page, click Log in with SSO.
  2. Enter your work email (must match an enforcedDomains entry, otherwise you'll see "No SSO is configured for this email domain").
  3. You're redirected to your IdP, authenticate there, and bounced back to Controlinfra.
  4. On first login, your user is auto-created. The role assigned depends on group routing — see below.

Role assignment on login

There are two role-related fields in play, and they aren't competing — they cover different cases:

FieldWhere you set itWhat it's for
Default RoleSSO config (this page)Fallback for users not matched by any SCIM group
Group → Role mappingSCIM Group MappingsRole for everyone in a specific IdP group

On every SAML login, the callback runs two stages:

  1. SCIM group routing. If the SAML assertion includes a groups claim AND any value in it matches a pushed SCIM group in this org, that group's mapped role is used. The user's role is reconciled to this on every login — group routing is the source of truth.
  2. Default fallback. If no group matched (no claim, or no pushed group with that name), the SSO Default Role is used. This only applies on the user's first login — it does NOT overwrite an existing user's role on subsequent logins.

So defaultRole is the safety net for "everyone who slips through SCIM" — typically you'd set it to Member (so unmapped users at least get access) or Viewer (more conservative).

Two separate Okta configs are required for group routing

For routing via SCIM groups to fire, both of these must be configured in Okta:

  1. Push Groups has happened (SCIM tab) — so the ScimGroup doc exists in our DB. See SCIM Provisioning › Step 3.
  2. Group Attribute Statement is set on the SAML app — so the SAML assertion actually carries the groups claim at login time. See Step 1.6 above.

If you've done #1 but not #2, the user logs in successfully but our callback sees an empty groups claim and falls back to Default Role. The Group Mappings UI will show the user under their group, but the role at login won't come from there — that's the most common source of "why is this user stuck on the default role" confusion.

If a user matches multiple SCIM groups with different mapped roles (e.g. engineers → Member and eng-admins → Admin), the highest-privilege role wins. Order: viewer < member < admin < owner.

Login flow under the hood

Browser           Controlinfra            IdP
  │                    │                    │
  │  type email        │                    │
  │ ─────────────────► │                    │
  │  GET /api/sso/discover?email=…          │
  │                    │  match enforcedDomains
  │ ◄───────────────── │  { orgId, provider }
  │                    │                    │
  │  GET /api/sso/saml/login?orgId=…        │
  │ ─────────────────► │                    │
  │ ◄───────────────── 302 to IdP (RelayState=orgId)
  │ ──────────────────────────────────────► │
  │                    │   user authenticates
  │ ◄────────────────────────────────────── 302 to ACS
  │  POST /api/sso/saml/callback (SAMLResponse, RelayState)
  │ ─────────────────► │                    │
  │                    │ verify signature + cert,
  │                    │ find/create user,
  │                    │ run SCIM group routing,
  │                    │ issue exchange code
  │ ◄───────────────── 302 to /auth/callback?code=…

Troubleshooting

SymptomLikely cause
Lands back on /login after entering emailThe frontend couldn't reach the SAML login endpoint. Check that VITE_API_URL is set on the deployed frontend.
/auth/error?reason=missing_relayThe IdP didn't send RelayState back in the callback. Re-check the IdP app — RelayState should be passed through, not stripped.
/auth/error?reason=sso_not_configuredNo SSO config matches the org or it's been disabled.
/auth/error?reason=saml_failedSignature/certificate mismatch or expired cert. Re-test on the SSO card; if Certificate fails, re-upload the PEM.
User logs in but lands in the wrong roleEither no SCIM group routing matched (check the SCIM page) or the matched group's mapping is set to a different role. Group routing reconciles role on every login — change the dropdown then have the user re-login.
Refusing to probe — host resolves to a private/internal address on Test ConnectionThe IdP entry point hostname resolves to an RFC1918 / loopback / link-local IP. Use a public IdP URL.

Reading a SAML response

If a callback fails and you need to inspect the assertion:

  1. Open the browser Network tab before clicking "Continue with SSO".
  2. Find the POST /api/sso/saml/callback request — its body has the SAMLResponse field (Base64-encoded XML).
  3. Decode it (e.g. samltool.com's decoder, or base64 -d locally) and check <saml:Issuer>, <saml:Subject>, and <ds:Signature> against your IdP config.

Next steps