Endpoints: SSO & SCIM
Per-organization OIDC connection management (org-admin), plus the public login flow. See Quick Start: SSO & SCIM — Single sign-on for a setup walkthrough.
POST /orgs/{org_id}/sso
Section titled “POST /orgs/{org_id}/sso”Requires admin role. Connects an OIDC provider to this org. 409 if a connection
already exists — use PATCH to modify one, or DELETE it first to fully replace it.
Request body
{ "display_name": "Acme Corp", "issuer": "https://login.microsoftonline.com/{tenant-id}/v2.0", "client_id": "app-client-id", "client_secret": "app-client-secret", "default_role": "member", "allowed_domains": ["acme.com"], "enabled": true}| Field | Type | Default | Description |
|---|---|---|---|
display_name | string | — | Shown on the login page’s SSO button |
issuer | string | — | OIDC issuer URL |
client_id | string | — | — |
client_secret | string | — | Encrypted at rest, never returned by any response |
default_role | string | member | Role granted to a user auto-provisioned on first login. Cannot be owner |
allowed_domains | string[] | null | null | Optional email-domain allowlist. If set, no other org’s connection may claim the same domain |
enabled | bool | true | — |
Response 201 — secret never included, only whether one is set
{ "provider": "oidc", "display_name": "Acme Corp", "issuer": "https://login.microsoftonline.com/{tenant-id}/v2.0", "client_id": "app-client-id", "client_secret_set": true, "default_role": "member", "allowed_domains": ["acme.com"], "enabled": true, "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}| Status | Description |
|---|---|
201 | Connection created |
403 | Caller does not have admin role |
409 | A connection already exists for this org, or allowed_domains includes a domain already claimed by a different org’s connection |
422 | default_role was owner |
GET /orgs/{org_id}/sso
Section titled “GET /orgs/{org_id}/sso”Requires admin role. Returns the org’s connection, or 404 if none is configured.
PATCH /orgs/{org_id}/sso
Section titled “PATCH /orgs/{org_id}/sso”Requires admin role. All fields optional — client_secret only re-encrypts and
replaces the stored secret when provided, so enabled/default_role/allowed_domains
can be changed without re-entering it.
| Status | Description |
|---|---|
200 | Connection updated |
404 | No connection configured |
409 | Updated allowed_domains includes a domain already claimed by a different org’s connection |
DELETE /orgs/{org_id}/sso
Section titled “DELETE /orgs/{org_id}/sso”Requires admin role. Disconnects the org’s SSO connection.
POST /auth/sso/discover
Section titled “POST /auth/sso/discover”Public — no authentication (the caller isn’t logged in yet). Convenience for the login
page’s “Continue with SSO” box: matches the email’s domain against every enabled
connection’s allowed_domains.
Request body
Response 200
{"org_id": "3f4a5b6c-...", "display_name": "Acme Corp"}| Status | Description |
|---|---|
200 | A connection’s allowed_domains matched |
404 | No connection matches — fall back to a password login |
GET /auth/sso/{org_id}/start
Section titled “GET /auth/sso/{org_id}/start”Public. Redirects (307) the browser to the org’s IdP authorization endpoint, with a
PKCE challenge/nonce/CSRF state stored in a short-lived sso_state cookie.
| Status | Description |
|---|---|
307 | Redirect to the IdP |
404 | No enabled connection for this org |
502 | The identity provider was unreachable |
GET /auth/sso/callback
Section titled “GET /auth/sso/callback”Public. The IdP redirects here with ?code=&state= after the user authenticates.
Validates the state/PKCE/nonce/ID-token, resolves or JIT-provisions the user and
membership (linking an existing local-password account by email rather than duplicating
it; an existing membership’s role is never downgraded), sets the session cookie, and
redirects into the dashboard.
| Status | Description |
|---|---|
307 | Login completed — redirected into the dashboard, session cookie set |
400 | Missing/expired/tampered state, or the code exchange / ID-token verification failed |
403 | The connection has allowed_domains set and the asserted email’s domain isn’t in it |
502 | The identity provider was unreachable |
The session this sets is marked as established through an identity provider, so it satisfies the
org’s require_2fa policy without TOTP.
Social login
Section titled “Social login”Platform-wide Google and Microsoft sign-in, configured by a superadmin under social_login_* in
PATCH /admin/settings. See
Quick Start: Sign in with Google or Microsoft for setup and for the
order in which a callback resolves the account. {provider} is google or microsoft; anything
else is a 422.
GET /auth/providers
Section titled “GET /auth/providers”Public. Which provider buttons the login and register pages render: a provider is listed only
while social_login_enabled, its own social_login_{provider}_enabled, and both its client ID and
secret are set.
{"providers": [{"id": "google", "label": "Google"}], "allow_signup": true}GET /auth/oauth/{provider}/start
Section titled “GET /auth/oauth/{provider}/start”Public. Redirects (307) to the provider, with a PKCE challenge, nonce and CSRF state in a
short-lived oauth_state cookie (10 minutes). Optional ?invite=<token> carries an invitation
link through the round trip.
| Status | Description |
|---|---|
307 | Redirect to the provider |
404 | The provider isn’t enabled |
502 | The provider’s discovery document was unreachable |
GET /auth/oauth/{provider}/callback
Section titled “GET /auth/oauth/{provider}/callback”Public. The provider redirects here. Always answers 307 into the dashboard, never with an
error body. The only exception is a redirect to GET /auth/sso/{org_id}/start when the email’s
domain is claimed by an org’s SSO connection.
| Redirect | When |
|---|---|
/overview | Signed in to an account with an organization; session cookie set |
/onboarding | Signed in to an account with no organization yet, including a new one |
/login#mfa_token=… | The account has its own TOTP. The dashboard completes it with POST /auth/session/2fa, the same as a password login |
/account?linked={provider} | A link started from /auth/me/identities/{provider}/link succeeded |
/login?oauth_error=<code> | Sign-in failed. For a link attempt, /account?oauth_error=<code>. See error codes |
GET /auth/me/identities
Section titled “GET /auth/me/identities”Session. The providers linked to the current account.
GET /auth/me/identities/{provider}/link
Section titled “GET /auth/me/identities/{provider}/link”Session. Redirects to the provider. The callback attaches the provider account the user picks to
this account (its email need not match), then returns to /account?linked={provider}.
DELETE /auth/me/identities/{provider}
Section titled “DELETE /auth/me/identities/{provider}”Session. Unlinks the provider.
| Status | Description |
|---|---|
200 | Unlinked |
404 | That provider isn’t linked |
409 | It is the account’s only way to sign in — no password, no org SSO connection, no other linked provider |
GET /admin/social-login/{provider}/test
Section titled “GET /admin/social-login/{provider}/test”Superadmin. Tests the provider’s saved configuration with a real round trip: it redirects to
the provider like start does, whether or not the provider or the master switch is on. The
callback runs every check a sign-in runs, then redirects to
/admin/settings/sign-in?tab={provider}&test=<result> instead of signing anyone in. No account is
created or linked, and the caller’s session is untouched. Recorded in the audit log as
social_login.test.
test= | Also carries | Meaning |
|---|---|---|
ok | email, live (1/0), warning=sso_domain when applicable | The configuration works |
not_configured | — | No saved client ID and secret |
discovery_failed | detail | The provider’s discovery document couldn’t be loaded |
token_exchange_failed | detail — the provider’s own error, e.g. invalid_client: … | The code exchange was refused |
id_token_invalid | detail | Signature, audience or issuer (Microsoft: tenant) didn’t verify |
email_unverified, email_missing | — | The same refusals a sign-in would hit |
provider_error | detail | The provider redirected back with an error |
cancelled | — | The user backed out at the provider |
Per-organization SCIM 2.0 provisioning (RFC 7643/7644) — Entra ID (or any SCIM-compliant IdP) pushes Users/Groups directly into this org, independent of any interactive login. Complements SSO above: SSO handles authentication, SCIM handles provisioning. See Quick Start: SSO & SCIM — Automatic provisioning via SCIM for a setup walkthrough.
POST /orgs/{org_id}/scim
Section titled “POST /orgs/{org_id}/scim”Requires admin role. Enables SCIM provisioning for this org. 409 if a config already
exists — use PATCH to modify one. Returns the bearer token once — it is never shown again.
Request body
{ "enabled": true, "default_role": "member", "group_role_mappings": [ {"pattern": "^admin", "role": "admin"} ]}| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | — |
default_role | string | member | Role granted to a SCIM-provisioned user whose current groups match no group_role_mappings entry. Cannot be owner |
group_role_mappings | array | [] | Ordered {pattern, role} rules — pattern is a regular expression matched against each of a user’s current SCIM group (Team) names; the first entry that matches any of them wins. role cannot be owner |
Response 201
{"token": "gRHDHWLB4utU3APb9ahLZXsTpyhDl8NghxL-H4lIPT4"}| Status | Description |
|---|---|
201 | Config created — copy token now |
403 | Caller does not have admin role |
409 | A config already exists for this org |
422 | default_role/a mapping’s role was owner, or a pattern did not compile as a regular expression |
GET /orgs/{org_id}/scim
Section titled “GET /orgs/{org_id}/scim”Requires admin role. Returns the org’s config, or 404 if none is configured.
{ "enabled": true, "default_role": "member", "group_role_mappings": [{"pattern": "^admin", "role": "admin"}], "token_set": true, "base_url": "https://your-api/scim/v2/3f4a5b6c-...", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}token_set is whether a bearer token exists — the raw value is never returned here, only at
creation/rotation time. base_url is the exact tenant URL to paste into Entra ID.
PATCH /orgs/{org_id}/scim
Section titled “PATCH /orgs/{org_id}/scim”Requires admin role. All fields optional. Does not touch the bearer token — use
POST /orgs/{org_id}/scim/rotate-token to rotate it.
POST /orgs/{org_id}/scim/rotate-token
Section titled “POST /orgs/{org_id}/scim/rotate-token”Requires admin role. Mints a new bearer token and invalidates the previous one
immediately. Returns {"token": "..."} once, same as creation.
DELETE /orgs/{org_id}/scim
Section titled “DELETE /orgs/{org_id}/scim”Requires admin role. Disconnects SCIM provisioning and invalidates the bearer token. Does not delete any Team or membership SCIM already created — same reasoning as disconnecting SSO not deleting Users.
SCIM protocol surface — /scim/v2/{org_id}/*
Section titled “SCIM protocol surface — /scim/v2/{org_id}/*”Authenticated with the bearer token above (Authorization: Bearer <token>), never a session
cookie or a regular API key — this credential only unlocks this surface, nothing else in the
REST API. 401 if the token is missing/invalid/disabled, 403 if it belongs to a different org.
| Method & path | Description |
|---|---|
GET /ServiceProviderConfig, GET /ResourceTypes, GET /Schemas | Static discovery documents, for the IdP’s “Test Connection” step |
GET /Users | List/search — supports filter=userName eq "..." and startIndex/count pagination |
POST /Users | Provision a user — links an existing local account by email (same rule as SSO’s JIT provisioning) or creates one, at the config’s default_role. Idempotent by email |
GET /Users/{id} | Get one |
PATCH /Users/{id}, PUT /Users/{id} | Update — {"active": false} deactivates the org membership (Entra sends this on unassignment), true reactivates. 409 if the target is the org’s owner |
DELETE /Users/{id} | Removes the org membership (not the underlying account, which may belong to other orgs). 409 if the target is the org’s owner |
GET /Groups | List/search — supports filter=displayName eq "..." |
POST /Groups | Creates a Team with this displayName |
GET /Groups/{id} | Get one |
PATCH /Groups/{id}, PUT /Groups/{id} | Update displayName/members — adding/removing members syncs Team membership and recomputes each affected member’s role via group_role_mappings |
DELETE /Groups/{id} | Deletes the Team (cascades its memberships) and recomputes every former member’s role |