Skip to content

SSO & SAML 2.0

Monozu Cloud supports SAML 2.0 SSO as a per-tenant, enterprise-tier alternative to the Microsoft Entra ID OIDC integration described in SSO & Entra ID. It exists for customers whose identity provider is Okta, ADFS, or any other IdP that speaks SAML rather than OIDC. Configuration, account-linking safety, group sync, enforcement, and break-glass access are shared concepts between SAML and Entra — this page documents the SAML-specific flow; see sso-entra.mdx for the parts that are identical across both providers.

Gated behind the sso_saml license add-on (Management → tenant license → SSO, 249 PLN/month by default). Disabled for all tenants until staff enables the add-on; Cloud syncs entitlement via the license webhook into tenant_features. Settings → SSO shows a paywall with Request access when the module is off.

  • SP-initiated only. Every login starts at Monozu Cloud, redirects to the IdP, and returns to a per-tenant ACS URL. IdP-initiated login is explicitly unsupported (see Login flow below).
  • Per-tenant configuration. Each tenant configures its own IdP (metadata XML/URL, or direct entity ID + SSO URL + certificates) via Settings → SSO — a real, self-service admin UI, not a manual/support-ticket process.
  • Unified storage with Entra OIDC. SAML and Azure AD OIDC provider configs live in the same tenant_sso_providers table, distinguished by a provider column (saml vs azure_ad).

tenant_sso_providers (unified provider table)

Section titled “tenant_sso_providers (unified provider table)”

Introduced in db/migration/V58__saml_sso.sql, tenant_sso_providers replaced the older single-provider tenant_auth_settings table (which only ever held one Azure AD OIDC config per tenant). A tenant may now have up to one azure_ad row and one saml row in parallel — enforced by uq_tenant_sso_provider UNIQUE (tenant_id, provider).

Key SAML-relevant columns:

ColumnPurpose
provider'azure_ad' or 'saml' (ck_tsp_provider CHECK constraint)
is_enabledForced false on creation; can only be set true after a successful test-connection dry-run (see Setup flow)
saml_idp_entity_id, saml_idp_sso_url, saml_idp_slo_urlDirect IdP fields, used when no metadata XML is available
saml_idp_metadata_urlMetadata URL for periodic refresh (see Background jobs)
saml_idp_metadata_xmlCached/pasted metadata XML — always preferred over the direct fields when present
saml_nameid_formatOptional NameID format the SP requests
saml_want_assertions_signed, saml_want_response_signedSigning expectations (default 1/true)
saml_allow_idp_initiatedPersisted but not currently consulted anywhere — see Login flow
saml_metadata_refreshed_at, saml_metadata_errorSet by the metadata refresh job
group_sync_mode'additive' or 'authoritative' (ck_tsp_group_sync_mode, default 'additive')
groups_attributeThe SAML attribute name carrying group membership, if group sync is configured
last_tested_at, last_test_result, last_test_errorAdded in V59__sso_provider_test_tracking.sql; back the enable-gate described below

Supporting tables created in the same migration: tenant_sso_idp_certs (IdP signing certs, multiple rows allowed per provider to support certificate rollover), tenant_sso_group_mappings (IdP group value → local RBAC group), tenant_sso_domains (verified email domains for login routing, globally unique), tenant_saml_replay (assertion replay protection), and user_sso_sessions (IdP SessionIndex/NameID recorded per login, used for SLO). None of these child tables carries its own tenant_id — they are protected under Row-Level Security via rls.fn_sso_provider_access(provider_id), which joins back to tenant_sso_providers.tenant_id, mirroring the precedent set by dbo.group_permissions.

Unlike a typical single-tenant SAML integration, Monozu Cloud deliberately does not use one global SP entity ID / ACS URL for all tenants. Each tenant gets its own, built from its slug (internal/domain/auth/saml.go):

SP Entity ID: {APIPublicURL}/api/v1/auth/saml/{tenant-slug}/metadata
ACS URL: {APIPublicURL}/api/v1/auth/saml/{tenant-slug}/acs

This is a tenant-isolation control, not just a routing convenience: it means an assertion signed for one tenant’s SP entity ID and audience-restricted to it cannot be replayed against another tenant’s ACS endpoint even if an attacker obtained a valid assertion — services.ParseAndValidateResponse’s AudienceRestriction check (see Security posture) ties every accepted assertion to the specific tenant’s SP entity ID.

All tenants on a deployment share one SP signing key pair (the SP identity is per-tenant only in the entity ID/ACS URL, not in the signing key). Resolved by internal/pki/saml_sp.go’s ResolveSAMLSPKey, sourced from:

Env varPurpose
SAML_SP_CERT_PEMSP certificate PEM content (Key Vault / Container App env)
SAML_SP_KEY_PEMSP private key PEM content (Key Vault / Container App env)
SAML_SP_CERT_PATHSP certificate PEM file path (local dev fallback)
SAML_SP_KEY_PATHSP private key PEM file path (local dev fallback)

File paths take precedence over PEM env vars when both are set. When neither is configured, SAML SSO is simply unavailable on that deployment (errSAMLSPKeyUnresolved) rather than treated as a startup failure — this lets non-SAML deployments run without the extra key material.

SAML_SP_CERT_PEM_NEXT / SAML_SP_KEY_PEM_NEXT also exist in config (for a future certificate-rollover flow) but as of this writing are not consumed anywhereResolveSAMLSPKey only reads the primary four env vars. Treat these as reserved, not functional yet.

SAML_CLOCK_SKEW (default 60s) bounds the clock-skew tolerance ParseAndValidateResponse applies when re-checking Conditions.NotBefore/NotOnOrAfter — independent of and stricter than the SAML library’s own fixed 180s default.

An admin with settings.auth.manage configures a SAML provider under Settings → SSO:

  1. Create (POST /api/v1/settings/sso/providers) — paste IdP metadata XML/URL, or fill in entity ID / SSO URL / SLO URL directly. The provider is always created with is_enabled: false, regardless of what the client sends — there is no way to create an already-enabled provider.
  2. Test connection (POST /api/v1/settings/sso/providers/{id}/test) — a dry-run that resolves this deployment’s SP key material and the tenant’s IdP config into an actual *saml.ServiceProvider and verifies an AuthnRequest can be built. It never redirects anywhere or exposes the generated URL/RelayState. The result (success/failure + a sanitized reason) is persisted to last_test_result/last_test_error/last_tested_at.
  3. Enable (PATCH /api/v1/settings/sso/providers/{id} with is_enabled: true) — rejected with a validation error unless last_test_result == 'success' as currently stored in the DB. A config edit in the same request does not retroactively invalidate a prior successful test — admins are expected to re-run the test after further changes.

If the provider is metadata-URL-only and the URL hasn’t been fetched yet (no cached saml_idp_metadata_xml), the test endpoint returns a specific, actionable failure rather than a generic “no IdP metadata” error, telling the admin to paste XML directly or wait for the next scheduled metadata refresh.

sequenceDiagram
    participant Browser
    participant SPA as Cloud SPA
    participant API as Cloud Backend
    participant IdP as SAML IdP (Okta / ADFS / ...)

    Browser->>SPA: Enter email, or navigate via check-method
    SPA->>API: POST /api/v1/auth/check-method
    API-->>SPA: { method: "saml", sso_login_url }
    SPA->>API: GET /api/v1/auth/saml/{tenant}/login
    API->>API: Build AuthnRequest + sign RelayState JWT (tenant_id, origin, request_id)
    API-->>Browser: 302 Redirect to IdP SSO URL
    Browser->>IdP: AuthnRequest (redirect binding)
    IdP-->>Browser: SAMLResponse (POST binding) + RelayState
    Browser->>API: POST /api/v1/auth/saml/{tenant}/acs
    API->>API: Validate RelayState, tenant match, assertion (see Security posture)
    API->>API: SSOProvisioner.MatchExisting / JITProvision
    API->>API: Group sync, session recording, issue JWT pair
    API-->>Browser: Redirect to {SPA origin}/auth/callback?token=...

SP-initiated only, IdP-initiated is unsupported despite a DB field existing for it. tenant_sso_providers.saml_allow_idp_initiated can be set by an admin at the DB/API level, and services.BuildServiceProvider even passes it through to saml.ServiceProvider.AllowIDPInitiated. However:

  • SAMLHandler.acs hard-rejects any request with no RelayState form value — there is no fallback path for an unsolicited (IdP-initiated) response.
  • services.ParseAndValidateResponse unconditionally requires InResponseTo to be present and match the AuthnRequest ID this SP itself generated — both at the Response level (explicit pre-check) and, separately, at the individual assertion’s SubjectConfirmationData.InResponseTo (an explicit post-check that closes a signature-wrapping gap the underlying library only checks when AllowIDPInitiated is false).

In short: saml_allow_idp_initiated is persisted but not consulted by any login code path today. Treat it as a no-op field, not a working toggle.

RelayState-based tenant binding. RelayState is a short-lived (5 minute) signed JWT carrying tenant_id, the SPA origin to redirect back to, and the AuthnRequest’s own ID — this is SAML’s analogue of the signed state parameter OIDC uses. acs validates the RelayState’s tenant_id against the tenant slug in the POSTed URL (checkRelayStateTenant) before trusting anything else, closing the same cross-tenant-response CSRF class the OIDC flow’s signed state already closes.

Account-linking safety. SAML reuses the exact same SSOProvisioner (internal/domain/auth/provisioning.go) that the Entra OIDC flow and platform Microsoft OAuth flow use — MatchExisting and JITProvision are shared code, not parallel implementations:

  • A user is matched by external_id (the SAML NameID) first. If found, the login proceeds normally.
  • If no external_id match exists but the asserted email matches an existing user in the tenant, the login is not allowed to proceed as that user. Instead a sso_pending_links row is created/refreshed and the user is redirected to an “account link pending” error — an admin must explicitly approve the link. This closes an account-takeover vector where an IdP-side admin could mint an assertion for any email address and silently take over an existing account.
  • If a pending link was previously rejected by an admin, its rejected_until cooldown is honored — the caller gets a distinct “declined by administrator” redirect instead of “awaiting approval,” and the row is left untouched during the cooldown.
  • If neither external_id nor email match anything, JITProvision creates a new user (read_only role, tenant owner iff first user in the tenant, active immediately) and bootstraps RBAC built-in groups.

If groups_attribute is configured on the provider, syncSAMLGroupMemberships (in internal/domain/auth/saml.go) runs on every successful login and maps IdP-asserted group values to local RBAC groups via tenant_sso_group_mappings. Matching is case-sensitive exact string match against SAMLUserInfo.RawAttributes — no fuzzy matching.

Two modes, controlled by group_sync_mode:

ModeBehavior
additive (default)IdP-asserted groups are added to the user’s existing memberships. Never removes anything.
authoritativeThe user’s local group memberships are replaced by the IdP-asserted set on every login.

Authoritative-mode safety detail: if the configured groups_attribute key is entirely absent from the assertion (not just empty), authoritative sync is skipped for that login and a warning is logged — applying an absent attribute literally would be indistinguishable from “the IdP asserts zero groups” and would strip every local membership purely due to an IdP misconfiguration. If the attribute key is present but maps to an empty list, that is treated as a legitimate “member of zero groups” and authoritative sync proceeds, replacing the user’s groups with the empty set.

Dual-provider precedence (decideCheckMethodResponse)

Section titled “Dual-provider precedence (decideCheckMethodResponse)”

When a tenant has both azure_ad and saml providers configured and enabled, and the user’s own account is still on local auth (has never completed either SSO flow), POST /api/v1/auth/check-method must pick one method to hint. SAML wins — this is a fixed, deterministic rule, not based on config recency (UpdatedAt), because Azure AD provider rows are created once via tenant registration and never touched again, while SAML provider rows are routinely edited for unrelated reasons (display name, SLO URL, attribute map) that would otherwise bias a recency-based tie-break toward SAML anyway. SAML was picked as the fixed winner as the more deliberate, higher-touch enterprise setup an admin is more likely to be actively rolling out when both exist. If the user’s account already has AuthProvider set to azure_ad or saml from a prior login, that value wins outright regardless of what’s configured.

Mapping a SAML group to the tenant’s built-in Admin group requires the caller to hold both settings.auth.manage (the whole /settings/sso/* API’s baseline gate) and settings.groups.write (sso_domain_group_handler.go’s validateGroupIDsBelongToTenant). Without this second gate, PUT /settings/sso/providers/{id}/group-mappings would itself be a privilege-escalation path: once an Admin-group mapping is saved, syncSAMLGroupMemberships re-applies it on every login, so in authoritative mode the IdP would silently grant or revoke full tenant-admin access to anyone it asserts into/out of the mapped group. Every login-time grant or revoke of Admin-group membership caused by sync — regardless of mode — is separately audited as sso.group_sync.admin_group_changed (category auth), because the config-time sso.group_mapping.changed audit event only records a mapping count, not who it later promotes or demotes.

PUT /api/v1/settings/sso/enforce (enforceSSO in sso_provider_handler.go) turns on tenants.enforce_sso, which blocks local password login for everyone in the tenant except the owner. Enabling requires both preconditions to hold, checked at enable-time (not per-login) to avoid a chicken-and-egg lockout:

  1. The tenant has at least one enabled SSO provider that has passed its test-connection dry-run (is_enabled == true && last_test_result == 'success') — otherwise enforcement would lock everyone out of an SSO path that doesn’t actually work.
  2. The tenant owner already has MFA fully set up (MFAEnabled && MFAVerified) — checked via ListActiveTenantOwners. This is what makes the owner’s break-glass path (below) safe: the second factor must exist before lockout becomes possible, never be set up during it.

Disabling (enforce=false) has no preconditions — it only loosens access.

Owner break-glass exception. With enforce_sso on, every non-owner local-password login is rejected outright (sso_enforced). The tenant owner keeps a local-password path, but it is treated as a distinct, high-severity, closely audited exception in auth.go’s login()/mfaLogin():

  • Every owner local-login attempt while enforce_sso is on fires a dedicated audit event, auth.breakglass.owner_password_login (category admin, severity: high, distinct from the routine auth.login event), at every step — challenge issuance, bad password, bad TOTP, and success — so the whole attempt stays visible end-to-end.
  • As a defensive check independent of the enable-time precondition, if the owner’s MFA is ever !MFAEnabled || !MFAVerified at login time (e.g. MFA was reset after enforcement was turned on), break-glass login is blocked outright rather than silently degrading into a no-second-factor login.

GET|POST /api/v1/auth/saml/{tenant}/sloIdP-initiated only. This endpoint only ever responds to a LogoutRequest the IdP sends; Monozu Cloud never initiates SP-initiated SLO itself (there is no “tell the IdP” step wired into the app’s own logout button).

The handler is unauthenticated by necessity (the IdP calls it directly, with no session cookie of ours attached), so ParseAndValidateLogoutRequest (internal/services/saml.go) applies the same rigor as assertion validation: XML well-formedness, the same signature-algorithm/transform allowlists as ParseAndValidateResponse, and mandatory enveloped-XML-signature verification against the IdP’s registered signing cert — an unsigned LogoutRequest is rejected outright (anyone could otherwise force-terminate any user’s session). The Issuer is checked against the tenant’s configured saml_idp_entity_id at the handler layer.

On a match (by SessionIndex, falling back to NameID), the handler calls RefreshTokenRepository.RevokeAllForUser (all sessions, not just one — there is no single browser cookie to target from an IdP-to-server call) and deletes the recorded user_sso_sessions row. If no local session matches — already logged out, a stale LogoutRequest, or a session this SP never recorded — the handler still responds with a valid signed LogoutResponse rather than an error, since “no active session” is exactly the state a LogoutRequest is asking for either way. Every completion fires sso.slo.completed (category auth).

Documented scope limitation: ParseAndValidateLogoutRequest only handles the plain-base64 XML encoding used on both the GET (redirect) and POST bindings this endpoint implements. It does not implement the DEFLATE-compressed, query-string-signed variant of the SAML HTTP-Redirect binding (SAML core spec §3.4.4.1) that some IdPs use for LogoutRequest — a LogoutRequest sent that way will fail to parse. This is a documented follow-up, not an oversight.

Two schedulers in internal/jobs/, both following the existing CVEFeedScheduler ticker pattern (immediate run on start, then on a fixed interval, a per-tenant failure never aborts the sweep for other tenants):

JobIntervalPurpose
SAMLMetadataScheduler (saml_metadata_job.go)12 hoursFor every enabled, metadata-URL-configured SAML provider, fetches the metadata URL (5 MiB response cap, 30s HTTP timeout), parses it, and inserts any newly-seen signing certs into tenant_sso_idp_certs. Certs are only ever added, never removed — certificate rollover requires the old and new cert to coexist during transition; removing a stale cert is a separate, explicit admin action. A fetch/parse failure only sets saml_metadata_error; it never touches saml_metadata_refreshed_at, existing certs, or is_enabled.
SAMLReplayCleanupScheduler (saml_replay_cleanup_job.go)1 hourPurges expired tenant_saml_replay rows across all tenants via PurgeExpiredReplayRecords. Hourly is a deliberate judgment call: assertion NotOnOrAfter windows are short (minutes), so rows are safe to purge well within an hour.

On a metadata fetch failure, the job does not retry more aggressively than its normal 12-hour cadence. There is no backoff/retry-sooner logic — a tenant whose metadata URL is temporarily unreachable (or misconfigured) simply waits until the next scheduled sweep, or the admin manually re-runs the test-connection dry-run (which uses whatever XML/certs are already cached, not a fresh fetch) after fixing the URL.

The validation logic in internal/services/saml.go (ParseAndValidateResponse and ParseAndValidateLogoutRequest) is deliberately defense-in-depth on top of what the underlying crewjam/saml/goxmldsig libraries already provide. This maps directly to NIS2/ISO 27001/CRA authentication-integrity control objectives and is the section to review for a security audit of this feature:

  • Exactly-one-assertion check. The library itself only errors when a Response has zero valid assertions — it silently accepts the first successfully-parsed assertion out of two or more, which would let an attacker smuggle a second, unsigned/tampered <Assertion> alongside a validly-signed one. This code counts direct <Assertion>/<EncryptedAssertion> children and rejects anything other than exactly 1, before any signature crypto runs.
  • SHA-1 signature rejection. goxmldsig will happily validate a cryptographically-correct RSA-SHA1 signature (a supported, non-default algorithm in its lookup table). This code explicitly allowlists only RSA-SHA256/384/512 for every <SignatureMethod Algorithm> in the document and rejects everything else, including SHA-1.
  • XSLT/transform denylist. Every <Transform Algorithm> anywhere in the document must be on an explicit allowlist (enveloped-signature plus the standard canonicalization algorithms). The underlying library already fails closed on unrecognized transforms as an incidental side effect of its own switch statement; this makes that rejection explicit, deliberate, and pre-crypto rather than relying on an unrelated implementation detail.
  • Forged InResponseTo closed at both layers. The Response-level InResponseTo attribute is checked explicitly and unconditionally. Separately, the assertion’s own (signed) SubjectConfirmationData.InResponseTo is checked too — this matters because when only the <Assertion> (not the whole <Response>) is signed, the outer Response-level attribute is itself unsigned and could be rewritten by an attacker holding a validly-signed assertion for a different AuthnRequest. The library only enforces the assertion-level check when AllowIDPInitiated is false; this code enforces it regardless, since IdP-initiated flows are out of scope entirely (see Login flow).
  • Replay protection. assertion.ID plus Conditions.NotOnOrAfter are recorded via CheckAndInsertReplay against tenant_saml_replay; a previously-seen assertion ID is rejected. This check runs last, after signature/time/audience checks pass, so a mangled replay attempt can’t burn the real assertion’s ID before it arrives.
  • Clock skew tolerance. Conditions.NotBefore/NotOnOrAfter are re-checked against a caller-supplied SAML_CLOCK_SKEW (default 60s) — stricter than the library’s own fixed 180s default, and configurable per deployment without touching shared library state.
  • Non-empty, tenant-scoped audience restriction. The library treats an assertion with zero AudienceRestriction elements as valid, which this project considers too permissive for SP-initiated flows. This code requires at least one AudienceRestriction whose value equals the SP’s own (per-tenant) entity ID.
  • Tenant-scoped SP entity IDs prevent cross-tenant assertion replay. Because the SP entity ID and ACS URL are per-tenant (see Architecture), the audience-restriction check above ties every accepted assertion to one specific tenant — an assertion valid for tenant A’s SP can never pass audience validation against tenant B’s ACS endpoint.
  • Mandatory signature on inbound LogoutRequest. ParseAndValidateLogoutRequest rejects any LogoutRequest with no <Signature> element at all — an unsigned inbound logout request would let anyone force-terminate any user’s session.
  • No raw error text to unauthenticated clients. Both ParseAndValidateResponse and ParseAndValidateLogoutRequest document that callers must never echo their raw error.Error() back to an unauthenticated client. SAMLHandler.acs/slo log a categorized, sanitized reason server-side (via classifySAMLLibraryError) and redirect with a generic error=sso_failed.
  • IdP-initiated login is not supported, despite saml_allow_idp_initiated existing as a persisted DB field and being wired through to saml.ServiceProvider.AllowIDPInitiated. No login code path consults it — acs hard-rejects any request without a valid RelayState, and ParseAndValidateResponse unconditionally requires InResponseTo.
  • SP-initiated SLO is not supported. The slo handler only ever responds to a LogoutRequest from the IdP; there is no code path where Monozu Cloud’s own logout initiates a LogoutRequest to the IdP.
  • DEFLATE-compressed HTTP-Redirect LogoutRequest binding is not supported. Only the plain-base64 XML encoding (used on both the GET and POST bindings this endpoint implements) is parsed. A LogoutRequest sent via the DEFLATE-compressed, query-string-signed redirect-binding variant (SAML core spec §3.4.4.1) will fail to parse.
  • Metadata-URL-only providers must be manually retested if the initial fetch fails, or wait out the full 12-hour cycle. There is no accelerated retry/backoff in SAMLMetadataScheduler — a persistently-broken metadata URL surfaces via saml_metadata_error and the admin either fixes the URL and waits for the next sweep, or pastes metadata XML directly and re-runs the test-connection dry-run.
  • SAML_SP_CERT_PEM_NEXT/SAML_SP_KEY_PEM_NEXT are reserved but unused. They exist in config for a future certificate-rollover flow but ResolveSAMLSPKey does not currently read them.