Guides

SSO — SAML and OIDC

AdminUpdated Sep 19, 2026

SSO — SAML and OIDC

Wire Chatly into Okta, Auth0, Microsoft Entra, Google Workspace, JumpCloud, or any IdP that speaks SAML 2.0 or OIDC.

Chatly speaks SAML 2.0 and OpenID Connect. Each identity provider becomes its own SSO connection in the workspace, with its own configuration and its own email domain — the domain is what pins a login to a workspace, and it is unique across the whole platform.

Info — Which one should you pick?

If your IdP supports both, use OIDC. It is the only protocol Chatly can start a login on: a user types their work email on the Chatly login page and we redirect them to the IdP. SAML here is IdP-initiated only — users have to launch Chatly from the provider's app catalogue.

Prerequisites

  • Owner or Admin in the workspace. Every write on /v1/sso requires the workspace:write permission, which Owner and Admin hold.

  • Your IdP admin console open in another tab.

The values your IdP needs from us

Read them from the API, or copy them from Workspace → SSO → "Give these to your identity provider" in the dashboard. Do not hand-assemble them.

GET /v1/sso/setup — Bearer token

{
  "saml": {
    "entityId": "https://api.chatlychat.com/v1/auth/sso/saml",
    "connections": [
      {
        "id": "01920f3c-...",
        "acsUrl": "https://api.chatlychat.com/v1/auth/sso/saml/01920f3c-.../acs",
        "metadataUrl": "https://api.chatlychat.com/v1/auth/sso/saml/01920f3c-.../metadata"
      }
    ]
  },
  "oidc": { "redirectUri": "https://app.chatlychat.com/auth/sso/callback" },
  "scim": { "baseUrl": "https://api.chatlychat.com/v1/scim/v2" }
}

SP-side values

Name

Type

Description

SAML entity ID / audience

url

One value for the whole deployment: {API_PUBLIC_URL}/v1/auth/sso/saml. Every assertion must name it as its audience.

SAML ACS URL

url

Per connection: {API_PUBLIC_URL}/v1/auth/sso/saml/{connectionId}/acs. The connection id in the path is what pins the tenant, so there is no single ACS URL.

SAML metadata URL

url

Per connection: {API_PUBLIC_URL}/v1/auth/sso/saml/{connectionId}/metadata. Serves our SP metadata XML, unauthenticated — the IdP fetches it.

OIDC redirect URI

url

{DASHBOARD_PUBLIC_URL}/auth/sso/callback. Fixed for the deployment and never taken from a request — a caller-supplied redirect URI is a code-exfiltration primitive.

Warning — The ACS URL contains the connection id

You cannot register a SAML app before the connection exists in Chatly. Create the connection first (below), then read GET /v1/sso/setup for that connection's ACS and metadata URLs.

SAML 2.0

1. Create the connection in Chatly

Dashboard

Workspace → SSO → Add connection, protocol SAML. Four fields:

  • Name — display only (e.g. Okta).

  • Email domain — e.g. acme.com. Assertions whose email is outside this domain are rejected.

  • Sign-in URL — the IdP's SSO endpoint.

  • Signing certificate — the IdP's PEM certificate, pasted.

API

POST /v1/sso — Bearer token

POST /v1/sso
Authorization: Bearer …
Idempotency-Key: 8f1c…
Content-Type: application/json

{
  "provider": "saml",
  "name": "Okta",
  "domain": "acme.com",
  "metadata": {
    "kind": "saml",
    "entryPoint": "https://acme.okta.com/app/exk.../sso/saml",
    "issuer": "http://www.okta.com/exk1a2b3c4",
    "certificate": "-----BEGIN CERTIFICATE-----\nMIID…\n-----END CERTIFICATE-----",
    "attributeMapping": { "email": "email", "name": "name" },
    "jitProvisioning": true,
    "jitRole": "agent"
  }
}

metadata.kind must equal provider, or the write is rejected — a connection that advertises SAML and behaves as OIDC is not a state the API will store.

metadata (kind: saml)

Name

Type

Description

kind (required)

"saml"

Discriminator. Must match provider.

entryPoint (required)

url

The IdP's sign-in URL.

issuer (required)

string

The IdP entity id — the Issuer we require on every assertion.

certificate (required)

PEM

The IdP signing certificate, 64–16384 chars. Pasted, not fetched. Never returned on read — GET /v1/sso answers certificateSet: true instead.

attributeMapping

object

{ email, name, groups? } — which attribute carries each field. Defaults email / name. groups is accepted and stored but nothing reads it yet.

jitProvisioning

bool

Create a membership on first successful login. Set false for a workspace provisioned over SCIM. Default: true.

jitRole

enum

One of admin, agent, restricted, light_agent. owner is deliberately not offered — an IdP misconfiguration must never mint a workspace owner. Default: agent.

2. Create the SAML app in your IdP

Use the entity ID and the connection's ACS URL from GET /v1/sso/setup, or point the IdP at the connection's metadata URL and let it read them.

What we require of the assertion

Name

Type

Description

Signed assertion (required)

bool

Required. wantAssertionsSigned is on.

Signed response (required)

bool

Also required. wantAuthnResponseSigned is on — an IdP that signs only the envelope leaves the assertion substitutable, which is the signature-wrapping shape this is guarding against.

Audience (required)

url

Must be our entity id. An assertion minted for another service provider on the same IdP is refused.

Clock skew

seconds

120s tolerance on assertion validity windows.

Name ID format

any

Unconstrained — we do not pin a format. emailAddress is the sane choice because nameID is the last fallback we read an email from.

Warning — We do not decrypt encrypted assertions

Our SP metadata is published without an SP certificate, so there is no key for an IdP to encrypt to. Leave assertion encryption off. The transport is HTTPS and the assertion is signed.

3. Map attributes

What we read off the assertion

Name

Type

Description

email (required)

string

Read from attributeMapping.email (default email), then a literal email attribute, then nameID. If your IdP names it emailAddress, mail, or a claim URI, set attributeMapping.email to that exact name — we do not guess.

name

string

Read from attributeMapping.name (default name), then displayName, then cn.

groups

string[]

Configurable as attributeMapping.groups and stored on the connection, but no group-to-role mapping is implemented. Role comes from jitRole.

The asserted email's domain must equal the connection's domain. This is the control that stops a workspace's own IdP from asserting [email protected] and taking over an unrelated account.

OpenID Connect

1. Create the OIDC app in your IdP

OIDC app settings

Name

Type

Description

Redirect URI (required)

url

{DASHBOARD_PUBLIC_URL}/auth/sso/callback, exactly as GET /v1/sso/setup reports it.

Grant type (required)

enum

authorization_code. PKCE is on by default and can be switched off per connection for IdPs that reject the extra parameters.

Scopes

string[]

Default openid email profile. Configurable per connection, up to 20 entries.

Token endpoint auth

enum

client_secret_post — the client id and secret go in the token-request body. client_secret_basic and private_key_jwt are not supported.

2. Add the connection

You need the issuer (the discovery root, without /.well-known/openid-configuration — we append it), the client_id, and the client_secret.

Dashboard

Workspace → SSO → Add connection, protocol OIDC. Fields: name, email domain, issuer URL, client ID, client secret.

API

POST /v1/sso — Bearer token

POST /v1/sso
Authorization: Bearer …
Idempotency-Key: 8f1c…
Content-Type: application/json

{
  "provider": "oidc",
  "name": "Auth0",
  "domain": "acme.com",
  "metadata": {
    "kind": "oidc",
    "issuer": "https://acme.auth0.com",
    "clientId": "abc123...",
    "clientSecret": "shhhhh",
    "scopes": ["openid", "email", "profile"],
    "usePkce": true,
    "jitProvisioning": true,
    "jitRole": "agent"
  }
}

metadata (kind: oidc)

Name

Type

Description

kind (required)

"oidc"

Discriminator. Must match provider.

issuer (required)

url

Discovery root. We fetch {issuer}/.well-known/openid-configuration and cache it for one hour.

clientId (required)

string

From the IdP.

clientSecret (required)

string

Write-only across the API boundary. Reads return clientSecretSet: true and never the value.

scopes

string[]

openid is mandatory regardless of what you send. Default: ["openid","email","profile"].

usePkce

bool

Turn off only for an IdP that rejects the PKCE parameters. Default: true.

attributeMapping

object

Same shape as SAML. OIDC claim names are standard, so the defaults are almost always right.

jitProvisioning

bool

Create a membership on first successful login. Default: true.

jitRole

enum

admin | agent | restricted | light_agent. Default: agent.

Managing connections

GET /v1/sso — Bearer token

PATCH /v1/sso/{id} — Bearer token

DELETE /v1/sso/{id} — Bearer token

GET /v1/sso returns every connection with its config redacted — a clientSecretSet / certificateSet boolean in place of the credential — plus a usable flag that is false when the stored config no longer parses.

PATCH takes exactly one field:

{ "enabled": false }

That is the switch for taking a misconfigured IdP out of the login path without deleting the configuration you are mid-way through fixing. There is no other editable field: to change an issuer, certificate, or secret, delete the connection and create a new one.

The login flows

Public login endpoints

Name

Type

Description

POST /v1/auth/sso/discover

public

Body { email }. Answers { available, name, provider } — deliberately nothing else, so an unauthenticated caller learns only that SSO exists for the domain.

POST /v1/auth/sso/start

public

Body { email, redirectTo? }. Returns { redirectUrl } for the browser to follow. OIDC only — a SAML connection is refused here with a message telling the user to open Chatly from their provider's launcher.

POST /v1/auth/sso/callback

public

Body { code, state }, posted by the dashboard's callback page. Returns session tokens in the body, so they never travel in a URL.

POST /v1/auth/sso/saml/{connectionId}/acs

public

The IdP form-POSTs SAMLResponse (and optionally RelayState) here. Answers 303 to the dashboard with a single-use handoff code.

POST /v1/auth/sso/handoff

public

Body { handoff }. Redeems that single-use code for session tokens.

GET /v1/auth/sso/saml/{connectionId}/metadata

public

SP metadata XML for the IdP to fetch.

OIDC (SP-initiated). User enters their work email → discoverstart → IdP → the IdP redirects the browser to the dashboard callback page with code + state → that page POSTs callback → session.

SAML (IdP-initiated). User clicks the Chatly tile in their IdP → the IdP POSTs the assertion to the connection's ACS → we answer 303 to the dashboard with a handoff code → the dashboard POSTs handoff → session.

RelayState, when the IdP echoes one, is treated as attacker-influencable and is validated against the same origin allowlist as redirectTo. Anything outside CORS_ORIGINS is dropped and the user lands on the dashboard root.

Just-in-time provisioning

With jitProvisioning: true, a first successful SSO login creates the membership — no admin invite. The role is jitRole, the same for everyone on the connection; there is no group-to-role mapping.

An existing user is matched only when they already hold a membership in this workspace. We never create a membership for an existing user as a side effect of SSO, and never match a user who belongs only to some other workspace.

What we store

Per connection: protocol, display name, email domain, the protocol config (with the client secret or IdP certificate stored server-side and never returned), an enabled flag, and timestamps.

We do not store IdP passwords. The IdP issues an assertion or ID token; we verify it cryptographically and mint our own session — an access token (JWT_ACCESS_TTL_SECONDS, 15 minutes by default) and a refresh token (JWT_REFRESH_TTL_SECONDS, 30 days by default).

Certificate and key rotation

SAML. The IdP certificate is a pasted PEM. There is no metadata URL polling and no automatic swap — when your IdP rotates its signing certificate, delete the connection and create a new one with the new certificate, or logins will start failing on signature verification.

OIDC. JWKS is fetched from the discovered jwks_uri and the JOSE client handles key rotation, so rotation is automatic provided your IdP serves both old and new keys during the overlap. The discovery document itself is cached for one hour.

Troubleshooting

Warning — SAML: 'Invalid signature'

The pasted certificate no longer matches what the IdP signs with. There is no auto-refresh: re-create the connection with the current certificate.

Warning — SAML: the login is refused after a valid assertion

The asserted email's domain has to equal the connection's domain exactly. A user at acme.co.uk cannot sign in through a connection registered for acme.com — that needs its own connection.

Warning — 'This workspace signs in through its identity provider'

That is /v1/auth/sso/start refusing a SAML connection. SAML here is IdP-initiated only; open Chatly from the provider's app launcher instead of typing your email on the Chatly login page.

Info — A connection shows as unusable

GET /v1/sso returns usable: false when the stored config no longer parses against the current schema. Delete and re-create it; a partially configured connection fails at /start rather than silently at the callback, which is where the user has already left our site.

Was this page helpful?