SSO & SCIM provisioning
Enterprise identity is two separate problems: proving who someone is at sign-in, and keeping the user directory correct as people join, move, and leave. This guide covers configuring OIDC or SAML single sign-on for your tenant, then wiring your identity provider to the SCIM 2.0 endpoints so accounts are provisioned and deprovisioned without anyone filing a ticket.
Both surfaces need tenant-admin rights
SSO configuration and SCIM writes are gated on tenant management permissions — in practice an
Owner or tenant administrator. A token created with the default service-account scope will
authenticate fine and then get 403. That is deliberate: creating and deactivating
identities is an administrative capability, not a machine one.
Part 1 — Single sign-on
How the configuration is stored
Your tenant holds one SSO configuration with three possible schemes: None, OIDC, or
SAML. The endpoint stores connection metadata only. It never accepts an OIDC client secret
or a SAML signing certificate — that material lives in the platform key vault and is handled
out of band.
Read the current state:
curl https://api.ospulse.app/api/v1/auth/sso \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"scheme": "None",
"oidc": null,
"saml": null,
"updatedAtUtc": null
}Configure OIDC
The oidc object is passed through as supplied, so it carries whatever your provider's
metadata needs — issuer, discovery document, client id, the claim you want mapped to an
OSPulse user.
curl -X POST https://api.ospulse.app/api/v1/auth/sso/configure \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"scheme": "OIDC",
"oidc": {
"issuer": "https://login.microsoftonline.com/2f1c…/v2.0",
"clientId": "8b0e4a17-73d5-4c92-a610-5fd9c2e0b783",
"discoveryUrl": "https://login.microsoftonline.com/2f1c…/v2.0/.well-known/openid-configuration",
"emailClaim": "preferred_username"
},
"saml": null
}'{
"scheme": "OIDC",
"updatedAtUtc": "2026-07-28T12:03:51Z"
}Configure SAML
Same endpoint, different branch. Set scheme to SAML and populate saml; the oidc
object is ignored when the scheme is SAML, and vice versa.
curl -X POST https://api.ospulse.app/api/v1/auth/sso/configure \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"scheme": "SAML",
"oidc": null,
"saml": {
"entityId": "https://app.ospulse.app/saml/metadata",
"singleSignOnServiceUrl": "https://example.okta.com/app/…/sso/saml",
"nameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
}
}'Identity provider setup, conceptually
The specifics differ per provider and their consoles change often, so treat this as the shape rather than a click path. Check your provider's current documentation for the exact screens.
Create the application in your IdP
Register OSPulse as an application. For OIDC this is a web application with a redirect URI; for SAML it is a SAML 2.0 service provider with an ACS URL and entity id. Okta, Microsoft Entra ID, Google Workspace, and any standards-compliant SAML or OIDC provider all work — no provider-specific integration is required on our side.
Decide the identifier that binds the two systems
An OSPulse user is keyed on email address. Whatever claim or attribute your IdP sends as the
subject identifier must resolve to the same address SCIM provisions as userName. Getting
this wrong is the single most common cause of "SSO works but the user has no access" —
authentication succeeds and then matches no directory row.
Post the configuration
Use the calls above. Every change writes an audit entry with a full before-and-after diff, so
the configuration history is replayable — read it back from
GET /api/v1/audit-log.
Verify before you enforce
Confirm one real user can sign in through the IdP before switching the rest of the organisation over. Keep a working API token to hand: if the configuration is wrong, that token is how you fix it.
Configuration requires a user principal
POST /api/v1/auth/sso/configure returns 403 for a service-account token, and 400 for an
unrecognised scheme. Only OIDC, SAML, and None are accepted, matched
case-insensitively.
Part 2 — SCIM provisioning
Base URL and authentication
SCIM lives outside the versioned API prefix, at its own RFC-mandated path:
https://api.ospulse.app/scim/v2Authentication is the same bearer token you use elsewhere — configure your IdP's SCIM
connector with Authorization: Bearer <token>, using a token created by an Owner or tenant
administrator. Responses are served as application/scim+json.
Use a dedicated token
Create a token that exists only for the SCIM connector, name it accordingly, and record it in your secret store. When you rotate it, you rotate exactly one integration — see Authentication.
Discovery
Three unauthenticated-shape discovery endpoints let a connector introspect the service before it is trusted with the directory. All three require a valid token but carry no tenant data.
curl https://api.ospulse.app/scim/v2/ServiceProviderConfig \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"patch": { "supported": true },
"bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
"filter": { "supported": true, "maxResults": 200 },
"changePassword": { "supported": false },
"sort": { "supported": false },
"etag": { "supported": false },
"authenticationSchemes": [
{
"type": "oauthbearertoken",
"name": "OAuth Bearer Token",
"description": "OSPulse tenant API token",
"primary": true
}
]
}GET /scim/v2/ResourceTypes and GET /scim/v2/Schemas return the resource and schema
definitions. Read ServiceProviderConfig before assuming a capability — bulk operations,
password change, sorting, and ETags are all explicitly unsupported.
Supported operations
| Operation | Route | Permission |
|---|---|---|
| List users | GET /scim/v2/Users | Tenant read |
| Get user | GET /scim/v2/Users/{id} | Tenant read |
| Create user | POST /scim/v2/Users | Tenant manage |
| Update user | PATCH /scim/v2/Users/{id} | Tenant manage |
| Deactivate user | DELETE /scim/v2/Users/{id} | Tenant manage |
| List groups | GET /scim/v2/Groups | Tenant read |
User lifecycle
Provision
curl -X POST https://api.ospulse.app/scim/v2/Users \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "avery.hale@example.com",
"displayName": "Avery Hale",
"externalId": "00u1a2b3c4d5e6f7g8h9",
"active": true
}'{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "3c8f0a52-91d7-4e63-8b04-2fa61c9d5e78",
"userName": "avery.hale@example.com",
"displayName": "Avery Hale",
"active": true,
"externalId": "00u1a2b3c4d5e6f7g8h9",
"emails": [{ "value": "avery.hale@example.com", "primary": true }],
"meta": {
"resourceType": "User",
"created": "2026-07-28T12:11:04Z",
"lastModified": "2026-07-28T12:11:04Z",
"location": "/scim/v2/Users/3c8f0a52-91d7-4e63-8b04-2fa61c9d5e78"
}
}Creation is idempotent on userName: if a user with that address already exists in your
tenant, you get 200 with the existing resource instead of 201 and a duplicate. Store
externalId — it is how you correlate back to the IdP record.
Look one up
Filtering supports a single equality clause on userName, email, or externalId, which is
what IdP connectors use to reconcile:
curl -G https://api.ospulse.app/scim/v2/Users \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
--data-urlencode 'filter=userName eq "avery.hale@example.com"'{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 1,
"startIndex": 1,
"itemsPerPage": 1,
"Resources": [
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "3c8f0a52-91d7-4e63-8b04-2fa61c9d5e78",
"userName": "avery.hale@example.com",
"displayName": "Avery Hale",
"active": true,
"externalId": "00u1a2b3c4d5e6f7g8h9",
"emails": [{ "value": "avery.hale@example.com", "primary": true }],
"meta": { "resourceType": "User", "location": "/scim/v2/Users/3c8f…" }
}
]
}Resources is capitalised and Operations is capitalised — that is RFC 7644, not a typo. A
client that lower-cases them will not parse the response.
Update
PATCH takes the standard PatchOp envelope. The supported operations are replace on
active and replace on displayName.
curl -X PATCH https://api.ospulse.app/scim/v2/Users/$USER_ID \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{ "op": "replace", "path": "displayName", "value": "Avery Hale-Osei" }
]
}'The response is the updated user resource. Suspending someone in the IdP typically arrives
as { "op": "replace", "path": "active", "value": false }, which deactivates the OSPulse
account without deleting it.
Deprovision
curl -X DELETE https://api.ospulse.app/scim/v2/Users/$USER_ID \
-H "Authorization: Bearer $OSPULSE_TOKEN"Returns 204. This is a soft deactivation, not a hard delete: the user can no longer
sign in, and their historical audit and attribution records stay intact. That is what you
want for a compliance record — a deleted actor makes an audit log unreadable. If you need a
genuine erasure for a data-subject request, use the privacy endpoints under
/api/v1/me/dsr.
Groups and role mapping
GET /scim/v2/Groups is implemented and returns a well-formed SCIM ListResponse, currently
empty — role-as-group mapping is not yet wired to the OSPulse role model. Assign roles inside
OSPulse after a user is provisioned; group push from your IdP will not set them today.
Plan your rollout accordingly: SCIM gives you accurate account existence and lifecycle automatically, and role assignment remains a deliberate act inside OSPulse. For most teams that is an acceptable split — role changes are rarer than joiners and leavers, and they are the change you most want a human to make.
Do not configure group push yet
Pointing an IdP at group provisioning that returns an empty list will make some connectors report a sync error or repeatedly retry. Leave group push disabled until it is supported — see the changelog for when it lands.
The SCIM error shape
SCIM does not use RFC 7807. Errors follow RFC 7644 §3.12, with status as a string:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "404",
"detail": "User 3c8f0a52-91d7-4e63-8b04-2fa61c9d5e78 not found."
}{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "400",
"scimType": "invalidValue",
"detail": "userName is required"
}Everything outside /scim/v2 uses problem details as normal. If your
error handler assumes one shape across both, it will break on whichever it meets second.
Troubleshooting
401 on every SCIM call. The token is missing, expired, or revoked. Check the header is
Authorization: Bearer … and that the token has not been rotated out from under the
connector.
403 on create, update, or delete but reads work. The token holds read but not manage
rights. Reissue it from an Owner or tenant-administrator account.
400 invalidValue with userName is required. The connector sent no userName, or sent
it under a different attribute. Map your IdP's primary email attribute to userName.
Filter returns nothing for a user you can see in the list. Only a single equality clause
is supported — userName eq "value". Compound filters, co, sw, and pr operators are
not parsed, and an unrecognised filter is ignored rather than rejected, so you get the
unfiltered page instead of an error.
A connector reports a parse failure on list responses. It is almost always the capital
Resources member. Confirm the client library follows RFC 7644 rather than camel-casing
everything.
SSO signs in but the user has no access. Authentication succeeded and matched no
directory row. Confirm the claim your IdP sends resolves to exactly the address SCIM
provisioned as userName, then check the user exists via
GET /scim/v2/Users?filter=userName eq "…".
Cross-tenant confusion. Every SCIM route is scoped to the token's own tenant. A user
belonging to another tenant is a 404, never a match — existence is never leaked across the
boundary. See Tenancy & access.
