Skip to content

Identity: the OIDC provider

For operators

Registering an external OpenID Connect provider with the gateway; what the gateway then decides about administrators, linking and recovery is Identity.

This page is about the provider: what to register on it, which claims to send, and what goes wrong at the seam between it and the gateway. The variables that name it (OIDC_ISSUER, the client ids and secrets, the admin rules) and everything the gateway does with the answer are on Identity. To use the bundled Authelia instead, see Bundled accounts.

Registering the clients on your provider

Register a confidential web client per application, and the public machine client:

Client Redirect URI Scopes
console (pystino-console) <origin>/auth/callback/default openid profile email groups
chat (cerea) <origin>/chat/login/callback openid profile email groups
machine (opencode-enrollment) device flow, plus loopback http://localhost/callback openid profile email groups offline_access

The machine client needs the device-flow and refresh-token grants, and no secret.

  • Redirect URIs are compared as exact strings. Never use a wildcard: an open redirect on an OIDC client hands the authorization code to whoever asks.
  • Take the issuer from the provider's own discovery document (https://<provider>/.well-known/openid-configuration). The gateway compares iss byte for byte.
  • For /v1, the chat and coding agents send the person's access token. It must be a signed JWT (RS256), carry OIDC_AUDIENCE in aud, name an accepted client in azp, and carry the groups claim itself: /v1 validates the token locally and never calls userinfo.

The flow needs no configuration: authorization code with PKCE (S256), state and nonce, ID-token signature checked against the provider's JWKS (refetched once on an unknown key id, so key rotation needs no restart), and iss, aud, exp and sub checked. Discovery is read at startup: restart the gateway after changing the issuer.

Groups claim by provider

Choose the groups claim for your provider, and check where it appears: the console login merges userinfo into the ID token's claims, but /v1 sees only what is in the access token.

Provider Claim Where
Bundled Authelia groups ID token, access token, userinfo
GitLab groups_direct (direct memberships), or groups (with inherited ones) groups_direct in the ID token; groups in userinfo only
Microsoft Entra ID groups (object IDs unless transformed) either
Keycloak realm_access.roles, or a groups mapper ID token
Namespaced (Auth0 and others) e.g. https://example\.org/groups (dotted paths walk nested objects; escape a literal dot) either

A string claim is one group; it is never split on separators. OIDC_GROUPS_CLAIM names it, and how it is applied is OIDC_GROUP_SYNC.

email_verified by provider, and what it means for invitations

An invitation is claimed by the invitee's verified email, so what a provider puts in email_verified decides whether invites work against it. Each row below was checked against the provider's current documentation (October 2026), not guessed; run pystino idp check on your own issuer to confirm what yours asserts — the token report prints the claim and, in invite mode, warns when it is absent.

Provider Sends email_verified? Notes, and what it means for invitations
Bundled Authelia yes true for console-made accounts — invites are not the bundled flow anyway
Google yes with the email scope; ID token and userinfo
GitLab yes, conditionally only with the email scope and a public email on the user's profile; without one, an invite waits until the profile has an address
Keycloak yes the built-in email client scope carries the email_verified mapper (source-checked, 2026-10); true once the realm considers the address verified
Okta yes email scope, boolean; in the ID token for id_token response types, from userinfo on the code flow (which the console merges)
Authentik yes, but value varies before 2025.10 always true (even for unverified addresses); since 2025.10 false until the address is verified through authentik's flow — an invitation stays unclaimable until then, or use SCIM

Authentik before 2025.10, with self-registration on

On those versions every self-registered account asserts email_verified: true (authentik's own email scope mapping always returned true), even though nobody verified the address. With self-registration enabled, that lets someone claim an invitee's invitation: they register at the IdP with the invited person's address, sign in, and the invite matches on the (falsely) verified email. On authentik versions before 2025.10, use SCIM or approval instead of invites — or turn self-registration off.

| Microsoft Entra ID | no | email_verified is not among Entra's optional claims and work accounts never get one: invitations cannot be claimed by email — use SCIM (accounts pre-created by oid, see SCIM setup) or approval |

When the answer is "no", or the claim stays false, the first sign-in of an invited person is refused with the invite-mode text and the pending account waits; nothing is provisioned twice. SCIM and the directory pull do not need the claim — they key users by externalId/oid, not by email.

Turning on provisioning per provider

The User sync dialog mints the token; what the IdP's side needs, per provider:

  • Microsoft Entra ID: create an Enterprise application from the gallery template SCIM (or Provisioning), point its tenant at https://<gateway>/scim/v2/<provider name> with the minted bearer token, and set Provision Azure Active Directory Users on. Entra's externalId is the user's objectId (a GUID); the provider row must carry subject_claim: oid so sign-ins key on the same id the sync created — it is a field on the provider row (default sub), set on the Entra row in the console or with PUT /api/admin/identity-providers/{id} (subject_claim: "oid"). Attribute mapping: externalId ← objectId, mail ← mail, displayName ← displayName, active ← accountEnabled.
  • Okta: an app integration of type SCIM (Workforce requires Okta's SCIM 2.0 app), base URL as above, auth mode HTTP Header with the minted token. externalId is Okta's user id by default; leave it unless you have a reason to map something else.
  • Authentik: an Outpost-integrated SCIM provider is not needed — authentik 2025.x has an outbound SCIM provider type: set the base URL and token as above. authentik sends externalId as its user UID, so sign-ins key on it; no subject_claim override is needed.

Everything else (dry run, confirmation, the deactivation valve) is User sync, which SCIM shares.

Providers that reject the groups scope

Some providers publish a groups claim but no groups scope, and answer a sign-in that asks for it with invalid_scope. Infomaniak's discovery lists scopes_supported = openid, profile, email, phone, for one. The gateway asks for the scopes in GATEWAY_OIDC__SCOPES, so the fix is to drop groups from that list and from the chat's OIDC_SCOPES, which are one setting spelled two ways (a JSON list for the gateway, words for the chat).

With the deploy kit, ./configure --idp external reads the issuer's discovery document and asks only for the scopes it lists (openid always stays), writes both spellings (OIDC_SCOPES and OIDC_SCOPES_JSON) and says so when groups is dropped. ./configure --check warns about a configured scope the issuer does not list, and ./configure --oidc-scopes 'openid profile email' (or --set OIDC_SCOPES=…) sets them by hand. In deploy/ here, edit the GATEWAY_OIDC__SCOPES line of compose.yaml.

Without the scope, group-based features (group sync, quotas and billing by group, an admin claim rule on groups) work only if the provider puts a groups claim in its tokens regardless. pystino idp check --device prints the claim. With no groups claim, users sign in with no group memberships, and an admin rule by email still works.

The machine client follows the same rule: galopin asks for offline_access only when the issuer lists it. A provider that grants refresh tokens without the scope is still asked, and galopin enroll stops with a message instead of enrolling a machine that would lose its sign-in within the hour, if no refresh token comes back. Enable refresh tokens for opencode-enrollment at the provider.

Access tokens without an audience (for example Keycloak)

Some providers' access tokens (Keycloak's, for one) carry no aud claim at all unless an audience mapper puts one there; it has only azp, which says who asked for the token, not who it is for. The gateway checks the audience (OIDC_AUDIENCE, pystino-api by default) rather than azp, because a token minted for any other purpose by a permitted client would otherwise be accepted at the API. So every client that is to call /v1 (the console's, the chat's and the machine client) needs a mapper (in Keycloak, a protocol mapper of type Audience) that adds OIDC_AUDIENCE to the access token.

Without it nothing fails loudly at the provider. The symptom is that every chat sign-in fails, and the only trace is a WARNING line in the gateway's log:

access token rejected: access token audience ['account'] does not include 'pystino-api'; the client needs an audience mapper naming this gateway

(the chat's announce door logs the same reason as announce: token rejected: …). The list in the message is whatever aud the token did carry. Because it is a WARNING, it shows without raising the log level.

Signing out

Signing out of the console or the chat ends the session in both, and at the provider, so the next sign-in asks for a password. When the provider publishes an end_session_endpoint, it is used. When it does not, set OIDC_LOGOUT_URL — GATEWAY_OIDC__LOGOUT_URL in the gateway — and the chat reads the same value. For the bundled Authelia it is <origin>/authelia/logout?rd={redirect}, which the deployments set for you.

User sync

Optional, and for an external provider only. People appear at their first sign-in whether or not you set it up. User sync… on the provider's card in the console (it does not appear for the bundled Authelia, whose accounts the Users page manages directly) keeps the user list in step with the provider: users exist, with their groups and pre-assigned access, before their first sign-in, and are deactivated when removed there. A directory never deletes anyone, grants and revokes only what it granted, and never deactivates half the deployment because it returned an empty list.

The dialog offers the adapters the provider kind can support:

Adapter How the directory tells the gateway about users
Off (default) users appear at their first sign-in
SCIM 2.0 the provider pushes changes to /scim/v2/<provider>
Authelia users file not needed: the Users page manages bundled accounts

A provider with no listing API (OIDC itself has none) is just-in-time only, and the dialog says so. A first pull is a dry run: nothing changes until an administrator has looked at what the adapter would do and confirmed it.

Worked example: GitLab

  1. On GitLab, add an application (Edit profile → Applications, or a group's or the instance's Applications page). Mark it confidential, with the console's redirect URI and the scopes openid profile email. openid is the one that carries group memberships. Copy the secret: it is shown once. Add a second application for the chat if you deploy it, also confidential, with the chat's redirect URI and the same scopes. For agent machines (galopin), add a third: not confidential (galopin has no secret), the scopes openid profile email, and the device authorization grant enabled; its redirect URI can be http://127.0.0.1/callback, which only the browser flow on a laptop uses. Its Application ID is the machine client id.
  2. In .env, leave authelia out of COMPOSE_PROFILES and set:
OIDC_ISSUER='https://gitlab.com'          # self-managed: your instance's URL
OIDC_INTERNAL_BASE_URL=''
OIDC_KIND='generic'
OIDC_CONSOLE_CLIENT_ID='<application id>'
OIDC_CONSOLE_CLIENT_SECRET='<secret>'
OIDC_GROUPS_CLAIM='groups_direct'
OIDC_ADMIN_EMAIL='you@example.org'
# with the chat:
OIDC_CHAT_CLIENT_ID='<chat application id>'
OIDC_CHAT_CLIENT_SECRET='<chat secret>'
# with agent machines:
OIDC_MACHINE_CLIENT_ID='<machine application id>'

OIDC_MACHINE_CLIENT_ID replaces the default opencode-enrollment, an id GitLab cannot have (it mints its own): it goes into ACCEPTED_CLIENTS and into the pairing dialog's galopin enroll --client-id. A machine enrolled with the default id gets Client authentication failed due to unknown client from GitLab's sign-in page.

Group names arrive as full paths (my-group/sub-group); name group-based model access and admin claims accordingly. Run pystino idp check --device after the first sign-in to see what GitLab actually asserted — the email, its email_verified type, and whether the admin rule would grant. 3. docker compose up -d --wait, and sign in at <origin>/.

What to know about GitLab:

  • No nonce in its ID tokens (gitlab.com's discovery lists none). The gateway sends one and accepts a token without it, but refuses a token whose nonce does not match the browser's flow.
  • No end_session_endpoint. Signing out ends the gateway's session only, and GitLab's own session persists: signing in again is one click.
  • RS256 only, which is what the gateway expects.
  • Its access tokens are opaque, not JWTs, so the gateway cannot read who issued one or check a signature. It asks GitLab instead, at the introspection_endpoint its discovery advertises (/oauth/introspect, RFC 7662), authenticating with client_secret_basic. GitLab answers active: true only to the application the token was issued to, so the chat's tokens (the chat signing in through POST /v1/session/announce, and every /v1 call it then makes) are introspected with the chat application's own id and secret: OIDC_CHAT_CLIENT_SECRET must reach the gateway (GATEWAY_OIDC__CHAT_CLIENT_SECRET), not only the chat. Without it every chat sign-in is refused, and the gateway log says inactive for client '<console id>'. A token is accepted only when GitLab says it is active, its client_id is the application that asked, and its exp is in the future; it then counts as issued to that client (azp), so ACCEPTED_CLIENTS and the chat-client check apply as they do to a JWT. GitLab's answer carries no sub, so the gateway reads it from userinfo with the same token: the person is (issuer, GitLab user id), exactly what a console sign-in produces, so console and chat land on the same account. The chat's application needs the openid scope for that (openid profile email, as above).
  • Agent machines' tokens are checked at /oauth/token/info. galopin's application is public, so it has no secret, and introspection under it is impossible; under any other application GitLab answers active: false. For a token no introspection vouched for, the gateway asks GitLab's GET /oauth/token/info with the token itself as the bearer (only at a provider whose introspection endpoint is Doorkeeper's /oauth/introspect). GitLab answers 401 for an unknown, revoked or expired token, and otherwise names the application the token belongs to and the seconds it has left. The token is accepted only when that application is in ACCEPTED_CLIENTS and is neither the console's nor the chat's (those two are confidential: an inactive introspection for them stays final, so a missing chat secret still fails loudly), and when it expires; the person is then read from userinfo, the same (issuer, GitLab user id) as everywhere else. Answers are cached exactly like introspection's.
  • GitLab's device endpoint is not in its discovery. Its /.well-known/openid-configuration lists the device_code grant but no device_authorization_endpoint; the endpoint is <issuer>/oauth/authorize_device. galopin derives it (from 0.10.13 on); an older galopin stops with no device endpoint advertised. pystino idp check derives it the same way and reports device flow: available via <issuer>/oauth/authorize_device (not advertised; derived ...); it fails with device flow: MISSING only for an issuer with no device endpoint and no way to derive one.
  • Revocation takes up to a minute. Answers are cached in the gateway, keyed by a hash of the token (never the token), for 60 seconds or until the token's own exp, whichever is sooner; a refusal is cached for 10 seconds. A token revoked on GitLab therefore keeps working here for at most 60 seconds, and /v1 calls do not reach GitLab on every request.
  • Why a chat sign-in failed is in docker compose logs gateway, one WARNING per refusal beginning announce: token rejected: (or access token rejected: for other /v1 calls), naming the reason — not a JWT and no provider vouched for it, inactive for a client, a client other than the chat, an expired token, a client outside ACCEPTED_CLIENTS — and never the token itself.
  • Email is present only for users with a public email on their profile. They can still sign in; the console shows the subject until an email appears.
  • Every GitLab group a person is in arrives in the claim. Under the old behaviour (OIDC_GROUP_IMPORT=auto) each name became a group at first sight: one operator's first sign-in created 67 groups. With OIDC_GROUP_IMPORT=manual, the default, a sign-in creates none. The names appear on the console's Groups page under Groups from your identity provider, with how many users carry each and when it was last seen; Map the few that should mean something here (the dialog asks what the name means: a new group of the same name, a new group under another name, or an existing group; under every_login the users who carry it get it at once) and Ignore the rest. Map full paths — a subgroup under your organisation, acme/staff, not the bare staff a user could create top-level on their own account. Meanwhile everyone is in users (the default group), so they can use the public models from the first sign-in. OIDC_GROUP_ALLOWLIST narrows what is considered at all, if the list is long and the wanted names are known; it vouches for nothing by itself — a listed name still needs a mapping.
  • Cleaning up groups created before. An install that ran with the old behaviour keeps the groups it made (and migration 0058 keeps their claim-fed memberships working through self-mappings). Delete the unwanted ones on the console's Groups page: their memberships go with them, and under manual they are not recreated; the names return to the Seen list at the next sign-in, to map or dismiss.