Skip to content
bucker

SSO and SCIM

Packaging

SSO and SCIM are both on every paid tier. The "SSO tax" — gating single sign-on, or the directory sync that makes it operable, behind an Enterprise quote — is a documented churn trigger that comes up in procurement, and there is deliberately no separate "enterprise SSO" or "enterprise SCIM" flag anywhere in the codebase.

Feature Free Pro Team Enterprise
SSO (SAML + OIDC) — ✅ ✅ ✅
SCIM directory sync — ✅ ✅ ✅

This matches the code, not just the marketing. billing/plans.ts sets both sso: true and scim: true on every paid tier (PRO, TEAM, ENTERPRISE) and both false on FREE — design §3.1 ("on all paid tiers (Pro and up), never enterprise-gated") is the binding commitment there, and the file notes that the §13 plan sketch listing SCIM under Enterprise-only is superseded by it.

SSO

One code path serves SAML and OIDC. Both reduce to (subject, email, name, groups), and everything after that — find-or-create the user, find-or-create the org membership at the mapped role, mint a session — is protocol-agnostic.

Attribute mapping

Well-known claim names are used by default: the SAML URNs Entra ID, Okta and Google Workspace emit, plus the short names OIDC and simpler IdPs emit. A connection can override any of them.

Two provisioning rules that matter

A JIT-provisioned user gets no password. passwordHash stays null, so a federated account cannot be taken over through a password-reset flow against an address the IdP controls.

An existing member's role is never silently downgraded by an unmapped group. The IdP is authoritative for membership, but a mapping typo that demotes every admin at once is the failure mode that makes teams distrust SSO. Role changes only ever move a membership up unless the connection maps the group explicitly.

Enforcing SSO

An org can require SSO. It is enforced by a global request hook on the routes where a password is exchanged for a session — which keeps the whole feature inside the SSO module, leaving the auth module unaware federation exists.

Owners are exempt, deliberately. An org whose IdP configuration breaks — expired certificate, rotated key, deleted app registration — must retain a way back in. Exempting owners is the standard break-glass; exempting nobody means one bad certificate rotation locks a customer out of their own account permanently.

SCIM 2.0

RFC 7644, at /scim/v2:

GET  /scim/v2/ServiceProviderConfig
GET  /scim/v2/ResourceTypes
     /scim/v2/Users    GET POST · /:id GET PUT PATCH DELETE
     /scim/v2/Groups   GET POST · /:id GET PUT PATCH DELETE
     /scim/v2/Agents   GET POST · /:id GET PUT PATCH DELETE

/scim/v2/Agents is the interesting one: non-human identities are provisioned through the same directory as humans. Agents are principals on the same spine (see agent-governance.md), so an org that offboards a person through its IdP can offboard an agent the same way.

Tenancy and errors

The /scim/v2 tree lives in an encapsulated scope so it can do two things it must do:

  • authenticate with a SCIM bearer token instead of a JWT. The token is the org binding — /scim/v2/Users has no org in its path, because IdP connectors will not put one there — so the credential carries the tenant.
  • render errors in SCIM's own envelope without changing the API-wide error shape. IdP connectors parse the SCIM error body and will not follow a bespoke one.

Only the sha256 hash of a SCIM token is stored; a leaked database yields nothing replayable, and the raw value is shown exactly once, like every other opaque token here.

Token administration is a normal JWT-authenticated admin surface:

GET/POST/DELETE /orgs/:orgSlug/scim/tokens[/:tokenId]

Not built

  • No SCIM /Me endpoint.
  • No IdP-initiated deprovisioning webhooks beyond the SCIM DELETE path.
  • No per-connection audit export; SSO and SCIM events land in the normal audit log.