Files
selfphoneng/docs/spec/12-sso.md
T
iamdoubz 1db5aa22dc security: sign-in by email and connecting accounts (08 §7 #1 #2 #5 #8)
Matching by email skips admins and anyone with a connected account, in both
directions. A connected directory account is only swapped by an admin, and
an OpenID connect finishes only in the browser whose session asked.
2026-10-28 16:01:56 -05:00

42 KiB
Raw Blame History

12 — Single sign-on: OpenID Connect and LDAP

Owner, 2026-10-25: people can sign in to the web app (admin panel, user portal, browser softphone) with the company's identity provider as well as, or instead of, a SelfPhoneNG password:

  • OpenID Connect (S1–S3): "Sign in with Keycloak" on the login page. The owner's install uses Keycloak, but nothing here is Keycloak-only: any provider that follows OpenID Connect Core and Discovery works (§3.6 lists the ones checked).
  • LDAP / LDAPS (S4–S5): the normal password form, checked against the company directory (Active Directory, OpenLDAP, FreeIPA, lldap, Samba AD, 389 DS …) when the SelfPhoneNG password doesn't match.

A user connects their OpenID account to their SelfPhoneNG account and can then sign in either way: with their password or with the provider. The admin picks which ways are on (§1.1).

Only web sign-in changes. SIP passwords, voicemail PINs, desk phone provisioning, API tokens and the softphone's SIP login are untouched: a phone never talks to the identity provider.

1. Rules that hold for both

  • Identities: a user has a SelfPhoneNG password (or none), and at most one connected OpenID account and one directory account (user_identities, §2). Each is one more way to sign in to the same account; none replaces the others.
  • Who someone is at the provider is its stable id, never the email or name: OIDC iss + sub, LDAP the entry's UUID attribute (entryUUID, objectGUID, ipaUniqueID, nsUniqueId).
  • No automatic accounts (owner, 2026-10-25): signing in with a provider never creates a user. An admin creates every user on Users, as today; the provider only signs in users who exist.
  • Break glass: the last active admin with a password can't be deleted, disabled or demoted (extends assertNotLastAdmin), and admins can always sign in with a password, even in OpenID-only mode (§1.1). The identity provider being down never locks the owner out.
  • One OIDC provider and one directory per install. More than one OIDC provider: later (§7).
  • Role: when the provider's admin mapping is set (adminGroups, adminFilter), the role follows it on every sign-in and check through that provider (§3.4, §4.4), and Users shows it read-only (PATCH role → 409 role_managed). Unset: the role is managed in SelfPhoneNG as now. A sync never demotes or disables the last active admin with a password (it keeps them and logs a warning).
  • Extensions stay manual: an admin assigns one on Users.
  • Our own session: every sign-in makes the same sp_session cookie as today (04 §2.2, 14 days, sliding). sessions.method says how it was made. Provider tokens never reach the browser, except the ID token as id_token_hint in the sign-out redirect to the provider (§3.5; RP-initiated logout works that way).
  • Two-step sign-in (08 §2.4, owner 2026-10-25): password sign-ins ask for our TOTP code when it's on, whether the password was checked here or by the directory. OpenID sign-ins don't: MFA there is the provider's job.
  • Self-signed or private CA (a home-lab Keycloak or a domain controller): NODE_EXTRA_CA_CERTS (01 env list) names a PEM file mounted into the api container; Node then trusts it for both HTTPS and LDAPS. No "skip certificate check" switch anywhere.
  • The api runs with host networking (01), so a Keycloak or directory on the LAN is reachable as is. Both settings are admin-only, so the webhook private-address block (04 §9) doesn't apply.
  • Never logged: client secret, bind password, user passwords, authorization codes, ID/access/ 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 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):

alter table users alter column password_hash drop not null; -- null: no password (made in oidc mode)

create type identity_kind as enum ('oidc', 'ldap');
create table user_identities (
  id           uuid primary key default gen_random_uuid(),
  user_id      uuid not null references users(id) on delete cascade,
  kind         identity_kind not null,
  subject      text not null,        -- oidc: "<iss> <sub>"; ldap: the UUID attribute's value
  email        text,                 -- as the provider last said, shown in My account and Users
  groups       text[] not null default '{}', -- oidc: groupsClaim values at the last sign-in (§3.4)
  created_at   timestamptz not null default now(),
  last_used_at timestamptz,
  unique (kind, subject),
  unique (user_id, kind)
);

alter table sessions
  add column method text not null default 'password' check (method in ('password', 'ldap', 'oidc')),
  add column idp_refresh_enc  text,  -- oidc: refresh token, encrypt() (§3.5)
  add column idp_id_token_enc text,  -- oidc: ID token, encrypt(), for signing out there (§3.5)
  add column idp_checked_at   timestamptz;

Settings (02 §3), all admin-only, written by Settings → Sign-in, secrets write-only (has… in GET, an empty value keeps the stored one), like smtp:

Key Type Default Notes
auth.mode 'password' | 'mixed' | 'oidc' 'password' §1.1
auth.oidc object | null null §3.1
auth.ldap object | null null §4.1

UserDto and AuthUser (/api/auth/me) get signIn: { password: boolean, oidc: { email } | null, ldap: boolean }.

Public route for the login page: GET /api/auth/methods → { mode, ldap: boolean, oidc: { label } | null }. Nothing else about the setup is public.

3. OpenID Connect

Library: openid-client 6.x (panva, MIT; its two dependencies oauth4webapi and jose are the same author's, MIT). Decision 0004. It does discovery, PKCE, state and nonce checks, ID token signature and claim checks (RS/PS/ES/EdDSA), RFC 9207 iss checks, JWKS caching and key rotation, and builds the end-session URL. We don't hand-roll any of that.

3.1 Settings (auth.oidc)

{
  label: string,              // button text: "Sign in with <label>", e.g. "Keycloak"
  issuer: string,             // https URL; discovery reads <issuer>/.well-known/openid-configuration
  clientId: string,
  clientSecretEnc: string | null, // null: public client (PKCE only), for providers that allow it
  scopes: string,             // default "openid email profile"; add "groups", "offline_access" … per provider
  groupsClaim: string,        // dotted path, default "groups" (e.g. "realm_access.roles", "roles")
  adminGroups: string[],      // in groupsClaim → admin; others → user. [] = role managed here
  allowedGroups: string[],    // [] = anyone the provider signs in (who has an account here)
  trustEmail: boolean,        // default false: treat email as verified without email_verified (§3.3)
}
  • On or off is auth.mode (§1.1), not a flag here.
  • issuer must be https: (http://localhost… allowed for the fake provider only, NODE_ENV=test).
  • Discovery runs on first use and is cached in memory; a settings change or a failed discovery forgets it. Client authentication: client_secret_basic when the provider lists it, else client_secret_post, else none for a public client (openid-client ClientSecretBasic, ClientSecretPost, None). private_key_jwt: later, if asked.
  • Two fixed URIs, shown in Settings with Copy buttons, to register at the provider:
    • redirect URI https://${PUBLIC_HOSTNAME}/api/auth/oidc/callback;
    • post-logout redirect URI https://${PUBLIC_HOSTNAME}/login (§3.5).
  • Response mode is the default query (a form_post POST wouldn't carry our SameSite=Lax cookie).
  • Test (POST /api/settings/auth/oidc/test, from the unsaved form): discovery works, the issuer in the document equals issuer exactly, the token endpoint accepts the client (a deliberately bad authorization_code grant: invalid_grant means the client was accepted, invalid_client means it wasn't), and says which signing algorithms and whether PKCE, refresh tokens and an end-session endpoint are offered. The real check is the one Settings suggests next: connect your own account in My account.

3.2 Flow

  1. Login page (mixed, oidc): Sign in with , a plain link to /api/auth/oidc/start?next=<path> (same-site paths only, as next is today). My account: Connect posts POST /api/auth/oidc/connect ({ next? }, a session, the CSRF header) and sends the browser to the url it answers. A POST, so another site can't start a connect and attach whatever provider account the browser is signed in to (security audit, 2026-10-26).
  2. GET /api/auth/oidc/start: 404 in password mode. Makes state, nonce and a PKCE verifier (always all three, even when the provider advertises PKCE), stores them with next (and, to connect, the session id) in the cookie sp_oidc = encrypt(JSON); HttpOnly; Secure; SameSite=Lax; Path=/api/auth/oidc; Max-Age=600, and answers 302 to buildAuthorizationUrl (code_challenge_method=S256).
  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 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 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 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) " 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 is not an admin and has no password and no connected account of any kind 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, an admin, or someone who signs in through the directory 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 (08 §7 #2).
  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.
  • The callback must arrive in the browser whose session asked: its sp_session is the session in sp_oidc, else expired. A planted sp_oidc (from a sibling subdomain) can't connect the victim's provider account to someone else's user (08 §7 #8).
  • 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 (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.

3.5 Leavers and signing out

Session check (sso/oidcCheck.ts, job every 5 min, owner 2026-10-25: keep it). Our session lasts 14 days, so a person disabled in Keycloak must lose it sooner:

  • Sessions with method = 'oidc', a refresh token and idp_checked_at older than 15 min → refreshTokenGrant, store the new refresh and ID tokens when rotated, fetch userinfo with the new access token, run applyClaims on the new ID token's claims with userinfo's on top (as at sign-in: Entra's app roles are in the ID token only). No groups claim at all (the refresh brought no ID token and userinfo lacks it) → role and access unchanged this run; an empty one counts. The provider refuses (invalid_grant: user disabled, session revoked, refresh token expired) or applyClaims says not allowed → delete that session (the live socket closes within 30 s, 04 §6). Role, groups and email changes apply. The provider unreachable or 5xx → keep the session, try next run (an outage doesn't sign everyone out).
  • Only oidc sessions: password sessions of the same user are untouched (§1.1, mixed).
  • No refresh token offered (Google without access_type=offline, some setups) → no check: Settings → Sign-in says " gives no refresh token: someone disabled there stays signed in here until they sign out, their session ends, or you disable them on Users."
  • Keycloak note (admin guide): a normal refresh token dies with Keycloak's SSO session (Session Idle 30 min, Max 10 h by default). The 15-minute check keeps it from going idle, but at Session Max the user is signed out of SelfPhoneNG too. For people who keep the softphone open all week, add offline_access to scopes (Keycloak then issues an offline token, Offline Session Idle 30 days); disabling the user in Keycloak still revokes it.

Signing out at the provider too (owner, 2026-10-25: yes). POST /api/auth/logout on an oidc session, when the provider has an end_session_endpoint: delete our session as today, then answer { ok: true, redirect } with buildEndSessionUrl (id_token_hint from idp_id_token_enc, client_id, post_logout_redirect_uri = https://${PUBLIC_HOSTNAME}/login). The web client goes there; the provider signs out and sends the browser back to our login page. Password sessions and providers without the endpoint: no redirect, as today. The session check removing a session doesn't sign out at the provider (the provider is what said no). OIDC Back-Channel Logout: later.

3.6 Providers

Each row is "checked" only after a real sign-in, role mapping, the session check and signing out pass against it (06's verify rule). The others are expected to work; the admin guide gives the same table.

Provider Issuer Groups / roles Notes Checked
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
Authentik https://auth.example.com/application/o/<slug>/ groups (default profile scope)
Authelia https://auth.example.com groups scope → groups claims mostly in userinfo (handled, §3.2 step 4); needs offline_access for refresh tokens
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)

{
  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)
OpenLDAP (memberof overlay) (&(objectClass=inetOrgPerson)(|(uid={login})(mail={login}))) entryUUID (memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com)
FreeIPA (&(objectClass=person)(!(nsAccountLock=TRUE))(|(uid={login})(mail={login}))) ipaUniqueID (memberOf=cn=pbx-admins,cn=groups,cn=accounts,dc=example,dc=com)
lldap (&(objectClass=person)(|(uid={login})(mail={login}))) entryUUID (memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com)

To limit sign-in to one group, the admin adds (memberOf=…) to userFilter. Directories without memberOf on the user (plain OpenLDAP without the overlay, posixGroup) can't map groups yet: // TODO(spec) group search, if someone needs it (§7).

Rules, checked by PATCH /api/settings (400 with the reason):

  • No password in clear text over the network: ldap:// needs startTls, unless the host is 127.0.0.1/::1/localhost (a directory on the same machine).
  • userFilter contains {login}; userFilter and adminFilter parse as LDAP filters (ldapts).
  • Timeouts are fixed: connect 5 s, each operation 5 s. Not settings.

Test (POST /api/settings/auth/ldap/test, from the unsaved form): connect, StartTLS, bind the service account, count entries matching userFilter with {login} → * (at most 1,000), and with an optional test login (username + password, never stored or logged): the whole §4.2 flow without signing in, answering the DN, email, name, id, the SelfPhoneNG user it matches (or none) and the role it would get, or the step that failed.

4.2 Flow (POST /api/auth/login)

The form field becomes Email or username when LDAP is on; the body is { login, password, totp? } (email renamed to login, a string of 1–254; email is still accepted, so older scripts keep working).

  1. Rate limit as today, keyed by IP and login.
  2. A user with email = login who has a password, and it's right → that user (as today).
  3. Else, LDAP on → ldapAuthenticate:
    1. Empty password → fail before any bind (an LDAP simple bind with a DN and no password is an "unauthenticated bind" that many servers accept as anonymous success, RFC 4513 §5.1.2).
    2. Connect, StartTLS if set, bind the service account (or none).
    3. Search baseDn, subtree, userFilter with every {login} replaced by the escaped login (escapeFilter), attributes: id, email, name. Not exactly one entry → fail.
    4. Bind as that entry's DN with the password. Refused → fail. Any answer from the server is a refusal (wrong password, locked, expired: 389 DS and FreeIPA answer a locked account with 19 or 53, not 49); only no answer at all is "unavailable". Else locking someone's directory account would turn their sign-in into a 503.
    5. adminFilter set → search base = that DN, scope base, filter adminFilter, with the service connection: one entry → admin, none → user.
    6. Unbind and close (one connection per sign-in, no pool: a few sign-ins a day). Success → find the user (§4.3), copy name and role.
  4. Mode check (§1.1): oidc mode and not an admin → 403 password_sign_in_off.
  5. TOTP if they turned it on, then the session (method = password or ldap), login audited ("… signed in with the directory" for LDAP).
  6. Anything else → 401 "Wrong email or password", as today (no hint whether the account exists). The directory unreachable → 503 directory_unavailable "The directory can't be reached. Passwords set in SelfPhoneNG still work." and a dashboard warning. The 503 is counted and audited as a failed sign-in first: else, while the directory is down, a wrong local password (503) and a right one (200) would be free unlimited guesses.

A user can have both a SelfPhoneNG password and a directory account: either password works.

4.3 Finding the user

  1. A user_identities row (ldap, id) → its user. Their email here follows the directory's when it changes (unless another user has it: kept, logged).
  2. Else, a user with the directory email who is not an admin, has no password or only the temporary one from Users (must_change_password) and no connected account of any kind (an OpenID user's account is theirs, 08 §7 #1) → connect it (add the row) and sign in, audit ldap_connected. Anyone else connects on My settings → Sign-in → Connect directory account (POST /api/me/identities/ldap, { login, password }, checked by the directory and counted by the login limiter; 409 taken when another user has it, 409 connected_other when they already have a different one: an admin disconnects it first, 08 §7 #5). Security audit, 2026-10-26: a directory where people can edit their own mail (common OpenLDAP access rules) must not hand out the admin's account.
  3. Else → 401 "Wrong email or password" (no account is made, §1), with login_failed audited as "directory account … has no phone system account". The admin's Test shows the same answer.
  4. No email on the entry → same 401, logged.

Users → a user shows the directory account and Disconnect (same rules as §3.3). Only an admin disconnects a directory account (DELETE /api/me/identities/ldap → 403 admin_only for a user): it's what the leaver check (§4.5) follows, so a leaver mustn't be able to drop it on the way out.

4.4 Ids

objectGUID is binary: ldapts explicitBufferAttributes: ['objectGUID'], stored as lowercase hex, searched back with the Buffer through escapeFilter (it escapes bytes). Text ids are stored as is.

4.5 Leavers: directoryCheck (job, hourly)

For every active user with a directory account, one connection per run:

  • Search baseDn for (&(<idAttribute>=<id>)<userFilter with {login} → *>). Replacing {login} with * keeps every other condition of the filter (the group, AD's disabled bit, FreeIPA's lock) and makes the login parts presence tests.
  • Found → update the identity's email and the role (adminFilter); the user's own name and email stay as an admin set them (§8.4). Gone → the user is disabled (is_active = false, sessions deleted, as an admin's disable), audit directory_disabled "… is no longer in the directory". The directory account is the company's record of the person, so it disables the whole user, password and OpenID account included.
  • The directory unreachable or the bind refused → change nothing, warn on the dashboard (directory_check). The run would disable more than half of these users (and at least 3) → change nothing, warn: a broken filter or base DN is likelier than a mass layoff.
  • It never re-enables: an admin does that on Users (they may have disabled the person themselves).

5. UI (05)

  • Login (/login): by mode (§1.1): the button, the form, or both; "Email or username" when LDAP is on; in oidc mode the Admin sign-in link; sso_error messages (§3.2); password_sign_in_off says "Use Sign in with ".
  • Sign out: follows redirect when the answer has one (§3.5).
  • Settings → Sign-in (admin, new tab): the mode (§1.1, with the mixed warning and the "N people can't sign in yet" list for oidc), then two cards, OpenID Connect and Directory (LDAP), each with the §3.1 / §4.1 fields, presets, Test, the two URIs with Copy (OIDC), the warnings of §3.5 and §4.5, and a link to the admin guide page.
  • Users: a Signs in with column (Password, , Directory: any of them); a user's drawer shows their connected accounts (email, groups, last used) with Disconnect; role read-only with "From " when mapped.
  • My account → Sign-in: password (Change password, two-step when they have a password), the OpenID account (Connect / its email and Disconnect, in mixed and oidc), the directory account (read-only).

6. Tests

  • sso/claims.test.ts: dotted paths (Keycloak realm_access.roles, resource_access.x.roles, Zitadel's urn:… object), allow and role decisions, trustEmail.
  • sso/oidc.test.ts against a fake provider (test/fake-oidc.ts: node:http + node:crypto, RS256 and ES256 keys, discovery, JWKS, authorize redirect, token, userinfo, refresh, end session; no new dependency): a full sign-in; bad state, nonce, issuer, audience, expired token, wrong signature each refused; userinfo sub mismatch refused; §3.3 (connect, taken, email 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. Done, see §8.1
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. Done, see §8.2
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. Done (against a throwaway Keycloak 26.8.1), see §8.3; the owner's own Keycloak still to try
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. Done, see §8.4
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. Done, see §8.5

8.1 As built: S1

  • Migration 0012: users.password_hash nullable, user_identities, the sessions columns (§2).
  • auth.mode exists; PATCH /api/settings refuses anything but password until S2 adds the OIDC check. POST /api/auth/login already refuses non-admin passwords when it's oidc.
  • signIn on UserDto and AuthUser; Users has a Signs in with column ("OpenID" until S2 gives it the provider's label).
  • apps/api/test/sign-in.test.ts.

8.2 As built: S2

  • sso/oidc.ts (discovery cached per settings value, client auth, start/finish, end session, Test), sso/claims.ts (pure), routes/oidc.ts (start, callback, both Disconnect routes).
  • ID token signatures are checked (enableNonRepudiationChecks): openid-client by default trusts TLS to the token endpoint instead (OIDC Core §3.1.3.7 allows it), and the fake provider's "bad signature" test caught that. Asymmetric algorithms only: a provider signing ID tokens with HS256 (the client secret) is refused. Keycloak, Authentik, Authelia and the others default to RS256 or ES256.
  • client_secret_basic form-encodes the id and secret before base64 (RFC 6749 §2.3.1: - → %2D); providers decode it (Keycloak does: the live test's secret has -+/=). If one doesn't, a secret with symbols fails with invalid_client: fall back to client_secret_post then, if it's ever seen.
  • Callback failures count against the IP only (isLoginBlocked(ip, null)); one more code, too_many.
  • must_change_password doesn't block OpenID sessions (guards and the live socket both use mustChange()): it's about the password, which they didn't use.
  • signIn.oidc carries the groups too, shown in the Users drawer to copy into Settings.
  • The callback's own log line has no query; there is no automated check that Fastify's request log skips it (logLevel: 'silent', as the caller-name route).
  • Web: Settings → Sign-in, the login page button and sso_error messages, Admin sign-in in oidc mode, a Sign-in card on My account and My settings (connect, disconnect), the Users drawer's connected account, sign out following redirect.
  • apps/api/test/oidc.test.ts (every case with RS256 and with ES256), fake-oidc.ts.
  • Not yet seen in a browser against a real provider: that's S3 with the owner's Keycloak.

8.3 As built: S3

  • sso/oidcCheck.ts: checkOidcSessions (job oidcCheck, every 5 minutes, sessions last checked over 15 minutes ago) and applyClaims, which the callback uses too. Refresh, then userinfo with the new access token (expected subject: the identity's sub), then the same allowed/role rules as a sign-in. invalid_grant ends the session; anything else (down, 5xx, invalid_client) keeps it for the next run. Rotated refresh and ID tokens are stored.
  • apps/api/test/oidc-live.test.ts (KEYCLOAK_LIVE_URL, KEYCLOAK_LIVE_ADMIN): makes a throwaway realm (client with a Group Membership mapper, group, user), signs in through Keycloak's own login form, and deletes the realm. Passed against Keycloak 26.8.1 on 2026-10-25: Test, connect, sign-in, group → admin, signing out there (302 back to /login, and Keycloak asks for the password again), disabling the user ending our session at the next check.
  • NODE_EXTRA_CA_CERTS in .env.example and 01 §1.2: the PEM goes in data/tls/ (already mounted read-only into the api), so no compose change.
  • Admin guide: docs/admin-guide/sign-in.md. There is no audit log page yet, so failures are found in the api log (OpenID sign-in failed, reason).

8.4 As built: S4

  • sso/ldap.ts (ldapts 9.2: assertLdap, withLogin through escapeFilter, anyLogin, ldapAuthenticate, testLdap), sso/directory.ts (entry → user, connecting by email), sso/role.ts (applyRole, shared with OpenID). The user's own bind is on a second connection, so the admin filter is read with the service account.
  • Login (§4.2): the SelfPhoneNG password first; when it isn't that, the directory. The "last active local admin" exception of the first draft is gone: connecting a directory account no longer removes a password (§1), so there is nothing to protect. Sessions are method = 'ldap'.
  • The user's name and email here are not changed by the directory (as with OpenID): only the identity's email and the role (adminFilter) follow it. §4.3 step 1's "email follows" is dropped; an admin edits users on Users.
  • lldap answers a base search whose filter doesn't match the entry with noSuchObject (0x20), not an empty result: that counts as "not admin".
  • Disconnect is DELETE /api/me/identities/:kind and /api/users/:id/identities/:kind (oidc or ldap), with one "is there still a way in" rule for both.
  • apps/api/src/sso/ldap.test.ts, apps/api/test/ldap-live.test.ts (LDAP_LIVE_URL, LDAP_LIVE_HTTP, LDAP_LIVE_LDAPS_URL; users made through lldap's GraphQL, passwords with an RFC 3062 password modify). Passed against lldap 0.6.3 on 2026-10-25, LDAPS with a throwaway CA through NODE_EXTRA_CA_CERTS. The AD, FreeIPA and OpenLDAP presets were not checked in S4 (AD: §8.5).
  • Web: Settings → Sign-in → Directory (presets, Test with an optional test login), "Email or username" on the login page, the directory account on My settings and the Users drawer.

8.5 As built: S5

  • checkDirectory in sso/directory.ts (job directoryCheck, hourly, jobs.ts), lookupIds in sso/ldap.ts: one connection, one search per id. Any failure in the run (connect, bind, a wrong base DN's noSuchObject) changes nothing, so a half-done run never reads as "gone".
  • The by-id filter is a filter object, not a string. ldapts parses f in a string filter to U+00FF and sends it as UTF-8 (two bytes), so AD's binary objectGUID never matched: every connected AD user looked gone, and the "more than half" guard is what stopped it disabling them all (the Samba check caught it). EqualityFilter with a Buffer value goes out as is; the rest is FilterParser.parseString(userFilter with {login} → *). Sign-in never searches by id, so S4 was unaffected.
  • Dashboard warning directory_check (04 §7.3): setting system.directoryWarning (read-only, set by the system), written by a sign-in's directory_unavailable and by the job, cleared by a clean run. Settings → Sign-in → Directory shows it too, with the hourly check's note.
  • The last active admin with a password is kept (logged) when gone, as applyRole keeps them.
  • test/directory-check.test.ts (a fake lookup: update, disable, down, the half guard, last admin, directory off), test/ad-live.test.ts (AD_LIVE_URL, AD_LIVE_ADMIN_PASS; users and a nested group made over LDAPS): passed against Samba 4 (diegogslomp/samba-ad-dc) on 2026-10-25, LDAPS with a throwaway CA: sign-in by sAMAccountName, UPN and mail, admin through a nested group, the disabled bit refused, objectGUID as hex, a deleted user disabled. ldap-live.test.ts gained a removed user disabled by the check. The OpenLDAP and FreeIPA presets are still unchecked against a real server.

8.6 Security audit, 2026-10-26

Two separate reviews (one by an independent agent) of the whole branch and every API route. All 207 routes were called signed out and as a plain user: each is guarded as meant. Fixed, each with a test:

  • Login: a directory 503 is counted and audited as a failed sign-in (§4.2); a locked directory account is a refused password, not "unavailable" (passwordWorks: any ResultCodeError).
  • next after OpenID sign-in: no control character or backslash (safeNext): "//evil" was an open redirect, browsers drop the tab.
  • Directory email matching only for non-admins without a password of their own (§4.3); others connect on My settings (POST /api/me/identities/ldap). Only admins disconnect a directory account (§4.3, §4.5).
  • Session check: the refreshed ID token's claims under userinfo's (§3.5).
  • Connect is POST /api/auth/oidc/connect (§3.2), not a GET.
  • A stored bind password or client secret is only reused for the same URL and bind DN, or issuer and client ID (400 "enter it again" otherwise, PATCH and Test); SMTP's password is dropped when the host or username changes.
  • PATCH /api/users/:id role → 409 role_managed when a provider sets it (§1); UserDto.roleFrom, the Users drawer shows "From …" read-only.
  • @fastify/rate-limit on every route (04 §2.2), stricter on Test buttons; a password asked again shares the login limiter. PATCH /api/me/contrack with nothing to set: 400, not 500.

Left as is: directory usernames can be told apart by timing (one more bind when the entry exists); the ID token goes to the provider as id_token_hint on sign-out (§1).

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).
  • Owner, 2026-10-25: password sign-ins, directory ones included, keep our two-step sign-in (§1).
  • 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).