refresh tokens. The OIDC callback route is `logLevel: 'silent'` (its query holds the code, like
the caller-name route, 11 §6.1) and writes its own line without the query.
### 1.1 Sign-in mode (`auth.mode`)
Settings → Sign-in, admin:
| Mode | Login page | `POST /api/auth/login` (password, local or directory) | OpenID |
|---|---|---|---|
| `password` (default) | the password form | everyone | off (start/callback answer 404) |
| `mixed` | the **Sign in with <label>** button and the password form | everyone | everyone with a connected account |
| `oidc` | the button only; a small **Admin sign-in** link shows the password form | **admins only**, everyone else 403 `password_sign_in_off` | everyone with a connected account |
-`mixed` and `oidc` need `auth.oidc` set up and its Test passed (PATCH → 400 otherwise).
- In `oidc` mode a user without a connected account can still get in once, by verified email
(§3.3 step 2), so pre-made users work without a password. Settings shows "N people can't sign in
yet" with their names: no connected account and an email the provider can't match.
- Switching modes signs no one out. Passwords are kept, only refused, so switching back works.
- In `mixed` mode the password stays a separate way in: disabling someone in Keycloak ends their
Keycloak sign-ins (§3.5), but their password still works until they're disabled on Users. Settings
says so next to the mode; `oidc` mode is the one where Keycloak alone decides.
## 2. Data
Migration (02 §users gets the table and these notes):
```sql
altertableusersaltercolumnpassword_hashdropnotnull;-- null: no password (made in oidc mode)
3.`GET /api/auth/oidc/callback` (`logLevel: 'silent'`): reads and clears `sp_oidc`, builds the
current URL from `PUBLIC_HOSTNAME` and the path (never from the Host header), and calls
`authorizationCodeGrant` with the verifier, `expectedState` and `expectedNonce`.
4. Claims: the ID token's, then **always** the userinfo endpoint's when the provider has one
(`fetchUserInfo` with the ID token's `sub` as the expected subject), merged, userinfo winning.
Several providers (Authelia, and Keycloak's mappers by default) put email or groups only in
userinfo, so one rule covers them all.
5.`applyClaims` (§3.4): allowed, role, groups.
6. Connecting (§3.3 step 3) → back to My account with "Connected". Signing in → find the user
(§3.3), make the session (`method = 'oidc'`, refresh and ID tokens kept, §3.5), set
`last_login_at` and `last_used_at`, audit `login` "… signed in with Keycloak", 302 to `next` or
the user's home.
Any failure: 302 to `/login?sso_error=<code>` (to `/me/account?sso_error=` when connecting), audit
`login_failed` with the reason, the provider's `error_description` in the log only:
| Code | When | Says |
|---|---|---|
| `expired` | no or bad `sp_oidc` (took over 10 min, or started in another tab) | "Sign-in took too long. Try again." |
| `denied` | the provider answered `error=access_denied` | "Sign-in was cancelled." |
| `not_allowed` | not in `allowedGroups` | "Your <label> account isn't allowed to use the phone system. Ask your admin." |
| `not_connected` | no user has this account, and none can be matched by email (§3.3) | "This <label> account isn't connected to a phone system account. Sign in with your password and connect it in My account, or ask your admin." |
| `taken` | connecting an account that's already someone else's | "This <label> account is already connected to another user." |
| `disabled` | the user is disabled here | "Your account is disabled." |
| `provider` | anything else (discovery, token endpoint, signature, clock) | "<label> sign-in didn't work. Your admin can see why in the log." |
Failed callbacks count in the IP login limiter (04 §2.2).
### 3.3 Finding the user, and connecting
Signing in, in this order:
1. A `user_identities` row (`oidc`, `"<iss> <sub>"`) → its user.
2. Else, a user with that email (case-insensitive) who has **no password and no OpenID account
yet** (made by an admin in `oidc` mode, or whose password an admin removed), **and the email is
verified** (`email_verified === true`, or `trustEmail`) → connect it now (step 3's row) and sign
in. Users who have a password are never matched by email: they connect from My account, so a
provider where people can set their own email can't hand over an account.
3. Else `not_connected`.
Connecting (from My account, already signed in, any mode but `password`):
- After the callback, the session's user gets the `user_identities` row (email and groups as
sent). Refused (`taken`) when that `iss sub` belongs to another user; a user who already has one
replaces it (confirm in the UI). Other sessions stay. Audit `sso_connected`.
- **Disconnect** (`DELETE /api/me/identities/oidc`; admins also on Users,
`DELETE /api/users/:id/identities/oidc`): ends that user's `oidc` sessions. Refused (409
`last_way_in`) when it would leave them no way to sign in in the current mode (no password, or
`oidc` mode and not an admin). Audit `sso_disconnected`.
- A user without a password in `mixed` mode can get one from an admin (Users → Reset password, as
today). Setting a password yourself without one: later, if asked.
`trustEmail` help text: "Turn on only if people can't change their own email address at <label>
(true for a single company's Keycloak with email editing off, or a single-tenant Entra ID)". The
issuer is pinned to one realm or tenant, so emails come from one admin's directory. It only
matters for step 2.
Users → Create: in `oidc` mode no temporary password is made (the person signs in by email, step
2); in the other modes as today.
### 3.4 Claims → allowed and role (`sso/claims.ts`, pure, unit-tested)
-`groupsClaim` is a dotted path into the merged claims. Its value becomes a string list: a string
→ `[s]`; an array → its strings; an object → its keys (Zitadel's
`urn:zitadel:iam:org:project:roles` is `{ role: { orgId: domain } }`). Path lookup: the whole
remaining path as one key first, else split on the first `.`, so a claim name with dots or
colons and `resource_access.selfphoneng.roles` both work.
- Group names compare exactly (case-sensitive): Keycloak with "Full group path" on sends
`/pbx-admins`, off sends `pbx-admins`; the admin types what their provider sends. They are stored
on the identity (`groups`), and Users → a user shows them, to copy from.
- Allowed: `allowedGroups` empty or any match.
- Role: `adminGroups` empty → unchanged; else admin when any matches.
| Keycloak (owner) | `https://kc.example.com/realms/<realm>` | client scope with a **Group Membership** mapper → `groups` (add to ID token and userinfo), or realm roles → `realm_access.roles` (turn on "Add to userinfo": off by default) | client: OpenID Connect, Client authentication on, Standard flow only; Valid redirect URIs and Valid post logout redirect URIs from Settings; `offline_access` (§3.5) | S3 |
| Zitadel | `https://<instance>` | `urn:zitadel:iam:org:project:roles` (object → keys) | turn on "Assert roles on authentication" | |
| Microsoft Entra ID | `https://login.microsoftonline.com/<tenant>/v2.0` | app roles → `roles` (group claims are GUIDs and stop at 200) | single tenant only (never `common`); no `email_verified`: see `trustEmail` | |
| Google Workspace | `https://accounts.google.com` | none | refresh tokens need `access_type=offline`: §3.5; no end-session endpoint | |
| Okta, Auth0, Kanidm, Pocket ID, Dex, GitLab, Rauthy, LemonLDAP::NG | per provider | per provider | Kanidm requires PKCE S256 and ES256 (both supported) | |
## 4. LDAP / LDAPS
Library: **ldapts** (MIT, TypeScript, maintained). Decision 0004. Its `escapeFilter` does RFC 4515
filter escaping.
The directory is one more way to check a **password**: it works in `password` and `mixed` mode for
everyone, and in `oidc` mode for admins only (§1.1).
### 4.1 Settings (`auth.ldap`)
```ts
{
enabled: boolean,
preset:'ad'|'openldap'|'freeipa'|'lldap'|'other',// fills the defaults below in the form only
url: string,// ldaps://dc1.example.com (636) or ldap://… with startTls
startTls: boolean,
bindDn: string|null,// service account; null = anonymous search
bindPasswordEnc: string|null,
baseDn: string,// where people are searched (subtree)
userFilter: string,// must contain {login}; who may sign in
idAttribute: string,// stable UUID attribute
emailAttribute: string,// default "mail"
nameAttribute: string,// default "displayName", falls back to "cn"
adminFilter: string|null,// matched against the user's own entry → admin. null = role managed here
}
```
Presets:
| Preset | `userFilter` | `idAttribute` | `adminFilter` example |
|---|---|---|---|
| Active Directory / Samba AD | `(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(\|(sAMAccountName={login})(userPrincipalName={login})(mail={login})))` | `objectGUID` (binary, read as hex) | `(memberOf:1.2.840.113556.1.4.1941:=CN=PBX Admins,OU=Groups,DC=example,DC=com)` (nested groups) |
match only without a password and only verified, `not_connected`, never a new user); the three
modes (§1.1: password refused for a user in `oidc` mode, allowed for an admin; start 404 in
`password` mode); Disconnect's `last_way_in`; the session check removing an `oidc` session on
`invalid_grant`, keeping it on 503 and leaving password sessions alone; logout's `redirect`; the
callback writing no `code=` to the log.
-`sso/ldap.test.ts`: filter building (`{login}` = `*)(uid=*))(|(uid=*` is escaped; `{login}` →
`*` for the check), empty password refused without a bind, `ldap://` to a remote host refused.
-`test/ldap-live.test.ts` against a real **lldap** container (`LDAP_LIVE_URL`, like
`contrack-live.test.ts`): sign-in by uid and by email, connect by email, no user → refused,
admin by `memberOf`, a removed user disabled by `directoryCheck`, LDAPS with a private CA through
`NODE_EXTRA_CA_CERTS`. AD presets checked once against a Samba AD DC container (nested groups,
disabled bit, `objectGUID`).
-`test/oidc-live.test.ts` against the owner's **Keycloak** (`OIDC_LIVE_ISSUER` + a test client and
user; a headless sign-in through Keycloak's login form): connect, sign-in, group → admin, signing
out at Keycloak, disabling the user in Keycloak ending the SelfPhoneNG session at the next check.
-`scripts/e2e/sso.mjs`: the login page per mode, the button and errors, in a browser.
## 7. Later (not in S1–S5)
- OIDC Back-Channel Logout.
- More than one OIDC provider; `private_key_jwt`.
- Setting your own password when you have none.
- LDAP group search for directories without `memberOf`; several directory URLs (failover).
- SCIM or a scheduled import of directory users (today an admin creates each user).
- Using the directory as the contacts book (11).
## 8. Order of work
| Task | What | Done when |
|---|---|---|
| S1 | Decision 0004; migration (§2); `auth.mode` (§1.1) with only `password` selectable; break glass; `GET /api/auth/methods`; the `login` field; Users "Signs in with" | `pnpm lint && typecheck && test` pass; sign-in works as before |
| S2 | OIDC: settings + Test, start/callback, claims (§3.4), §3.3 (connect, disconnect, email match), `mixed` and `oidc` modes, sign out at the provider, login page and My account, fake provider tests | every §6 OIDC case passes against the fake provider |
| S3 | OIDC session check (§3.5); Keycloak live test; admin guide page `docs/admin-guide/sign-in.md` with the §3.6 table | the owner connects their Keycloak account, signs in with it or their password, a Keycloak group makes them admin, signing out also signs out of Keycloak, and disabling them in Keycloak ends their Keycloak session here within 20 min |
| S4 | LDAP: settings + Test, presets, sign-in flow (§4.2), §4.3, unit + lldap live tests | sign-in by uid and email against lldap over LDAPS; admin by group; no user made for a stranger |
| S5 | `directoryCheck` (§4.5), dashboard warnings; Samba AD check | a user removed from the directory is disabled within an hour; AD nested-group admin works |
## 9. Decided
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
either their password or OpenID; the admin picks passwords only, OpenID only, or mixed (§1.1).
- Owner, 2026-10-25: no automatic accounts, from either provider (§1).
- Owner, 2026-10-25: keep the 15-minute session check (§3.5).
- Owner, 2026-10-25: signing out also signs out at the provider (§3.5).
- Default (not asked): in OpenID-only mode admins can still sign in with a password (break glass,
§1.1), and pre-made users without a password are matched once by verified email (§3.3).
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.