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.
Overview
Section titled “Overview”- 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_providerstable, distinguished by aprovidercolumn (samlvsazure_ad).
Architecture
Section titled “Architecture”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:
| Column | Purpose |
|---|---|
provider | 'azure_ad' or 'saml' (ck_tsp_provider CHECK constraint) |
is_enabled | Forced 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_url | Direct IdP fields, used when no metadata XML is available |
saml_idp_metadata_url | Metadata URL for periodic refresh (see Background jobs) |
saml_idp_metadata_xml | Cached/pasted metadata XML — always preferred over the direct fields when present |
saml_nameid_format | Optional NameID format the SP requests |
saml_want_assertions_signed, saml_want_response_signed | Signing expectations (default 1/true) |
saml_allow_idp_initiated | Persisted but not currently consulted anywhere — see Login flow |
saml_metadata_refreshed_at, saml_metadata_error | Set by the metadata refresh job |
group_sync_mode | 'additive' or 'authoritative' (ck_tsp_group_sync_mode, default 'additive') |
groups_attribute | The SAML attribute name carrying group membership, if group sync is configured |
last_tested_at, last_test_result, last_test_error | Added 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.
Per-tenant SP Entity ID and ACS URL
Section titled “Per-tenant SP Entity ID and ACS URL”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}/metadataACS URL: {APIPublicURL}/api/v1/auth/saml/{tenant-slug}/acsThis 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.
Platform SP key material
Section titled “Platform SP key material”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 var | Purpose |
|---|---|
SAML_SP_CERT_PEM | SP certificate PEM content (Key Vault / Container App env) |
SAML_SP_KEY_PEM | SP private key PEM content (Key Vault / Container App env) |
SAML_SP_CERT_PATH | SP certificate PEM file path (local dev fallback) |
SAML_SP_KEY_PATH | SP 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 anywhere — ResolveSAMLSPKey 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.
Setup flow
Section titled “Setup flow”An admin with settings.auth.manage configures a SAML provider under Settings → SSO:
- 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 withis_enabled: false, regardless of what the client sends — there is no way to create an already-enabled provider. - 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.ServiceProviderand verifies anAuthnRequestcan be built. It never redirects anywhere or exposes the generated URL/RelayState. The result (success/failure+ a sanitized reason) is persisted tolast_test_result/last_test_error/last_tested_at. - Enable (
PATCH /api/v1/settings/sso/providers/{id}withis_enabled: true) — rejected with a validation error unlesslast_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.
Login flow
Section titled “Login flow”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.acshard-rejects any request with noRelayStateform value — there is no fallback path for an unsolicited (IdP-initiated) response.services.ParseAndValidateResponseunconditionally requiresInResponseToto 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’sSubjectConfirmationData.InResponseTo(an explicit post-check that closes a signature-wrapping gap the underlying library only checks whenAllowIDPInitiatedis 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_idmatch exists but the asserted email matches an existing user in the tenant, the login is not allowed to proceed as that user. Instead asso_pending_linksrow 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_untilcooldown 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_idnor email match anything,JITProvisioncreates a new user (read_onlyrole, tenant owner iff first user in the tenant, active immediately) and bootstraps RBAC built-in groups.
Group sync
Section titled “Group sync”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:
| Mode | Behavior |
|---|---|
additive (default) | IdP-asserted groups are added to the user’s existing memberships. Never removes anything. |
authoritative | The 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.
Admin-group mapping gate
Section titled “Admin-group mapping gate”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.
Enforce SSO
Section titled “Enforce SSO”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:
- 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. - The tenant owner already has MFA fully set up (
MFAEnabled && MFAVerified) — checked viaListActiveTenantOwners. 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_ssois on fires a dedicated audit event,auth.breakglass.owner_password_login(categoryadmin,severity: high, distinct from the routineauth.loginevent), 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 || !MFAVerifiedat 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.
Single Logout (SLO)
Section titled “Single Logout (SLO)”GET|POST /api/v1/auth/saml/{tenant}/slo — IdP-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.
Background jobs
Section titled “Background jobs”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):
| Job | Interval | Purpose |
|---|---|---|
SAMLMetadataScheduler (saml_metadata_job.go) | 12 hours | For 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 hour | Purges 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.
Security posture
Section titled “Security posture”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.
goxmldsigwill 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
InResponseToclosed at both layers. The Response-levelInResponseToattribute is checked explicitly and unconditionally. Separately, the assertion’s own (signed)SubjectConfirmationData.InResponseTois 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 whenAllowIDPInitiatedis false; this code enforces it regardless, since IdP-initiated flows are out of scope entirely (see Login flow). - Replay protection.
assertion.IDplusConditions.NotOnOrAfterare recorded viaCheckAndInsertReplayagainsttenant_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/NotOnOrAfterare re-checked against a caller-suppliedSAML_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
AudienceRestrictionelements as valid, which this project considers too permissive for SP-initiated flows. This code requires at least oneAudienceRestrictionwhose 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.ParseAndValidateLogoutRequestrejects anyLogoutRequestwith 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
ParseAndValidateResponseandParseAndValidateLogoutRequestdocument that callers must never echo their rawerror.Error()back to an unauthenticated client.SAMLHandler.acs/slolog a categorized, sanitized reason server-side (viaclassifySAMLLibraryError) and redirect with a genericerror=sso_failed.
Known limitations / follow-ups
Section titled “Known limitations / follow-ups”- IdP-initiated login is not supported, despite
saml_allow_idp_initiatedexisting as a persisted DB field and being wired through tosaml.ServiceProvider.AllowIDPInitiated. No login code path consults it —acshard-rejects any request without a valid RelayState, andParseAndValidateResponseunconditionally requiresInResponseTo. - SP-initiated SLO is not supported. The
slohandler only ever responds to aLogoutRequestfrom the IdP; there is no code path where Monozu Cloud’s own logout initiates aLogoutRequestto the IdP. - DEFLATE-compressed HTTP-Redirect
LogoutRequestbinding is not supported. Only the plain-base64 XML encoding (used on both the GET and POST bindings this endpoint implements) is parsed. ALogoutRequestsent 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 viasaml_metadata_errorand 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_NEXTare reserved but unused. They exist in config for a future certificate-rollover flow butResolveSAMLSPKeydoes not currently read them.