Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was guarded as meant. Fixed, each with a test: - login: a directory 503 counts as a failed sign-in; a locked directory account is a refused password, not "unavailable" - open redirect after OpenID sign-in (a tab in next) - directory email matching only for non-admins without their own password; others connect on My settings (POST /api/me/identities/ldap); only admins disconnect a directory account - OpenID session check reads the refreshed ID token's claims too - connect is POST /api/auth/oidc/connect (CSRF header) - stored bind password, client secret and SMTP password tied to their server - 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400 - @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a password asked again shares the login limiter Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
11 KiB
Sign-in: passwords, OpenID Connect and LDAP
People sign in to SelfPhoneNG (the admin pages, their own portal and the browser phone) with a password, with your company's identity provider through OpenID Connect, or either. This only changes the web sign-in: desk phones, phone apps and the browser phone's line keep their own SIP passwords and never talk to the identity provider.
Any provider that speaks OpenID Connect works. This page uses Keycloak; the table at the end says what is different for the others. A company directory (Active Directory, OpenLDAP, FreeIPA, lldap …) can check passwords too: see Directory (LDAP).
How it works
-
You still make every user on Users. Signing in with the provider never creates an account.
-
Each person connects their provider account once, in My settings (users) or My account (admins): Connect Keycloak, sign in at Keycloak, done. From then on they sign in with Sign in with Keycloak or with their password.
-
Settings → Sign-in → How people sign in picks what the login page offers:
Choice Login page Who can use a password Passwords the password form everyone Passwords or OpenID the button and the form everyone OpenID only the button, and a small Admin sign-in link admins only Admins keep their password in every mode so you can still get in when the provider is down. SelfPhoneNG also won't let you delete, disable or demote the last admin who has a password.
-
Someone disabled in Keycloak loses their Keycloak sign-in to SelfPhoneNG within about 20 minutes: every 15 minutes SelfPhoneNG asks Keycloak whether each Keycloak session is still good. With Passwords or OpenID, their SelfPhoneNG password still works until you disable them on Users too. With OpenID only, Keycloak alone decides.
-
Sign out in SelfPhoneNG also signs out of Keycloak, when the session came from Keycloak.
-
Two-step sign-in (the authenticator app code) is asked for password sign-ins only. Keycloak sign-ins use whatever Keycloak asks for (set up OTP or passkeys there).
1. In Keycloak
-
Pick the realm your people are in (or make one). The issuer is
https://<keycloak>/realms/<realm>. -
Clients → Create client:
- Client type OpenID Connect, Client ID
selfphoneng. - Client authentication: On. Authentication flow: Standard flow only (turn off Direct access grants).
- Valid redirect URIs:
https://pbx.example.com/api/auth/oidc/callback - Valid post logout redirect URIs:
https://pbx.example.com/login
(Use your own host name. Settings → Sign-in shows both with Copy buttons.)
- Client type OpenID Connect, Client ID
-
Credentials tab of the client: copy the Client secret.
-
To make admins from a group (optional): Groups → Create group
pbx-adminsand put your admins in it. Then the client's Client scopes tab →selfphoneng-dedicated→ Add mapper → By configuration → Group Membership: namegroups, Token Claim Namegroups, Full group path off, and Add to ID token, Add to access token and Add to userinfo on. -
People who keep the browser phone open all week: Keycloak ends a normal session after 10 hours (Realm settings → Sessions → SSO Session Max), and SelfPhoneNG signs them out then too. To keep them signed in, add
offline_accessto the Scopes in step 2 below. Disabling the user in Keycloak still signs them out.
2. In SelfPhoneNG
Settings → Sign-in:
- Issuer
https://keycloak.example.com/realms/company, Client IDselfphoneng, Client secret from step 1.3. Button nameKeycloak(shown as "Sign in with Keycloak"). - Scopes
openid email profile(addoffline_access, see 1.5). - Admin groups
pbx-adminsif you did step 1.4: members become admins at each sign-in, and everyone else becomes a user. Leave it empty to set roles on Users yourself. Allowed groups: leave empty, or name the groups whose members may sign in. - Test. It should say "Works", with refresh tokens and "signs out there too".
- Pick Passwords or OpenID and Save.
- My account → Connect Keycloak, then sign in from a private window with Sign in with Keycloak. If a group should have made you admin and didn't, Users → your user shows the groups Keycloak sent: type them exactly like that.
Once everyone has connected (Users shows "Signs in with" for each person), you can switch to OpenID only. People you add from then on get no password: they sign in with Keycloak, and SelfPhoneNG matches them by their verified email the first time.
Keycloak with a self-signed or private certificate
Put the CA certificate (PEM) in data/tls/idp-ca.pem, add this to .env, and restart the api
(docker compose up -d api):
NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem
There is no "don't check the certificate" switch.
When it doesn't work
The login page says what went wrong in plain words. The reason Keycloak gave is in the api's log:
docker compose logs api | grep "OpenID sign-in failed" (the reason field).
| Message | Fix |
|---|---|
| "…isn't connected to a phone system account" | They sign in with their password and Connect Keycloak first. Or: their SelfPhoneNG user has no password and Keycloak's email for them isn't verified (Keycloak → Users → the user → Email verified). |
| "…already connected to another user" | Someone else connected that Keycloak account. Disconnect it on Users → that user. |
| "…isn't allowed to use the phone system" | Not in Allowed groups. |
"…sign-in didn't work" with invalid_client |
Client ID or secret wrong, or Client authentication off. |
…with redirect_uri |
The Valid redirect URI in Keycloak doesn't match exactly (https, host, path). |
| …with an issuer mismatch | The issuer must be exactly what Keycloak's /.well-known/openid-configuration says, including /realms/<realm>. |
| Test: "fetch failed" / certificate errors | The server can't reach Keycloak, or doesn't trust its certificate (see above). |
| Signed out after 10 hours | Add offline_access to Scopes (1.5). |
Other providers
| Provider | Issuer | Groups claim | Notes |
|---|---|---|---|
| Authentik | https://auth.example.com/application/o/<slug>/ (keep the last /) |
groups |
|
| Authelia | https://auth.example.com |
groups (add the groups scope) |
add offline_access for the 15-minute check |
| Zitadel | https://<instance> |
urn:zitadel:iam:org:project:roles |
turn on "Assert roles on authentication" |
| Microsoft Entra ID | https://login.microsoftonline.com/<tenant id>/v2.0 |
roles (use app roles; group claims are IDs) |
Entra doesn't send "email verified": turn on Trust email addresses only for your own single tenant |
| Google Workspace | https://accounts.google.com |
none | no sign-out endpoint and no refresh tokens by default: people disabled in Google stay signed in until they sign out or you disable them on Users |
| Okta, Auth0, Kanidm, Pocket ID, Dex, GitLab, Rauthy | as the provider documents | as the provider documents | expected to work, not tested yet |
Keycloak (26.8.1) is the one SelfPhoneNG is tested against.
Directory (LDAP)
With a directory, people sign in with their directory username or email and password. When the password isn't the one set in SelfPhoneNG, SelfPhoneNG asks the directory. Nobody gets a user just for being in the directory, and their SelfPhoneNG password, if they have one, keeps working too.
The first time, the directory account is connected to their user here:
- New users (still on the temporary password from Users, or with none): automatically, by the directory's email.
- Everyone else, and every admin: they sign in with their SelfPhoneNG password once, then My settings (admins: My account) → Sign-in → Connect directory account with their directory username and password. An email alone never connects an admin or someone who chose their own password, so a directory that lets people edit their own email can't hand out an account.
Only an admin can disconnect a directory account (Users → the user → Sign-in): it's what the hourly leaver check follows.
Settings → Sign-in → Directory (LDAP):
- Directory type: Active Directory, OpenLDAP, FreeIPA or lldap. It fills in the filters.
- Address:
ldaps://dc1.example.com(port 636), orldap://…with StartTLS. A plainldap://address is refused unless the directory runs on this same server, because passwords would cross the network in clear text. For a private CA, see the certificate section (same file). - Service account DN and password: an account that may search people, e.g.
CN=pbx-search,OU=Service,DC=example,DC=com. Empty searches anonymously. - Where people are: the base DN, e.g.
DC=example,DC=comorou=people,dc=example,dc=com. - User filter:
{login}is what they type. Only one group may sign in? Add(memberOf=CN=Phone users,OU=Groups,DC=example,DC=com)inside the(&…). - Admin filter: people it matches are admins at each sign-in, the others users, e.g.
(memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com). Active Directory's preset usesmemberOf:1.2.840.113556.1.4.1941:=so nested groups count. Empty: you set roles on Users. - Test, with a test login and its password: it says how many people the filter matches, and who that login would sign in as here.
- Tick Check passwords against the directory and Save.
Leavers
Every hour, SelfPhoneNG looks up each user who has signed in with the directory. Someone deleted from it, disabled there (Active Directory's disabled bit, FreeIPA's lock) or no longer matched by the user filter (removed from the group you added to it) is disabled here: their password and OpenID sign-in stop working too, and they're signed out. The role follows the admin filter in the same run. Nobody is enabled again automatically: do that on Users.
If a run would disable more than half of these people (and at least 3), it disables nobody and the dashboard says so: a wrong base DN or filter is likelier than everyone leaving. The last admin who has a password is never disabled.
If the directory is down, people get "The directory can't be reached", passwords set in
SelfPhoneNG still work, and the dashboard shows a warning until the next hourly check reaches it.
Directories without memberOf on people (OpenLDAP without the memberof overlay) can't map groups
yet. Tested against lldap 0.6.3 and Samba 4 Active Directory (nested groups, the disabled bit).