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 comparesissbyte for byte. - For
/v1, the chat and coding agents send the person's access token. It must be a signed JWT (RS256), carryOIDC_AUDIENCEinaud, name an accepted client inazp, and carry the groups claim itself:/v1validates 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 |
| 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'sexternalIdis the user'sobjectId(a GUID); the provider row must carrysubject_claim: oidso sign-ins key on the same id the sync created — it is a field on the provider row (defaultsub), set on the Entra row in the console or withPUT /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.
externalIdis 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
externalIdas its user UID, so sign-ins key on it; nosubject_claimoverride 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¶
- 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.openidis 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 scopesopenid profile email, and the device authorization grant enabled; its redirect URI can behttp://127.0.0.1/callback, which only the browser flow on a laptop uses. Its Application ID is the machine client id. - In
.env, leaveautheliaout ofCOMPOSE_PROFILESand 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
noncein 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_endpointits discovery advertises (/oauth/introspect, RFC 7662), authenticating withclient_secret_basic. GitLab answersactive: trueonly to the application the token was issued to, so the chat's tokens (the chat signing in throughPOST /v1/session/announce, and every/v1call it then makes) are introspected with the chat application's own id and secret:OIDC_CHAT_CLIENT_SECRETmust reach the gateway (GATEWAY_OIDC__CHAT_CLIENT_SECRET), not only the chat. Without it every chat sign-in is refused, and the gateway log saysinactive for client '<console id>'. A token is accepted only when GitLab says it is active, itsclient_idis the application that asked, and itsexpis in the future; it then counts as issued to that client (azp), soACCEPTED_CLIENTSand the chat-client check apply as they do to a JWT. GitLab's answer carries nosub, 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 theopenidscope 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 answersactive: false. For a token no introspection vouched for, the gateway asks GitLab'sGET /oauth/token/infowith 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 inACCEPTED_CLIENTSand 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-configurationlists thedevice_codegrant but nodevice_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 checkderives it the same way and reportsdevice flow: available via <issuer>/oauth/authorize_device (not advertised; derived ...); it fails withdevice flow: MISSINGonly 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/v1calls do not reach GitLab on every request. - Why a chat sign-in failed is in
docker compose logs gateway, one WARNING per refusal beginningannounce: token rejected:(oraccess token rejected:for other/v1calls), 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 outsideACCEPTED_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. WithOIDC_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; underevery_loginthe users who carry it get it at once) and Ignore the rest. Map full paths — a subgroup under your organisation,acme/staff, not the barestaffa user could create top-level on their own account. Meanwhile everyone is inusers(the default group), so they can use the public models from the first sign-in.OIDC_GROUP_ALLOWLISTnarrows 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
manualthey are not recreated; the names return to the Seen list at the next sign-in, to map or dismiss.