> ## 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 install with your own OIDC identity provider: Keycloak, Okta or Entra ID.

The **SSO** button uses the OIDC configuration an admin enters **in xpander Chat** under **Platform > Organization > Access**. Google and GitHub social buttons are a separate path (GoTrue providers, `supabase.auth.extraEnv` in the chart).

![Diagram](https://docs.xpander.ai/images/diagrams/self-hosted-sso-e5f691de.svg)

## How a sign-in works

1. The user clicks **SSO** and types their work email under **Sign in with SSO**. The email only picks the organization and is never sent to the IdP.
2. `client-auth` redirects the browser to your IdP with scopes `openid profile email` plus any you add. The client is confidential (`client_id` and `client_secret`, no PKCE).
3. The IdP redirects back to `https://<app-host>/oidc/<organization-id>/callback` with a code.
4. `client-auth` exchanges the code and reads the user from the IdP's **userinfo** endpoint, **from inside the cluster**. The IdP must be reachable from the pods, not only from browsers.
5. xpander finds or creates the user by email in GoTrue and starts the session.

![Keycloak sign-in page titled Sign in to your account with Username or email and Password fields](https://mintcdn.com/xpanderai-099931d1/OdLxX9OyFVicAV0l/images/verify/lab-sso-keycloak-login.png?fit=max\&auto=format\&n=OdLxX9OyFVicAV0l\&q=85\&s=6e87f5556c38204a7bd37c39a898b627)

After the SSO button: the identity provider's own sign-in page, here Keycloak. Shown with sample data.

## What to configure

### In your identity provider

| Setting | Value |
| - | - |
| Client type | Confidential (client authentication on) |
| Flow | Authorization code (Keycloak: "Standard flow") |
| Valid redirect URIs | Three exact URIs, no wildcard: `.../oidc/<org-id>/callback`, `.../oidc/<org-id>/logout` and `.../oidc/<org-id>/login`. Copy them from Redirect URIs under **Platform > Organization > Access** |
| Web origins | `https://<app-host>` |
| Scopes | `openid`, `profile`, `email`. For group sync, add a `groups` client scope ([recipe](#group-sync-with-keycloak)) |
| Claims | `email` must be in userinfo and be the user's address |

**Give the pods a routable path to the IdP.** Node egress addresses change, so an IP-restricted public entry refuses the token and userinfo calls even when the browser redirect works. Use a private-zone record and an internal load balancer, for example `keycloak.internal.<domain>` on an internal ingress class, resolved so the issuer hostname still matches.

For [agent pre-authentication](#agent-pre-authentication), the client needs extra settings. Turn that switch on only after sign-in works.

**Keycloak and `preferred_username`.** Keycloak sends the account username, so user creation fails with `Unable to validate email address: invalid format`. Map the email onto it:

**1. Open the mapper**

Client scopes, the client's dedicated scope, Add mapper, By configuration, **User Property**.

**2. Map email to preferred\_username**

Name `preferred_username`, Property `email`, Token Claim Name `preferred_username`, type String, "Add to userinfo" on.

**3. Retry**

Retry the SSO sign-in.

Or set "Email as username" under Realm settings, Login. Releases with the September 2026 edge-functions image or later read `email` first, but add the mapper anyway: it works on every release.

### In xpander (Platform > Organization > Access)

| Field | Value |
| - | - |
| Issuer URL | Exactly as in the IdP's discovery document, **including any context path**: `https://idp.example.com/auth/realms/<realm>` for a Keycloak under `/auth`. xpander appends `/.well-known/openid-configuration` |
| Client ID, Client secret | From your IdP client |
| Additional sign-in scopes | Optional. **Add**, then **Save configuration**. `groups` turns on group sync |
| Service app (optional) | A second client for agent token exchange, plus a **Directory key** for key-pair directory reads (required by Okta) |
| Agent pre-authentication | Off to start. See [Agent pre-authentication](#agent-pre-authentication) |

### 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 published as `client-auth.<domain>` and also serves skill OAuth redirects.

## When it fails

| Symptom | Cause | Fix |
| - | - | - |
| Browser never reaches the IdP | No OIDC config, or SSO not in the license | Fill the OIDC settings. Check license features. |
| IdP shows an invalid redirect URI | `APP_URL` is not the UI origin, or a URI is not registered | Set `client-auth.env.APP_URL` to the exact origin. Register all three URIs. |
| "Code exchange process failed" with `Unable to validate email address: invalid format` in the auth pod log | The IdP returned a username, not an address | Add the `preferred_username` mapper or upgrade the edge-functions image |
| "Code exchange process failed", token or userinfo error in the auth pod log | Pods cannot reach the IdP, or the issuer is wrong | Check the issuer path. Route the pods to the IdP internally. |
| A different user is signed in | Two profiles share an address in different organizations | The org-scoped match wins. Check the organization. |
| Back on `/` signed out, no message | The IdP returned an error, for example `invalid_scope` | Read `error=` on the callback in the browser's network log, or the IdP event log |

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

## Group sync with Keycloak

IdP groups map to xpander user groups in four steps:

1. **Create a `groups` client scope.** Client scopes, Create client scope, name `groups`, type Optional. Add a **Group Membership** mapper: name `groups`, Token Claim Name `groups`, Full group path off, Add to userinfo on. Then add `groups` to your xpander client as **Optional**. A mapper on the dedicated scope alone is not enough: Keycloak answers `error=invalid_scope`.
2. **Request the scope.** Under **Additional sign-in scopes**, **Add** `groups`, then **Save configuration**. xpander records claim `groups` from `userinfo`.
3. **Sign in once,** so xpander sees the group values.
4. **Link a group.** **Platform > Organization > Members & groups**, the group's menu, **Link SSO groups**. Pick a seen value or **Add a group by name or ID** with **Add to list**, then **Save links**. The row shows an **SSO** badge.

Members join the xpander group at their next sign-in.

![Members & groups on the Groups tab with the Link SSO groups to Engineering panel open: no groups received yet, an Add a group by name or ID field with Add to list, and Save links](https://mintcdn.com/xpanderai-099931d1/wqIIXSsh0kqA2hZ_/images/verify/lab-user-groups-link-dialog.png?fit=max\&auto=format\&n=wqIIXSsh0kqA2hZ_\&q=85\&s=83377ec6afea181708d7be88dfd8b67a)

Link SSO groups, before the identity provider has sent any groups: add one by name or ID, then Save links. Shown with sample data.

`GET /oidc/<org-id>/groups` exists for Okta and Entra ID only. With Keycloak it answers `400`, which is harmless.

## Running Keycloak yourself

| Setting | Why |
| - | - |
| `--hostname=https://<keycloak host>/auth` | A pathless hostname advertises endpoints the server then refuses |
| `--hostname-backchannel-dynamic=true` | Pods use an internal name, browsers the public one |
| `--proxy-headers=xforwarded` | Needed behind a load balancer |

Give the pod at least 2 GiB when you use `kcadm.sh` (each call starts a second JVM). Install Keycloak after its database, role and admin secret exist, or the release deadlocks.

## Agent pre-authentication

Lets an agent act with the asking person's own permissions where systems allow. xpander exchanges the person's sign-in token (OIDC token exchange, RFC 8693) for a token scoped to the target system.

At the IdP, the xpander client also needs:

* Standard token exchange: on Keycloak 26, `standard.token.exchange.enabled=true`.
* Direct access grants enabled.
* An audience client for the target system.
* A client scope with an audience mapper for that client.

In xpander, the **Service app** card takes the **Service app client ID**, **Service app client secret** and **Directory key**. The **Agent pre-authentication** card has the **Enable agent pre-authentication** switch, **Audiences** (one per system, for example `acme-inventory`) and **Token exchange scopes** (for example the audience's scope and `openid`). Click **Save configuration**.

## Per-person sign-in to MCP connectors

An MCP connector can sign each person in through this IdP: [Per-person sign-in for MCP connectors](https://docs.xpander.ai/run/integrations/mcp-per-person-sign-in.md).

## Next steps

> [card] [Sign each person in to MCP connectors](https://docs.xpander.ai/run/integrations/mcp-per-person-sign-in.md)
>
> Per-person permissions.

> [card] [Check what the license enables](https://docs.xpander.ai/self-hosted/licensing.md)
>
> SSO is a license feature.


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