> Raw Markdown twin (generated at build time from the source Markdown). Rendered page: https://docs.gatellm.io/en/howto/configure-sso · Doc index: https://docs.gatellm.io/en/llms.txt


# Configure SSO enterprise login

SSO (Single Sign-On) lets you call the gateway with a token issued by your enterprise identity system (IdP, Identity Provider, such as Azure Entra / Okta), or log into the console with an enterprise account — without issuing a gateway key to each person/program separately.

Entry point: Console → **Settings** → **SSO** tab. The configuration is stored in the database and takes effect immediately after changes, with no restart and no environment variables needed.

SSO does **two mutually independent things** in the gateway; you can enable just one, or both for the same provider:

1. **Data-plane authentication**: your program calls the proxy endpoints like `/v1/...` with an IdP-issued Bearer token; after the gateway verifies the signature, it takes a name from the token, looks up a real key with the same name in the **access key** table, and applies that key's permissions.
2. **Console login**: a person clicks "Log in with enterprise account" on the login page, jumps to the IdP to authenticate, and on return the gateway takes the username from the id_token and looks up a user with the same name in the **console user** table; only a match is let in.

> Core principle: **the gateway never fabricates an identity out of thin air.** For the data plane you must pre-create an access key with the same name; for the console you must pre-create a console user with the same name; if not found it always rejects (401) and never synthesizes one on the fly.

> **Don't confuse this with [Upstream SSO credential login](/en/howto/upstream-sso-login.md)**: this page covers "authenticating **who calls the gateway** with an enterprise IdP"; that page covers "the admin logging into an **upstream vendor** (e.g. Kiro) to obtain a refresh credential for the gateway to forward with". The directions are opposite.

## Understand the four words in 30 seconds

They recur below; get familiar with them first:

- **issuer**: the `iss` field in the token — a sentence the IdP writes meaning "I issued this token"; it is a URL. The gateway uses it to **match the passphrase** — the token's `iss` must equal the issuer you fill in here, and only then does the gateway accept that this token belongs to this provider.
- **audience**: the `aud` field in the token — "which application this ticket is issued to". The gateway uses it to confirm the token is issued to your gateway, not to another application.
- **discovery document (the IdP's self-introduction)**: every IdP has a public JSON self-introduction that spells out four things — ① who I am `issuer`, ② where my public key list is `jwks_uri`, ③ the login URL, ④ the token-exchange URL. **The gateway prefers to read this self-introduction to get those URLs, rather than guessing.**
- **JWKS / public key list**: the IdP's public key list, which the gateway uses to verify "the token hasn't been tampered with".

## Three provider types, what to fill in each box

On the page, each provider has these core boxes (common to all three types):

| Box | What to fill in |
|----|--------|
| **ID** | An internal name you give this provider (e.g. `corp-azure`), used in URLs; don't change it after creation. |
| **Issuer URL** | Used exactly as you typed; the gateway will not assemble it from a domain/tenant. The token's `iss` must equal it (**trailing slash is fine either way**, the gateway normalizes it when comparing). |
| **Audience** | Must equal the token's `aud`. Data plane only. |
| **Client ID** | The application ID you registered with the IdP. Console login uses it to exchange tokens + validate the id_token audience; SPA mode also uses it for exact client matching on the data plane. |
| **Attribute mapping name_expr** | Data plane: which claim of the token to take the "name" from, to find a key with the same name in the access key table (JSONPath or expression, e.g. `$.oid`, `$.upn`). |
| **Console username username_expr** | Console login: which claim of the id_token to take the username from, to find a user with the same name in the console user table. |

For **Type** choose `azure` / `okta` / `generic`: the first two the gateway knows the URL conventions of and can derive automatically; `generic` is for any standard OIDC, where the login/token-exchange URLs must be filled in by hand.

The advanced collapse section also has: `jwks_uri` (hand-fill the public key URL, usually not needed), the **Use OIDC discovery** toggle, **Discovery URL (optional)**, clock tolerance, and the public key refresh interval. The Azure section below covers when `discovery_url` must be filled in.

## Azure section (focus)

Azure is the easiest to misconfigure, because its URL system is split and the v1/v2 token shapes differ. Copy the following and it should work on the first try.

### What to fill in for issuer

- **Global v2**: go to the Entra overview page and copy the Directory (tenant) ID (a GUID), fill it into the template `https://login.microsoftonline.com/<paste tenant ID>/v2.0`, and the whole string is the issuer. **Do not omit the trailing `/v2.0`**, and don't fill in `common`, `organizations`, or `{tenantid}`.
- **v1 / China cloud / sovereign cloud**: directly fill in the token's `iss` (decode the token to read the `iss` field, or assemble by tenant as `https://sts.chinacloudapi.cn/<tenant ID>`). This token's `iss` often has a trailing slash — no matter, the gateway normalizes it when comparing.

### When does audience carry `api://`

Look at what you set in the portal's **Expose an API → Application ID URI**:

- Set to a bare GUID (default) → audience is the bare GUID (same as Client ID).
- Set to `api://<client_id>` → the token's `aud` will carry the `api://` prefix, and **here you must also fill `api://<client_id>`**, not just the bare GUID, otherwise it won't match → 401.

### Where to find discovery_url (required for v1 / China cloud / sovereign cloud)

**Why you need it**: the v1 / China cloud token's `iss` is on the `sts.*` domain, but the IdP's self-introduction (discovery document) is on the `login.*` domain — two different URLs. issuer is only used to match the passphrase and **cannot** be used to assemble the self-introduction URL (that would 404). So you must tell the gateway separately where the self-introduction is. Global v2 doesn't need it (leave blank), because `iss` and the self-introduction share the same address.

**Preferred: copy from the portal directly, zero guessing.**

1. Enter the Entra portal (China cloud is `portal.azure.cn`) → left-side **App registrations** → click your application.
2. Left menu **Endpoints**.
3. In the right panel, the **"OpenID Connect metadata document"** row — **copy the whole row** into **Discovery URL**.

> This row is usually the v2 one (with `/v2.0`); **no problem, it works**: the v1 and v2 signing keys of the same application/tenant share one set, so the v2 self-introduction's public keys still verify your v1 token.

**Alternative: hand-assemble the template** (`{tid}` is the `tid` in the token, or the Directory (tenant) ID on the portal overview page):

- China cloud: `https://login.partner.microsoftonline.cn/{tid}/v2.0/.well-known/openid-configuration`
- Global cloud: `https://login.microsoftonline.com/{tid}/v2.0/.well-known/openid-configuration`

> ⚠️ China cloud's login domain is **`login.partner.microsoftonline.cn`**, **not** `chinacloudapi.cn` — the latter is the Microsoft Graph domain, don't confuse them. Take the portal's Endpoints panel as authoritative.

**How to confirm you assembled it right**: open the discovery_url in a browser or `curl` — it should return JSON containing `jwks_uri`; then open the `jwks_uri` inside it, and seeing the public key list means it's solid.

### name_expr: map a claim to the access key's name

- **v1 access token has `upn`** (e.g. `alice@corp.example.com`): if your access key name is built as the part before `@`, use an expression to strip the domain: `{{ replace(claim:$.upn, "@.*$", "") }}` → `alice`.
- **v2 access token has no `upn`**: use `$.oid` (the full user object ID), and build the access key's name as that OID string.
- Prerequisite: **there must be a key in the access key table whose name equals the parse result**, otherwise 401.

## Complete configuration examples

### Example A: Azure China cloud v1 (with discovery_url)

```jsonc
{
  "id": "azure-cn",
  "type": "azure",
  "enabled": true,
  // Match the passphrase: character-for-character equal to the token's iss
  // (trailing slash is fine either way). The tenant/object IDs below are
  // example placeholders — replace them with the Directory (tenant) ID /
  // Application (client) ID you copied from the portal overview page.
  "issuer": "https://sts.chinacloudapi.cn/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  // Who the ticket is issued to: character-for-character equal to the token's
  // aud. Here it carries api:// because the portal set the Application ID URI
  // to api://<client_id>.
  "audience": "api://11111111-2222-3333-4444-555555555555",
  "client_id": "11111111-2222-3333-4444-555555555555",
  // Data plane: strip upn after @ → alice (the access key table must have name=alice).
  "access_key": {
    "enabled": true,
    "name_expr": "{{ replace(claim:$.upn, \"@.*$\", \"\") }}"
  },
  // ★ Self-introduction URL: copy the "OpenID Connect metadata document" row
  // from the portal's Endpoints panel. The public key URL is derived from it
  // automatically; no need to hand-fill jwks_uri.
  "discovery_url": "https://login.partner.microsoftonline.cn/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/v2.0/.well-known/openid-configuration"
}
```

### Example B: global v2 (no discovery_url, zero changes to old config)

```jsonc
{
  "id": "azure-global",
  "type": "azure",
  "enabled": true,
  "issuer": "https://login.microsoftonline.com/<your tenant ID>/v2.0",
  "audience": "<your aud>",
  "client_id": "<your client id>",
  "access_key": { "enabled": true, "name_expr": "$.oid" }
  // No discovery_url → the gateway assembles the self-introduction URL from
  // issuer (v2 assembles correctly).
}
```

## How multiple providers work

You can configure Azure + Okta + generic OIDC all at once. The gateway does **not** try verifying your token against each provider: it first decodes the token's `iss`, matches the passphrase to find the **unique** provider that claims it, and verifies the signature only once. Each provider's issuer is different, so a token is claimed by at most one provider.

**A token claimed by no provider** (not a JWT issued by an enterprise IdP, or an ordinary `sk-...` key) **falls back to the gateway's built-in access key authentication** — SSO will not wrongly reject your original keys.

## Verification

Don't wait to hit the 401 table after a mishap; do an end-to-end verification first.

### 1. Get a test token

Get an access token issued by the IdP whose audience equals the value you configured for the gateway. With Azure you can use the Azure CLI directly:

```bash
# --resource is the audience you configured (carry the api:// prefix if you set it)
az account get-access-token \
  --resource <your audience> \
  --query accessToken -o tsv
```

Okta / generic OIDC use each IdP's own token-issuance flow (OAuth authorization-code flow or client_credentials).

### 2. Verify data-plane authentication

Use this token as the Bearer to call the model list endpoint, expecting **200**:

```bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:7890/v1/models \
  -H "Authorization: Bearer <the token from the previous step>"
```

Returning 200 means the signature verified and a key with the same name was found in the access key table. Returning 401 means you should go down the troubleshooting table below item by item.

### 3. Verify console enterprise login

Open the console login page and click "Log in with enterprise account"; after jumping to IdP authentication and returning, you should be able to enter the console (prerequisite: the console user table already has an enabled user with the same name as the id_token username). After first entry, go to the "Console users" page to confirm the user exists and the role is correct.

## Common 401 troubleshooting table

| Symptom / suspicion | How to check / fix |
|--------------|----------------|
| Token is v1 / China cloud, issuer doesn't match | Fill issuer with the token's `iss` as-is; don't fuss over the trailing slash. If the `iss` domain is `sts.*`, you **must** also fill `discovery_url` (the login domain). |
| audience doesn't match | Look at the portal's Expose an API → Application ID URI: if it's `api://<id>` fill it with `api://`; if it's a bare GUID fill the bare GUID. |
| Signature verification fails / can't fetch public keys | Is the `discovery_url` domain right (China cloud is `login.partner.microsoftonline.cn`, not `chinacloudapi.cn`); opening it in a browser should return JSON containing `jwks_uri`. |
| Signature verified but still 401 | name_expr can't extract a value, or the extracted name has **no same-name key in the access key table**. When a v2 access token has no `upn`/`azp`, don't use them — use `$.oid`. |
| Console login won't come back / reports not_in_list | The username extracted by the id_token's username_expr has **no same-name enabled user in the console user table**. |
| Token expired | Decode the token to read `exp`; if expired, re-fetch the token. |
| SSO configured but the token seems not to go through SSO | Confirm both the provider's `enabled` and `access_key.enabled` are on; confirm the SSO plugin is built into and enabled with the image (the official image enables it by default). |

## Next steps

- [Console login & roles](/en/console/login-and-roles.md) — create console users, understand roles and sessions.
- [Access key & key group fields](/en/reference/access-keys-groups-fields.md) — create the same-name access keys the data plane needs.
- [Endpoints & auth](/en/reference/endpoints.md) — the auth order: how SSO and built-in keys connect.
