> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xpander.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# SSO with your identity provider

> Sign in to an Air-Gapped xpander install with your own OIDC identity provider (Keycloak, Okta, Entra ID): what the SSO button uses, what to configure at the IdP, and what to check when it fails

The **SSO** button on the login page is driven by the OIDC configuration an administrator enters **in Xpander Chat**, not by GoTrue's external providers. Google and GitHub social buttons are GoTrue providers (`supabase.auth.extraEnv` in the chart); SSO is a separate path, and this page is about that path.

## How a sign-in works

1. The user opens the login page, clicks **SSO** and types their work email under **Sign in with SSO**. The email only picks the organization; it is never sent to the IdP.
2. The `client-auth` component builds the authorization URL from the organization's OIDC config and redirects the browser to your IdP. Scopes are `openid profile email` plus any extra scopes you add. The client is confidential: `client_id` and `client_secret`, no PKCE.
3. Your IdP redirects back to `https://<app-host>/oidc/<organization-id>/callback` with a code.
4. `client-auth` exchanges the code at the IdP's token endpoint and reads the user from the IdP's **userinfo** endpoint. Token and userinfo calls run from inside the cluster, so the IdP must be reachable from the pods, not only from browsers.
5. xpander finds or creates the user by email in the in-cluster auth (GoTrue), then mints the session.

<Frame caption="After the SSO button: the identity provider's own sign-in page, here Keycloak. Shown with sample data.">
  <img src="https://mintcdn.com/xpanderai-099931d1/OdLxX9OyFVicAV0l/images/verify/lab-sso-keycloak-login.png?fit=max&auto=format&n=OdLxX9OyFVicAV0l&q=85&s=6e87f5556c38204a7bd37c39a898b627" alt="Keycloak sign-in page titled Sign in to your account with Username or email and Password fields" width="1440" height="900" data-path="images/verify/lab-sso-keycloak-login.png" />
</Frame>

## What to configure

### In your identity provider

Create an OIDC client for xpander with:

| Setting | Value |
| - | - |
| Client type | Confidential (client authentication on) |
| Flow | Authorization code (Keycloak: "Standard flow") |
| Valid redirect URIs | Three exact URIs, not a wildcard: a sign-in URI (`.../oidc/<org-id>/callback`), a sign-out URI (`.../oidc/<org-id>/logout`) and an initiate-login URI (`.../oidc/<org-id>/login`); Settings, OIDC, Redirect URIs shows and copies each one |
| Web origins | `https://<app-host>` |
| Scopes | The defaults (`openid`, `profile`, `email`). For group sync, a client scope named `groups` with a Group Membership mapper, assigned to the client as optional ([recipe below](#group-sync-with-keycloak)) |
| Claims | `email` must be present in userinfo and must be the user's address |

**The pods need a routable path to the IdP.** Step 4 above runs from inside the cluster, and node egress addresses change. An IdP whose public entry point is IP-restricted refuses the token and userinfo calls, even though the browser redirect worked. Give the pods their own path: a private-zone record and an internal load balancer entry for the IdP. The reference install publishes Keycloak as `keycloak.internal.<domain>` on an internal ingress class. Resolve that record from inside the cluster, so the issuer hostname still matches what the IdP advertises.

**For agent pre-authentication** (the token-exchange option in Settings, OIDC), the Keycloak client needs more than the sign-in flow:

* Standard token exchange enabled (Keycloak 26: the client attribute `standard.token.exchange.enabled=true`)
* Direct access grants enabled
* An audience client that represents the system agents call with the user's own token, where that system allows it
* A client scope carrying an audience mapper for that client

Leave the **Agent pre-authentication** switch on **Settings > OIDC** off until sign-in works. Then add the items above in your identity provider and turn the switch on.

**Keycloak and `preferred_username`.** The login reads the user from Keycloak's userinfo and expects `preferred_username` to carry the email. Keycloak sends the account username by default, so the auth service is asked to create a user called, for example, `usman` and refuses it (`Unable to validate email address: invalid format`). Fix it in Keycloak, on the client you created for xpander: Client scopes, the client's dedicated scope, Add mapper, By configuration, **User Property**. Name `preferred_username`, Property `email`, Token Claim Name `preferred_username`, type String, "Add to userinfo" on. Then retry the SSO sign-in. Realm-wide alternative: Realm settings, Login, "Email as username". Nothing changes on the xpander side; if the redirect round trip already works, your issuer, client and redirect URIs are correct.

Releases with the September 2026 edge-functions image or later read `email` first and use `preferred_username` only when it is an address and `email` is absent, so on those releases the mapper is a safeguard rather than a requirement. Add it anyway; it works on every release.

### In xpander (Settings, OIDC)

| Field | Value |
| - | - |
| Issuer URL | The realm or tenant issuer, exactly as in the IdP's discovery document, **including any context path**. A Keycloak serving under `/auth` is `https://idp.example.com/auth/realms/<realm>`, not the bare host. xpander appends `/.well-known/openid-configuration` |
| Client ID, Client secret | From the client you created |
| Additional sign-in scopes | Optional. Click **Add**, type the scope, then **Save configuration**. `groups` turns on group sync from the claim of that name ([recipe below](#group-sync-with-keycloak)) |
| API service app (optional) | A second client's ID and secret, used for agent token exchange, plus a **Directory key** you generate and paste into that client's key-pair authentication settings for directory reads (Okta requires key-pair auth here) |
| Agent pre-authentication | Optional token exchange for agents acting with the user's own permissions where systems allow; leave off to start. The card holds the **Enable agent pre-authentication** switch, **Audiences** and **Token exchange scopes**; **Save configuration** applies them. When you turn it on, the IdP client needs the token-exchange settings listed [above](#in-your-identity-provider) |

<Frame caption="Settings > OIDC: the three redirect URIs and the Agent pre-authentication card with its switch, Audiences and Token exchange scopes. Shown with sample data.">
  <img src="https://mintcdn.com/xpanderai-099931d1/OdLxX9OyFVicAV0l/images/verify/lab-oidc-preauth.png?fit=max&auto=format&n=OdLxX9OyFVicAV0l&q=85&s=e95a57e86f846f9db25aa4d311a464e4" alt="OIDC settings showing Sign-in, Sign-out and Initiate login redirect URIs, then an Agent pre-authentication card with the Enable agent pre-authentication switch on, one audience, two token exchange scopes and a Save configuration button" width="1440" height="900" data-path="images/verify/lab-oidc-preauth.png" />
</Frame>

### In the chart

```yaml theme={"dark"}
client-auth:
  env:
    APP_URL: https://<app-host>        # the UI origin, no trailing slash; it forms the redirect_uri
ui:
  authProviders: [sso]                 # renders the SSO button
```

`client-auth` is one of the published hosts (`client-auth.<domain>`); it also serves the OAuth redirects for skills.

## When it fails

| Symptom | Cause | Fix |
| - | - | - |
| Browser never reaches the IdP | Organization has no OIDC config, or the SSO feature is not in the license | Fill Settings, OIDC; check the license features |
| IdP shows an invalid redirect URI | `APP_URL` differs from the UI origin, or the URI is not registered | Set `client-auth.env.APP_URL` to the exact UI origin; register the three exact URIs from Settings, OIDC, Redirect URIs |
| "Code exchange process failed" with `Unable to validate email address: invalid format` in the auth pod log | The email the IdP returned is not an address (Keycloak `preferred_username` case above) | Add the `preferred_username` mapper or upgrade the edge-functions image |
| "Code exchange process failed", auth pod log shows a token or userinfo error | The pods cannot reach the IdP (IP-restricted public entry, no internal route), or the issuer is wrong | Verify the issuer including its path; give the pods an internal load balancer or private-zone route to the IdP |
| A different user is signed in than expected | Two profiles share an address in different organizations | Check the organization the email belongs to; the org-scoped match wins |
| The browser returns to `/` signed out, with no message | The IdP answered the callback with an error, for example `invalid_scope` when a requested scope has no client scope at the IdP | Read the `error=` parameter on the callback request in the browser's network log, or the IdP's event log; fix the scope at the IdP |

Logs to read: the `client-auth` pod (token and userinfo exchange), the `auth` (GoTrue) pod (user creation), and the `edge-runtime` pod (the `oidc-callback` function).

## Group sync with Keycloak

Group membership from the identity provider maps to xpander user groups. Four steps: two at the IdP, two in xpander.

1. **A client scope named `groups`.** In Keycloak: Client scopes, Create client scope, name `groups`, type Optional. On that scope: Mappers, Add mapper, By configuration, **Group Membership**; name `groups`, Token Claim Name `groups`, Full group path off, Add to userinfo on. Then Clients, your xpander client, Client scopes, Add client scope, `groups` as **Optional**. A protocol mapper on the client's dedicated scope alone is not enough: xpander requests `scope=groups` at sign-in, and Keycloak answers `error=invalid_scope` when no client scope of that name is assigned.
2. **Ask for the scope.** Settings > OIDC > **Additional sign-in scopes**: click **Add**, type `groups`, then **Save configuration**. xpander records the claim name `groups`, the source `userinfo` and the extra scope `groups` for the organization.
3. **Sign in once.** Each value the IdP sends in the `groups` claim is recorded for the organization, with when it was first and last seen.
4. **Link a group.** Settings > **User groups**, the group row's menu (three dots), **Link SSO groups**. The dialog, **Link SSO groups to** followed by the group name, lists the observed values behind a search box, plus **Add a group value manually (name or ID)** for one that has not been seen yet. Click **Save links**. The row then carries an **SSO** badge and reads "Linked to the Keycloak ... group" with the linked value.

Members of the linked identity-provider groups join the xpander group at their next sign-in, as the dialog says.

<Frame caption="Settings > OIDC with groups under Additional sign-in scopes. Shown with sample data.">
  <img src="https://mintcdn.com/xpanderai-099931d1/OdLxX9OyFVicAV0l/images/verify/lab-oidc-groups-scope.png?fit=max&auto=format&n=OdLxX9OyFVicAV0l&q=85&s=5101fe23a3e83903b4f58330eb22de65" alt="OIDC settings with Issuer URL, Client ID, a saved Client secret, and groups entered under Additional sign-in scopes" width="1440" height="900" data-path="images/verify/lab-oidc-groups-scope.png" />
</Frame>

<Frame caption="Settings > User groups: the row menu with Link SSO groups.">
  <img src="https://mintcdn.com/xpanderai-099931d1/OdLxX9OyFVicAV0l/images/verify/lab-user-groups-row-menu.png?fit=max&auto=format&n=OdLxX9OyFVicAV0l&q=85&s=82b331b34f3c027c4c1f6a3097a30154" alt="User groups list with a row menu open showing Edit group, Customize features, Link SSO groups, Make default and Delete" width="1440" height="900" data-path="images/verify/lab-user-groups-row-menu.png" />
</Frame>

<Frame caption="Link SSO groups: the observed identity-provider values, a manual entry field and Save links. Shown with sample data.">
  <img src="https://mintcdn.com/xpanderai-099931d1/OdLxX9OyFVicAV0l/images/verify/lab-user-groups-link-dialog.png?fit=max&auto=format&n=OdLxX9OyFVicAV0l&q=85&s=40906a53b763622e1e18968db09a7fd2" alt="Link SSO groups to Ops dialog reading 'Members of the linked identity-provider groups join this group at their next sign-in', with a search box, one observed group value, a manual entry field and a Save links button" width="1440" height="900" data-path="images/verify/lab-user-groups-link-dialog.png" />
</Frame>

A directory listing of groups (`GET /oidc/<org-id>/groups`) exists for Okta and Entra ID only. With Keycloak it answers `400`; nothing depends on it, so the answer is harmless.

**A failed callback is silent.** When the IdP answers the callback with an error, `invalid_scope` for example, the browser lands on `/` signed out and no message is shown. Read the `error=` parameter on the callback request in the browser's network log, or the identity provider's event log.

## Running Keycloak yourself

If the identity provider is a Keycloak you install next to xpander, three server settings decide whether the discovery document works from inside the cluster:

* `--hostname=https://<keycloak host>/auth`. A pathless hostname advertises endpoints the server then refuses.
* `--hostname-backchannel-dynamic=true`, so pods can reach it on an internal name while browsers use the public one.
* `--proxy-headers=xforwarded`, behind a load balancer.

Give the pod at least 2 GiB of memory when you administer it with `kcadm.sh`: each admin call starts a second JVM in the same cgroup. Install Keycloak only after its database, role and admin secret exist, or the release deadlocks waiting for a database that is not there.

## Agent pre-authentication

Agent pre-authentication lets an agent act with the asking person's own permissions where systems allow. At the moment of a call, xpander exchanges that person's sign-in token (OIDC token exchange, RFC 8693) for a token scoped to a target system.

At the IdP, the xpander client needs, beyond the sign-in flow:

* Standard token exchange enabled. On Keycloak 26 that is the client attribute `standard.token.exchange.enabled=true`.
* Direct access grants enabled.
* An audience client that represents the system agents call with the person's own token.
* A client scope carrying an audience mapper for that client.

In xpander, open **Settings > OIDC**. The **API service app** card holds the exchange client's **Service app client ID** and **Service app client secret**. The same card holds the **Directory key** used for key-pair directory reads. Further down, the **Agent pre-authentication** card has the **Enable agent pre-authentication** switch, **Audiences** (one per target system, for example `acme-inventory`) and **Token exchange scopes** (for example the audience's scope and `openid`). Click **Save configuration**. Leave the switch off until plain sign-in works.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.