API reference: SAML & SCIM
Cushy SAML 2.0 SSO endpoints (metadata, SP-initiated login, ACS), the per-organization SAML/SCIM/group-mapping configuration APIs, and the SCIM 2.0 provisioning surface (Users, Groups, discovery).
SAML 2.0 sign-in (public)
/api/auth/saml/metadataPublicSP metadata XML (entity ID + HTTP-POST ACS). Import this into your IdP. Add ?org=<slug> to include that organization's Single Log-Out block — its SingleLogoutService endpoints (Redirect and POST) and the SP signing certificate the IdP needs to verify our LogoutRequest. The SP signing key is per organization, so the un-scoped document carries no certificate.
| Name | In | Required | Description |
|---|---|---|---|
org | query | no | Organization slug — adds the SLO endpoints + SP signing certificate. |
<md:EntityDescriptor entityID="https://host/api/auth/saml/metadata">…</md:EntityDescriptor>| Status | When |
|---|---|
200 | Always — SP metadata is public by design (it contains no secret). |
/api/auth/saml/loginPublicBegin SP-initiated SSO: match the work email domain / org slug to an enabled SAML config and return the IdP redirect URL (deflate+base64 AuthnRequest).
| Name | In | Required | Description |
|---|---|---|---|
email | body | no | Work email (its domain is matched). |
slug | body | no | Organization slug (alternative to email). |
{ "email": "alice@corp.example" }{ "ok": true, "redirectUrl": "https://idp/sso?SAMLRequest=…&RelayState=…", "requestId": "_abc…" }| Status | When |
|---|---|
404 | SAML isn't configured for that domain/org (enumeration-safe, generic). |
/api/auth/saml/acsPublic (form POST binding)Assertion Consumer Service: verify the signed assertion (exc-c14n + RSA-SHA256, Conditions, single-use replay), JIT-match by email with the group-mapped role, and set a session cookie (303 to /).
| Name | In | Required | Description |
|---|---|---|---|
SAMLResponse | body | yes | base64 SAML Response (application/x-www-form-urlencoded). |
RelayState | body | no | Round-tripped from the AuthnRequest. |
// application/x-www-form-urlencoded (HTTP-POST binding), not JSON:
SAMLResponse=<base64 signed assertion>&RelayState=/dashboard// 302 redirect — never JSON.
// success → Location: <RelayState or /dashboard> (Set-Cookie: cc_session=…)
// failure → Location: /login?sso=failed| Status | When |
|---|---|
303 | Success — redirect to / with a session cookie. |
400 | Bad/expired/replayed/unsigned/tampered assertion, wrong audience, or InResponseTo mismatch (generic). |
403 | The organization is suspended. |
/api/auth/saml/sloPublic (HTTP-Redirect binding; POST for the HTTP-POST binding)Single Log-Out, both directions on one endpoint. A <LogoutResponse> answering a logout Cushy started is checked for signature, InResponseTo (single-use) and StatusCode — a PartialLogout is reported as partial, never as success — and the browser lands back on the login page. An IdP-initiated <LogoutRequest> must be signed and must verify against that organization's configured IdP certificate; the matching session (its SessionIndex, or every session of the principal when none is named) is revoked and Cushy replies with a signed <LogoutResponse>. On the Redirect binding the signature is the SAML Bindings §3.4.4.1 signature over the URL-encoded query string, not over the XML.
| Name | In | Required | Description |
|---|---|---|---|
SAMLRequest | query | no | Deflated+base64 LogoutRequest (IdP-initiated). Must be signed. |
SAMLResponse | query | no | Deflated+base64 LogoutResponse answering a Cushy-initiated logout. |
RelayState | query | no | Round-tripped onto our LogoutResponse. |
SigAlg | query | no | Must be rsa-sha256; SHA-1 is refused. |
Signature | query | no | Base64 signature over SAMLRequest|SAMLResponse=…&RelayState=…&SigAlg=…. |
// 302 redirect to the IdP's SLO endpoint (or /login when none is configured).| Status | When |
|---|---|
303 | Success — to the IdP with a signed LogoutResponse, or to /login?signedout=1. |
400 | Unsigned or badly-signed LogoutRequest, unknown/expired InResponseTo, wrong Destination or Issuer, expired or replayed message (all generic). |
SAML config (Org Admin, session only)
/api/org/samlOrg Admin (session only)Create/update the org's SAML config. Accepts pasted IdP metadata XML (auto-fills entity ID / SSO URL / cert) or explicit fields. The signing certificate is validated on save.
| Name | In | Required | Description |
|---|---|---|---|
metadataXml | body | no | IdP metadata XML — entity ID / SSO URL / cert are extracted from it. |
idpEntityId | body | no | IdP entity ID (required if no metadata). |
ssoUrl | body | no | IdP SSO URL (https). |
sloUrl | body | no | IdP single-logout URL (https). Optional — omit it and sign-out ends the Cushy session only. |
certificate | body | no | IdP X.509 signing cert (PEM or base64). Public value. |
defaultRole | body | no | Role when no group maps; default Viewer. |
emailDomains | body | no | Domains routed to this IdP. |
attrEmail | body | no | Assertion attribute for email (blank ⇒ NameID). |
attrGroups | body | no | Assertion attribute for group membership. |
{
"idpEntityId": "https://idp.example.com/realms/acme",
"ssoUrl": "https://idp.example.com/realms/acme/protocol/saml",
"sloUrl": "https://idp.example.com/realms/acme/protocol/saml",
"certificate": "-----BEGIN CERTIFICATE-----\nMIID…\n-----END CERTIFICATE-----",
"emailDomains": ["acme.io"],
"defaultRole": "Viewer",
"attrEmail": "email",
"attrGroups": "groups"
}
// or paste the IdP descriptor instead: { "metadataXml": "<EntityDescriptor …>" }{ "ok": true, "saml": { "idpEntityId": "…", "ssoUrl": "…", "sloUrl": "…", "certificateSet": true, "defaultRole": "Viewer", "emailDomains": ["corp.example"], "attrGroups": "groups", "enabled": true, "spSloUrl": "https://host/api/auth/saml/slo", "spMetadataUrl": "https://host/api/auth/saml/metadata?org=acme", "spCertificate": "-----BEGIN CERTIFICATE-----…", "sloReady": true } }| Status | When |
|---|---|
400 | Missing entity ID, non-https SSO URL, unusable certificate, bad role/domains, or unparseable metadata. |
403 | Caller lacks manage_users_sso. |
GET /api/org/saml returns the config (the IdP cert is never echoed, only certificateSet); PATCH /api/org/saml toggles { enabled }.
Saving generates the organization's SP signing keypair if it has none — required to sign a <LogoutRequest>. spCertificate is the PUBLIC half, returned so you can install it at the IdP; the private half is encrypted at rest and is never present in any response. An existing key is never rotated by a config save.
SCIM tokens & group mappings (Org Admin, session only)
/api/org/scimOrg Admin (session only)Issue a per-org SCIM bearer token (cc_scim_…, shown once) and the SCIM base URL.
{ "name": "keycloak-acme" } // a label for the token you are about to mint{ "ok": true, "id": 3, "token": "cc_scim_…", "scimBaseUrl": "https://host/api/scim/v2" }| Status | When |
|---|---|
401 | Not signed in. |
403 | Only org admins can manage SCIM tokens. |
GET /api/org/scim lists token metadata; DELETE /api/org/scim/{id} revokes. GET/POST /api/org/group-mappings lists / upserts a { groupName, role } mapping; DELETE /api/org/group-mappings/{id} clears a group's role.
SCIM 2.0 provisioning (per-org Bearer token)
All /api/scim/v2/* endpoints require Authorization: Bearer <cc_scim_…> and return application/scim+json. Discovery: GET /ServiceProviderConfig, GET /ResourceTypes, GET /Schemas.
/api/scim/v2/UsersSCIM BearerProvision a user (JIT). Role starts at the SCIM default (Viewer) and is elevated by group membership. Duplicate userName → 409 uniqueness.
{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "alice@corp.example", "name": { "formatted": "Alice A" }, "active": true, "externalId": "0oaExt…" }{ "schemas": ["…:User"], "id": "42", "userName": "alice@corp.example", "active": true, "roles": [{ "value": "Viewer", "primary": true }], "meta": { "resourceType": "User" } }| Status | When |
|---|---|
401 | Missing/invalid/revoked SCIM token (SCIM error envelope). |
409 | A user with that userName already exists (scimType uniqueness). |
/api/scim/v2/Users/{id}SCIM BearerRFC 7644 PatchOp. replace/add of active (active:false deprovisions = deactivate + kill sessions; active:true reactivates), displayName, externalId.
{ "Operations": [{ "op": "replace", "path": "active", "value": false }] }{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "id": "42",
"userName": "ada.lovelace@acme.io", "active": false,
"meta": { "resourceType": "User", "location": "https://cushy.example.com/api/scim/v2/Users/42" } }| Status | When |
|---|---|
400 | Malformed PatchOp body. |
401 | Missing or invalid SCIM bearer token. |
404 | No such user in the token's organization. |
Also: GET /Users?filter=userName eq "x" (and externalId eq), GET /Users/{id}, PUT /Users/{id}, DELETE /Users/{id} (deprovision, 204). Groups: GET/POST /Groups, GET/PATCH/PUT/DELETE /Groups/{id} — PATCH members add/remove applies the group→role mapping to each member.