On this page0
Unfamiliar word? See Glossary: words used in these docs. Looking for the tasks in order? Begin at Start Here.
Reference
Included in: All plans. Which Plan Includes What
SSO, Passwordless Login & SCIM
Three ways in that never ask your instance for a password: single sign-on (SSO) through your own identity provider, single-use sign-in links sent by email, and sign-in with a passkey.
What It Does
Each of these lets an existing user sign in without typing a local password, but they solve different problems, and by default none of them creates accounts.
SSO Trusts Your Identity Provider, Not A New Password
Magic Links Are Single-Use And Expire
A Passkey Is The Whole Sign-In
Sized For A Shared Office IP
If something here is not working as you expect, start with When something goes wrong.
The plain explanation is above. This part is for IT and developers: the exact settings, permission names, API addresses and data fields behind it. Closed: select the heading above to open it. While it is closed, your browser’s find-on-page (Ctrl+F or Cmd+F) does not search this part.
Federated, Not Self-Hosted
Your instance runs the service-provider side of either an OpenID Connect Authorization-Code flow or a SAML 2.0 SP-initiated flow (IdP-initiated SAML is refused): one IdP per instance. It never sees the user’s IdP password — your identity provider authenticates the person (applying its own MFA and conditional-access), and your instance trusts the signed ID token or assertion it returns. For ordinary users that is the whole sign-in; administrators and other privileged accounts are additionally asked for a Zonitix second factor unless the IdP’s response proves it performed MFA. The magic-link path is the same idea without an IdP: a 15-minute, single-use token delivered by email. Both are configured in the database through the admin panel, not in .env.
Configuring Your IdP
SSO configuration lives in the database (a SecuritySetting row keyed sso_config), not in an environment file. The OIDC client secret is stored encrypted at rest (AES-256-GCM, the shared key ring) and is never returned to any caller — reads report only whether a secret is set. The config endpoints are gated at the route on the manage_security permission, and the controller checks it again; the gateway injects the authenticated, signed x-user after validating the session. Writing the config additionally requires a fresh sudo step-up (SUDO_REQUIRED otherwise), because repointing sign-in at a different IdP is a security-boundary change.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/v1/identity-admin/sso/config | Read config with the client secret masked (clientSecretSet boolean only) and SAML certificates as summaries | Session + manage_security |
| POST | /api/v1/identity-admin/sso/config | Write config; a supplied client secret is encrypted, an omitted one is kept | Session + manage_security + sudo |
| POST | /api/v1/identity-admin/sso/saml/parse-metadata | Parse IdP metadata (XML or an https URL) into the form fields. Saves nothing | Session + manage_security |
| GET | /api/v1/auth/sso/saml/metadata | Public SAML SP metadata (entity ID + ACS URL) to hand to your IdP | Public (rate-limited) |
| GET | /api/v1/auth/sso/status | Public: is SSO enabled, which protocol and provider label, and is email sign-in available? Drives the login page; leaks nothing account-specific | Public |
The write body carries the fields below. Validation is protocol-aware — only the active protocol has to be complete, and the other protocol’s fields are kept untouched so you can switch back. When enabled is true and protocol is oidc, issuer must be an https URL, clientId is required, and a client secret must be supplied or already stored; when it is saml, the IdP entity ID, SSO URL and at least one signing certificate are required. A failed check rejects the save with 400. A save is audit-logged as SSO_CONFIG_UPDATED.
enabled,protocol(oidcorsaml) andpreset(your provider, which only labels the sign-in button and the setup hints).- OIDC:
issuer,clientId,clientSecret(optional on update — omit it to keep the stored one). - SAML:
saml: { idpEntityId, idpSsoUrl, idpCerts }; certificates are kept when not re-supplied, and several are allowed during a rotation. jit— the just-in-time provisioning block (off by default): allowed email domains and a non-privileged default role or group.mfaAcrValues— optional list of authentication-context values your IdP sends after MFA that should count as a proven second factor.
// Enable OIDC SSO. clientSecret is optional on update — omit it to keep the stored one.
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/identity-admin/sso/config \
-H 'Content-Type: application/json' \
-H 'Origin: https://<your-instance>.zonitix.com' \
# authenticated admin session cookie (production name is __Host-token), inside the sudo window:
--cookie '__Host-token=<session-jwt>' \
-d '{"enabled":true,"protocol":"oidc","preset":"okta","issuer":"https://login.example-idp.com","clientId":"zonitix-instance","clientSecret":"s3cr3t-from-your-idp"}'
// The masked read — the secret itself is never in the response:
GET /api/v1/identity-admin/sso/config
{
"enabled": true, "protocol": "oidc", "preset": "okta",
"issuer": "https://login.example-idp.com",
"clientId": "zonitix-instance",
"clientSecretSet": true,
"saml": { "idpEntityId": "", "idpSsoUrl": "", "certs": [] }, // certs are summaries, not PEM
"jit": { "enabled": false, ... },
"mfaAcrValues": []
}
At your IdP, register your instance as an OIDC confidential client and grant the scopes openid email profile (the values your instance requests). For the redirect URI (OIDC) or the Entity ID and ACS URL (SAML), copy the exact values shown in Security Settings → SSO (OIDC / SAML) rather than typing them — they are built from your instance’s own address and end in /auth/sso/callback and /auth/sso/saml/acs. The OIDC issuer you enter must be the one whose /.well-known/openid-configuration discovery document echoes the same issuer back — a mismatch is rejected at discovery time.
What To Set On The OIDC Client At Your IdP
- Flow: authorization code (
response_type=code). No implicit or hybrid flow is used. - Client authentication at the token endpoint:
client_secret_post. The client id and client secret are sent in the form body of the token request.client_secret_basic(an Authorization header) andprivate_key_jwtare not used, so the client at your IdP must allowclient_secret_post. - PKCE is not sent. The authorize request carries no
code_challengeand the token request nocode_verifier. If your IdP requires PKCE for this client, the sign-in will fail; set PKCE to optional for it. The exchange is protected by the client secret,stateandnonce. - ID-token signing algorithm:
RS256. A token signed with any other algorithm is refused. - Discovery must be reachable. Your instance reads
/.well-known/openid-configurationunder the issuer and the key set it names, and keeps each for up to one hour. Those addresses and the token endpoint must be public: one that resolves to a private, loopback or cloud-metadata address is refused, and a redirect from any of them is not followed.
The Login Exchange
The browser-facing flow is owned by the gateway, which binds the run to a signed, short-lived sso_flow cookie; the OIDC and SAML logic and session issuance live in the identity service. Both protocols finish in the same place, with one of three outcomes: a session, a parked continuation (a second factor or Terms acceptance is still owed), or a denial.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/v1/auth/sso/login | Begin the flow for whichever protocol is configured: builds the IdP redirect, binds it to a signed cookie, 302-redirects to your IdP | Public (rate-limited) |
| GET | /api/v1/auth/sso/saml/login | Alias of the above, so a SAML-only IdP tile or bookmark has an obvious URL | Public (rate-limited) |
| GET | /api/v1/auth/sso/callback | OIDC redirect target: verifies state, exchanges the code, then finishes with one of the three outcomes | Public (rate-limited) |
| POST | /api/v1/auth/sso/saml/acs | SAML Assertion Consumer Service: the IdP posts the signed response here; bound to the browser by the request ID in the signed cookie | Public (rate-limited) |
| GET | /api/v1/auth/sso/pending | The sign-in page redeems a parked MFA or Terms continuation. Single use; 404 if none, 410 if expired | Public (rate-limited) |
// 1. Browser hits /auth/sso/login. Gateway fetches the authorize URL + a fresh
// state/nonce, signs them into a 10-minute sso_flow cookie (path=/auth/sso),
// and redirects the browser to the IdP (OIDC shown; SAML binds a request ID instead):
302 Location: https://login.example-idp.com/authorize
?response_type=code&client_id=zonitix-instance
&redirect_uri=<the redirect URI shown in SSO settings>
&scope=openid%20email%20profile&state=<state>&nonce=<nonce>
// If SSO is off or discovery fails, the gateway redirects to /login?sso_error=config
// 2. IdP authenticates the user, then redirects back with code + state.
// 3. /auth/sso/callback checks state == the value in the signed cookie (CSRF),
// then the identity service exchanges + verifies the id_token:
POST /sso/callback { code, redirectUri, expectedNonce } (gateway → identity)
→ 200 { auth: true, token } gateway sets __Host-token and redirects to /
→ 200 { mfaRequired | termsAcceptanceRequired, pendingHandle }
no session yet: gateway sets an httpOnly sso_pending cookie (5 min) and redirects
to /login?sso=continue, where the page redeems it at GET /auth/sso/pending
→ 401 { error: 'no_matching_user' } → /login?sso_error=no_account
→ 401 { error: 'sso_failed' } → /login?sso_error=failed (any token/verification failure)
State, Nonce And The Token Request
stateandnonceare two separate values of 32 random bytes each (base64url), made new for every sign-in.- Where they are kept. Not on the server. The gateway puts both, with the redirect URI, into a token signed with HS256 under a per-instance secret and valid for 10 minutes, and sets it as the
sso_flowcookie:HttpOnly, path/auth/sso, and in productionSecurewithSameSite=None. - On return the cookie’s signature and expiry are checked first, then the
statein the address must equal the one in the cookie. A missing or expired cookie, or a differentstate, sends the browser to/login?sso_error=stateand no code is exchanged. - The token request is a form POST from your instance to the IdP’s token endpoint with
grant_type=authorization_code,code,redirect_uri,client_idandclient_secretin the body (client_secret_post). No PKCE verifier is sent. Only theid_tokenin the answer is used.
The ID-token verification is where the trust decision is made. Every one of these guards must pass, and an inspector can hold the flow to each:
- Algorithm is pinned to
RS256. The token’s ownalgheader is never trusted; a token presenting any other algorithm is rejected before verification — the classicalg:none/ HS256-confusion bypass cannot apply. - Signature is checked against the IdP’s JWKS by
kid, andissuer,audience(yourclientId) and expiry are all enforced. An unknownkidtriggers exactly one JWKS re-fetch to follow key rotation. - Nonce must equal the one this flow generated. The value bound to the browser’s signed cookie has to match the token’s
nonceclaim — the replay/injection binding. - Email must be present and verified. An
email_verifiedoffalseis refused, and the email is lower-cased before lookup. - JIT provisioning is off by default. The email must map to a local user whose
statusisactive. Otherwise the attempt is denied and audited asSSO_LOGIN_DENIED; nothing is created. The one exception is opt-in just-in-time provisioning: when you enable it, an email with no account at all, from an allow-listed domain, is created with your chosen non-privileged default role or group (audited asUSER_JIT_PROVISIONED). An existing inactive or offboarded account is never re-opened.
SAML Response Verification
SAML has the same trust decision as the ID-token checks above, made on the response your IdP posts to the ACS URL. Every guard must pass; any failure ends at /login?sso_error=… with no session.
- The assertion itself must be signed by one of the IdP certificates you configured. A signature on the outer response alone is not enough.
- SHA-1 is refused. A response that uses an RSA-SHA1, DSA-SHA1, HMAC-SHA1 or SHA-1 digest algorithm is rejected:
SAMLResponse uses a SHA-1 signature/digest; SHA-256 or stronger is required. - A response containing a
DOCTYPEis refused before it is parsed. - Size. The gateway accepts the ACS form post up to 100 KB, with at most 5 form fields. That limit is on the URL-encoded form, so the usable
SAMLResponseis somewhat smaller. The identity service has its own cap of 256 KB on the base64SAMLResponse, which the gateway limit is reached before. Keep the group and attribute claims your IdP puts in the assertion small. InResponseTois required and single-use. It must equal the request ID bound to this browser’s signed cookie, the request is valid for 10 minutes, and its ID is consumed on first use, so a replayed response fails. This is also why IdP-initiated sign-in is refused.- Destination and Recipient must be your ACS URL. The response’s
Destination, when present, and theRecipienton every bearerSubjectConfirmationDatamust equal the ACS URL exactly. - Issuer and audience are pinned. The assertion’s
Issuermust equal the IdP entity ID you configured, and the audience must be your instance’s SP entity ID (its metadata URL). - Expiry is mandatory. The assertion must carry exactly one
Conditionselement withNotOnOrAfter, andNotOnOrAfteron its bearerSubjectConfirmationData. Clock skew tolerance is 60 seconds. - The email decides who signs in. The requested NameID format is
emailAddress. The email is read from an email attribute (email,mail, the standardemailaddressclaim) or, failing that, from the NameID; it is lower-cased and must be a well-formed address. - No authentication context is requested. Your IdP chooses how the person authenticates. The
AuthnContextClassRefit returns is compared against your MFA-satisfying authentication contexts list for privileged accounts.
On the configuration side, the IdP SSO URL must be https, IdP metadata is fetched over https only (8-second timeout, 512 KB cap), and metadata must offer an HTTP-Redirect SingleSignOnService and at least one signing certificate. The service provider is configured with no signing or decryption key of its own, so it does not sign its AuthnRequest and does not accept encrypted assertions: have your IdP send a signed, unencrypted assertion.
Who Still Gets A Zonitix Second Factor After SSO
For an ordinary user, your IdP is the authenticator: a successful SSO login issues a session that is already marked verified, and your instance does not re-challenge for TOTP or a passkey on top — so set their MFA policy at the IdP. Privileged accounts are different. The administrator role, anyone holding manage_security, manage_users or pay_invoice, and the Zonitix support account always need a proven second factor. If the IdP’s response proves MFA — an amr claim naming a second factor, or an authentication-context value you listed under MFA-satisfying authentication contexts — the session is issued. If it does not, no session is minted: the sign-in is audited as SSO_MFA_REQUIRED and the person is asked for their Zonitix passkey or authenticator code (or taken through setting one up) before they get in.
By Default, SSO Does Not Provision Users
Unless you have turned on just-in-time provisioning, a user who exists in your IdP but not on your instance is refused, not created — provision the local account first (by hand, directory sync or SCIM). With JIT on, an unknown email on an allow-listed domain is created with your non-privileged default role or group. Either way, a local account that exists but is not active (disabled, inactive, deleted, suspended, offboarded, and the like) cannot sign in via SSO and is never re-opened by it.
Magic-Link Passwordless
A magic link is a JWT carrying { id, email, isMagicLink: true }, signed with the instance JWT_SECRET and valid for 15 minutes. It is a single-purpose email credential, not a session: /validate and /refresh-token both reject an isMagicLink token outright, so a leaked link can only be redeemed at the verify endpoint.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /api/v1/auth/magic-link/request | Email a single-use sign-in link to an active user. Same generic message for every address when email is configured; 409 when it is not | Public (rate-limited) |
| GET | /api/v1/auth/magic-link/verify | Redeem the link token; consumes it on first use. Returns a session, or the next step still owed (password change, second factor, Terms) | Public (rate-limited) |
| POST | /api/v1/auth/accept-terms | Exchange a Terms token for a session once the person has accepted the current Terms | Public (rate-limited) |
| POST | /api/v1/auth/settings/security/smtp/test | Send a test email to confirm SMTP can deliver the link | Session + manage_security |
// Request a link. With email configured, the response is identical whether or not
// the account exists — anti-enumeration. Only an ACTIVE user is ever emailed.
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/auth/magic-link/request \
-H 'Content-Type: application/json' \
-d '{"email":"user@company.cc"}'
→ 200 { "message": "If an account exists with that email, a magic link has been sent." }
→ 409 email delivery is not configured on this instance (no link can be sent to anyone)
// The emailed link points at the FRONTEND /login?token=… (never the API GET,
// which returns JSON). The token is never logged, persisted or returned in a response.
// Redeem the token — single use. A session is issued only when nothing else is owed.
curl 'https://<your-instance>-api.zonitix.com/api/v1/auth/magic-link/verify?token=<jwt>'
→ 200 { auth: true, token } session cookie set AND returned
→ 200 { mfaRequired: true, mfaStatus, mfaToken, mfaMethods } second factor still owed
→ 200 { termsAcceptanceRequired: true, termsVersion, termsToken } → POST /auth/accept-terms
→ 200 a password-change token, when the account has a forced password change pending
→ 400 { error: 'This magic link has already been used.' }
→ 400 { error: 'Magic link expired or invalid' }
→ 400 { error: 'Account is not active' }
A magic link replaces the password, nothing more. It proves control of the inbox — the first factor — so the same gates as a password sign-in run after it, in the same order: a pending forced password change, then the second factor when MFA is required for that person (a short-lived MFA-challenge token is returned and the client completes /mfa/verify or a passkey), then acceptance of the current Terms. Only when none of those is owed does verify return a session.
The single-use guarantee is not cosmetic: verify atomically claims the token before it loads the user or issues anything, so even a race between two redemptions of the same link cannot mint two sessions, and the link stays consumed even when the outcome is an MFA challenge. A used or expired link reaches the caller as 400 with { error, correlationId }: the identity service answers 401 and the gateway turns that into 400. A completed sign-in is audit-logged as LOGIN_MAGIC_LINK_SUCCESS; the request itself as MAGIC_LINK_REQUESTED.
SMTP Is A Hard Dependency
No link reaches a user unless outgoing mail works. Two things must be in place: SMTP settings (host, user, password — the password is stored encrypted at rest and never returned) configured through POST /api/v1/auth/settings/security, and a sign-in base URL so the link can be built (Zonitix sets your instance’s domain when we provision it; you do not configure it). Verify deliverability first with the SMTP-test endpoint, which sends a real message and returns { success } or a 400 if SMTP is not configured:
// Send a test email using the saved SMTP config (omit host/user/pass to use stored).
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/auth/settings/security/smtp/test \
-H 'Content-Type: application/json' \
-H 'Origin: https://<your-instance>.zonitix.com' \
--cookie '__Host-token=<session-jwt>' \
-d '{"to":"admin@company.cc"}'
→ 200 { success: true, ... }
→ 400 { success: false, error: 'SMTP is not configured…' }
Unset Mail Is Loud; A Failed Send Is Silent
These are two different cases. If SMTP is not configured at all, magic-link/request refuses with a 409 instead of pretending a link was sent, and the sign-in page hides the FORGOT PASSWORD? SIGN IN WITH MAGIC LINK option entirely (it reads the public smtpConfigured flag from /auth/sso/status). That is a fact about the instance, not about any account, so it reveals nothing. If SMTP is configured but one message fails to send, the request still returns the generic success message — it must, or the response would reveal which addresses have accounts — and the email simply never arrives. That second case is why the SMTP-test endpoint exists: it is the way to confirm the mail path actually delivers.
Passkey Sign-In
The SIGN IN WITH A PASSKEY button on the sign-in page runs a discoverable-credential (usernameless) WebAuthn ceremony: no username, no password and no MFA-challenge token. The browser offers the passkeys stored for your instance, the authenticator demands user verification (biometric or PIN), and the assertion itself tells the server whose credential it is. Because possession and user verification are both proven in that one gesture, a successful ceremony issues a session that is already MFA-verified — there is no separate second-factor step. It works only for someone who has already added a passkey, and it needs no SMTP and no IdP.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/v1/auth/webauthn/login/options | Get a one-shot challenge with no allowCredentials list and user verification required | Public (rate-limited) |
| POST | /api/v1/auth/webauthn/login/verify | Verify the assertion, resolve the user from the credential, and issue a session (or the next step still owed) | Public (rate-limited) |
POST /api/v1/auth/webauthn/login/verify
→ 200 { auth: true, token } MFA-verified session; cookie set
→ 200 { termsAcceptanceRequired: true, termsVersion, termsToken } → POST /auth/accept-terms
→ 200 a password-change token, when the account has a forced password change pending
→ 400 malformed assertion, or the challenge expired / was already used
→ 401 { code: 'UNKNOWN_PASSKEY' } the credential is not registered on this instance
→ 401 passkey verification failed
→ 403 the account is locked out, or is not active
The challenge is stored server-side and consumed on the first verify whatever the outcome, which is what stops a replayed assertion. The account gates a password sign-in applies still run after the ceremony: an active lockout, a non-active status, a pending forced password change and acceptance of the current Terms. A successful sign-in is audit-logged as WEBAUTHN_LOGIN_SUCCESS; an unrecognised credential as WEBAUTHN_LOGIN_UNKNOWN_CRED. The relying-party ID, attestation, user-verification, algorithm, timeout and challenge values are listed under Passkey Parameters.
Rate Limiting
Three gateway limiters cover these paths, all keyed by the true client IP (the address Cloudflare reports, not a spoofable header):
- Sign-in limiter — 20 failed attempts per 15 minutes. Shared by password login,
magic-link/verify,accept-terms,sso/login,sso/callback, the SAML ACS and the SAML metadata endpoint. It counts failed responses only, so successful sign-ins never spend from it. Exceeding it returns 429 withToo many authentication attempts. Please try again later. - Magic-link request limiter — 30 requests per 15 minutes.
magic-link/requesthas its own separate budget that counts every request (it answers with the same success message either way, so a failures-only limiter would never trip). Its 429 readsToo many sign-in link requests from this network. Please try again later. - Second-factor limiter — 20 failed attempts per 5 minutes. Covers the passkey sign-in endpoints along with the other MFA routes.
All three of these limiters fail closed: if the Redis store is unreachable the request is refused with a 503 rather than allowed through, so a store outage never silently disables brute-force protection. The SCIM limiter below does not: it lets requests through while Redis is down. The full list is on Rate, Size & Time Limits.
Separate Budgets
/login?sso_error=…), and a redirect is not counted as a failure — so an sso_error loop is a configuration problem to diagnose at the IdP, not a rate limit.Scope Of This Phase
SSO supports OIDC and SAML 2.0 with one IdP per instance. By default it is login only: it decides whether an already-provisioned user may start a session. Opt-in just-in-time provisioning can create an unknown user from an allow-listed email domain with a default, non-privileged role or group, and it never re-opens an offboarded account. SSO itself does not synchronise groups, roles or profile attributes. Push provisioning from your IdP goes through the separate inbound SCIM 2.0 endpoint (https://<your-api-host>/scim/v2, per-instance bearer token), which Okta and Entra ID can use to create, update and deactivate users and groups.
SCIM 2.0 Provisioning
SCIM is a standard way for an identity provider to create, update and deactivate accounts in another system as people join, change and leave. An administrator switches it on by generating a token under Security Settings → Provisioning (SCIM) and pasting the base URL and token into the identity provider; from then on the provider pushes changes by itself. Steps: Push users with SCIM. The rest of this section is the protocol detail an integrator needs.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /scim/v2/ServiceProviderConfig | Discovery document. Also /scim/v2/ResourceTypes(/:id) and /scim/v2/Schemas(/:id) | SCIM bearer token |
| GET | /scim/v2/Users | List or filter users | SCIM bearer token |
| POST | /scim/v2/Users | Create a user | SCIM bearer token |
| GET | /scim/v2/Users/:id | Read one user. PUT replaces, PATCH updates, DELETE removes | SCIM bearer token |
| GET | /scim/v2/Groups | List or filter SCIM-managed groups. POST creates one | SCIM bearer token |
| GET | /scim/v2/Groups/:id | Read one group. PUT replaces, PATCH updates, DELETE removes | SCIM bearer token |
- Authentication. Every route needs
Authorization: Bearer zscim_…. There is no session and no other credential. The token is shown once when it is generated; only its SHA-256 hash is stored. At most 10 tokens can be active at a time. A missing, malformed, unknown or revoked token gets 401 with a SCIM error body and aWWW-Authenticate: Bearerheader. - Supported features.
PATCHand filtering are supported; what aPATCHaccepts is set out under SCIM PATCH: Operations, Paths And Errors, below. Bulk operations, sorting, ETags and password change are not, and the discovery document says so. - Filters. Only
<attribute> eq <value>clauses, joined byand. Users can be filtered onuserName,externalId,emails.valueandid; groups ondisplayName,externalIdandid. Any other operator or attribute, or a filter longer than 1,000 characters, is 400 withscimType: invalidFilter. - Paging.
startIndexis 1-based.countdefaults to 100 and is capped at 200 per page. - Request bodies.
application/scim+jsonorapplication/json, up to 1 MB. Malformed JSON is 400invalidSyntax; a larger body is 413. - User attributes read.
userName,externalId,emails(the primary entry, else the work entry),name.givenName,name.familyName,title,active,phoneNumbers, and from the enterprise extensiondepartment,employeeNumberandmanager. The sign-in email is taken fromemails, or fromuserNamewhen it is an email address; a create with neither is 400invalidValue. - Errors. Every error uses the SCIM error schema (
urn:ietf:params:scim:api:messages:2.0:Error) withstatus,detailand, where it applies,scimType. A duplicateuserName,externalIdor group name is 409uniqueness. The primary administrator is 403; so is a user owned by another directory connection. An unknown path is 404. - Rate limit. 600 requests per minute per client IP at the gateway.
- Policy defaults. The provisioning policy (
GET/PUT /api/v1/user-management/scim-provisioning/settings,manage_security) starts withonboardOnCreate: falseandallowGroupCreate: true. With group creation off,POST /scim/v2/Groupsis 403.
The token and policy routes (/api/v1/user-management/scim-provisioning/*) are listed in the API Reference.
SCIM PATCH: Operations, Paths And Errors
PATCH /scim/v2/Users/:id and PATCH /scim/v2/Groups/:id take the standard PatchOp message: a JSON object with schemas naming urn:ietf:params:scim:api:messages:2.0:PatchOp and a non-empty Operations array. A successful call answers 200 with the whole user or group as it now stands. On a user, the operations are applied in the order given. On a group they are combined into one change, as set out under Group Operations, below.
- Operations.
opisadd,replaceorremove, in any letter case (Replaceis accepted). The array may be sent asOperationsoroperations. - Path form. An attribute name, optionally followed by a filter in square brackets, optionally followed by a dot and a sub-attribute:
active,title,name.givenName,emails[type eq "work"].value. A path may start with a schema URN and a colon; the enterprise extension is addressed that way, as inurn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department. The filter inside the brackets follows the same rule as a list filter:eqclauses joined byand. - No path. An
addorreplacewithout a path takes an object as its value, and each key of the object is read as a path. A key may be dotted ("name.givenName") or carry the URN prefix, and the enterprise extension URN may be a key holding an object of its own. - On a user, an add or replace with a path and no value is skipped, and the rest of the operations still apply. A group reads a missing value differently: see the table under Group Operations.
- Booleans.
activeis read from a JSON boolean, from the text"true"or"false"in any letter case ("False"is accepted), or from1or0. - User attributes a PATCH changes. The ones listed under User attributes read, above. A
removeclearstitle,phoneNumbers,externalIdand the enterprisedepartment,employeeNumberandmanager; aremoveonactive,userName,name.givenNameorname.familyNameleaves the stored value as it is. Settingactiveto false on an active account suspends it.
Group Operations
A group PATCH reads three paths only: members, displayName and externalId, in any letter case. A member is named by the user’s SCIM id.
| Operation | What it does |
|---|---|
| add, path members, value [{ "value": "<user id>" }] | Adds those users to the group. |
| remove, path members[value eq "<user id>"] | Removes that one user. |
| remove, path members, value [{ "value": "<user id>" }] | Removes the listed users. |
| remove, path members, no value | Removes every member. |
| add, path members, no value | Changes nothing. |
| replace, path members, value [{ "value": "<user id>" }] | Sets the member list to exactly the listed users. |
| replace, path members, no value | Sets the member list to empty: every member is removed. |
| replace, path displayName or externalId | Renames the group, or sets its external id. A remove on externalId clears it, and so does a replace or add on externalId with no value; a remove on displayName changes nothing. |
| add or replace, no path, value { displayName, externalId, members } | Any of the three keys. With replace, members sets the list exactly; with add, it adds to it. |
- Order inside one request. The operations of one group request are not applied one after another. A rename and an external id are saved first, then a single membership change is worked out from all the operations together. If any operation is a
replaceofmembers, it decides the final list, whatever the same request adds or removes and wherever it sits in the array; with more than one, the last decides. Aremoveofmemberswith no value removes everyone who was a member before the request, and users the same request adds who were not members before are still added. - Privileged groups. Adding a member to a group that grants privileged access is 403 until an administrator allows that group under Security Settings → Provisioning (SCIM). The refusal is written to the audit log as
SCIM_GROUP_PRIVILEGED_DENIED. No membership change from that request is written, but a rename or external id sent in the same request has already been saved. - Groups created in Zonitix and opted in to SCIM take membership changes only: a rename sent for one is ignored and its external id is left as it is.
- A member id that is not a whole number, or that names no active user, is ignored and the rest of the change applies.
- Members owned by another SCIM connection are left out of the change, and
SCIM_OWNERSHIP_REFUSEDis written to the audit log.
PATCH Errors
These are in addition to the 401 for a bad token and the body errors listed under SCIM 2.0 Provisioning, above. A row marked none carries no scimType.
| What is wrong | Status | scimType |
|---|---|---|
| The body is not a JSON object, or it lists schemas and the PatchOp schema is not among them | 400 | invalidSyntax |
| Operations is missing, not an array, or empty | 400 | invalidSyntax |
| op is anything other than add, replace or remove | 400 | invalidSyntax |
| A path that does not fit the form below; on a group, a path other than members, displayName or externalId | 400 | invalidPath |
| A remove with no path | 400 | noTarget |
| An add or replace with no path whose value is not a JSON object. On a group, a missing or null value is accepted and changes nothing | 400 | invalidValue |
| A filter inside a path that is not eq clauses joined by and; on a group, a members filter that does not select on value | 400 | invalidFilter |
| A change that would give a user a sign-in email, or a group a name, that another one already has | 409 | uniqueness |
| A change to the sign-in email of an account that holds elevated access | 400 | mutability |
| The id names no user or group this connection can see: not a whole number, unknown, a user removed through SCIM, a group that is not SCIM-managed, or a group another SCIM connection owns | 404 | none |
| The user is the primary administrator or is owned by another directory; or the user or group was created through SCIM and this connection is not its owner | 403 | none |
| The user is the Zonitix support account | 409 | none |
| An add of members to a group that grants privileged access and has not been allowed | 403 | none |
# Deactivate user 42.
curl -sS -X PATCH https://<your-instance>-api.zonitix.com/scim/v2/Users/42 \
-H "Authorization: Bearer $ZSCIM_TOKEN" -H "Content-Type: application/scim+json" \
-d '{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"replace","path":"active","value":false}]}'
# Add user 42 to group 7.
curl -sS -X PATCH https://<your-instance>-api.zonitix.com/scim/v2/Groups/7 \
-H "Authorization: Bearer $ZSCIM_TOKEN" -H "Content-Type: application/scim+json" \
-d '{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"add","path":"members","value":[{"value":"42"}]}]}'