From ba293f87033172a4c50f0ba382366a375f9cee8c Mon Sep 17 00:00:00 2001 From: iamdoubz Date: Sun, 25 Oct 2026 13:09:45 -0500 Subject: [PATCH 1/7] spec: single sign-on (12), OpenID Connect and LDAP Owner decisions 2026-10-25: a user connects their OpenID account and signs in with either it or their password; the admin picks passwords only, OpenID only (admins keep a password way in), or mixed. No automatic accounts. OIDC sessions re-checked every 15 min with the refresh token; signing out also signs out at the provider; password sign-ins keep two-step. LDAP is one more way to check a password. Decision 0004: openid-client and ldapts. Tasks S1-S5. --- AGENTS.md | 1 + docs/decisions/0004-oidc-ldap.md | 11 + docs/spec/00-overview.md | 2 + docs/spec/09-roadmap.md | 4 +- docs/spec/12-sso.md | 467 +++++++++++++++++++++++++++++++ 5 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0004-oidc-ldap.md create mode 100644 docs/spec/12-sso.md diff --git a/AGENTS.md b/AGENTS.md index 58d0544..210f7e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ write the software around it: database, config generator, API, web UI. | `docs/spec/08-security-ops.md` | Security, backups, monitoring, legal requirements | Before any release, and when touching auth or networking | | `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task | | `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names | +| `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles | ## Hard rules diff --git a/docs/decisions/0004-oidc-ldap.md b/docs/decisions/0004-oidc-ldap.md new file mode 100644 index 0000000..c587730 --- /dev/null +++ b/docs/decisions/0004-oidc-ldap.md @@ -0,0 +1,11 @@ +# 0004 — openid-client and ldapts for single sign-on + +Owner, 2026-10-25: sign-in with an OpenID Connect provider and with an LDAP directory (spec 12). +Both protocols are security code we must not write ourselves: OIDC needs PKCE, state/nonce, ID +token signature and claim checks, `iss` response checks and JWKS rotation; LDAP needs BER encoding, +StartTLS and RFC 4515 filter escaping. **openid-client** 6.x (panva, MIT) is the reference +certified OpenID relying party library for Node; its only dependencies, `oauth4webapi` and `jose`, +are the same author's and MIT. **ldapts** (MIT, TypeScript, promise API) is the maintained LDAP +client (`ldapjs` was decommissioned in 2024) and has `escapeFilter`. Both are open source with no +native code. No OIDC test server is added: the tests use a small fake provider on `node:http` +and `node:crypto`, and live tests run against a real Keycloak and lldap. diff --git a/docs/spec/00-overview.md b/docs/spec/00-overview.md index 576c995..a34a217 100644 --- a/docs/spec/00-overview.md +++ b/docs/spec/00-overview.md @@ -161,6 +161,8 @@ AstDB keys (family/key). This list is complete; do not add keys without updating | Multipart uploads | @fastify/multipart | latest | | Rate limiting | @fastify/rate-limit | latest | | Cookies | @fastify/cookie | latest (decisions/0002) | +| OpenID Connect client | openid-client | 6.x (decisions/0004) | +| LDAP client | ldapts | latest (decisions/0004) | | PostgreSQL driver | pg | latest (decisions/0002) | | QR codes | qrcode | latest | | Logging | pino | latest | diff --git a/docs/spec/09-roadmap.md b/docs/spec/09-roadmap.md index b905dc7..317ebe3 100644 --- a/docs/spec/09-roadmap.md +++ b/docs/spec/09-roadmap.md @@ -9,7 +9,9 @@ Legend: 📖 read · 🔨 build · ✅ done when --- ## Backlog (owner's order, 2026-10-21) -0. **Contacts** (owner, 2026-10-23: needed next), tasks C1–C6 in [11 §6](11-contacts.md#6-order-of-work): +0. **Single sign-on** (owner, 2026-10-25: next), tasks S1–S5 in [12 §8](12-sso.md#8-order-of-work): + OpenID Connect (any provider; the owner uses Keycloak), then LDAP/LDAPS. +0. Done: **Contacts** (owner, 2026-10-23), tasks C1–C6 in [11 §6](11-contacts.md#6-order-of-work): Simple (builtin phonebook) or Robust (Contrack), picked in the setup wizard. The owner uses Contrack. 1. **Grandstream HT802 V2 set up automatically** (step 3 of diff --git a/docs/spec/12-sso.md b/docs/spec/12-sso.md new file mode 100644 index 0000000..03d6abe --- /dev/null +++ b/docs/spec/12-sso.md @@ -0,0 +1,467 @@ +# 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. +- **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