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 ** 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
+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: " "; 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`)
+
+```ts
+{
+ label: string, // button text: "Sign in with ", e.g. "Keycloak"
+ issuer: string, // https URL; discovery reads /.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=` (same-site paths only, as `next` is today).
+ My account: **Connect **, a link to `/api/auth/oidc/start?connect=1` (needs a session).
+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=` (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`, `" "`) → 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
+(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`. 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/` | 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//` | `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://` | `urn:zitadel:iam:org:project:roles` (object → keys) | turn on "Assert roles on authentication" | |
+| Microsoft Entra ID | `https://login.microsoftonline.com//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) |
+| 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; web and API change
+together, no outside client signs in with a password).
+
+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.
+ 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.
+
+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 → connect it (add the row) and sign in, audit
+ `ldap_connected`. The directory is the company's own and the person just proved its password,
+ so its email is trusted, unlike an OpenID provider's (§3.3).
+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).
+
+### 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 `(&(=))`. 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 email, name, and role (`adminFilter`). 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 |
+| 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).
+- 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).
--
2.34.1
From 3a9423af1fd941c36878268d59dd3158ace2456b Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Sun, 25 Oct 2026 13:30:58 -0500
Subject: [PATCH 2/7] auth: sign-in ways, sign-in mode, and the admin who keeps
a password (S1)
Spec 12 S1: migration 0012 (password_hash nullable, user_identities, sessions.method and the
OpenID token columns), auth.mode (only "password" until S2), GET /api/auth/methods, login body
{login, password} (email still accepted), signIn on users and /api/auth/me, a Signs in with
column on Users. The last active admin with a password can't be deleted, disabled or demoted.
---
apps/api/src/auth/sessions.ts | 2 +
apps/api/src/db/migrations/0012_sso.sql | 21 +
.../src/db/migrations/meta/0012_snapshot.json | 4487 +++++++++++++++++
apps/api/src/db/migrations/meta/_journal.json | 7 +
apps/api/src/db/schema.ts | 37 +-
apps/api/src/routes/auth.ts | 63 +-
apps/api/src/routes/settings.ts | 4 +
apps/api/src/routes/users.ts | 51 +-
apps/api/test/schema.test.ts | 6 +-
apps/api/test/sign-in.test.ts | 110 +
apps/web/src/api/client.ts | 4 +-
apps/web/src/pages/LoginPage.tsx | 12 +-
apps/web/src/pages/admin/UsersPage.tsx | 11 +-
docs/spec/02-data-model.md | 25 +-
docs/spec/04-backend-api.md | 5 +
docs/spec/12-sso.md | 15 +-
packages/shared/src/auth.ts | 26 +-
packages/shared/src/settings.ts | 2 +
packages/shared/src/users.ts | 2 +
19 files changed, 4847 insertions(+), 43 deletions(-)
create mode 100644 apps/api/src/db/migrations/0012_sso.sql
create mode 100644 apps/api/src/db/migrations/meta/0012_snapshot.json
create mode 100644 apps/api/test/sign-in.test.ts
diff --git a/apps/api/src/auth/sessions.ts b/apps/api/src/auth/sessions.ts
index 5aa617a..4c1e5a5 100644
--- a/apps/api/src/auth/sessions.ts
+++ b/apps/api/src/auth/sessions.ts
@@ -32,6 +32,7 @@ export async function createSession(
userId: string,
ip: string | null,
userAgent: string | null,
+ method: 'password' | 'ldap' | 'oidc' = 'password',
): Promise {
const token = randomBytes(32).toString('base64url');
await db.insert(sessions).values({
@@ -40,6 +41,7 @@ export async function createSession(
expiresAt: new Date(Date.now() + LIFETIME),
ip,
userAgent: userAgent?.slice(0, 500) ?? null,
+ method,
});
return token;
}
diff --git a/apps/api/src/db/migrations/0012_sso.sql b/apps/api/src/db/migrations/0012_sso.sql
new file mode 100644
index 0000000..29c0ad7
--- /dev/null
+++ b/apps/api/src/db/migrations/0012_sso.sql
@@ -0,0 +1,21 @@
+CREATE TYPE "public"."identity_kind" AS ENUM('oidc', 'ldap');--> statement-breakpoint
+CREATE TABLE "user_identities" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "user_id" uuid NOT NULL,
+ "kind" "identity_kind" NOT NULL,
+ "subject" text NOT NULL,
+ "email" text,
+ "groups" text[] DEFAULT '{}' NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "last_used_at" timestamp with time zone,
+ CONSTRAINT "user_identities_kind_subject_unique" UNIQUE("kind","subject"),
+ CONSTRAINT "user_identities_userId_kind_unique" UNIQUE("user_id","kind")
+);
+--> statement-breakpoint
+ALTER TABLE "users" ALTER COLUMN "password_hash" DROP NOT NULL;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "method" text DEFAULT 'password' NOT NULL;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "idp_refresh_enc" text;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "idp_id_token_enc" text;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "idp_checked_at" timestamp with time zone;--> statement-breakpoint
+ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "sessions" ADD CONSTRAINT "sessions_method_check" CHECK ("sessions"."method" in ('password', 'ldap', 'oidc'));
\ No newline at end of file
diff --git a/apps/api/src/db/migrations/meta/0012_snapshot.json b/apps/api/src/db/migrations/meta/0012_snapshot.json
new file mode 100644
index 0000000..95d5f3f
--- /dev/null
+++ b/apps/api/src/db/migrations/meta/0012_snapshot.json
@@ -0,0 +1,4487 @@
+{
+ "id": "92199db9-7d77-4ea4-8cc5-fe79a0d702e5",
+ "prevId": "e22c65f0-c8ee-42c8-aa4f-667a4831350d",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.announcements": {
+ "name": "announcements",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sound_id": {
+ "name": "sound_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "destination": {
+ "name": "destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "announcements_sound_id_sound_files_id_fk": {
+ "name": "announcements_sound_id_sound_files_id_fk",
+ "tableFrom": "announcements",
+ "tableTo": "sound_files",
+ "columnsFrom": [
+ "sound_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_tokens": {
+ "name": "api_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_prefix": {
+ "name": "token_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "api_tokens_created_by_users_id_fk": {
+ "name": "api_tokens_created_by_users_id_fk",
+ "tableFrom": "api_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "api_tokens_tokenHash_unique": {
+ "name": "api_tokens_tokenHash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_log": {
+ "name": "audit_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_ip": {
+ "name": "actor_ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "diff": {
+ "name": "diff",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "audit_log_actor_user_id_users_id_fk": {
+ "name": "audit_log_actor_user_id_users_id_fk",
+ "tableFrom": "audit_log",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.blocked_numbers": {
+ "name": "blocked_numbers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "blocked_numbers_number_unique": {
+ "name": "blocked_numbers_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.call_extensions": {
+ "name": "call_extensions",
+ "schema": "",
+ "columns": {
+ "call_id": {
+ "name": "call_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "call_extensions_call_id_calls_id_fk": {
+ "name": "call_extensions_call_id_calls_id_fk",
+ "tableFrom": "call_extensions",
+ "tableTo": "calls",
+ "columnsFrom": [
+ "call_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "call_extensions_extension_id_extensions_id_fk": {
+ "name": "call_extensions_extension_id_extensions_id_fk",
+ "tableFrom": "call_extensions",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "call_extensions_call_id_extension_id_role_pk": {
+ "name": "call_extensions_call_id_extension_id_role_pk",
+ "columns": [
+ "call_id",
+ "extension_id",
+ "role"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "call_extensions_role_check": {
+ "name": "call_extensions_role_check",
+ "value": "\"call_extensions\".\"role\" in ('caller', 'rang', 'answered')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.calls": {
+ "name": "calls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "direction": {
+ "name": "direction",
+ "type": "call_direction",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "call_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "answered_at": {
+ "name": "answered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_number": {
+ "name": "from_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_number": {
+ "name": "to_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "did_id": {
+ "name": "did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "answered_by_extension_id": {
+ "name": "answered_by_extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "talk_seconds": {
+ "name": "talk_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "recording_id": {
+ "name": "recording_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "calls_started_at_index": {
+ "name": "calls_started_at_index",
+ "columns": [
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "calls_did_id_dids_id_fk": {
+ "name": "calls_did_id_dids_id_fk",
+ "tableFrom": "calls",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "calls_answered_by_extension_id_extensions_id_fk": {
+ "name": "calls_answered_by_extension_id_extensions_id_fk",
+ "tableFrom": "calls",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "answered_by_extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "calls_recording_id_recordings_id_fk": {
+ "name": "calls_recording_id_recordings_id_fk",
+ "tableFrom": "calls",
+ "tableTo": "recordings",
+ "columnsFrom": [
+ "recording_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "calls_linkedid_unique": {
+ "name": "calls_linkedid_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "linkedid"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cdr": {
+ "name": "cdr",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "uniqueid": {
+ "name": "uniqueid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "start_at": {
+ "name": "start_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "answer_at": {
+ "name": "answer_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "end_at": {
+ "name": "end_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration": {
+ "name": "duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "billsec": {
+ "name": "billsec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "src": {
+ "name": "src",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dst": {
+ "name": "dst",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dcontext": {
+ "name": "dcontext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "clid": {
+ "name": "clid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dstchannel": {
+ "name": "dstchannel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastapp": {
+ "name": "lastapp",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastdata": {
+ "name": "lastdata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disposition": {
+ "name": "disposition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "accountcode": {
+ "name": "accountcode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userfield": {
+ "name": "userfield",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sp_did": {
+ "name": "sp_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sp_direction": {
+ "name": "sp_direction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sp_recording": {
+ "name": "sp_recording",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "raw": {
+ "name": "raw",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "cdr_linkedid_index": {
+ "name": "cdr_linkedid_index",
+ "columns": [
+ {
+ "expression": "linkedid",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.conferences": {
+ "name": "conferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pin_enc": {
+ "name": "pin_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "admin_pin_enc": {
+ "name": "admin_pin_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "max_members": {
+ "name": "max_members",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 20
+ },
+ "record": {
+ "name": "record",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "conferences_number_unique": {
+ "name": "conferences_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contact_shares": {
+ "name": "contact_shares",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_user_id": {
+ "name": "to_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "contact": {
+ "name": "contact",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "contact_shares_to_user_id_index": {
+ "name": "contact_shares_to_user_id_index",
+ "columns": [
+ {
+ "expression": "to_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"contact_shares\".\"status\" = 'pending'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "contact_shares_from_user_id_users_id_fk": {
+ "name": "contact_shares_from_user_id_users_id_fk",
+ "tableFrom": "contact_shares",
+ "tableTo": "users",
+ "columnsFrom": [
+ "from_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "contact_shares_to_user_id_users_id_fk": {
+ "name": "contact_shares_to_user_id_users_id_fk",
+ "tableFrom": "contact_shares",
+ "tableTo": "users",
+ "columnsFrom": [
+ "to_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "contact_shares_status_check": {
+ "name": "contact_shares_status_check",
+ "value": "\"contact_shares\".\"status\" in ('pending', 'accepted', 'denied')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.contacts": {
+ "name": "contacts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company": {
+ "name": "company",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "numbers": {
+ "name": "numbers",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "contacts_owner_user_id_users_id_fk": {
+ "name": "contacts_owner_user_id_users_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contrack_call_log": {
+ "name": "contrack_call_log",
+ "schema": "",
+ "columns": {
+ "call_id": {
+ "name": "call_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "interaction_id": {
+ "name": "interaction_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "logged_at": {
+ "name": "logged_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "contrack_call_log_call_id_calls_id_fk": {
+ "name": "contrack_call_log_call_id_calls_id_fk",
+ "tableFrom": "contrack_call_log",
+ "tableTo": "calls",
+ "columnsFrom": [
+ "call_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "contrack_call_log_user_id_users_id_fk": {
+ "name": "contrack_call_log_user_id_users_id_fk",
+ "tableFrom": "contrack_call_log",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "contrack_call_log_call_id_user_id_pk": {
+ "name": "contrack_call_log_call_id_user_id_pk",
+ "columns": [
+ "call_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.devices": {
+ "name": "devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mac": {
+ "name": "mac",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vendor": {
+ "name": "vendor",
+ "type": "phone_vendor",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provisioning_password_enc": {
+ "name": "provisioning_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_provisioned_at": {
+ "name": "last_provisioned_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_provisioned_ip": {
+ "name": "last_provisioned_ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "devices_extension_id_extensions_id_fk": {
+ "name": "devices_extension_id_extensions_id_fk",
+ "tableFrom": "devices",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "devices_mac_unique": {
+ "name": "devices_mac_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "mac"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.did_sms_users": {
+ "name": "did_sms_users",
+ "schema": "",
+ "columns": {
+ "did_id": {
+ "name": "did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "did_sms_users_did_id_dids_id_fk": {
+ "name": "did_sms_users_did_id_dids_id_fk",
+ "tableFrom": "did_sms_users",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "did_sms_users_user_id_users_id_fk": {
+ "name": "did_sms_users_user_id_users_id_fk",
+ "tableFrom": "did_sms_users",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "did_sms_users_did_id_user_id_pk": {
+ "name": "did_sms_users_did_id_user_id_pk",
+ "columns": [
+ "did_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.dids": {
+ "name": "dids",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trunk_id": {
+ "name": "trunk_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "destination": {
+ "name": "destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_id_name_prefix": {
+ "name": "caller_id_name_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "record_inbound": {
+ "name": "record_inbound",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sms_enabled": {
+ "name": "sms_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "e911_address": {
+ "name": "e911_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "dids_trunk_id_trunks_id_fk": {
+ "name": "dids_trunk_id_trunks_id_fk",
+ "tableFrom": "dids",
+ "tableTo": "trunks",
+ "columnsFrom": [
+ "trunk_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "dids_number_unique": {
+ "name": "dids_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.emergency_events": {
+ "name": "emergency_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "extension_number": {
+ "name": "extension_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dialed": {
+ "name": "dialed",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_id_used": {
+ "name": "caller_id_used",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notified": {
+ "name": "notified",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.extensions": {
+ "name": "extensions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sip_password_enc": {
+ "name": "sip_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "webrtc_enabled": {
+ "name": "webrtc_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "max_contacts": {
+ "name": "max_contacts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "ring_seconds": {
+ "name": "ring_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 25
+ },
+ "voicemail_enabled": {
+ "name": "voicemail_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "voicemail_pin_enc": {
+ "name": "voicemail_pin_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "voicemail_email": {
+ "name": "voicemail_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "voicemail_attach_audio": {
+ "name": "voicemail_attach_audio",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "voicemail_delete_after_email": {
+ "name": "voicemail_delete_after_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "outbound_class": {
+ "name": "outbound_class",
+ "type": "outbound_class",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'national'"
+ },
+ "outbound_caller_id_did_id": {
+ "name": "outbound_caller_id_did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "emergency_caller_id_did_id": {
+ "name": "emergency_caller_id_did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "emergency_location": {
+ "name": "emergency_location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "record_mode": {
+ "name": "record_mode",
+ "type": "record_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'never'"
+ },
+ "pickup_group": {
+ "name": "pickup_group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ },
+ "moh_class": {
+ "name": "moh_class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "blf_numbers": {
+ "name": "blf_numbers",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "call_report": {
+ "name": "call_report",
+ "type": "call_report",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'off'"
+ },
+ "call_report_time": {
+ "name": "call_report_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'08:00'"
+ },
+ "call_report_weekday": {
+ "name": "call_report_weekday",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "call_report_summaries": {
+ "name": "call_report_summaries",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "call_report_sent_at": {
+ "name": "call_report_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "extensions_user_id_users_id_fk": {
+ "name": "extensions_user_id_users_id_fk",
+ "tableFrom": "extensions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "extensions_outbound_caller_id_did_id_dids_id_fk": {
+ "name": "extensions_outbound_caller_id_did_id_dids_id_fk",
+ "tableFrom": "extensions",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "outbound_caller_id_did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "extensions_emergency_caller_id_did_id_dids_id_fk": {
+ "name": "extensions_emergency_caller_id_did_id_dids_id_fk",
+ "tableFrom": "extensions",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "emergency_caller_id_did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "extensions_number_unique": {
+ "name": "extensions_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ },
+ "extensions_userId_unique": {
+ "name": "extensions_userId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.generator_runs": {
+ "name": "generator_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "files_changed": {
+ "name": "files_changed",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "commands": {
+ "name": "commands",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ok": {
+ "name": "ok",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.holidays": {
+ "name": "holidays",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "start_date": {
+ "name": "start_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "end_date": {
+ "name": "end_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "recurring_yearly": {
+ "name": "recurring_yearly",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ivr_options": {
+ "name": "ivr_options",
+ "schema": "",
+ "columns": {
+ "ivr_id": {
+ "name": "ivr_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "digit": {
+ "name": "digit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "destination": {
+ "name": "destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ivr_options_ivr_id_ivrs_id_fk": {
+ "name": "ivr_options_ivr_id_ivrs_id_fk",
+ "tableFrom": "ivr_options",
+ "tableTo": "ivrs",
+ "columnsFrom": [
+ "ivr_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "ivr_options_ivr_id_digit_pk": {
+ "name": "ivr_options_ivr_id_digit_pk",
+ "columns": [
+ "ivr_id",
+ "digit"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "ivr_options_digit_check": {
+ "name": "ivr_options_digit_check",
+ "value": "\"ivr_options\".\"digit\" ~ '^[0-9*#]$'"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.ivrs": {
+ "name": "ivrs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "greeting_sound_id": {
+ "name": "greeting_sound_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invalid_sound_id": {
+ "name": "invalid_sound_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timeout_seconds": {
+ "name": "timeout_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "max_retries": {
+ "name": "max_retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 3
+ },
+ "allow_direct_dial": {
+ "name": "allow_direct_dial",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "timeout_destination": {
+ "name": "timeout_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invalid_destination": {
+ "name": "invalid_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ivrs_greeting_sound_id_sound_files_id_fk": {
+ "name": "ivrs_greeting_sound_id_sound_files_id_fk",
+ "tableFrom": "ivrs",
+ "tableTo": "sound_files",
+ "columnsFrom": [
+ "greeting_sound_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "ivrs_invalid_sound_id_sound_files_id_fk": {
+ "name": "ivrs_invalid_sound_id_sound_files_id_fk",
+ "tableFrom": "ivrs",
+ "tableTo": "sound_files",
+ "columnsFrom": [
+ "invalid_sound_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "ivrs_number_unique": {
+ "name": "ivrs_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.moh_classes": {
+ "name": "moh_classes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "shuffle": {
+ "name": "shuffle",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "moh_classes_name_unique": {
+ "name": "moh_classes_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.outbound_routes": {
+ "name": "outbound_routes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "patterns": {
+ "name": "patterns",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "required_class": {
+ "name": "required_class",
+ "type": "outbound_class",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trunk_ids": {
+ "name": "trunk_ids",
+ "type": "uuid[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_id_did_id": {
+ "name": "caller_id_did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "outbound_routes_caller_id_did_id_dids_id_fk": {
+ "name": "outbound_routes_caller_id_did_id_dids_id_fk",
+ "tableFrom": "outbound_routes",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "caller_id_did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paging_group_members": {
+ "name": "paging_group_members",
+ "schema": "",
+ "columns": {
+ "paging_group_id": {
+ "name": "paging_group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "paging_group_members_paging_group_id_paging_groups_id_fk": {
+ "name": "paging_group_members_paging_group_id_paging_groups_id_fk",
+ "tableFrom": "paging_group_members",
+ "tableTo": "paging_groups",
+ "columnsFrom": [
+ "paging_group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "paging_group_members_extension_id_extensions_id_fk": {
+ "name": "paging_group_members_extension_id_extensions_id_fk",
+ "tableFrom": "paging_group_members",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "paging_group_members_paging_group_id_extension_id_pk": {
+ "name": "paging_group_members_paging_group_id_extension_id_pk",
+ "columns": [
+ "paging_group_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paging_groups": {
+ "name": "paging_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duplex": {
+ "name": "duplex",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "paging_groups_number_unique": {
+ "name": "paging_groups_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queue_calls": {
+ "name": "queue_calls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uniqueid": {
+ "name": "uniqueid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "joined_at": {
+ "name": "joined_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "queue_outcome",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "wait_seconds": {
+ "name": "wait_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_extension_id": {
+ "name": "agent_extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "queue_calls_queue_id_joined_at_index": {
+ "name": "queue_calls_queue_id_joined_at_index",
+ "columns": [
+ {
+ "expression": "queue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "joined_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "queue_calls_uniqueid_index": {
+ "name": "queue_calls_uniqueid_index",
+ "columns": [
+ {
+ "expression": "uniqueid",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "queue_calls_queue_id_queues_id_fk": {
+ "name": "queue_calls_queue_id_queues_id_fk",
+ "tableFrom": "queue_calls",
+ "tableTo": "queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "queue_calls_agent_extension_id_extensions_id_fk": {
+ "name": "queue_calls_agent_extension_id_extensions_id_fk",
+ "tableFrom": "queue_calls",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "agent_extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queue_member_state": {
+ "name": "queue_member_state",
+ "schema": "",
+ "columns": {
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "paused": {
+ "name": "paused",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "queue_member_state_queue_id_queues_id_fk": {
+ "name": "queue_member_state_queue_id_queues_id_fk",
+ "tableFrom": "queue_member_state",
+ "tableTo": "queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "queue_member_state_extension_id_extensions_id_fk": {
+ "name": "queue_member_state_extension_id_extensions_id_fk",
+ "tableFrom": "queue_member_state",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "queue_member_state_queue_id_extension_id_pk": {
+ "name": "queue_member_state_queue_id_extension_id_pk",
+ "columns": [
+ "queue_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queue_members": {
+ "name": "queue_members",
+ "schema": "",
+ "columns": {
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "penalty": {
+ "name": "penalty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "queue_members_queue_id_queues_id_fk": {
+ "name": "queue_members_queue_id_queues_id_fk",
+ "tableFrom": "queue_members",
+ "tableTo": "queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "queue_members_extension_id_extensions_id_fk": {
+ "name": "queue_members_extension_id_extensions_id_fk",
+ "tableFrom": "queue_members",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "queue_members_queue_id_extension_id_pk": {
+ "name": "queue_members_queue_id_extension_id_pk",
+ "columns": [
+ "queue_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queues": {
+ "name": "queues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "queue_strategy",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'rrmemory'"
+ },
+ "member_ring_seconds": {
+ "name": "member_ring_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "retry_seconds": {
+ "name": "retry_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "wrapup_seconds": {
+ "name": "wrapup_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 10
+ },
+ "max_wait_seconds": {
+ "name": "max_wait_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 300
+ },
+ "max_callers": {
+ "name": "max_callers",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "moh_class": {
+ "name": "moh_class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "announce_position": {
+ "name": "announce_position",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "announce_every_seconds": {
+ "name": "announce_every_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "join_when_no_agents": {
+ "name": "join_when_no_agents",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "record": {
+ "name": "record",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sla_seconds": {
+ "name": "sla_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 20
+ },
+ "caller_id_name_prefix": {
+ "name": "caller_id_name_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timeout_destination": {
+ "name": "timeout_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "queues_number_unique": {
+ "name": "queues_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.recordings": {
+ "name": "recordings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transcript": {
+ "name": "transcript",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_done_at": {
+ "name": "ai_done_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_error": {
+ "name": "ai_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "recordings_filePath_unique": {
+ "name": "recordings_filePath_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "file_path"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ring_group_members": {
+ "name": "ring_group_members",
+ "schema": "",
+ "columns": {
+ "ring_group_id": {
+ "name": "ring_group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ring_group_members_ring_group_id_ring_groups_id_fk": {
+ "name": "ring_group_members_ring_group_id_ring_groups_id_fk",
+ "tableFrom": "ring_group_members",
+ "tableTo": "ring_groups",
+ "columnsFrom": [
+ "ring_group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ring_group_members_extension_id_extensions_id_fk": {
+ "name": "ring_group_members_extension_id_extensions_id_fk",
+ "tableFrom": "ring_group_members",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "ring_group_members_ring_group_id_extension_id_pk": {
+ "name": "ring_group_members_ring_group_id_extension_id_pk",
+ "columns": [
+ "ring_group_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ring_groups": {
+ "name": "ring_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "ring_strategy",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ringall'"
+ },
+ "ring_seconds": {
+ "name": "ring_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 25
+ },
+ "caller_id_name_prefix": {
+ "name": "caller_id_name_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "music_instead_of_ring": {
+ "name": "music_instead_of_ring",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "no_answer_destination": {
+ "name": "no_answer_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "ring_groups_number_unique": {
+ "name": "ring_groups_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip": {
+ "name": "ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'password'"
+ },
+ "idp_refresh_enc": {
+ "name": "idp_refresh_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "idp_id_token_enc": {
+ "name": "idp_id_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "idp_checked_at": {
+ "name": "idp_checked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "sessions_user_id_index": {
+ "name": "sessions_user_id_index",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "sessions_method_check": {
+ "name": "sessions_method_check",
+ "value": "\"sessions\".\"method\" in ('password', 'ldap', 'oidc')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sms_messages": {
+ "name": "sms_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "did_id": {
+ "name": "did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "direction": {
+ "name": "direction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remote_number": {
+ "name": "remote_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media": {
+ "name": "media",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sent_by_user_id": {
+ "name": "sent_by_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "sms_messages_did_id_remote_number_created_at_index": {
+ "name": "sms_messages_did_id_remote_number_created_at_index",
+ "columns": [
+ {
+ "expression": "did_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "remote_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sms_messages_did_id_dids_id_fk": {
+ "name": "sms_messages_did_id_dids_id_fk",
+ "tableFrom": "sms_messages",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "sms_messages_sent_by_user_id_users_id_fk": {
+ "name": "sms_messages_sent_by_user_id_users_id_fk",
+ "tableFrom": "sms_messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "sent_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "sms_messages_direction_check": {
+ "name": "sms_messages_direction_check",
+ "value": "\"sms_messages\".\"direction\" in ('in', 'out')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.sound_files": {
+ "name": "sound_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "moh_class_id": {
+ "name": "moh_class_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tts_text": {
+ "name": "tts_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_filename": {
+ "name": "original_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "asterisk_name": {
+ "name": "asterisk_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sound_files_moh_class_id_moh_classes_id_fk": {
+ "name": "sound_files_moh_class_id_moh_classes_id_fk",
+ "tableFrom": "sound_files",
+ "tableTo": "moh_classes",
+ "columnsFrom": [
+ "moh_class_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sound_files_asteriskName_unique": {
+ "name": "sound_files_asteriskName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "asterisk_name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "sound_files_kind_check": {
+ "name": "sound_files_kind_check",
+ "value": "\"sound_files\".\"kind\" in ('prompt', 'moh')"
+ },
+ "sound_files_source_check": {
+ "name": "sound_files_source_check",
+ "value": "\"sound_files\".\"source\" in ('upload', 'tts', 'phone')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.system_events": {
+ "name": "system_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "system_events_created_at_index": {
+ "name": "system_events_created_at_index",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "system_events_severity_check": {
+ "name": "system_events_severity_check",
+ "value": "\"system_events\".\"severity\" in ('info', 'warning', 'error')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.time_conditions": {
+ "name": "time_conditions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "open_ranges": {
+ "name": "open_ranges",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "use_holidays": {
+ "name": "use_holidays",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "override_digit": {
+ "name": "override_digit",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override_state": {
+ "name": "override_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "open_destination": {
+ "name": "open_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "closed_destination": {
+ "name": "closed_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "holiday_destination": {
+ "name": "holiday_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "time_conditions_overrideDigit_unique": {
+ "name": "time_conditions_overrideDigit_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "override_digit"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "time_conditions_override_digit_check": {
+ "name": "time_conditions_override_digit_check",
+ "value": "\"time_conditions\".\"override_digit\" between 1 and 9"
+ },
+ "time_conditions_override_state_check": {
+ "name": "time_conditions_override_state_check",
+ "value": "\"time_conditions\".\"override_state\" in ('open', 'closed')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.trunks": {
+ "name": "trunks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "trunk_provider",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'voipms'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "server_host": {
+ "name": "server_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_port": {
+ "name": "server_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5060
+ },
+ "transport": {
+ "name": "transport",
+ "type": "sip_transport",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'udp'"
+ },
+ "auth_mode": {
+ "name": "auth_mode",
+ "type": "trunk_auth_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'registration'"
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password_enc": {
+ "name": "password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_user": {
+ "name": "from_user",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_domain": {
+ "name": "from_domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "codecs": {
+ "name": "codecs",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{ulaw}'"
+ },
+ "srtp": {
+ "name": "srtp",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "max_channels": {
+ "name": "max_channels",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "voipms_api_username": {
+ "name": "voipms_api_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "voipms_api_password_enc": {
+ "name": "voipms_api_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "trunks_name_unique": {
+ "name": "trunks_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_identities": {
+ "name": "user_identities",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "identity_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "groups": {
+ "name": "groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_identities_user_id_users_id_fk": {
+ "name": "user_identities_user_id_users_id_fk",
+ "tableFrom": "user_identities",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_identities_kind_subject_unique": {
+ "name": "user_identities_kind_subject_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "kind",
+ "subject"
+ ]
+ },
+ "user_identities_userId_kind_unique": {
+ "name": "user_identities_userId_kind_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "kind"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_runtime_state": {
+ "name": "user_runtime_state",
+ "schema": "",
+ "columns": {
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "dnd": {
+ "name": "dnd",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "cfu": {
+ "name": "cfu",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cfb": {
+ "name": "cfb",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cfna": {
+ "name": "cfna",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_runtime_state_extension_id_extensions_id_fk": {
+ "name": "user_runtime_state_extension_id_extensions_id_fk",
+ "tableFrom": "user_runtime_state",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "citext",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "user_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'user'"
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "totp_secret_enc": {
+ "name": "totp_secret_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "totp_recovery_hashes": {
+ "name": "totp_recovery_hashes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "totp_last_step": {
+ "name": "totp_last_step",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "must_change_password": {
+ "name": "must_change_password",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "last_login_at": {
+ "name": "last_login_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contrack_token_enc": {
+ "name": "contrack_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contrack_log_calls": {
+ "name": "contrack_log_calls",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "contrack_log_summaries": {
+ "name": "contrack_log_summaries",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "contrack_log_since": {
+ "name": "contrack_log_since",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.voicemail_messages": {
+ "name": "voicemail_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "msg_id": {
+ "name": "msg_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "folder": {
+ "name": "folder",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_number": {
+ "name": "caller_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "caller_name": {
+ "name": "caller_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_s": {
+ "name": "duration_s",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "emailed_at": {
+ "name": "emailed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transcript": {
+ "name": "transcript",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "voicemail_messages_extension_id_extensions_id_fk": {
+ "name": "voicemail_messages_extension_id_extensions_id_fk",
+ "tableFrom": "voicemail_messages",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "voicemail_messages_msgId_unique": {
+ "name": "voicemail_messages_msgId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "msg_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhook_deliveries": {
+ "name": "webhook_deliveries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "webhook_id": {
+ "name": "webhook_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "attempt": {
+ "name": "attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "response_code": {
+ "name": "response_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_attempt_at": {
+ "name": "next_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "webhook_deliveries_webhook_id_webhooks_id_fk": {
+ "name": "webhook_deliveries_webhook_id_webhooks_id_fk",
+ "tableFrom": "webhook_deliveries",
+ "tableTo": "webhooks",
+ "columnsFrom": [
+ "webhook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhooks": {
+ "name": "webhooks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret_enc": {
+ "name": "secret_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "events": {
+ "name": "events",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.call_direction": {
+ "name": "call_direction",
+ "schema": "public",
+ "values": [
+ "inbound",
+ "outbound",
+ "internal"
+ ]
+ },
+ "public.call_report": {
+ "name": "call_report",
+ "schema": "public",
+ "values": [
+ "off",
+ "hourly",
+ "daily",
+ "weekly"
+ ]
+ },
+ "public.call_status": {
+ "name": "call_status",
+ "schema": "public",
+ "values": [
+ "answered",
+ "missed",
+ "voicemail",
+ "abandoned",
+ "busy",
+ "failed"
+ ]
+ },
+ "public.identity_kind": {
+ "name": "identity_kind",
+ "schema": "public",
+ "values": [
+ "oidc",
+ "ldap"
+ ]
+ },
+ "public.outbound_class": {
+ "name": "outbound_class",
+ "schema": "public",
+ "values": [
+ "internal",
+ "local",
+ "national",
+ "international"
+ ]
+ },
+ "public.phone_vendor": {
+ "name": "phone_vendor",
+ "schema": "public",
+ "values": [
+ "yealink",
+ "grandstream",
+ "polycom",
+ "generic"
+ ]
+ },
+ "public.queue_outcome": {
+ "name": "queue_outcome",
+ "schema": "public",
+ "values": [
+ "answered",
+ "abandoned",
+ "timed_out"
+ ]
+ },
+ "public.queue_strategy": {
+ "name": "queue_strategy",
+ "schema": "public",
+ "values": [
+ "ringall",
+ "leastrecent",
+ "fewestcalls",
+ "random",
+ "rrmemory",
+ "linear"
+ ]
+ },
+ "public.record_mode": {
+ "name": "record_mode",
+ "schema": "public",
+ "values": [
+ "never",
+ "inbound",
+ "outbound",
+ "all"
+ ]
+ },
+ "public.ring_strategy": {
+ "name": "ring_strategy",
+ "schema": "public",
+ "values": [
+ "ringall",
+ "hunt"
+ ]
+ },
+ "public.sip_transport": {
+ "name": "sip_transport",
+ "schema": "public",
+ "values": [
+ "udp",
+ "tcp",
+ "tls"
+ ]
+ },
+ "public.trunk_auth_mode": {
+ "name": "trunk_auth_mode",
+ "schema": "public",
+ "values": [
+ "registration",
+ "ip"
+ ]
+ },
+ "public.trunk_provider": {
+ "name": "trunk_provider",
+ "schema": "public",
+ "values": [
+ "voipms",
+ "generic"
+ ]
+ },
+ "public.user_role": {
+ "name": "user_role",
+ "schema": "public",
+ "values": [
+ "admin",
+ "user"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json
index 2997d7f..2e12f72 100644
--- a/apps/api/src/db/migrations/meta/_journal.json
+++ b/apps/api/src/db/migrations/meta/_journal.json
@@ -85,6 +85,13 @@
"when": 1792788393381,
"tag": "0011_contrack_call_log",
"breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "7",
+ "when": 1792951905226,
+ "tag": "0012_sso",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts
index a3d9433..6849a51 100644
--- a/apps/api/src/db/schema.ts
+++ b/apps/api/src/db/schema.ts
@@ -19,6 +19,7 @@ import {
smallint,
text,
timestamp,
+ unique,
uuid,
} from 'drizzle-orm/pg-core';
@@ -53,7 +54,8 @@ export const users = pgTable('users', {
id: id(),
email: citext().notNull().unique(),
displayName: text().notNull(),
- passwordHash: text().notNull(),
+ /** argon2id; null: no password, signs in with OpenID only (12 §3.3). */
+ passwordHash: text(),
role: userRole().notNull().default('user'),
isActive: boolean().notNull().default(true),
totpSecretEnc: text(),
@@ -83,9 +85,40 @@ export const sessions = pgTable(
expiresAt: tstz().notNull(),
ip: inet(),
userAgent: text(),
+ /** How it was made (12 §2): password, ldap or oidc. */
+ method: text().notNull().default('password'),
+ /** OpenID sessions (12 §3.5): refresh token for the leaver check, ID token for signing out. */
+ idpRefreshEnc: text(),
+ idpIdTokenEnc: text(),
+ idpCheckedAt: tstz(),
...timestamps,
},
- (t) => [index().on(t.userId)],
+ (t) => [
+ index().on(t.userId),
+ check('sessions_method_check', sql`${t.method} in ('password', 'ldap', 'oidc')`),
+ ],
+);
+
+// One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
+export const identityKind = pgEnum('identity_kind', ['oidc', 'ldap']);
+export const userIdentities = pgTable(
+ 'user_identities',
+ {
+ id: id(),
+ userId: uuid()
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ kind: identityKind().notNull(),
+ /** oidc: " "; ldap: the entry's UUID attribute. */
+ subject: text().notNull(),
+ /** As the provider last said. */
+ email: text(),
+ /** oidc: groupsClaim values at the last sign-in (12 §3.4). */
+ groups: emptyTextArray(),
+ createdAt: createdAt(),
+ lastUsedAt: tstz(),
+ },
+ (t) => [unique().on(t.kind, t.subject), unique().on(t.userId, t.kind)],
);
export const apiTokens = pgTable('api_tokens', {
diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts
index 285d53b..1987b85 100644
--- a/apps/api/src/routes/auth.ts
+++ b/apps/api/src/routes/auth.ts
@@ -8,7 +8,9 @@ import {
PasswordConfirmSchema,
TotpEnableSchema,
APP_NAME,
+ type AuthMethods,
type AuthUser,
+ type SignInWays,
type TotpSetup,
type TotpStatus,
} from '@selfphoneng/shared';
@@ -22,7 +24,8 @@ import {
otpauthUrl,
} from '../auth/totp.ts';
import type { Db } from '../db/client.ts';
-import { extensions, users } from '../db/schema.ts';
+import { extensions, userIdentities, users } from '../db/schema.ts';
+import { getSetting } from '../services/settings.ts';
import {
unauthorized,
rateLimited,
@@ -49,11 +52,28 @@ import {
setSessionCookie,
} from '../auth/sessions.ts';
+/** 12 §2: the user's password and connected accounts, from their user_identities rows. */
+export const signInWays = (
+ u: { passwordHash: string | null },
+ ids: { kind: 'oidc' | 'ldap'; email: string | null }[],
+): SignInWays => {
+ const oidc = ids.find((i) => i.kind === 'oidc');
+ return {
+ password: u.passwordHash !== null,
+ oidc: oidc ? { email: oidc.email } : null,
+ ldap: ids.some((i) => i.kind === 'ldap'),
+ };
+};
+
export async function toAuthUser(db: Db, u: CurrentUser): Promise {
const [ext] = await db
.select({ id: extensions.id, number: extensions.number, name: extensions.name })
.from(extensions)
.where(eq(extensions.userId, u.id));
+ const ids = await db
+ .select({ kind: userIdentities.kind, email: userIdentities.email })
+ .from(userIdentities)
+ .where(eq(userIdentities.userId, u.id));
return {
id: u.id,
email: u.email,
@@ -61,6 +81,7 @@ export async function toAuthUser(db: Db, u: CurrentUser): Promise {
role: u.role,
mustChangePassword: u.mustChangePassword,
extension: ext ?? null,
+ signIn: signInWays(u, ids),
};
}
@@ -118,7 +139,8 @@ export async function authRoutes(app: FastifyInstance) {
const u = await myRow(req.user!.id);
if (u.totpSecretEnc) throw conflict('Two-step sign-in is already on');
// Like turning it off: a stolen session alone can't lock the owner out with its own secret.
- if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
+ if (!u.passwordHash || !(await verifyPassword(u.passwordHash, password)))
+ throw badRequest('Wrong password');
const step = checkTotp(secret, code, null);
if (step === null)
throw badRequest('That code doesn’t match. Check the phone’s clock and try again.');
@@ -142,7 +164,8 @@ export async function authRoutes(app: FastifyInstance) {
async function confirmPassword(id: string, body: unknown) {
const { password } = PasswordConfirmSchema.parse(body);
const u = await myRow(id);
- if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
+ if (!u.passwordHash || !(await verifyPassword(u.passwordHash, password)))
+ throw badRequest('Wrong password');
return u;
}
@@ -179,15 +202,24 @@ export async function authRoutes(app: FastifyInstance) {
return { recoveryCodes: codes };
});
+ /** The login page's choices (12 §2). Public: says nothing about any user. */
+ app.get('/api/auth/methods', async (): Promise => ({
+ mode: await getSetting(db, 'auth.mode'),
+ ldap: false, // S4
+ oidc: null, // S2
+ }));
+
app.post('/api/auth/login', async (req, reply) => {
- const body = LoginSchema.parse(req.body);
- if (isLoginBlocked(req.ip, body.email)) throw rateLimited();
- const [user] = await db.select().from(users).where(eq(users.email, body.email));
- const ok = user?.isActive
- ? await verifyPassword(user.passwordHash, body.password)
- : (await burnVerify(body.password), false);
+ const { login, ...body } = LoginSchema.parse(req.body);
+ if (isLoginBlocked(req.ip, login)) throw rateLimited();
+ const [user] = await db.select().from(users).where(eq(users.email, login));
+ // No password (OpenID only, 12 §3.3) fails like a wrong one, after the same work.
+ const ok =
+ user?.isActive && user.passwordHash
+ ? await verifyPassword(user.passwordHash, body.password)
+ : (await burnVerify(body.password), false);
if (!user || !ok) {
- recordLoginFailure(req.ip, body.email);
+ recordLoginFailure(req.ip, login);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed');
await audit(db, {
actorUserId: user?.id,
@@ -195,16 +227,19 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login_failed',
entityType: 'user',
entityId: user?.id,
- summary: `Failed login for ${body.email}`,
+ summary: `Failed login for ${login}`,
});
throw unauthorized('Wrong email or password');
}
+ // OpenID only (12 §1.1): passwords are for admins, the way in when the provider is down.
+ if (user.role !== 'admin' && (await getSetting(db, 'auth.mode')) === 'oidc')
+ throw forbidden('Use the sign-in button instead', 'password_sign_in_off');
// Two-step sign-in (08 §2.4): the password was right; now the code.
if (user.totpSecretEnc) {
if (!body.totp)
throw new HttpError(401, 'totp_required', 'Enter the code from your authenticator app');
if (!(await useSecondFactor(user, body.totp))) {
- recordLoginFailure(req.ip, body.email);
+ recordLoginFailure(req.ip, login);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed: code');
await audit(db, {
actorUserId: user.id,
@@ -212,7 +247,7 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login_failed',
entityType: 'user',
entityId: user.id,
- summary: `Failed login for ${body.email} (wrong authenticator or recovery code)`,
+ summary: `Failed login for ${login} (wrong authenticator or recovery code)`,
});
throw new HttpError(401, 'totp_invalid', 'That code is wrong or was used already');
}
@@ -252,6 +287,8 @@ export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/change-password', { preHandler: requireAuth }, async (req) => {
const body = ChangePasswordSchema.parse(req.body);
const user = req.user!;
+ if (!user.passwordHash)
+ throw badRequest('You have no password here. Ask an admin to reset it to get one.');
if (!(await verifyPassword(user.passwordHash, body.currentPassword)))
throw badRequest('Current password is wrong');
if (body.newPassword === body.currentPassword)
diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts
index 1d94319..e69a9b9 100644
--- a/apps/api/src/routes/settings.ts
+++ b/apps/api/src/routes/settings.ts
@@ -145,6 +145,10 @@ export async function settingsRoutes(app: FastifyInstance) {
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
}
await checkRefs(db, values);
+ // 12 §1.1: mixed and oidc need OpenID Connect set up first.
+ // S2 makes this "auth.oidc set and its Test passed".
+ if (values['auth.mode'] !== undefined && values['auth.mode'] !== 'password')
+ throw badRequest('Set up OpenID Connect first');
for (const key of ['ai.llm', 'ai.stt', 'ai.tts'] as const) {
const ep = values[key] as { url: string } | null | undefined;
try {
diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts
index 9ea2bf8..b90574b 100644
--- a/apps/api/src/routes/users.ts
+++ b/apps/api/src/routes/users.ts
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/users (04 §7.3). Admin only.
-import { and, asc, count, eq, ne } from 'drizzle-orm';
+import { and, asc, count, eq, isNotNull, ne } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
@@ -10,17 +10,23 @@ import {
type UserDto,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
-import { extensions, users } from '../db/schema.ts';
+import { extensions, userIdentities, users } from '../db/schema.ts';
import { badRequest, conflict, notFound } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { hashPassword } from '../auth/passwords.ts';
import { deleteUserSessions } from '../auth/sessions.ts';
import { randomAlnum } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
+import { signInWays } from './auth.ts';
type UserRow = typeof users.$inferSelect;
+type IdentityRow = { userId: string; kind: 'oidc' | 'ldap'; email: string | null };
-export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): UserDto => ({
+export const toUserDto = (
+ u: UserRow,
+ extension: UserDto['extension'] = null,
+ ids: IdentityRow[] = [],
+): UserDto => ({
id: u.id,
email: u.email,
displayName: u.displayName,
@@ -31,8 +37,20 @@ export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): U
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
createdAt: u.createdAt.toISOString(),
extension,
+ signIn: signInWays(u, ids),
});
+/** Connected accounts (12 §2), for one user or all of them. */
+const identitiesOf = (db: Db, userId?: string): Promise =>
+ db
+ .select({
+ userId: userIdentities.userId,
+ kind: userIdentities.kind,
+ email: userIdentities.email,
+ })
+ .from(userIdentities)
+ .where(userId ? eq(userIdentities.userId, userId) : undefined);
+
const extOf = async (db: Db, userId: string) => {
const [e] = await db
.select({ id: extensions.id, number: extensions.number })
@@ -56,13 +74,23 @@ async function getUser(db: Db, id: string): Promise {
return u;
}
-/** Throws if removing `id` from the active admins would leave none. */
+/**
+ * Throws if removing `id` from the active admins would leave none with a password: the way in
+ * when the identity provider is down (12 §1).
+ */
async function assertNotLastAdmin(db: Db, id: string) {
const [r] = await db
.select({ n: count() })
.from(users)
- .where(and(eq(users.role, 'admin'), eq(users.isActive, true), ne(users.id, id)));
- if (!r || r.n === 0) throw conflict('This is the last active admin');
+ .where(
+ and(
+ eq(users.role, 'admin'),
+ eq(users.isActive, true),
+ isNotNull(users.passwordHash),
+ ne(users.id, id),
+ ),
+ );
+ if (!r || r.n === 0) throw conflict('This is the last active admin with a password');
}
export async function userRoutes(app: FastifyInstance) {
@@ -76,9 +104,14 @@ export async function userRoutes(app: FastifyInstance) {
.from(users)
.leftJoin(extensions, eq(extensions.userId, users.id))
.orderBy(asc(users.displayName), asc(users.id));
+ const ids = await identitiesOf(db);
return {
items: rows.map((r) =>
- toUserDto(r.u, r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null),
+ toUserDto(
+ r.u,
+ r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null,
+ ids.filter((i) => i.userId === r.u.id),
+ ),
),
nextCursor: null,
};
@@ -86,7 +119,7 @@ export async function userRoutes(app: FastifyInstance) {
app.get('/api/users/:id', async (req) => {
const { id } = IdParams.parse(req.params);
- return toUserDto(await getUser(db, id), await extOf(db, id));
+ return toUserDto(await getUser(db, id), await extOf(db, id), await identitiesOf(db, id));
});
app.post('/api/users', async (req, reply) => {
@@ -150,7 +183,7 @@ export async function userRoutes(app: FastifyInstance) {
after: Object.fromEntries(changed.map((k) => [k, after[k]])),
},
});
- return toUserDto(after, await extOf(db, id));
+ return toUserDto(after, await extOf(db, id), await identitiesOf(db, id));
});
app.delete('/api/users/:id', async (req) => {
diff --git a/apps/api/test/schema.test.ts b/apps/api/test/schema.test.ts
index 3e512df..f64ff4c 100644
--- a/apps/api/test/schema.test.ts
+++ b/apps/api/test/schema.test.ts
@@ -15,7 +15,7 @@ const EXPECTED: Record> = {
id: 'uuid|NO|gen_random_uuid()',
email: 'USER-DEFINED:citext|NO|',
display_name: 'text|NO|',
- password_hash: 'text|NO|',
+ password_hash: 'text|YES|',
role: "USER-DEFINED:user_role|NO|'user'::user_role",
is_active: 'boolean|NO|true',
totp_secret_enc: 'text|YES|',
@@ -115,11 +115,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
- it('creates all 43 tables', async () => {
+ it('creates all 44 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
- expect(r.rows[0]).toEqual({ n: 43 });
+ expect(r.rows[0]).toEqual({ n: 44 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
diff --git a/apps/api/test/sign-in.test.ts b/apps/api/test/sign-in.test.ts
new file mode 100644
index 0000000..fc2b4e3
--- /dev/null
+++ b/apps/api/test/sign-in.test.ts
@@ -0,0 +1,110 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// 12 S1: ways to sign in, the sign-in mode, and the admin with a password kept as the way in.
+import { eq } from 'drizzle-orm';
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { sessions, userIdentities, users } from '../src/db/schema.ts';
+import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
+import { setSetting } from '../src/services/settings.ts';
+import { hasTestDb } from './db.ts';
+import { ADMIN, H, createTestApp } from './app.ts';
+
+describe.skipIf(!hasTestDb)('sign-in (12 S1)', () => {
+ let t: Awaited>;
+ let admin: string;
+ beforeAll(async () => {
+ t = await createTestApp();
+ await t.clearMustChange(ADMIN.email);
+ admin = await t.login(ADMIN.email, ADMIN.password);
+ });
+ afterAll(() => t?.close());
+ beforeEach(() => resetLoginLimiter());
+
+ const post = (url: string, payload: object, cookie?: string) =>
+ t.app.inject({ method: 'POST', url, payload, headers: { ...H, ...(cookie && { cookie }) } });
+
+ it('GET /api/auth/methods is public and offers passwords only', async () => {
+ const r = await t.app.inject({ url: '/api/auth/methods' });
+ expect(r.statusCode).toBe(200);
+ expect(r.json()).toEqual({ mode: 'password', ldap: false, oidc: null });
+ });
+
+ it('signs in with `login`, and still with `email`; the session says how', async () => {
+ const r = await post('/api/auth/login', { login: ADMIN.email, password: ADMIN.password });
+ expect(r.statusCode).toBe(200);
+ expect(r.json().user.signIn).toEqual({ password: true, oidc: null, ldap: false });
+ const rows = await t.db.select({ method: sessions.method }).from(sessions);
+ expect(rows.every((s) => s.method === 'password')).toBe(true);
+ });
+
+ it('a user without a password fails like a wrong password, and has none to change', async () => {
+ const u = await t.addUser('nopw@example.com', 'temporary-pass-1');
+ const cookie = await t.login('nopw@example.com', 'temporary-pass-1');
+ await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, u.id));
+ const r = await post('/api/auth/login', { login: 'nopw@example.com', password: 'anything' });
+ expect(r.statusCode).toBe(401);
+ expect(r.json().error.message).toBe('Wrong email or password');
+ const c = await post(
+ '/api/auth/change-password',
+ { currentPassword: 'x', newPassword: 'a-new-password-1' },
+ cookie,
+ );
+ expect(c.statusCode).toBe(400);
+ });
+
+ it('users show their connected accounts', async () => {
+ const u = await t.addUser('linked@example.com', 'linked-password-1');
+ await t.db
+ .insert(userIdentities)
+ .values({ userId: u.id, kind: 'oidc', subject: 'https://kc/realms/x abc', email: 'l@kc' });
+ const r = await t.app.inject({ url: `/api/users/${u.id}`, headers: { cookie: admin } });
+ expect(r.json().signIn).toEqual({ password: true, oidc: { email: 'l@kc' }, ldap: false });
+ });
+
+ it('auth.mode can only be password until OpenID Connect is set up', async () => {
+ const r = t.app.inject({
+ method: 'PATCH',
+ url: '/api/settings',
+ payload: { 'auth.mode': 'mixed' },
+ headers: { ...H, cookie: admin },
+ });
+ expect((await r).statusCode).toBe(400);
+ });
+
+ it('OpenID-only mode refuses passwords, except for admins', async () => {
+ await t.addUser('plain@example.com', 'plain-password-1');
+ await setSetting(t.db, 'auth.mode', 'oidc');
+ try {
+ const r = await post('/api/auth/login', {
+ login: 'plain@example.com',
+ password: 'plain-password-1',
+ });
+ expect(r.statusCode).toBe(403);
+ expect(r.json().error.code).toBe('password_sign_in_off');
+ const a = await post('/api/auth/login', { login: ADMIN.email, password: ADMIN.password });
+ expect(a.statusCode).toBe(200);
+ } finally {
+ await setSetting(t.db, 'auth.mode', 'password');
+ }
+ });
+
+ it('the last active admin with a password stays, even with other admins', async () => {
+ const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
+ const other = await t.addUser('sso-admin@example.com', 'sso-admin-pass-1', 'admin');
+ await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, other.id));
+ const demote = await t.app.inject({
+ method: 'PATCH',
+ url: `/api/users/${me!.id}`,
+ payload: { role: 'user' },
+ headers: { ...H, cookie: admin },
+ });
+ expect(demote.statusCode).toBe(409);
+ expect(demote.json().error.message).toBe('This is the last active admin with a password');
+ // An admin without a password can go: the one with a password is still there.
+ const del = await t.app.inject({
+ method: 'DELETE',
+ url: `/api/users/${other.id}`,
+ headers: { ...H, cookie: admin },
+ });
+ expect(del.statusCode).toBe(200);
+ });
+});
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index cfb90c8..76dd4e1 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -149,9 +149,9 @@ async function upload(method: string, url: string, body: Blob): Promise {
export const authApi = {
me: () => api<{ user: AuthUser }>('GET', '/api/auth/me').then((r) => r.user),
- login: (email: string, password: string, totp?: string) =>
+ login: (login: string, password: string, totp?: string) =>
api<{ user: AuthUser }>('POST', '/api/auth/login', {
- email,
+ login,
password,
...(totp ? { totp } : {}),
}).then((r) => r.user),
diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx
index 810e607..f59005e 100644
--- a/apps/web/src/pages/LoginPage.tsx
+++ b/apps/web/src/pages/LoginPage.tsx
@@ -4,7 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link, useNavigate, useSearchParams } from 'react-router';
-import { APP_NAME, LoginSchema } from '@selfphoneng/shared';
+import { APP_NAME, LoginFields } from '@selfphoneng/shared';
import { ApiError, authApi } from '../api/client.ts';
import { homeFor } from '../auth/RequireAuth.tsx';
import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
@@ -32,13 +32,13 @@ export function LoginPage() {
// Two-step sign-in: shown once the server says the password was right and a code is needed.
const [needCode, setNeedCode] = useState(false);
const [code, setCode] = useState('');
- const form = useForm({ resolver: zodResolver(LoginSchema.omit({ totp: true })) });
+ const form = useForm({ resolver: zodResolver(LoginFields.omit({ totp: true })) });
const { errors, isSubmitting } = form.formState;
- const onSubmit = form.handleSubmit(async ({ email, password }) => {
+ const onSubmit = form.handleSubmit(async ({ login, password }) => {
setError(undefined);
try {
- const user = await authApi.login(email, password, needCode ? code : undefined);
+ const user = await authApi.login(login, password, needCode ? code : undefined);
qc.setQueryData(['me'], user);
const next = params.get('next');
// Only same-site paths: never redirect to another origin.
@@ -58,11 +58,11 @@ export function LoginPage() {
{user.twoStep && }
+ {user.signIn.oidc && }
)}
@@ -584,6 +587,33 @@ function EmergencyTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void
);
}
+/** Their connected OpenID account (12 §5): the groups it sent, and Disconnect. */
+function ConnectedOidc({ user }: { user: UserDto }) {
+ const qc = useQueryClient();
+ const label = useAuthMethods().data?.oidc?.label ?? 'OpenID';
+ const disconnect = useMutation({
+ mutationFn: () => usersApi.disconnectOidc(user.id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
+ });
+ const oidc = user.signIn.oidc!;
+ return (
+
+
+ {label} : {oidc.email ?? 'connected'}
+
+
+ Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
+
+
+ disconnect.mutate()} disabled={disconnect.isPending}>
+ Disconnect {label}
+
+
+ {disconnect.error &&
{disconnect.error.message} }
+
+ );
+}
+
/** For a user who lost their phone and recovery codes (08 §2.4). */
function ResetTwoStep({ user }: { user: UserDto }) {
const qc = useQueryClient();
diff --git a/apps/web/src/pages/admin/UsersPage.tsx b/apps/web/src/pages/admin/UsersPage.tsx
index 7c8faae..bb2544f 100644
--- a/apps/web/src/pages/admin/UsersPage.tsx
+++ b/apps/web/src/pages/admin/UsersPage.tsx
@@ -9,6 +9,7 @@ import { Alert, Button } from '../../components/ui.tsx';
import { UserDrawer } from './UserDrawer.tsx';
import { StatusDot } from '../../components/StatusDot.tsx';
import { useLive } from '../../live/store.ts';
+import { useAuthMethods } from '../../components/SignInCard.tsx';
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : 'Never');
@@ -129,6 +130,7 @@ function UsersTable({
onDelete: (u: UserDto) => void;
}) {
const live = useLive((s) => s.extensions);
+ const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
if (users.length === 0) return No users yet.
;
const ext = (u: UserDto) =>
u.extension ? (
@@ -153,7 +155,7 @@ function UsersTable({
);
// 12 §5: every way this user can sign in.
const ways = (u: UserDto) =>
- [u.signIn.password && 'Password', u.signIn.oidc && 'OpenID', u.signIn.ldap && 'Directory']
+ [u.signIn.password && 'Password', u.signIn.oidc && oidcLabel, u.signIn.ldap && 'Directory']
.filter(Boolean)
.join(', ') || '–';
const actions = (u: UserDto) => (
diff --git a/apps/web/src/pages/user/MePage.tsx b/apps/web/src/pages/user/MePage.tsx
index 2baee2b..f6a315a 100644
--- a/apps/web/src/pages/user/MePage.tsx
+++ b/apps/web/src/pages/user/MePage.tsx
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
import type { AuthUser } from '@selfphoneng/shared';
-import { authApi, meApi } from '../../api/client.ts';
+import { meApi, signOut } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Alert, Brand, Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
@@ -57,7 +57,7 @@ export function MePage({ user }: { user: AuthUser }) {
{
- await authApi.logout().catch(() => undefined);
+ await signOut(); // may leave for the OpenID provider's sign-out page (12 §3.5)
qc.clear();
nav('/login', { replace: true });
}}
diff --git a/apps/web/src/pages/user/MeSettingsPage.tsx b/apps/web/src/pages/user/MeSettingsPage.tsx
index c1ff22e..d9c8a34 100644
--- a/apps/web/src/pages/user/MeSettingsPage.tsx
+++ b/apps/web/src/pages/user/MeSettingsPage.tsx
@@ -4,10 +4,10 @@
// Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
-import { Link } from 'react-router';
import { formatNumber, type CallReport, type MeDto, type MeUpdate } from '@selfphoneng/shared';
import { meApi } from '../../api/client.ts';
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
+import { SignInCard } from '../../components/SignInCard.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
/** "102" stays as is; outside numbers get their usual format. */
@@ -70,12 +70,7 @@ function SettingsForms({ me }: { me: MeDto }) {
-
- Password
-
- Change password
-
-
+
);
}
diff --git a/docs/spec/02-data-model.md b/docs/spec/02-data-model.md
index 2fcc7c7..6839d5b 100644
--- a/docs/spec/02-data-model.md
+++ b/docs/spec/02-data-model.md
@@ -629,6 +629,7 @@ with a zod schema per key and a default. Missing rows return the default.
| `webhooks.allowPrivate` | boolean | false | allow webhook URLs on private/loopback IPs |
| `setup.completed` | boolean | false | first-run wizard done |
| `auth.mode` | `'password'\|'mixed'\|'oidc'` | `'password'` | sign-in ways offered (12 §1.1) |
+| `auth.oidc` | object \| null | null | the OpenID Connect provider, client secret encrypted (12 §3.1) |
| `sms.webhookToken` | string | random 32 chars, made on first read | secret part of the voip.ms SMS callback URL |
| `sip.restrictPhonesToLocalNets` | boolean | false | only accept phone registrations from `network.localNets` (+ `sip.extraAllowedNets`) |
| `sip.extraAllowedNets` | string[] | `[]` | e.g. a VPN subnet |
diff --git a/docs/spec/12-sso.md b/docs/spec/12-sso.md
index 93a5b40..8e7bbca 100644
--- a/docs/spec/12-sso.md
+++ b/docs/spec/12-sso.md
@@ -450,7 +450,7 @@ For every active user with a directory account, one connection per run:
| 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 |
+| 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 |
| 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 |
@@ -464,6 +464,31 @@ For every active user with a directory account, one connection per run:
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). If one doesn't, a secret with symbols fails with
+ `invalid_client`: `// TODO(spec)` 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.
+
## 9. Decided
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
diff --git a/packages/shared/src/auth.ts b/packages/shared/src/auth.ts
index 5872259..7f6b5dc 100644
--- a/packages/shared/src/auth.ts
+++ b/packages/shared/src/auth.ts
@@ -42,7 +42,8 @@ export type AuthUser = {
/** The ways a user can sign in (12 §2): their password, a connected OpenID account, the directory. */
export type SignInWays = {
password: boolean;
- oidc: { email: string | null } | null;
+ /** `groups`: what the provider sent at the last sign-in (12 §3.4), to copy into Settings. */
+ oidc: { email: string | null; groups: string[] } | null;
ldap: boolean;
};
diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts
index 7aae9bf..38073d6 100644
--- a/packages/shared/src/settings.ts
+++ b/packages/shared/src/settings.ts
@@ -118,6 +118,21 @@ export const SettingsSchemas = {
.default(null),
/** Sign-in (12 §1.1): passwords only, OpenID only (admins keep passwords), or both. */
'auth.mode': z.enum(['password', 'mixed', 'oidc']).default('password'),
+ /** OpenID Connect (12 §3.1). The client secret is encrypted; null = a public client. */
+ 'auth.oidc': z
+ .object({
+ label: z.string(),
+ issuer: z.string(),
+ clientId: z.string(),
+ clientSecretEnc: z.string().nullable(),
+ scopes: z.string(),
+ groupsClaim: z.string(),
+ adminGroups: z.array(z.string()),
+ allowedGroups: z.array(z.string()),
+ trustEmail: z.boolean(),
+ })
+ .nullable()
+ .default(null),
// Not in 02 §3's table but used by the generator (03 §3).
'system.transportHash': z.string().nullable().default(null),
};
@@ -150,12 +165,56 @@ export const ContrackInputSchema = z.object({
});
export type ContrackInput = z.input;
+const groupList = z
+ .array(z.string().trim().min(1).max(200))
+ .max(50)
+ .transform((l) => [...new Set(l)]);
+
+/** PATCH /api/settings `auth.oidc` and its Test: the client secret is write-only (empty keeps it). */
+export const OidcInputSchema = z.object({
+ label: z.string().trim().min(1, 'Required').max(40),
+ // Exactly as the provider writes it: the issuer is compared character for character
+ // (Authentik's ends in "/").
+ issuer: z.url({ protocol: /^https?$/ }).trim(),
+ clientId: z.string().trim().min(1, 'Required').max(200),
+ clientSecret: z.string().max(500).optional(),
+ clearClientSecret: z.boolean().optional(),
+ scopes: z
+ .string()
+ .trim()
+ .max(500)
+ .refine((s) => s.split(/\s+/).includes('openid'), 'Must include openid'),
+ groupsClaim: z.string().trim().min(1).max(200),
+ adminGroups: groupList,
+ allowedGroups: groupList,
+ trustEmail: z.boolean(),
+});
+export type OidcInput = z.input;
+
+/** POST /api/settings/auth/oidc/test. */
+export type OidcTestResult =
+ | {
+ ok: true;
+ issuer: string;
+ algorithms: string[];
+ pkce: boolean;
+ refreshTokens: boolean;
+ endSession: boolean;
+ redirectUri: string;
+ postLogoutRedirectUri: string;
+ }
+ | { ok: false; error: string };
+
/** GET /api/settings: every key, secrets replaced by hasPassword / hasCompanyToken. */
export type SettingsDto = {
- [K in Exclude]: SettingValue;
+ [
+ K in Exclude
+ ]: SettingValue;
} & {
smtp: (Omit & { hasPassword: boolean }) | null;
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
+ 'auth.oidc':
+ (Omit & { hasClientSecret: boolean }) | null;
};
/** POST /api/ai/test: one endpoint tried with a tiny request. `audio`: the TTS test, a data: URL. */
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5f3a79a..4fe4758 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -65,6 +65,9 @@ importers:
nodemailer:
specifier: 10.0.11
version: 10.0.11
+ openid-client:
+ specifier: ^6.8.8
+ version: 6.8.8
pg:
specifier: ^8.23.1
version: 8.23.1
@@ -1935,6 +1938,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
+ jose@6.2.13:
+ resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2171,6 +2177,9 @@ packages:
resolution: {integrity: sha512-/c4P7U7aGpiNu2Rl08q525q2zECkUpSaf2zQPgIStCcAjHAkkQQKwD4sTyi3l91mGcLQrKzL1syXPHymo+Ogtw==}
engines: {node: '>=20.0.0'}
+ oauth4webapi@3.8.8:
+ resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
+
obug@3.0.0:
resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
engines: {node: '>=12.20.0'}
@@ -2182,6 +2191,9 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+ openid-client@6.8.8:
+ resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==}
+
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -4097,6 +4109,8 @@ snapshots:
jiti@2.7.0: {}
+ jose@6.2.13: {}
+
js-tokens@4.0.0: {}
jsesc@3.1.0: {}
@@ -4267,6 +4281,8 @@ snapshots:
nodemailer@10.0.11: {}
+ oauth4webapi@3.8.8: {}
+
obug@3.0.0: {}
on-exit-leak-free@2.1.2: {}
@@ -4275,6 +4291,11 @@ snapshots:
dependencies:
wrappy: 1.0.2
+ openid-client@6.8.8:
+ dependencies:
+ jose: 6.2.13
+ oauth4webapi: 3.8.8
+
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
--
2.34.1
From 73963ee3a3028ec14ff80b497498b74cc84c2693 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Sun, 25 Oct 2026 15:14:59 -0500
Subject: [PATCH 4/7] auth: OpenID session check, live Keycloak test, sign-in
guide (S3)
Spec 12 S3: job oidcCheck refreshes every OpenID session at the provider every 15 minutes and
applies groups and role again; invalid_grant ends the session, an outage doesn't. A live test
makes a throwaway Keycloak realm and signs in through its login form (passed against 26.8.1).
NODE_EXTRA_CA_CERTS for a private CA. Admin guide: docs/admin-guide/sign-in.md.
---
.env.example | 3 +
apps/api/src/jobs.ts | 8 ++
apps/api/src/routes/oidc.ts | 24 +---
apps/api/src/sso/oidcCheck.ts | 144 ++++++++++++++++++++
apps/api/test/fake-oidc.ts | 6 +-
apps/api/test/oidc-live.test.ts | 226 ++++++++++++++++++++++++++++++++
apps/api/test/oidc.test.ts | 69 +++++++++-
docs/admin-guide/README.md | 1 +
docs/admin-guide/sign-in.md | 115 ++++++++++++++++
docs/spec/01-infrastructure.md | 1 +
docs/spec/12-sso.md | 24 +++-
11 files changed, 595 insertions(+), 26 deletions(-)
create mode 100644 apps/api/src/sso/oidcCheck.ts
create mode 100644 apps/api/test/oidc-live.test.ts
create mode 100644 docs/admin-guide/sign-in.md
diff --git a/.env.example b/.env.example
index 5835676..063e200 100644
--- a/.env.example
+++ b/.env.example
@@ -26,3 +26,6 @@ RTP_PORT_END=20000 # firewall and router. Two ports per call;
#AI_TTS_URL=http://127.0.0.1:8000/v1
#AI_TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
#AI_TTS_VOICE=af_heart
+# A private CA your identity provider or directory uses (admin guide: Sign-in). Put the PEM file
+# in data/tls/; the api reads it at start, so restart the api after changing it.
+#NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem
diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts
index 998cc3a..a933ada 100644
--- a/apps/api/src/jobs.ts
+++ b/apps/api/src/jobs.ts
@@ -27,6 +27,7 @@ import { backupDue, runBackup } from './services/backup.ts';
import { sendCallReports } from './services/callreport.ts';
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
import { logCallsToContrack } from './contacts/timeline.ts';
+import { checkOidcSessions } from './sso/oidcCheck.ts';
import { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
@@ -261,7 +262,13 @@ export function createJobs(d: JobDeps) {
await logCallsToContrack({ db: d.db, log: d.log, now: now() });
};
+ /** OpenID sessions refreshed at the provider; a leaver's end (12 §3.5). */
+ const oidcCheck = async () => {
+ await checkOidcSessions({ db: d.db, log: d.log, now: now() });
+ };
+
return {
+ oidcCheck,
daily,
backup,
publicIpWatch,
@@ -295,6 +302,7 @@ export function startJobs(d: JobDeps): () => void {
run('callReports', j.callReports);
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
run('contrackLog', j.contrackLog);
+ if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
diff --git a/apps/api/src/routes/oidc.ts b/apps/api/src/routes/oidc.ts
index 473e298..5b2d2e2 100644
--- a/apps/api/src/routes/oidc.ts
+++ b/apps/api/src/routes/oidc.ts
@@ -12,6 +12,7 @@ import { createSession, setSessionCookie } from '../auth/sessions.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decide, verifiedEmail } from '../sso/claims.ts';
+import { applyClaims } from '../sso/oidcCheck.ts';
import {
finishSignIn,
oidcError,
@@ -19,7 +20,6 @@ import {
type OidcSettings,
type PendingSignIn,
} from '../sso/oidc.ts';
-import { isLastAdmin } from './users.ts';
export const OIDC_COOKIE = 'sp_oidc';
const COOKIE_PATH = '/api/auth/oidc';
@@ -197,28 +197,10 @@ export async function oidcRoutes(app: FastifyInstance) {
const [user] = await db.select().from(users).where(eq(users.id, userId));
if (!user!.isActive) return fail('disabled', `${user!.email} is disabled`, userId);
- const role = d.admin === null ? user!.role : d.admin ? 'admin' : 'user';
- if (role !== user!.role) {
- if (role === 'user' && (await isLastAdmin(db, userId)))
- req.log.warn(
- { userId },
- `kept the last admin with a password, though ${s.label} says user`,
- );
- else {
- await db.update(users).set({ role }).where(eq(users.id, userId));
- await audit(db, {
- actorIp: req.ip,
- action: 'update',
- entityType: 'user',
- entityId: userId,
- summary: `${user!.email} is now ${role}, from ${s.label}'s groups`,
- diff: { before: { role: user!.role }, after: { role } },
- });
- }
- }
+ await applyClaims(db, req.log, s, userId, d, email, req.ip);
await db
.update(userIdentities)
- .set({ email, groups: d.groups, lastUsedAt: new Date() })
+ .set({ lastUsedAt: new Date() })
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
const token = await createSession(
db,
diff --git a/apps/api/src/sso/oidcCheck.ts b/apps/api/src/sso/oidcCheck.ts
new file mode 100644
index 0000000..3b5d193
--- /dev/null
+++ b/apps/api/src/sso/oidcCheck.ts
@@ -0,0 +1,144 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// What the provider says about a user, applied (12 §3.4), and the leaver check (12 §3.5): every
+// OpenID session is refreshed at the provider every 15 minutes, and ends when it says no.
+import { and, eq, gt, isNotNull, isNull, lt, or } from 'drizzle-orm';
+import * as oidc from 'openid-client';
+import type { Db } from '../db/client.ts';
+import { decrypt, encrypt } from '../crypto/secrets.ts';
+import { sessions, userIdentities, users } from '../db/schema.ts';
+import { isLastAdmin } from '../routes/users.ts';
+import { audit } from '../services/audit.ts';
+import { getSetting } from '../services/settings.ts';
+import { decide, type ClaimsDecision } from './claims.ts';
+import { oidcConfig, oidcError, type OidcSettings } from './oidc.ts';
+
+type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
+
+/** The role from adminGroups (never demoting the last admin with a password), groups, email. */
+export async function applyClaims(
+ db: Db,
+ log: Log,
+ s: OidcSettings,
+ userId: string,
+ d: ClaimsDecision,
+ email: string | null,
+ actorIp: string | null,
+): Promise {
+ const [user] = await db.select().from(users).where(eq(users.id, userId));
+ if (!user) return;
+ const role = d.admin === null ? user.role : d.admin ? 'admin' : 'user';
+ if (role !== user.role) {
+ if (role === 'user' && (await isLastAdmin(db, userId)))
+ log.info({ userId }, `kept the last admin with a password, though ${s.label} says user`);
+ else {
+ await db.update(users).set({ role }).where(eq(users.id, userId));
+ await audit(db, {
+ actorIp: actorIp ?? undefined,
+ action: 'update',
+ entityType: 'user',
+ entityId: userId,
+ summary: `${user.email} is now ${role}, from ${s.label}'s groups`,
+ diff: { before: { role: user.role }, after: { role } },
+ });
+ }
+ }
+ await db
+ .update(userIdentities)
+ .set({ email, groups: d.groups })
+ .where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
+}
+
+const CHECK_EVERY_MS = 15 * 60_000;
+
+/** One run of the oidcCheck job (every 5 minutes): sessions not checked for 15 minutes. */
+export async function checkOidcSessions(d: { db: Db; log: Log; now?: Date }): Promise {
+ const { db, log } = d;
+ const now = d.now ?? new Date();
+ const s = await getSetting(db, 'auth.oidc');
+ if (!s) return;
+ const due = await db
+ .select({
+ id: sessions.id,
+ userId: sessions.userId,
+ refreshEnc: sessions.idpRefreshEnc,
+ email: users.email,
+ subject: userIdentities.subject,
+ })
+ .from(sessions)
+ .innerJoin(users, eq(users.id, sessions.userId))
+ .leftJoin(
+ userIdentities,
+ and(eq(userIdentities.userId, sessions.userId), eq(userIdentities.kind, 'oidc')),
+ )
+ .where(
+ and(
+ eq(sessions.method, 'oidc'),
+ isNotNull(sessions.idpRefreshEnc),
+ gt(sessions.expiresAt, now),
+ or(
+ isNull(sessions.idpCheckedAt),
+ lt(sessions.idpCheckedAt, new Date(now.getTime() - CHECK_EVERY_MS)),
+ ),
+ ),
+ );
+ if (!due.length) return;
+ let config: oidc.Configuration;
+ try {
+ config = await oidcConfig(s);
+ } catch (err) {
+ log.warn?.({ err: oidcError(err) }, 'OpenID check skipped: the provider is unreachable');
+ return;
+ }
+
+ const end = async (row: (typeof due)[number], why: string) => {
+ await db.delete(sessions).where(eq(sessions.id, row.id));
+ await audit(db, {
+ actorUserId: row.userId,
+ action: 'logout',
+ entityType: 'user',
+ entityId: row.userId,
+ summary: `${row.email} signed out: ${why}`,
+ });
+ };
+
+ for (const row of due) {
+ // Disconnected since (Disconnect also ends these sessions; this is the belt to its braces).
+ if (!row.subject) {
+ await end(row, `no ${s.label} account connected any more`);
+ continue;
+ }
+ let tokens: Awaited>;
+ let claims: Record;
+ try {
+ tokens = await oidc.refreshTokenGrant(config, decrypt(row.refreshEnc!));
+ const sub = row.subject.slice(row.subject.indexOf(' ') + 1);
+ claims = config.serverMetadata().userinfo_endpoint
+ ? { ...(await oidc.fetchUserInfo(config, tokens.access_token, sub)) }
+ : { ...tokens.claims() };
+ } catch (err) {
+ // The provider said no: disabled, signed out there, or the refresh token expired.
+ if (err instanceof oidc.ResponseBodyError && err.error === 'invalid_grant') {
+ await end(row, `${s.label} ended the session (${oidcError(err)})`);
+ continue;
+ }
+ // Down, 5xx, a bad client setup: keep the session and try again next run.
+ log.warn?.({ err: oidcError(err) }, `OpenID check of a session failed`);
+ continue;
+ }
+ const verdict = decide(s, claims);
+ if (!verdict.allowed) {
+ await end(row, `no longer in ${s.label}'s allowed groups`);
+ continue;
+ }
+ await db
+ .update(sessions)
+ .set({
+ idpCheckedAt: now,
+ ...(tokens.refresh_token && { idpRefreshEnc: encrypt(tokens.refresh_token) }),
+ ...(tokens.id_token && { idpIdTokenEnc: encrypt(tokens.id_token) }),
+ })
+ .where(eq(sessions.id, row.id));
+ const email = typeof claims.email === 'string' ? claims.email : null;
+ await applyClaims(db, log, s, row.userId, verdict, email, null);
+ }
+}
diff --git a/apps/api/test/fake-oidc.ts b/apps/api/test/fake-oidc.ts
index 855a040..5930f42 100644
--- a/apps/api/test/fake-oidc.ts
+++ b/apps/api/test/fake-oidc.ts
@@ -35,6 +35,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
},
/** false: refresh tokens are refused, as for a user disabled at the provider. */
refreshOk: true,
+ /** true: the token endpoint answers 503, as a provider that's down. */
+ down: false,
refreshes: 0,
};
const codes = new Map();
@@ -83,6 +85,7 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
return res.end();
}
if (url.pathname === '/token' && req.method === 'POST') {
+ if (st.down) return json(503, { error: 'temporarily_unavailable' });
// client_secret_basic: both parts form-encoded before base64 (RFC 6749 §2.3.1).
const [id, secret] = Buffer.from(
(req.headers.authorization ?? '').replace(/^Basic /, ''),
@@ -97,7 +100,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
const user = refresh.get(form.get('refresh_token')!);
st.refreshes++;
if (!user || !st.refreshOk) return json(400, { error: 'invalid_grant' });
- return json(200, tokens(user, null));
+ // Today's claims, as a real provider gives on refresh.
+ return json(200, tokens(st.user.sub === user.sub ? { ...st.user } : user, null));
}
const c = codes.get(form.get('code') ?? '');
codes.delete(form.get('code') ?? '');
diff --git a/apps/api/test/oidc-live.test.ts b/apps/api/test/oidc-live.test.ts
new file mode 100644
index 0000000..494dd2a
--- /dev/null
+++ b/apps/api/test/oidc-live.test.ts
@@ -0,0 +1,226 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// 12 S3 against a real Keycloak: connect, sign in through Keycloak's own login form, a group
+// makes an admin, signing out there too, and disabling the user there ending our session.
+// Needs KEYCLOAK_LIVE_URL (e.g. http://127.0.0.1:18080) and KEYCLOAK_LIVE_ADMIN ("user:password"
+// of the master realm); makes a throwaway realm and deletes it. Passed against 26.8.1:
+// docker run -d -p 127.0.0.1:18080:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
+// -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.8.1 start-dev
+import { randomBytes } from 'node:crypto';
+import { eq } from 'drizzle-orm';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { encrypt } from '../src/crypto/secrets.ts';
+import { sessions, users } from '../src/db/schema.ts';
+import { setSetting } from '../src/services/settings.ts';
+import { checkOidcSessions } from '../src/sso/oidcCheck.ts';
+import { hasTestDb } from './db.ts';
+import { ADMIN, H, createTestApp } from './app.ts';
+
+const KC = process.env.KEYCLOAK_LIVE_URL;
+const [KC_USER, KC_PASS] = (process.env.KEYCLOAK_LIVE_ADMIN ?? 'admin:admin').split(':');
+
+/** A cookie jar for the browser's side of Keycloak. */
+class Browser {
+ jar = new Map();
+ async get(url: string, init: RequestInit = {}) {
+ const res = await fetch(url, {
+ ...init,
+ redirect: 'manual',
+ headers: { ...(init.headers as object), cookie: this.cookie() },
+ });
+ for (const c of res.headers.getSetCookie()) {
+ const [kv] = c.split(';');
+ const i = kv!.indexOf('=');
+ this.jar.set(kv!.slice(0, i), kv!.slice(i + 1));
+ }
+ return res;
+ }
+ cookie = () => [...this.jar].map(([k, v]) => `${k}=${v}`).join('; ');
+}
+
+describe.skipIf(!hasTestDb || !KC)('OpenID Connect with a real Keycloak (12 S3)', () => {
+ const realm = `sp-test-${randomBytes(4).toString('hex')}`;
+ const issuer = `${KC}/realms/${realm}`;
+ let t: Awaited>;
+ let token: string;
+ let kcUserId: string;
+ const browser = new Browser();
+
+ const kcAdmin = async (method: string, path: string, body?: unknown) => {
+ const res = await fetch(`${KC}/admin/realms${path}`, {
+ method,
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
+ return res;
+ };
+
+ beforeAll(async () => {
+ const tok = await fetch(`${KC}/realms/master/protocol/openid-connect/token`, {
+ method: 'POST',
+ body: new URLSearchParams({
+ grant_type: 'password',
+ client_id: 'admin-cli',
+ username: KC_USER!,
+ password: KC_PASS!,
+ }),
+ });
+ token = ((await tok.json()) as { access_token: string }).access_token;
+ await kcAdmin('POST', '', { realm, enabled: true });
+ await kcAdmin('POST', `/${realm}/clients`, {
+ clientId: 'selfphoneng',
+ protocol: 'openid-connect',
+ publicClient: false,
+ secret: 'live-secret-with-symbols-+/=',
+ standardFlowEnabled: true,
+ directAccessGrantsEnabled: false,
+ redirectUris: ['https://pbx.test/api/auth/oidc/callback'],
+ attributes: { 'post.logout.redirect.uris': 'https://pbx.test/login' },
+ protocolMappers: [
+ {
+ name: 'groups',
+ protocol: 'openid-connect',
+ protocolMapper: 'oidc-group-membership-mapper',
+ config: {
+ 'full.path': 'false',
+ 'id.token.claim': 'true',
+ 'access.token.claim': 'true',
+ 'userinfo.token.claim': 'true',
+ 'claim.name': 'groups',
+ },
+ },
+ ],
+ });
+ await kcAdmin('POST', `/${realm}/groups`, { name: 'pbx-admins' });
+ const u = await kcAdmin('POST', `/${realm}/users`, {
+ username: 'ann',
+ email: 'ann@example.com',
+ emailVerified: true,
+ firstName: 'Ann',
+ lastName: 'Lee',
+ enabled: true,
+ groups: ['/pbx-admins'],
+ credentials: [{ type: 'password', value: 'ann-keycloak-pw', temporary: false }],
+ });
+ kcUserId = u.headers.get('location')!.split('/').at(-1)!;
+
+ t = await createTestApp();
+ await t.clearMustChange(ADMIN.email);
+ await t.addUser('ann@example.com', 'ann-password-12');
+ await setSetting(t.db, 'auth.oidc', {
+ label: 'Keycloak',
+ issuer,
+ clientId: 'selfphoneng',
+ clientSecretEnc: encrypt('live-secret-with-symbols-+/='),
+ scopes: 'openid email profile',
+ groupsClaim: 'groups',
+ adminGroups: ['pbx-admins'],
+ allowedGroups: [],
+ trustEmail: false,
+ });
+ await setSetting(t.db, 'auth.mode', 'mixed');
+ }, 60_000);
+
+ afterAll(async () => {
+ await t?.close();
+ if (token) await kcAdmin('DELETE', `/${realm}`).catch(() => {});
+ });
+
+ /** start → Keycloak's login form, filled in like a person would → our callback. */
+ async function flow(cookie?: string, query = '') {
+ const start = await t.app.inject({
+ url: `/api/auth/oidc/start${query}`,
+ headers: cookie ? { cookie } : {},
+ });
+ const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
+ let res = await browser.get(start.headers.location as string);
+ if (res.status === 200) {
+ // Not signed in at Keycloak yet: post its login form.
+ const html = await res.text();
+ const action = /]*id="kc-form-login"[^>]*action="([^"]+)"/.exec(html)![1]!;
+ res = await browser.get(action.replaceAll('&', '&'), {
+ method: 'POST',
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ username: 'ann', password: 'ann-keycloak-pw' }).toString(),
+ });
+ }
+ expect(res.status).toBe(302);
+ const back = new URL(res.headers.get('location')!);
+ expect(back.origin).toBe('https://pbx.test');
+ return t.app.inject({
+ url: back.pathname + back.search,
+ headers: { cookie: [`sp_oidc=${pending.value}`, cookie].filter(Boolean).join('; ') },
+ });
+ }
+ const session = (r: Awaited>) => {
+ const c = r.cookies.find((x) => x.name === 'sp_session');
+ return c ? `sp_session=${c.value}` : null;
+ };
+
+ it('Test sees the realm and accepts the client', async () => {
+ const admin = await t.login(ADMIN.email, ADMIN.password);
+ const r = await t.app.inject({
+ method: 'POST',
+ url: '/api/settings/auth/oidc/test',
+ headers: { ...H, cookie: admin },
+ payload: {
+ label: 'Keycloak',
+ issuer,
+ clientId: 'selfphoneng',
+ scopes: 'openid email profile',
+ groupsClaim: 'groups',
+ adminGroups: [],
+ allowedGroups: [],
+ trustEmail: false,
+ },
+ });
+ expect(r.json()).toMatchObject({ ok: true, pkce: true, refreshTokens: true, endSession: true });
+ });
+
+ it('connects, signs in through the login form, and the group makes an admin', async () => {
+ const pw = await t.login('ann@example.com', 'ann-password-12');
+ const c = await flow(pw, '?connect=1');
+ expect(c.headers.location).toBe('/me/settings?sso=connected');
+ const r = await flow();
+ const cookie = session(r);
+ expect(cookie).toBeTruthy();
+ const me = (await t.app.inject({ url: '/api/auth/me', headers: { cookie: cookie! } })).json();
+ expect(me.user).toMatchObject({ role: 'admin' });
+ expect(me.user.signIn.oidc).toEqual({ email: 'ann@example.com', groups: ['pbx-admins'] });
+ });
+
+ it('signing out signs out of Keycloak too', async () => {
+ const r = await flow(); // already signed in at Keycloak: straight back
+ const out = await t.app.inject({
+ method: 'POST',
+ url: '/api/auth/logout',
+ headers: { ...H, cookie: session(r)! },
+ });
+ const to = out.json().redirect as string;
+ expect(to.startsWith(`${issuer}/protocol/openid-connect/logout`)).toBe(true);
+ const done = await browser.get(to);
+ expect(done.status).toBe(302);
+ expect(done.headers.get('location')).toBe('https://pbx.test/login');
+ // Keycloak forgot us: the next sign-in shows its login form again (flow() fills it).
+ expect(session(await flow())).toBeTruthy();
+ });
+
+ it('disabling the user in Keycloak ends their session at the next check', async () => {
+ const r = await flow();
+ const cookie = session(r)!;
+ const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
+ const check = (min: number) =>
+ checkOidcSessions({
+ db: t.db,
+ log: t.app.log,
+ now: new Date(Date.now() + min * 60_000),
+ });
+ await check(16);
+ expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie } })).statusCode).toBe(200);
+ await kcAdmin('PUT', `/${realm}/users/${kcUserId}`, { enabled: false });
+ await check(32);
+ const left = await t.db.select().from(sessions).where(eq(sessions.userId, ann!.id));
+ expect(left.filter((s) => s.method === 'oidc')).toEqual([]);
+ expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie } })).statusCode).toBe(401);
+ });
+});
diff --git a/apps/api/test/oidc.test.ts b/apps/api/test/oidc.test.ts
index abe2860..f881295 100644
--- a/apps/api/test/oidc.test.ts
+++ b/apps/api/test/oidc.test.ts
@@ -1,13 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 S2: OpenID Connect sign-in, connect, disconnect, modes and signing out, against a fake
// provider (test/fake-oidc.ts).
-import { and, eq } from 'drizzle-orm';
+import { and, desc, eq } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { encrypt } from '../src/crypto/secrets.ts';
import { sessions, userIdentities, users } from '../src/db/schema.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { setSetting } from '../src/services/settings.ts';
import type { OidcSettings } from '../src/sso/oidc.ts';
+import { checkOidcSessions } from '../src/sso/oidcCheck.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
import { startFakeOidc } from './fake-oidc.ts';
@@ -46,6 +47,8 @@ for (const alg of ['RS256', 'ES256'] as const)
resetLoginLimiter();
idp.st.tamper = {};
idp.st.user = { sub: 'u-1', email: 'ann@example.com', email_verified: true };
+ idp.st.refreshOk = true;
+ idp.st.down = false;
});
/** The browser's round trip: start → provider → callback. */
@@ -336,4 +339,68 @@ for (const alg of ['RS256', 'ES256'] as const)
});
expect(remote.json().error.message).toContain('https');
});
+
+ /** Ann's OpenID session, connected again (an earlier test disconnects her). */
+ async function annSession() {
+ const pw = await t.login('ann@example.com', 'ann-password-12');
+ await flow(pw, '?connect=1');
+ const { callback } = await flow();
+ const cookie = session(callback!)!;
+ const [row] = await t.db
+ .select()
+ .from(sessions)
+ .where(eq(sessions.method, 'oidc'))
+ .orderBy(desc(sessions.idpCheckedAt))
+ .limit(1);
+ return { cookie, id: row!.id };
+ }
+ const later = (min: number) => new Date(Date.now() + min * 60_000);
+ const check = (min: number) => checkOidcSessions({ db: t.db, log: t.app.log, now: later(min) });
+ const row = async (id: string) =>
+ (await t.db.select().from(sessions).where(eq(sessions.id, id)))[0];
+
+ it('the session check: kept while the provider says yes, ended when it says no (§3.5)', async () => {
+ const { cookie, id } = await annSession();
+ const signedIn = (await row(id))!.idpCheckedAt!.getTime();
+ await check(5); // checked at sign-in: not due yet
+ expect((await row(id))!.idpCheckedAt!.getTime()).toBe(signedIn);
+ await check(16);
+ expect((await row(id))!.idpCheckedAt!.getTime()).toBeGreaterThan(signedIn);
+
+ idp.st.down = true; // an outage signs no one out
+ await check(40);
+ expect(await row(id)).toBeTruthy();
+ idp.st.down = false;
+
+ idp.st.user = { ...idp.st.user, groups: ['pbx-admins'] };
+ await setSetting(t.db, 'auth.oidc', { ...oidcSettings, adminGroups: ['pbx-admins'] });
+ try {
+ await check(56); // groups changed at the provider: the role follows
+ const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
+ expect(ann!.role).toBe('admin');
+ await t.db.update(users).set({ role: 'user' }).where(eq(users.id, ann!.id));
+ } finally {
+ await setSetting(t.db, 'auth.oidc', oidcSettings);
+ }
+
+ idp.st.refreshOk = false; // disabled at the provider
+ await check(72);
+ expect(await row(id)).toBeUndefined();
+ expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie } })).statusCode).toBe(
+ 401,
+ );
+ });
+
+ it('the session check ends a session no longer in the allowed groups', async () => {
+ idp.st.user = { ...idp.st.user, groups: ['pbx-users'] };
+ await setSetting(t.db, 'auth.oidc', { ...oidcSettings, allowedGroups: ['pbx-users'] });
+ try {
+ const { id } = await annSession();
+ idp.st.user.groups = [];
+ await check(20);
+ expect(await row(id)).toBeUndefined();
+ } finally {
+ await setSetting(t.db, 'auth.oidc', oidcSettings);
+ }
+ });
});
diff --git a/docs/admin-guide/README.md b/docs/admin-guide/README.md
index 230bdbe..8bb2efd 100644
--- a/docs/admin-guide/README.md
+++ b/docs/admin-guide/README.md
@@ -12,6 +12,7 @@ keeping the system safe, legal and up to date.
7. [Analog phones (ATAs)](ata.md)
8. [Local AI](ai.md): voicemail transcripts
9. [Contacts](contacts.md): the builtin phonebook, or Contrack
+10. [Sign-in](sign-in.md): passwords, and OpenID Connect (Keycloak and others)
---
diff --git a/docs/admin-guide/sign-in.md b/docs/admin-guide/sign-in.md
new file mode 100644
index 0000000..0344dea
--- /dev/null
+++ b/docs/admin-guide/sign-in.md
@@ -0,0 +1,115 @@
+# Sign-in: passwords and OpenID Connect
+
+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.
+
+## 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
+
+1. Pick the realm your people are in (or make one). The **issuer** is
+ `https:///realms/`.
+2. **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.)
+3. **Credentials** tab of the client: copy the **Client secret**.
+4. To make admins from a group (optional): **Groups → Create group** `pbx-admins` and put your
+ admins in it. Then the client's **Client scopes** tab → `selfphoneng-dedicated` → **Add mapper →
+ By configuration → Group Membership**: name `groups`, Token Claim Name `groups`, **Full group
+ path off**, and **Add to ID token**, **Add to access token** and **Add to userinfo** on.
+5. 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_access` to the Scopes in step 2 below. Disabling the user in
+ Keycloak still signs them out.
+
+## 2. In SelfPhoneNG
+
+**Settings → Sign-in**:
+
+1. **Issuer** `https://keycloak.example.com/realms/company`, **Client ID** `selfphoneng`,
+ **Client secret** from step 1.3. **Button name** `Keycloak` (shown as "Sign in with Keycloak").
+2. **Scopes** `openid email profile` (add `offline_access`, see 1.5).
+3. **Admin groups** `pbx-admins` if 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.
+4. **Test**. It should say "Works", with refresh tokens and "signs out there too".
+5. Pick **Passwords or OpenID** and **Save**.
+6. **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`):
+
+```dotenv
+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/`. |
+| 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//` (keep the last `/`) | `groups` | |
+| Authelia | `https://auth.example.com` | `groups` (add the `groups` scope) | add `offline_access` for the 15-minute check |
+| Zitadel | `https://` | `urn:zitadel:iam:org:project:roles` | turn on "Assert roles on authentication" |
+| Microsoft Entra ID | `https://login.microsoftonline.com//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.
diff --git a/docs/spec/01-infrastructure.md b/docs/spec/01-infrastructure.md
index f59aca5..7293db1 100644
--- a/docs/spec/01-infrastructure.md
+++ b/docs/spec/01-infrastructure.md
@@ -121,6 +121,7 @@ APP_UID=1000
APP_GID=1000
LOG_LEVEL=info
TRUST_PROXY=true # true when behind Caddy/nginx
+#NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem # a private CA for the OpenID provider or LDAPS (12 §1)
```
`apps/api/src/config.ts` validates these with zod at startup. If one is
diff --git a/docs/spec/12-sso.md b/docs/spec/12-sso.md
index 8e7bbca..e04c033 100644
--- a/docs/spec/12-sso.md
+++ b/docs/spec/12-sso.md
@@ -451,7 +451,7 @@ For every active user with a directory account, one connection per run:
|---|---|---|
| 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 |
+| 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 |
| 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 |
@@ -474,8 +474,9 @@ For every active user with a directory account, one connection per run:
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). If one doesn't, a secret with symbols fails with
- `invalid_client`: `// TODO(spec)` fall back to `client_secret_post` then, if it's ever seen.
+ `%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
@@ -489,6 +490,23 @@ For every active user with a directory account, one connection per run:
- `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`).
+
## 9. Decided
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
--
2.34.1
From b06335586b11cae95f90333cd97b7833c31c8c31 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Sun, 25 Oct 2026 18:39:52 -0500
Subject: [PATCH 5/7] auth: sign in with LDAP/LDAPS, directory settings and
Test (S4)
Spec 12 S4: the login form checks the SelfPhoneNG password first, then the directory (ldapts,
service-account search, the user's own bind on a second connection). Presets for lldap, AD,
FreeIPA and OpenLDAP; Test with an optional test login. A directory account connects by email,
never makes a user; adminFilter sets the role (applyRole shared with OpenID). One Disconnect
route per kind (oidc, ldap). Live test against lldap 0.6.3 over LDAPS.
---
apps/api/package.json | 1 +
apps/api/src/routes/auth.ts | 45 ++++-
apps/api/src/routes/oidc.ts | 39 ++--
apps/api/src/routes/settings.ts | 82 +++++++-
apps/api/src/sso/directory.ts | 71 +++++++
apps/api/src/sso/ldap.test.ts | 42 +++++
apps/api/src/sso/ldap.ts | 216 ++++++++++++++++++++++
apps/api/src/sso/oidcCheck.ts | 23 +--
apps/api/src/sso/role.ts | 35 ++++
apps/api/test/ldap-live.test.ts | 205 ++++++++++++++++++++
apps/web/src/api/client.ts | 8 +-
apps/web/src/components/SignInCard.tsx | 5 +
apps/web/src/pages/LoginPage.tsx | 7 +-
apps/web/src/pages/admin/SettingsPage.tsx | 216 +++++++++++++++++++++-
apps/web/src/pages/admin/UserDrawer.tsx | 37 ++--
docs/admin-guide/sign-in.md | 35 +++-
docs/spec/12-sso.md | 26 ++-
packages/shared/src/settings.ts | 115 +++++++++++-
pnpm-lock.yaml | 16 ++
19 files changed, 1160 insertions(+), 64 deletions(-)
create mode 100644 apps/api/src/sso/directory.ts
create mode 100644 apps/api/src/sso/ldap.test.ts
create mode 100644 apps/api/src/sso/ldap.ts
create mode 100644 apps/api/src/sso/role.ts
create mode 100644 apps/api/test/ldap-live.test.ts
diff --git a/apps/api/package.json b/apps/api/package.json
index 30fad6c..324c4e7 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -18,6 +18,7 @@
"@selfphoneng/shared": "workspace:*",
"drizzle-orm": "^0.45.4",
"fastify": "^5.12.7",
+ "ldapts": "^9.2.0",
"nodemailer": "10.0.11",
"openid-client": "^6.8.8",
"pg": "^8.23.1",
diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts
index 5ca3cb9..0adfe79 100644
--- a/apps/api/src/routes/auth.ts
+++ b/apps/api/src/routes/auth.ts
@@ -26,6 +26,8 @@ import {
import type { Db } from '../db/client.ts';
import { extensions, sessions, userIdentities, users } from '../db/schema.ts';
import { endSessionUrl, oidcError } from '../sso/oidc.ts';
+import { directorySignIn } from '../sso/directory.ts';
+import { ldapAuthenticate } from '../sso/ldap.ts';
import { getSetting } from '../services/settings.ts';
import {
unauthorized,
@@ -213,7 +215,7 @@ export async function authRoutes(app: FastifyInstance) {
const oidc = await getSetting(db, 'auth.oidc');
return {
mode,
- ldap: false, // S4
+ ldap: Boolean((await getSetting(db, 'auth.ldap'))?.enabled),
oidc: oidc && mode !== 'password' ? { label: oidc.label } : null,
};
});
@@ -221,13 +223,32 @@ export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/login', async (req, reply) => {
const { login, ...body } = LoginSchema.parse(req.body);
if (isLoginBlocked(req.ip, login)) throw rateLimited();
- const [user] = await db.select().from(users).where(eq(users.email, login));
+ let [user] = await db.select().from(users).where(eq(users.email, login));
// No password (OpenID only, 12 §3.3) fails like a wrong one, after the same work.
- const ok =
- user?.isActive && user.passwordHash
- ? await verifyPassword(user.passwordHash, body.password)
- : (await burnVerify(body.password), false);
- if (!user || !ok) {
+ let ok = user?.passwordHash
+ ? await verifyPassword(user.passwordHash, body.password)
+ : (await burnVerify(body.password), false);
+ let method: 'password' | 'ldap' = 'password';
+ // Not this password here: the directory's, when it's on (12 §4.2).
+ const ldap = ok ? null : await getSetting(db, 'auth.ldap');
+ if (ldap?.enabled) {
+ let entry;
+ try {
+ entry = await ldapAuthenticate(ldap, login, body.password);
+ } catch (err) {
+ req.log.warn({ event: 'auth.ldap', err: (err as Error).message }, 'directory unavailable');
+ throw new HttpError(
+ 503,
+ 'directory_unavailable',
+ 'The directory can’t be reached. Passwords set in SelfPhoneNG still work.',
+ );
+ }
+ const found = entry && (await directorySignIn(db, req.log, entry, req.ip));
+ if (entry && !found)
+ req.log.warn({ event: 'auth.failed', ip: req.ip }, 'directory account has no user here');
+ if (found) [user, ok, method] = [found, true, 'ldap'];
+ }
+ if (!user || !ok || !user.isActive) {
recordLoginFailure(req.ip, login);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed');
await audit(db, {
@@ -261,7 +282,13 @@ export async function authRoutes(app: FastifyInstance) {
throw new HttpError(401, 'totp_invalid', 'That code is wrong or was used already');
}
}
- const token = await createSession(db, user.id, req.ip, req.headers['user-agent'] ?? null);
+ const token = await createSession(
+ db,
+ user.id,
+ req.ip,
+ req.headers['user-agent'] ?? null,
+ method,
+ );
setSessionCookie(reply, token);
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));
await audit(db, {
@@ -270,7 +297,7 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login',
entityType: 'user',
entityId: user.id,
- summary: `${user.email} logged in`,
+ summary: `${user.email} logged in${method === 'ldap' ? ' with the directory' : ''}`,
});
return { user: await toAuthUser(db, user) };
});
diff --git a/apps/api/src/routes/oidc.ts b/apps/api/src/routes/oidc.ts
index 5b2d2e2..1fcf4d4 100644
--- a/apps/api/src/routes/oidc.ts
+++ b/apps/api/src/routes/oidc.ts
@@ -224,38 +224,51 @@ export async function oidcRoutes(app: FastifyInstance) {
return reply.redirect(pending.next);
});
- /** Disconnect (12 §3.3): refused when it would leave them no way in under the current mode. */
- async function disconnect(req: FastifyRequest, reply: FastifyReply, userId: string) {
+ /**
+ * Disconnect an OpenID or directory account (12 §3.3, §4.3): refused when it would leave
+ * them no way in under the current mode. Sessions made with it end.
+ */
+ async function disconnect(
+ req: FastifyRequest,
+ reply: FastifyReply,
+ userId: string,
+ kind: 'oidc' | 'ldap',
+ ) {
const [u] = await db.select().from(users).where(eq(users.id, userId));
if (!u) throw notFound('User not found');
const ids = await db
.select({ kind: userIdentities.kind })
.from(userIdentities)
.where(eq(userIdentities.userId, userId));
- if (!ids.some((i) => i.kind === 'oidc')) throw notFound('No OpenID account is connected');
+ if (!ids.some((i) => i.kind === kind)) throw notFound('No such account is connected');
+ const left = ids.filter((i) => i.kind !== kind);
const mode = await getSetting(db, 'auth.mode');
- const passwordWay = u.passwordHash !== null || ids.some((i) => i.kind === 'ldap');
- if (!passwordWay || (mode === 'oidc' && u.role !== 'admin'))
+ const password = u.passwordHash !== null || left.some((i) => i.kind === 'ldap');
+ const openid = mode !== 'password' && left.some((i) => i.kind === 'oidc');
+ if (!openid && !(password && (mode !== 'oidc' || u.role === 'admin')))
throw new HttpError(409, 'last_way_in', 'That would leave no way to sign in');
await db
.delete(userIdentities)
- .where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
- await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.method, 'oidc')));
+ .where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, kind)));
+ await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.method, kind)));
+ const what = kind === 'oidc' ? 'OpenID' : 'directory';
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'sso_disconnected',
entityType: 'user',
entityId: userId,
- summary: `Disconnected the OpenID account of ${u.email}`,
+ summary: `Disconnected the ${what} account of ${u.email}`,
});
return reply.send({ ok: true });
}
- app.delete('/api/me/identities/oidc', { preHandler: requireAuth }, (req, reply) =>
- disconnect(req, reply, req.user!.id),
- );
- app.delete('/api/users/:id/identities/oidc', { preHandler: requireAdmin }, (req, reply) =>
- disconnect(req, reply, z.object({ id: z.uuid() }).parse(req.params).id),
+ const Kind = z.object({ kind: z.enum(['oidc', 'ldap']) });
+ app.delete('/api/me/identities/:kind', { preHandler: requireAuth }, (req, reply) =>
+ disconnect(req, reply, req.user!.id, Kind.parse(req.params).kind),
);
+ app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
+ const p = Kind.extend({ id: z.uuid() }).parse(req.params);
+ return disconnect(req, reply, p.id, p.kind);
+ });
}
diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts
index 0b254e8..0b38e62 100644
--- a/apps/api/src/routes/settings.ts
+++ b/apps/api/src/routes/settings.ts
@@ -7,12 +7,14 @@ import {
AiEndpointSchema,
AiTtsEndpointSchema,
ContrackInputSchema,
+ LdapInputSchema,
OidcInputSchema,
SettingsSchemas,
SmtpInputSchema,
type AiTestResult,
type AuthMode,
type Destination,
+ type LdapTestResult,
type OidcInput,
type OidcTestResult,
type SettingKey,
@@ -34,6 +36,8 @@ import { detectPublicIp } from '../jobs.ts';
import type { SendMail } from '../services/mail.ts';
import { AiError, assertLocalUrl, testEndpoint } from '../ai/client.ts';
import { forgetBook } from '../contacts/contrack.ts';
+import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts';
+import { directoryUserFor } from '../sso/directory.ts';
import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
declare module 'fastify' {
@@ -52,9 +56,29 @@ const READ_ONLY = new Set([
]);
async function toDto(db: Db): Promise {
- const { smtp, 'contacts.contrack': ctk, 'auth.oidc': oidc, ...rest } = await getAllSettings(db);
+ const {
+ smtp,
+ 'contacts.contrack': ctk,
+ 'auth.oidc': oidc,
+ 'auth.ldap': ldap,
+ ...rest
+ } = await getAllSettings(db);
return {
...rest,
+ 'auth.ldap': ldap && {
+ enabled: ldap.enabled,
+ preset: ldap.preset,
+ url: ldap.url,
+ startTls: ldap.startTls,
+ bindDn: ldap.bindDn,
+ baseDn: ldap.baseDn,
+ userFilter: ldap.userFilter,
+ idAttribute: ldap.idAttribute,
+ emailAttribute: ldap.emailAttribute,
+ nameAttribute: ldap.nameAttribute,
+ adminFilter: ldap.adminFilter,
+ hasBindPassword: ldap.bindPasswordEnc !== null,
+ },
'auth.oidc': oidc && {
label: oidc.label,
issuer: oidc.issuer,
@@ -117,6 +141,36 @@ async function checkRefs(db: Db, v: Record) {
export async function settingsRoutes(app: FastifyInstance) {
const { db } = app;
+ /** The stored form of `auth.ldap`: the bind password is write-only (empty keeps it). */
+ async function ldapFromInput(raw: unknown): Promise {
+ if (raw === null) return null;
+ const v = LdapInputSchema.parse(raw);
+ try {
+ assertLdap(v);
+ } catch (err) {
+ throw badRequest((err as Error).message);
+ }
+ const old = await getSetting(db, 'auth.ldap');
+ return {
+ enabled: v.enabled,
+ preset: v.preset,
+ url: v.url.replace(/\/$/, ''),
+ startTls: v.startTls,
+ bindDn: v.bindDn,
+ bindPasswordEnc: v.bindPassword
+ ? encrypt(v.bindPassword)
+ : v.clearBindPassword
+ ? null
+ : (old?.bindPasswordEnc ?? null),
+ baseDn: v.baseDn,
+ userFilter: v.userFilter,
+ idAttribute: v.idAttribute,
+ emailAttribute: v.emailAttribute,
+ nameAttribute: v.nameAttribute,
+ adminFilter: v.adminFilter,
+ };
+ }
+
/** The stored form of `auth.oidc`: the secret is write-only (empty keeps the stored one). */
async function oidcFromInput(input: OidcInput | null): Promise {
if (!input) return null;
@@ -184,6 +238,8 @@ export async function settingsRoutes(app: FastifyInstance) {
: (old?.companyTokenEnc ?? ''),
};
forgetBook(null);
+ } else if (key === 'auth.ldap') {
+ values[key] = await ldapFromInput(raw);
} else if (key === 'auth.oidc') {
values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
@@ -291,6 +347,30 @@ export async function settingsRoutes(app: FastifyInstance) {
return { notified };
});
+ /**
+ * Settings → Sign-in → Directory → Test, from the unsaved form (12 §4.1). The test password
+ * is used once and never stored or logged.
+ */
+ app.post('/api/settings/auth/ldap/test', async (req): Promise => {
+ const { testLogin, testPassword, ...form } = LdapInputSchema.extend({
+ testLogin: z.string().trim().max(254).optional(),
+ testPassword: z.string().max(200).optional(),
+ }).parse(req.body);
+ const s = (await ldapFromInput(form))!;
+ try {
+ const r = await testLdap(s, testLogin || undefined, testPassword);
+ if (!r.user) return { ok: true, matching: r.matching };
+ const matches = await directoryUserFor(db, r.user);
+ return {
+ ok: true,
+ matching: r.matching,
+ user: { ...r.user, matches: matches?.email ?? null },
+ };
+ } catch (err) {
+ return { ok: false, error: (err as Error).message };
+ }
+ });
+
/** Settings → Sign-in → Test, from the unsaved form (12 §3.1). */
app.post('/api/settings/auth/oidc/test', async (req): Promise => {
const s = await oidcFromInput(OidcInputSchema.parse(req.body));
diff --git a/apps/api/src/sso/directory.ts b/apps/api/src/sso/directory.ts
new file mode 100644
index 0000000..eaacefd
--- /dev/null
+++ b/apps/api/src/sso/directory.ts
@@ -0,0 +1,71 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
+// email. Never a new user (owner, 2026-10-25).
+import { and, eq } from 'drizzle-orm';
+import type { Db } from '../db/client.ts';
+import { userIdentities, users } from '../db/schema.ts';
+import { audit } from '../services/audit.ts';
+import type { DirectoryEntry } from './ldap.ts';
+import { applyRole } from './role.ts';
+
+type UserRow = typeof users.$inferSelect;
+
+/** Who this entry is here, without changing anything (Settings → Test says it too). */
+export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise {
+ const [byId] = await db
+ .select({ u: users })
+ .from(userIdentities)
+ .innerJoin(users, eq(users.id, userIdentities.userId))
+ .where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
+ if (byId) return byId.u;
+ if (!e.email) return null;
+ const [byEmail] = await db.select().from(users).where(eq(users.email, e.email));
+ return byEmail ?? null;
+}
+
+/**
+ * The user this directory sign-in is for, connected now if it was matched by email (the
+ * directory is the company's own and the person just proved its password), with the role from
+ * adminFilter. Null: nobody here has this directory account or email.
+ */
+export async function directorySignIn(
+ db: Db,
+ log: { info(o: object, m: string): void },
+ e: DirectoryEntry,
+ ip: string,
+): Promise {
+ const u = await directoryUserFor(db, e);
+ if (!u) return null;
+ const [mine] = await db
+ .select({ subject: userIdentities.subject })
+ .from(userIdentities)
+ .where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
+ if (!mine) {
+ await db.insert(userIdentities).values({
+ userId: u.id,
+ kind: 'ldap',
+ subject: e.id,
+ email: e.email,
+ lastUsedAt: new Date(),
+ });
+ await audit(db, {
+ actorUserId: u.id,
+ actorIp: ip,
+ action: 'ldap_connected',
+ entityType: 'user',
+ entityId: u.id,
+ summary: `${u.email} connected directory account ${e.dn}`,
+ });
+ } else if (mine.subject !== e.id) {
+ // Their email matches, but another directory account is already theirs: not this one.
+ log.info({ userId: u.id }, `directory entry ${e.dn} matches by email but isn't theirs`);
+ return null;
+ } else
+ await db
+ .update(userIdentities)
+ .set({ email: e.email, lastUsedAt: new Date() })
+ .where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
+ await applyRole(db, log, u.id, e.admin, 'the directory', ip);
+ const [after] = await db.select().from(users).where(eq(users.id, u.id));
+ return after!;
+}
diff --git a/apps/api/src/sso/ldap.test.ts b/apps/api/src/sso/ldap.test.ts
new file mode 100644
index 0000000..7c6fec4
--- /dev/null
+++ b/apps/api/src/sso/ldap.test.ts
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import { describe, expect, it } from 'vitest';
+import { anyLogin, assertLdap, ldapAuthenticate, withLogin, type LdapSettings } from './ldap.ts';
+
+describe('LDAP (12 §4)', () => {
+ const f = '(&(objectClass=person)(|(uid={login})(mail={login})))';
+
+ it('escapes the login, so it can only ever be a value (RFC 4515)', () => {
+ expect(withLogin(f, 'ann')).toBe('(&(objectClass=person)(|(uid=ann)(mail=ann)))');
+ expect(withLogin(f, '*)(uid=*))(|(uid=*')).toBe(
+ '(&(objectClass=person)(|(uid=\\2a\\29\\28uid=\\2a\\29\\29\\28|\\28uid=\\2a)(mail=\\2a\\29\\28uid=\\2a\\29\\29\\28|\\28uid=\\2a)))',
+ );
+ expect(withLogin('(uid={login})', 'a\\b\u0000')).toBe('(uid=a\\5cb\\00)');
+ });
+
+ it('{login} → * keeps the other conditions, for the leaver check', () => {
+ expect(anyLogin(f)).toBe('(&(objectClass=person)(|(uid=*)(mail=*)))');
+ });
+
+ it('never sends a password in clear text to another host', () => {
+ const ok = { startTls: false, userFilter: f, adminFilter: null };
+ expect(() => assertLdap({ ...ok, url: 'ldap://dc1.example.com' })).toThrow(/clear text/);
+ expect(() =>
+ assertLdap({ ...ok, url: 'ldap://dc1.example.com', startTls: true }),
+ ).not.toThrow();
+ expect(() => assertLdap({ ...ok, url: 'ldaps://dc1.example.com' })).not.toThrow();
+ expect(() => assertLdap({ ...ok, url: 'ldap://127.0.0.1:3890' })).not.toThrow();
+ expect(() => assertLdap({ ...ok, url: 'ldaps://x', startTls: true })).toThrow(/StartTLS/);
+ });
+
+ it('refuses filters that do not parse', () => {
+ const base = { url: 'ldaps://x', startTls: false, userFilter: f, adminFilter: null };
+ expect(() => assertLdap({ ...base, userFilter: '(uid={login}' })).toThrow(/User filter/);
+ expect(() => assertLdap({ ...base, adminFilter: 'memberOf=x)' })).toThrow(/Admin filter/);
+ });
+
+ it('an empty password fails before anything is sent (no "unauthenticated bind")', async () => {
+ // A directory that doesn't exist: any attempt to connect would throw DirectoryUnavailable.
+ const s = { url: 'ldaps://192.0.2.1', startTls: false, userFilter: f } as LdapSettings;
+ expect(await ldapAuthenticate(s, 'ann', '')).toBeNull();
+ });
+});
diff --git a/apps/api/src/sso/ldap.ts b/apps/api/src/sso/ldap.ts
new file mode 100644
index 0000000..1854fc6
--- /dev/null
+++ b/apps/api/src/sso/ldap.ts
@@ -0,0 +1,216 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// LDAP / LDAPS password check (12 §4) on ldapts: search the person with the service account,
+// bind as them with their password, read whether the admin filter matches them.
+import {
+ Client,
+ FilterParser,
+ InvalidCredentialsError,
+ NoSuchObjectError,
+ SizeLimitExceededError,
+ escapeFilter,
+ type Entry,
+} from 'ldapts';
+import type { SettingValue } from '@selfphoneng/shared';
+import { decrypt } from '../crypto/secrets.ts';
+
+export type LdapSettings = NonNullable>;
+
+/** The directory can't be asked (down, TLS, a refused service account): not a wrong password. */
+export class DirectoryUnavailable extends Error {}
+
+const LOCAL = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']);
+
+/** 12 §4.1: no password in clear text over the network, and filters that parse. */
+export function assertLdap(s: {
+ url: string;
+ startTls: boolean;
+ userFilter: string;
+ adminFilter: string | null;
+}): void {
+ const u = new URL(s.url);
+ if (u.protocol === 'ldaps:' && s.startTls) throw new Error('StartTLS is for ldap:// addresses');
+ if (u.protocol === 'ldap:' && !s.startTls && !LOCAL.has(u.hostname))
+ throw new Error('ldap:// sends passwords in clear text: use ldaps://, or turn on StartTLS');
+ for (const [name, f] of [
+ ['User filter', s.userFilter],
+ ['Admin filter', s.adminFilter],
+ ] as const)
+ try {
+ if (f) FilterParser.parseString(f.replaceAll('{login}', 'x'));
+ } catch (err) {
+ throw new Error(`${name}: ${(err as Error).message}`, { cause: err });
+ }
+}
+
+/** Every {login} replaced by the RFC 4515-escaped value (ldapts escapeFilter). */
+export function withLogin(filter: string, login: string): string {
+ const parts = filter.split('{login}');
+ const strings = Object.assign([...parts], { raw: parts }) as unknown as TemplateStringsArray;
+ return escapeFilter(strings, ...parts.slice(1).map(() => login));
+}
+
+/** {login} → * : the filter's other conditions (group, disabled bit) without the login (§4.5). */
+export const anyLogin = (filter: string) => filter.replaceAll('{login}', '*');
+
+const first = (v: Entry[string] | undefined) => (Array.isArray(v) ? v[0] : v);
+/** One attribute as text: binary ids (AD's objectGUID) as lowercase hex (12 §4.4). */
+const attr = (e: Entry, name: string): string | null => {
+ const v = first(e[name]);
+ if (v === undefined) return null;
+ return (Buffer.isBuffer(v) ? v.toString('hex') : String(v)) || null;
+};
+
+const newClient = (s: LdapSettings) =>
+ new Client({ url: s.url, timeout: 5000, connectTimeout: 5000 });
+
+async function startTls(c: Client, s: LdapSettings) {
+ if (s.startTls) await c.startTLS({ servername: new URL(s.url).hostname });
+}
+
+/** A connection bound as the service account (or anonymous). */
+async function connect(s: LdapSettings): Promise {
+ const c = newClient(s);
+ try {
+ await startTls(c, s);
+ if (s.bindDn) {
+ // A DN with an empty password is an anonymous "unauthenticated bind" (RFC 4513 §5.1.2).
+ if (!s.bindPasswordEnc) throw new Error('The service account has no password');
+ await c.bind(s.bindDn, decrypt(s.bindPasswordEnc));
+ }
+ return c;
+ } catch (err) {
+ await c.unbind().catch(() => {});
+ throw new DirectoryUnavailable(
+ err instanceof InvalidCredentialsError
+ ? 'The directory refused the service account'
+ : (err as Error).message,
+ );
+ }
+}
+
+const attributes = (s: LdapSettings) => [s.idAttribute, s.emailAttribute, s.nameAttribute, 'cn'];
+const buffers = (s: LdapSettings) => (s.idAttribute === 'objectGUID' ? ['objectGUID'] : []);
+
+/** The entries `login` matches (at most 2: more than one is as bad as none). */
+async function findByLogin(c: Client, s: LdapSettings, login: string): Promise {
+ try {
+ const r = await c.search(s.baseDn, {
+ scope: 'sub',
+ filter: withLogin(s.userFilter, login),
+ attributes: attributes(s),
+ explicitBufferAttributes: buffers(s),
+ sizeLimit: 2,
+ });
+ return r.searchEntries;
+ } catch (err) {
+ if (err instanceof SizeLimitExceededError) return [{ dn: '' }, { dn: '' }];
+ throw err;
+ }
+}
+
+export type DirectoryEntry = {
+ dn: string;
+ /** The stable id attribute's value (12 §4.4). */
+ id: string;
+ email: string | null;
+ name: string | null;
+ /** adminFilter's answer; null when there is none (the role is managed here). */
+ admin: boolean | null;
+};
+
+async function toEntry(c: Client, s: LdapSettings, e: Entry): Promise {
+ const id = attr(e, s.idAttribute);
+ if (!id) throw new DirectoryUnavailable(`${e.dn} has no ${s.idAttribute}`);
+ let admin: boolean | null = null;
+ if (s.adminFilter)
+ try {
+ const r = await c.search(e.dn, { scope: 'base', filter: s.adminFilter, attributes: ['1.1'] });
+ admin = r.searchEntries.length > 0;
+ } catch (err) {
+ // lldap answers "no such object" when the entry doesn't match a base search's filter.
+ if (!(err instanceof NoSuchObjectError)) throw err;
+ admin = false;
+ }
+ return {
+ dn: e.dn,
+ id,
+ email: attr(e, s.emailAttribute),
+ name: attr(e, s.nameAttribute) ?? attr(e, 'cn'),
+ admin,
+ };
+}
+
+/** Is this the password of that entry? Its own connection, so the service one stays bound. */
+async function passwordWorks(s: LdapSettings, dn: string, password: string): Promise {
+ const u = newClient(s);
+ try {
+ await startTls(u, s);
+ await u.bind(dn, password);
+ return true;
+ } catch (err) {
+ if (err instanceof InvalidCredentialsError) return false;
+ throw new DirectoryUnavailable((err as Error).message);
+ } finally {
+ await u.unbind().catch(() => {});
+ }
+}
+
+const unavailable = (err: unknown) =>
+ err instanceof DirectoryUnavailable ? err : new DirectoryUnavailable((err as Error).message);
+
+/**
+ * 12 §4.2 step 3: the directory entry for this login and password, or null when they don't
+ * match exactly one entry with that password. Throws DirectoryUnavailable otherwise.
+ */
+export async function ldapAuthenticate(
+ s: LdapSettings,
+ login: string,
+ password: string,
+): Promise {
+ // Never bind with an empty password: many servers take it as anonymous success.
+ if (!password) return null;
+ const c = await connect(s);
+ try {
+ const found = await findByLogin(c, s, login);
+ if (found.length !== 1) return null;
+ if (!(await passwordWorks(s, found[0]!.dn, password))) return null;
+ return await toEntry(c, s, found[0]!);
+ } catch (err) {
+ throw unavailable(err);
+ } finally {
+ await c.unbind().catch(() => {});
+ }
+}
+
+/** Settings → Sign-in → Directory → Test (12 §4.1), step by step so it can say which failed. */
+export async function testLdap(
+ s: LdapSettings,
+ login?: string,
+ password?: string,
+): Promise<{ matching: number; user?: DirectoryEntry }> {
+ const c = await connect(s);
+ try {
+ let matching: number;
+ try {
+ const r = await c.search(s.baseDn, {
+ scope: 'sub',
+ filter: anyLogin(s.userFilter),
+ attributes: ['1.1'],
+ sizeLimit: 1000,
+ });
+ matching = r.searchEntries.length;
+ } catch (err) {
+ if (!(err instanceof SizeLimitExceededError)) throw err;
+ matching = 1000;
+ }
+ if (!login) return { matching };
+ const found = await findByLogin(c, s, login);
+ if (found.length === 0) throw new Error(`No entry under ${s.baseDn} matches "${login}"`);
+ if (found.length > 1) throw new Error(`More than one entry matches "${login}"`);
+ if (!password || !(await passwordWorks(s, found[0]!.dn, password)))
+ throw new Error(`The directory refused the password for ${found[0]!.dn}`);
+ return { matching, user: await toEntry(c, s, found[0]!) };
+ } finally {
+ await c.unbind().catch(() => {});
+ }
+}
diff --git a/apps/api/src/sso/oidcCheck.ts b/apps/api/src/sso/oidcCheck.ts
index 3b5d193..32e2d64 100644
--- a/apps/api/src/sso/oidcCheck.ts
+++ b/apps/api/src/sso/oidcCheck.ts
@@ -6,15 +6,15 @@ import * as oidc from 'openid-client';
import type { Db } from '../db/client.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { sessions, userIdentities, users } from '../db/schema.ts';
-import { isLastAdmin } from '../routes/users.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decide, type ClaimsDecision } from './claims.ts';
+import { applyRole } from './role.ts';
import { oidcConfig, oidcError, type OidcSettings } from './oidc.ts';
type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
-/** The role from adminGroups (never demoting the last admin with a password), groups, email. */
+/** The role from adminGroups (12 §3.4), and the groups and email on the identity. */
export async function applyClaims(
db: Db,
log: Log,
@@ -24,24 +24,7 @@ export async function applyClaims(
email: string | null,
actorIp: string | null,
): Promise {
- const [user] = await db.select().from(users).where(eq(users.id, userId));
- if (!user) return;
- const role = d.admin === null ? user.role : d.admin ? 'admin' : 'user';
- if (role !== user.role) {
- if (role === 'user' && (await isLastAdmin(db, userId)))
- log.info({ userId }, `kept the last admin with a password, though ${s.label} says user`);
- else {
- await db.update(users).set({ role }).where(eq(users.id, userId));
- await audit(db, {
- actorIp: actorIp ?? undefined,
- action: 'update',
- entityType: 'user',
- entityId: userId,
- summary: `${user.email} is now ${role}, from ${s.label}'s groups`,
- diff: { before: { role: user.role }, after: { role } },
- });
- }
- }
+ await applyRole(db, log, userId, d.admin, `${s.label}'s groups`, actorIp);
await db
.update(userIdentities)
.set({ email, groups: d.groups })
diff --git a/apps/api/src/sso/role.ts b/apps/api/src/sso/role.ts
new file mode 100644
index 0000000..b4970e7
--- /dev/null
+++ b/apps/api/src/sso/role.ts
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// The role a provider says (12 §1): OpenID adminGroups or the LDAP adminFilter.
+import { eq } from 'drizzle-orm';
+import type { Db } from '../db/client.ts';
+import { users } from '../db/schema.ts';
+import { isLastAdmin } from '../routes/users.ts';
+import { audit } from '../services/audit.ts';
+
+/** admin null: the role is managed here. Never demotes the last admin with a password. */
+export async function applyRole(
+ db: Db,
+ log: { info(o: object, m: string): void },
+ userId: string,
+ admin: boolean | null,
+ source: string,
+ actorIp: string | null,
+): Promise {
+ if (admin === null) return;
+ const [user] = await db.select().from(users).where(eq(users.id, userId));
+ const role = admin ? 'admin' : 'user';
+ if (!user || role === user.role) return;
+ if (role === 'user' && (await isLastAdmin(db, userId))) {
+ log.info({ userId }, `kept the last admin with a password, though ${source} says user`);
+ return;
+ }
+ await db.update(users).set({ role }).where(eq(users.id, userId));
+ await audit(db, {
+ actorIp: actorIp ?? undefined,
+ action: 'update',
+ entityType: 'user',
+ entityId: userId,
+ summary: `${user.email} is now ${role}, from ${source}`,
+ diff: { before: { role: user.role }, after: { role } },
+ });
+}
diff --git a/apps/api/test/ldap-live.test.ts b/apps/api/test/ldap-live.test.ts
new file mode 100644
index 0000000..8935a5f
--- /dev/null
+++ b/apps/api/test/ldap-live.test.ts
@@ -0,0 +1,205 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// 12 S4 against a real lldap: sign in by uid and by email, connect by email, no user made for a
+// stranger, admin by memberOf, Test, LDAPS. Needs LDAP_LIVE_URL (ldap://127.0.0.1:) and
+// LDAP_LIVE_HTTP (its web port, to make the users); LDAP_LIVE_LDAPS_URL to try LDAPS too, with
+// NODE_EXTRA_CA_CERTS naming the CA of its certificate. Admin password: LDAP_LIVE_ADMIN_PASS.
+// Passed against lldap 0.6.3:
+// docker run -d -p 127.0.0.1:13890:3890 -p 127.0.0.1:16360:6360 -p 127.0.0.1:17170:17170 \
+// -e LLDAP_JWT_SECRET=… -e LLDAP_KEY_SEED=… -e LLDAP_LDAP_USER_PASS=admin-pass-123 \
+// -e LLDAP_LDAP_BASE_DN=dc=example,dc=com [-e LLDAP_LDAPS_OPTIONS__ENABLED=true …] \
+// lldap/lldap:v0.6.3-alpine
+import { randomBytes } from 'node:crypto';
+import { eq } from 'drizzle-orm';
+import { Client } from 'ldapts';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { encrypt } from '../src/crypto/secrets.ts';
+import { userIdentities, users } from '../src/db/schema.ts';
+import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
+import { setSetting } from '../src/services/settings.ts';
+import { hasTestDb } from './db.ts';
+import { ADMIN, H, createTestApp } from './app.ts';
+
+const URL_ = process.env.LDAP_LIVE_URL;
+const HTTP = process.env.LDAP_LIVE_HTTP;
+const LDAPS = process.env.LDAP_LIVE_LDAPS_URL;
+const ADMIN_PASS = process.env.LDAP_LIVE_ADMIN_PASS ?? 'admin-pass-123';
+const BASE = 'dc=example,dc=com';
+
+/** RFC 3062 password modify request: SEQUENCE { [0] user DN, [2] new password }. */
+function passwordModify(dn: string, password: string): Buffer {
+ const tlv = (tag: number, v: Buffer) => Buffer.concat([Buffer.from([tag, v.length]), v]);
+ return tlv(0x30, Buffer.concat([tlv(0x80, Buffer.from(dn)), tlv(0x82, Buffer.from(password))]));
+}
+
+describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)', () => {
+ const tag = randomBytes(3).toString('hex');
+ const ann = `ann${tag}`;
+ const bob = `bob${tag}`;
+ const group = `pbx-admins-${tag}`;
+ let t: Awaited>;
+ let jwt: string;
+ let admin: string;
+
+ const gql = async (query: string) => {
+ const r = await fetch(`${HTTP}/api/graphql`, {
+ method: 'POST',
+ headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' },
+ body: JSON.stringify({ query }),
+ });
+ const j = (await r.json()) as { data: Record; errors?: unknown };
+ if (j.errors) throw new Error(JSON.stringify(j.errors));
+ return j.data;
+ };
+ const settings = (url = URL_!) => ({
+ enabled: true,
+ preset: 'lldap' as const,
+ url,
+ startTls: false,
+ bindDn: `uid=admin,ou=people,${BASE}`,
+ bindPasswordEnc: encrypt(ADMIN_PASS),
+ baseDn: `ou=people,${BASE}`,
+ userFilter: '(&(objectClass=person)(|(uid={login})(mail={login})))',
+ idAttribute: 'entryUUID',
+ emailAttribute: 'mail',
+ nameAttribute: 'displayName',
+ adminFilter: `(memberOf=cn=${group},ou=groups,${BASE})`,
+ });
+ const login = (who: string, password: string) =>
+ t.app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ headers: H,
+ payload: { login: who, password },
+ });
+
+ beforeAll(async () => {
+ const r = await fetch(`${HTTP}/auth/simple/login`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ username: 'admin', password: ADMIN_PASS }),
+ });
+ jwt = ((await r.json()) as { token: string }).token;
+ for (const [id, name] of [
+ [ann, 'Ann Lee'],
+ [bob, 'Bob Roe'],
+ ])
+ await gql(
+ `mutation { createUser(user: { id: "${id}", email: "${id}@example.com", displayName: "${name}" }) { id } }`,
+ );
+ const g = (await gql(`mutation { createGroup(name: "${group}") { id } }`)) as {
+ createGroup: { id: number };
+ };
+ await gql(`mutation { addUserToGroup(userId: "${ann}", groupId: ${g.createGroup.id}) { ok } }`);
+ const c = new Client({ url: URL_! });
+ await c.bind(`uid=admin,ou=people,${BASE}`, ADMIN_PASS);
+ for (const id of [ann, bob])
+ await c.exop(
+ '1.3.6.1.4.1.4203.1.11.1',
+ passwordModify(`uid=${id},ou=people,${BASE}`, `${id}-directory-pw`),
+ );
+ await c.unbind();
+
+ t = await createTestApp();
+ await t.clearMustChange(ADMIN.email);
+ admin = await t.login(ADMIN.email, ADMIN.password);
+ await t.addUser(`${ann}@example.com`, 'ann-local-pw-12');
+ await setSetting(t.db, 'auth.ldap', settings());
+ }, 60_000);
+
+ afterAll(async () => {
+ await t?.close();
+ for (const id of [ann, bob])
+ await gql(`mutation { deleteUser(userId: "${id}") { ok } }`).catch(() => {});
+ });
+
+ it('methods say the directory is on', async () => {
+ expect((await t.app.inject({ url: '/api/auth/methods' })).json().ldap).toBe(true);
+ });
+
+ it('signs in by uid, connecting the user by email, and memberOf makes an admin', async () => {
+ resetLoginLimiter();
+ const r = await login(ann, `${ann}-directory-pw`);
+ expect(r.statusCode).toBe(200);
+ expect(r.json().user).toMatchObject({
+ email: `${ann}@example.com`,
+ role: 'admin',
+ signIn: { password: true, ldap: true },
+ });
+ const [u] = await t.db
+ .select()
+ .from(users)
+ .where(eq(users.email, `${ann}@example.com`));
+ const ids = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u!.id));
+ expect(ids).toMatchObject([{ kind: 'ldap', email: `${ann}@example.com` }]);
+ expect(ids[0]!.subject).toMatch(/^[0-9a-f-]{36}$/); // entryUUID
+ });
+
+ it('signs in by email too, and the local password still works', async () => {
+ resetLoginLimiter();
+ expect((await login(`${ann}@example.com`, `${ann}-directory-pw`)).statusCode).toBe(200);
+ expect((await login(`${ann}@example.com`, 'ann-local-pw-12')).statusCode).toBe(200);
+ });
+
+ it('a wrong password, an empty one, and a stranger are refused alike', async () => {
+ resetLoginLimiter();
+ for (const [who, pw] of [
+ [ann, 'wrong-password'],
+ [ann, ''],
+ [bob, `${bob}-directory-pw`], // in the directory, but nobody here: no user is made
+ ['*', `${ann}-directory-pw`],
+ ]) {
+ const r = await login(who!, pw!);
+ expect([400, 401]).toContain(r.statusCode);
+ }
+ const [b] = await t.db
+ .select()
+ .from(users)
+ .where(eq(users.email, `${bob}@example.com`));
+ expect(b).toBeUndefined();
+ });
+
+ it('a directory that is down is a 503, and local passwords still work', async () => {
+ resetLoginLimiter();
+ await setSetting(t.db, 'auth.ldap', settings('ldap://127.0.0.1:1'));
+ try {
+ const r = await login(ann, `${ann}-directory-pw`);
+ expect(r.statusCode).toBe(503);
+ expect(r.json().error.code).toBe('directory_unavailable');
+ expect((await login(`${ann}@example.com`, 'ann-local-pw-12')).statusCode).toBe(200);
+ } finally {
+ await setSetting(t.db, 'auth.ldap', settings());
+ }
+ });
+
+ it('Test: counts people, and a test login says who it would be', async () => {
+ const { bindPasswordEnc: _, ...form } = settings();
+ const r = await t.app.inject({
+ method: 'POST',
+ url: '/api/settings/auth/ldap/test',
+ headers: { ...H, cookie: admin },
+ payload: { ...form, testLogin: ann, testPassword: `${ann}-directory-pw` },
+ });
+ expect(r.json()).toMatchObject({
+ ok: true,
+ user: { email: `${ann}@example.com`, admin: true, matches: `${ann}@example.com` },
+ });
+ expect(r.json().matching).toBeGreaterThanOrEqual(3);
+ const bad = await t.app.inject({
+ method: 'POST',
+ url: '/api/settings/auth/ldap/test',
+ headers: { ...H, cookie: admin },
+ payload: { ...form, testLogin: ann, testPassword: 'nope' },
+ });
+ expect(bad.json()).toMatchObject({ ok: false, error: expect.stringContaining('refused') });
+ });
+
+ it.skipIf(!LDAPS)('LDAPS with a private CA (NODE_EXTRA_CA_CERTS)', async () => {
+ resetLoginLimiter();
+ await setSetting(t.db, 'auth.ldap', settings(LDAPS));
+ try {
+ expect((await login(ann, `${ann}-directory-pw`)).statusCode).toBe(200);
+ } finally {
+ await setSetting(t.db, 'auth.ldap', settings());
+ }
+ });
+});
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 213f37b..f085d09 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
import type {
+ LdapInput,
+ LdapTestResult,
AuthMethods,
OidcInput,
OidcTestResult,
@@ -184,7 +186,8 @@ export const usersApi = {
resetPassword: (id: string) =>
api<{ temporaryPassword: string }>('POST', `/api/users/${id}/reset-password`),
resetTwoStep: (id: string) => api<{ ok: true }>('POST', `/api/users/${id}/reset-two-step`, {}),
- disconnectOidc: (id: string) => api<{ ok: true }>('DELETE', `/api/users/${id}/identities/oidc`),
+ disconnect: (id: string, kind: 'oidc' | 'ldap') =>
+ api<{ ok: true }>('DELETE', `/api/users/${id}/identities/${kind}`),
};
export const extensionsApi = {
@@ -246,6 +249,9 @@ export const settingsApi = {
),
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
testOidc: (v: OidcInput) => api('POST', '/api/settings/auth/oidc/test', v),
+ /** 12 §4.1 */
+ testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
+ api('POST', '/api/settings/auth/ldap/test', v),
};
/** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */
diff --git a/apps/web/src/components/SignInCard.tsx b/apps/web/src/components/SignInCard.tsx
index 0c7842c..e476790 100644
--- a/apps/web/src/components/SignInCard.tsx
+++ b/apps/web/src/components/SignInCard.tsx
@@ -59,6 +59,11 @@ export function SignInCard() {
)}
+ {ways.ldap && (
+
+ Your company directory account is connected: its password works here too.
+
+ )}
{oidc && (
{oidc.label}
diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx
index 0cab7c1..3358746 100644
--- a/apps/web/src/pages/LoginPage.tsx
+++ b/apps/web/src/pages/LoginPage.tsx
@@ -99,12 +99,15 @@ export function LoginPage() {
noValidate
>
{error &&
{error} }
-
+
{(a) => (
diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx
index 65ed37a..9708aa4 100644
--- a/apps/web/src/pages/admin/SettingsPage.tsx
+++ b/apps/web/src/pages/admin/SettingsPage.tsx
@@ -8,7 +8,9 @@ import {
WEBHOOK_EVENTS,
formatNumber,
type AiTestResult,
+ LDAP_PRESETS,
type AuthMode,
+ type LdapInput,
type OidcInput,
type SettingsDto,
type WebhookDto,
@@ -77,7 +79,12 @@ export function SettingsPage() {
{tab === 'tokens' && }
{settings.data && tab === 'ai' && }
{settings.data && tab === 'contacts' && }
- {settings.data && tab === 'sign-in' && }
+ {settings.data && tab === 'sign-in' && (
+
+
+
+
+ )}
);
}
@@ -1570,3 +1577,210 @@ function SignInTab({ s }: { s: SettingsDto }) {
);
}
+
+/** Sign-in → Directory (spec 12 §4.1): LDAP / LDAPS, checked when a password isn't ours. */
+function DirectoryForm({ s }: { s: SettingsDto }) {
+ const save = useSave();
+ const d = s['auth.ldap'];
+ const [v, setV] = useState({
+ enabled: d?.enabled ?? false,
+ preset: d?.preset ?? ('ad' as LdapInput['preset']),
+ url: d?.url ?? '',
+ startTls: d?.startTls ?? false,
+ bindDn: d?.bindDn ?? '',
+ bindPassword: '',
+ baseDn: d?.baseDn ?? '',
+ userFilter: d?.userFilter ?? LDAP_PRESETS.ad.userFilter,
+ idAttribute: d?.idAttribute ?? LDAP_PRESETS.ad.idAttribute,
+ emailAttribute: d?.emailAttribute ?? 'mail',
+ nameAttribute: d?.nameAttribute ?? 'displayName',
+ adminFilter: d?.adminFilter ?? '',
+ });
+ const [probe, setProbe] = useState({ testLogin: '', testPassword: '' });
+ const input = (): LdapInput => ({ ...v, bindPassword: v.bindPassword || undefined });
+ const test = useMutation({ mutationFn: () => settingsApi.testLdap({ ...input(), ...probe }) });
+ const set = (k: keyof typeof v) => (e: { target: { value: string } }) =>
+ setV({ ...v, [k]: e.target.value });
+ const preset = (p: LdapInput['preset']) => {
+ const x = LDAP_PRESETS[p];
+ setV({
+ ...v,
+ preset: p,
+ userFilter: x.userFilter,
+ idAttribute: x.idAttribute,
+ adminFilter: x.adminFilter,
+ });
+ };
+ const text = (k: keyof typeof v, label: string, hint?: string, placeholder?: string) => (
+
+ {(a) => (
+
+ )}
+
+ );
+ return (
+ {
+ e.preventDefault();
+ save.mutate({ 'auth.ldap': v.url.trim() ? input() : null });
+ setV({ ...v, bindPassword: '' });
+ }}
+ >
+
+ Directory (LDAP)
+
+ When a password isn't the one set here, SelfPhoneNG asks the directory. People sign
+ in with their directory username or email; the first time, they're matched to their
+ user here by email. Nobody gets a user just for being in the directory.
+
+
+ setV({ ...v, enabled: e.target.checked })}
+ />
+ Check passwords against the directory
+
+
+ {(a) => (
+ preset(e.target.value as LdapInput['preset'])}
+ >
+ {Object.entries(LDAP_PRESETS).map(([id, p]) => (
+
+ {p.label}
+
+ ))}
+
+ )}
+
+ {text(
+ 'url',
+ 'Address',
+ 'ldaps:// (port 636), or ldap:// with StartTLS. Plain ldap:// only to this server itself.',
+ 'ldaps://dc1.example.com',
+ )}
+
+ setV({ ...v, startTls: e.target.checked })}
+ />
+ StartTLS (for ldap:// addresses)
+
+ {text(
+ 'bindDn',
+ 'Service account DN',
+ 'An account that may search people. Empty: search anonymously.',
+ 'CN=pbx-search,OU=Service,DC=example,DC=com',
+ )}
+
+ {(a) => (
+
+ )}
+
+ {text(
+ 'baseDn',
+ 'Where people are',
+ 'Searched with everything under it',
+ 'DC=example,DC=com',
+ )}
+ {text(
+ 'userFilter',
+ 'User filter',
+ '{login} is what they type. To allow one group only, add (memberOf=…) here.',
+ )}
+ {text(
+ 'adminFilter',
+ 'Admin filter',
+ 'People it matches are admins, the others users. Empty: you set roles on Users.',
+ )}
+ {text(
+ 'idAttribute',
+ 'ID attribute',
+ 'Never changes for a person: objectGUID, entryUUID, ipaUniqueID',
+ )}
+ {text('emailAttribute', 'Email attribute')}
+ {text('nameAttribute', 'Name attribute')}
+
+
+ {(a) => (
+ setProbe({ ...probe, testLogin: e.target.value })}
+ />
+ )}
+
+
+ {(a) => (
+ setProbe({ ...probe, testPassword: e.target.value })}
+ />
+ )}
+
+
+
+
test.mutate()} disabled={test.isPending || !v.url.trim()}>
+ Test
+
+ {test.data && (
+
+ {!test.data.ok
+ ? test.data.error
+ : `Works: ${test.data.matching >= 1000 ? '1000+' : test.data.matching} people match the user filter.` +
+ (test.data.user
+ ? ` ${test.data.user.dn}: ${test.data.user.email ?? 'no email'}, ${test.data.user.admin === null ? 'role set here' : test.data.user.admin ? 'admin' : 'user'}, ${test.data.user.matches ? `signs in as ${test.data.user.matches}` : 'no user here has that email'}.`
+ : '')}
+
+ )}
+ {test.error &&
{test.error.message} }
+
+
+ {save.isError && {save.error.message} }
+ {save.isSuccess && (
+
+ Saved.
+
+ )}
+
+
+ Save
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/admin/UserDrawer.tsx b/apps/web/src/pages/admin/UserDrawer.tsx
index 2ea255d..2843d5f 100644
--- a/apps/web/src/pages/admin/UserDrawer.tsx
+++ b/apps/web/src/pages/admin/UserDrawer.tsx
@@ -240,7 +240,8 @@ function GeneralTab({
Creates a new temporary password and signs them out everywhere.
{user.twoStep && }
- {user.signIn.oidc && }
+ {user.signIn.oidc && }
+ {user.signIn.ldap && }
)}
@@ -587,26 +588,36 @@ function EmergencyTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void
);
}
-/** Their connected OpenID account (12 §5): the groups it sent, and Disconnect. */
-function ConnectedOidc({ user }: { user: UserDto }) {
+/** A connected OpenID or directory account (12 §5): what it sent, and Disconnect. */
+function ConnectedAccount({ user, kind }: { user: UserDto; kind: 'oidc' | 'ldap' }) {
const qc = useQueryClient();
- const label = useAuthMethods().data?.oidc?.label ?? 'OpenID';
+ const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
+ const label = kind === 'oidc' ? oidcLabel : 'Directory';
const disconnect = useMutation({
- mutationFn: () => usersApi.disconnectOidc(user.id),
+ mutationFn: () => usersApi.disconnect(user.id, kind),
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
});
- const oidc = user.signIn.oidc!;
+ const oidc = user.signIn.oidc;
return (
-
- {label} : {oidc.email ?? 'connected'}
-
-
- Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
-
+ {kind === 'oidc' && oidc ? (
+ <>
+
+ {label} : {oidc.email ?? 'connected'}
+
+
+ Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
+
+ >
+ ) : (
+
+ {label} : connected, their directory password works
+ here
+
+ )}
disconnect.mutate()} disabled={disconnect.isPending}>
- Disconnect {label}
+ Disconnect {kind === 'oidc' ? label : 'the directory'}
{disconnect.error &&
{disconnect.error.message} }
diff --git a/docs/admin-guide/sign-in.md b/docs/admin-guide/sign-in.md
index 0344dea..f1c081f 100644
--- a/docs/admin-guide/sign-in.md
+++ b/docs/admin-guide/sign-in.md
@@ -1,4 +1,4 @@
-# Sign-in: passwords and OpenID Connect
+# 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
@@ -6,7 +6,8 @@ changes the web sign-in: desk phones, phone apps and the browser phone's line ke
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.
+says what is different for the others. A company directory (Active Directory, OpenLDAP, FreeIPA,
+lldap …) can check passwords too: see [Directory (LDAP)](#directory-ldap).
## How it works
@@ -113,3 +114,33 @@ The login page says what went wrong in plain words. The reason Keycloak gave is
| 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. The first time,
+they're matched to their user here by the directory's email; nobody gets a user just for being in
+the directory. Their SelfPhoneNG password, if they have one, keeps working too.
+
+**Settings → Sign-in → Directory (LDAP)**:
+
+1. **Directory type**: Active Directory, OpenLDAP, FreeIPA or lldap. It fills in the filters.
+2. **Address**: `ldaps://dc1.example.com` (port 636), or `ldap://…` with **StartTLS**. A plain
+ `ldap://` 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](#keycloak-with-a-self-signed-or-private-certificate) (same file).
+3. **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.
+4. **Where people are**: the base DN, e.g. `DC=example,DC=com` or `ou=people,dc=example,dc=com`.
+5. **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 `(&…)`.
+6. **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 uses
+ `memberOf:1.2.840.113556.1.4.1941:=` so nested groups count. Empty: you set roles on Users.
+7. **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.
+8. Tick **Check passwords against the directory** and **Save**.
+
+If the directory is down, people get "The directory can't be reached" and passwords set in
+SelfPhoneNG still work. Directories without `memberOf` on people (OpenLDAP without the memberof
+overlay) can't map groups yet. Tested against lldap 0.6.3.
diff --git a/docs/spec/12-sso.md b/docs/spec/12-sso.md
index e04c033..07367a7 100644
--- a/docs/spec/12-sso.md
+++ b/docs/spec/12-sso.md
@@ -452,7 +452,7 @@ For every active user with a directory account, one connection per run:
| 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 |
+| 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 |
### 8.1 As built: S1
@@ -507,6 +507,30 @@ For every active user with a directory account, one connection per run:
- 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 are not checked yet (S5: a
+ Samba AD DC).
+- 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.
+
## 9. Decided
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts
index 38073d6..b57910c 100644
--- a/packages/shared/src/settings.ts
+++ b/packages/shared/src/settings.ts
@@ -118,6 +118,24 @@ export const SettingsSchemas = {
.default(null),
/** Sign-in (12 §1.1): passwords only, OpenID only (admins keep passwords), or both. */
'auth.mode': z.enum(['password', 'mixed', 'oidc']).default('password'),
+ /** LDAP / LDAPS (12 §4.1). The bind password is encrypted; null = anonymous search. */
+ 'auth.ldap': z
+ .object({
+ enabled: z.boolean(),
+ preset: z.enum(['ad', 'openldap', 'freeipa', 'lldap', 'other']),
+ url: z.string(),
+ startTls: z.boolean(),
+ bindDn: z.string().nullable(),
+ bindPasswordEnc: z.string().nullable(),
+ baseDn: z.string(),
+ userFilter: z.string(),
+ idAttribute: z.string(),
+ emailAttribute: z.string(),
+ nameAttribute: z.string(),
+ adminFilter: z.string().nullable(),
+ })
+ .nullable()
+ .default(null),
/** OpenID Connect (12 §3.1). The client secret is encrypted; null = a public client. */
'auth.oidc': z
.object({
@@ -191,6 +209,93 @@ export const OidcInputSchema = z.object({
});
export type OidcInput = z.input
;
+/** The directory presets (12 §4.1): they only fill the form. */
+export const LDAP_PRESETS = {
+ ad: {
+ label: 'Active Directory / Samba AD',
+ userFilter:
+ '(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(|(sAMAccountName={login})(userPrincipalName={login})(mail={login})))',
+ idAttribute: 'objectGUID',
+ adminFilter: '(memberOf:1.2.840.113556.1.4.1941:=CN=PBX Admins,OU=Groups,DC=example,DC=com)',
+ },
+ openldap: {
+ label: 'OpenLDAP (memberof overlay)',
+ userFilter: '(&(objectClass=inetOrgPerson)(|(uid={login})(mail={login})))',
+ idAttribute: 'entryUUID',
+ adminFilter: '(memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com)',
+ },
+ freeipa: {
+ label: 'FreeIPA',
+ userFilter: '(&(objectClass=person)(!(nsAccountLock=TRUE))(|(uid={login})(mail={login})))',
+ idAttribute: 'ipaUniqueID',
+ adminFilter: '(memberOf=cn=pbx-admins,cn=groups,cn=accounts,dc=example,dc=com)',
+ },
+ lldap: {
+ label: 'lldap',
+ userFilter: '(&(objectClass=person)(|(uid={login})(mail={login})))',
+ idAttribute: 'entryUUID',
+ adminFilter: '(memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com)',
+ },
+ other: {
+ label: 'Other',
+ userFilter: '(&(objectClass=person)(|(uid={login})(mail={login})))',
+ idAttribute: 'entryUUID',
+ adminFilter: '',
+ },
+} as const;
+
+/** PATCH /api/settings `auth.ldap` and its Test: the bind password is write-only. */
+export const LdapInputSchema = z.object({
+ enabled: z.boolean(),
+ preset: z.enum(['ad', 'openldap', 'freeipa', 'lldap', 'other']),
+ url: z
+ .string()
+ .trim()
+ .regex(
+ /^ldaps?:\/\/[^/\s]+\/?$/,
+ 'Like ldaps://dc1.example.com or ldap://ldap.example.com:389',
+ ),
+ startTls: z.boolean(),
+ bindDn: z
+ .string()
+ .trim()
+ .max(500)
+ .transform((v) => v || null),
+ bindPassword: z.string().max(500).optional(),
+ clearBindPassword: z.boolean().optional(),
+ baseDn: z.string().trim().min(1, 'Required').max(500),
+ userFilter: z
+ .string()
+ .trim()
+ .max(2000)
+ .refine((v) => v.includes('{login}'), 'Must contain {login}'),
+ idAttribute: z.string().trim().min(1).max(100),
+ emailAttribute: z.string().trim().min(1).max(100),
+ nameAttribute: z.string().trim().min(1).max(100),
+ adminFilter: z
+ .string()
+ .trim()
+ .max(2000)
+ .transform((v) => v || null),
+});
+export type LdapInput = z.input;
+
+/** POST /api/settings/auth/ldap/test. `user`: with a test login, who it would sign in as. */
+export type LdapTestResult =
+ | {
+ ok: true;
+ matching: number;
+ user?: {
+ dn: string;
+ id: string;
+ email: string | null;
+ name: string | null;
+ admin: boolean | null;
+ matches: string | null;
+ };
+ }
+ | { ok: false; error: string };
+
/** POST /api/settings/auth/oidc/test. */
export type OidcTestResult =
| {
@@ -208,13 +313,21 @@ export type OidcTestResult =
/** GET /api/settings: every key, secrets replaced by hasPassword / hasCompanyToken. */
export type SettingsDto = {
[
- K in Exclude
+ K in Exclude<
+ SettingKey,
+ 'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc' | 'auth.ldap'
+ >
]: SettingValue;
} & {
smtp: (Omit & { hasPassword: boolean }) | null;
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
'auth.oidc':
(Omit & { hasClientSecret: boolean }) | null;
+ 'auth.ldap':
+ | (Omit, 'bindPassword' | 'clearBindPassword'> & {
+ hasBindPassword: boolean;
+ })
+ | null;
};
/** POST /api/ai/test: one endpoint tried with a tiny request. `audio`: the TTS test, a data: URL. */
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4fe4758..ddb30c9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -62,6 +62,9 @@ importers:
fastify:
specifier: ^5.12.7
version: 5.12.7
+ ldapts:
+ specifier: ^9.2.0
+ version: 9.2.0
nodemailer:
specifier: 10.0.11
version: 10.0.11
@@ -1969,6 +1972,10 @@ packages:
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
+ ldapts@9.2.0:
+ resolution: {integrity: sha512-5mDg3OT5fDP9DUXga9dCLW8cMW24OTOKT4pv/Qj+f2zCV3qxb+gkZLCzMAqzVTOANoycidKkYpPvkclUQU2aXw==}
+ engines: {node: '>=22'}
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -2479,6 +2486,9 @@ packages:
stream-shift@1.0.3:
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
+ strict-event-emitter-types@2.0.0:
+ resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
+
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -4131,6 +4141,10 @@ snapshots:
dependencies:
'@keyv/serialize': 1.1.1
+ ldapts@9.2.0:
+ dependencies:
+ strict-event-emitter-types: 2.0.0
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -4548,6 +4562,8 @@ snapshots:
stream-shift@1.0.3: {}
+ strict-event-emitter-types@2.0.0: {}
+
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
--
2.34.1
From 19610777a4a523ee7962a1c242cda53eb74c7b56 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Mon, 26 Oct 2026 07:47:12 -0500
Subject: [PATCH 6/7] auth: directory leavers check, dashboard warning, Samba
AD check (S5)
Spec 12 S5: job directoryCheck (hourly) looks up every active user with a directory account;
one deleted, disabled there or no longer matched by the user filter is disabled here, sessions
ended. Nothing changes when the directory is unreachable or a run would disable most people;
the last admin with a password is kept; nobody is re-enabled. Dashboard warning
directory_check (system.directoryWarning). The by-id search is a filter object: ldapts string
filters send bytes >= 0x80 as UTF-8, so AD's objectGUID never matched. Live test against a
Samba 4 AD DC: nested-group admin, the disabled bit, objectGUID, a deleted user disabled.
---
apps/api/src/jobs.ts | 8 ++
apps/api/src/routes/auth.ts | 6 +-
apps/api/src/routes/settings.ts | 7 +
apps/api/src/sso/directory.ts | 85 +++++++++++-
apps/api/src/sso/ldap.test.ts | 23 +++-
apps/api/src/sso/ldap.ts | 45 +++++++
apps/api/test/ad-live.test.ts | 157 ++++++++++++++++++++++
apps/api/test/directory-check.test.ts | 128 ++++++++++++++++++
apps/api/test/ldap-live.test.ts | 33 ++++-
apps/web/src/pages/admin/SettingsPage.tsx | 10 ++
docs/admin-guide/sign-in.md | 19 ++-
docs/spec/04-backend-api.md | 2 +-
docs/spec/12-sso.md | 32 ++++-
packages/shared/src/settings.ts | 2 +
14 files changed, 540 insertions(+), 17 deletions(-)
create mode 100644 apps/api/test/ad-live.test.ts
create mode 100644 apps/api/test/directory-check.test.ts
diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts
index a933ada..6be84b3 100644
--- a/apps/api/src/jobs.ts
+++ b/apps/api/src/jobs.ts
@@ -27,6 +27,7 @@ import { backupDue, runBackup } from './services/backup.ts';
import { sendCallReports } from './services/callreport.ts';
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
import { logCallsToContrack } from './contacts/timeline.ts';
+import { checkDirectory } from './sso/directory.ts';
import { checkOidcSessions } from './sso/oidcCheck.ts';
import { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts';
@@ -267,8 +268,14 @@ export function createJobs(d: JobDeps) {
await checkOidcSessions({ db: d.db, log: d.log, now: now() });
};
+ /** Directory accounts re-read; a leaver disabled (12 §4.5). */
+ const directoryCheck = async () => {
+ await checkDirectory({ db: d.db, log: d.log });
+ };
+
return {
oidcCheck,
+ directoryCheck,
daily,
backup,
publicIpWatch,
@@ -303,6 +310,7 @@ export function startJobs(d: JobDeps): () => void {
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
run('contrackLog', j.contrackLog);
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
+ if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts
index 0adfe79..4feccc6 100644
--- a/apps/api/src/routes/auth.ts
+++ b/apps/api/src/routes/auth.ts
@@ -26,7 +26,7 @@ import {
import type { Db } from '../db/client.ts';
import { extensions, sessions, userIdentities, users } from '../db/schema.ts';
import { endSessionUrl, oidcError } from '../sso/oidc.ts';
-import { directorySignIn } from '../sso/directory.ts';
+import { directorySignIn, directoryWarning } from '../sso/directory.ts';
import { ldapAuthenticate } from '../sso/ldap.ts';
import { getSetting } from '../services/settings.ts';
import {
@@ -237,6 +237,10 @@ export async function authRoutes(app: FastifyInstance) {
entry = await ldapAuthenticate(ldap, login, body.password);
} catch (err) {
req.log.warn({ event: 'auth.ldap', err: (err as Error).message }, 'directory unavailable');
+ await directoryWarning(
+ db,
+ `A directory sign-in failed: the directory couldn't be reached (${(err as Error).message}).`,
+ );
throw new HttpError(
503,
'directory_unavailable',
diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts
index 0b38e62..b016179 100644
--- a/apps/api/src/routes/settings.ts
+++ b/apps/api/src/routes/settings.ts
@@ -51,6 +51,7 @@ declare module 'fastify' {
const READ_ONLY = new Set([
'system.restartRequired',
'system.transportHash',
+ 'system.directoryWarning',
'sms.webhookToken',
'setup.completed',
]);
@@ -456,6 +457,12 @@ export async function settingsRoutes(app: FastifyInstance) {
message: "Today's backup hasn't been made. Check the api log, or press Back up now.",
link: '/admin/settings/backups',
});
+ if (s['system.directoryWarning'])
+ items.push({
+ code: 'directory_check',
+ message: s['system.directoryWarning'],
+ link: '/admin/settings/sign-in',
+ });
if (!s.smtp)
items.push({
code: 'smtp_missing',
diff --git a/apps/api/src/sso/directory.ts b/apps/api/src/sso/directory.ts
index eaacefd..c116de1 100644
--- a/apps/api/src/sso/directory.ts
+++ b/apps/api/src/sso/directory.ts
@@ -2,10 +2,13 @@
// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
// email. Never a new user (owner, 2026-10-25).
import { and, eq } from 'drizzle-orm';
+import { deleteUserSessions } from '../auth/sessions.ts';
import type { Db } from '../db/client.ts';
import { userIdentities, users } from '../db/schema.ts';
+import { isLastAdmin } from '../routes/users.ts';
import { audit } from '../services/audit.ts';
-import type { DirectoryEntry } from './ldap.ts';
+import { getSetting, setSetting } from '../services/settings.ts';
+import { lookupIds, type DirectoryEntry, type LdapSettings } from './ldap.ts';
import { applyRole } from './role.ts';
type UserRow = typeof users.$inferSelect;
@@ -69,3 +72,83 @@ export async function directorySignIn(
const [after] = await db.select().from(users).where(eq(users.id, u.id));
return after!;
}
+
+/** The dashboard's directory warning (12 §4.2, §4.5); null clears it. */
+export const directoryWarning = (db: Db, message: string | null) =>
+ setSetting(db, 'system.directoryWarning', message);
+
+type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
+
+/**
+ * One run of the directoryCheck job (hourly, 12 §4.5): every active user with a directory
+ * account still in it gets its email and role; one who isn't is disabled. Never re-enables.
+ * `lookup` is for tests.
+ */
+export async function checkDirectory(d: {
+ db: Db;
+ log: Log;
+ lookup?: (s: LdapSettings, ids: string[]) => Promise>;
+}): Promise {
+ const { db, log } = d;
+ const s = await getSetting(db, 'auth.ldap');
+ const rows = s?.enabled
+ ? await db
+ .select({ u: users, subject: userIdentities.subject })
+ .from(userIdentities)
+ .innerJoin(users, eq(users.id, userIdentities.userId))
+ .where(and(eq(userIdentities.kind, 'ldap'), eq(users.isActive, true)))
+ : [];
+ if (!s || !rows.length) return directoryWarning(db, null);
+
+ let found: Map;
+ try {
+ found = await (d.lookup ?? lookupIds)(
+ s,
+ rows.map((r) => r.subject),
+ );
+ } catch (err) {
+ log.warn?.({ err: (err as Error).message }, 'directory check skipped');
+ return directoryWarning(
+ db,
+ `The directory check couldn't reach the directory (${(err as Error).message}), so leavers aren't being disabled.`,
+ );
+ }
+ const gone = rows.filter((r) => !found.has(r.subject));
+ // A broken filter or base DN is likelier than a mass layoff.
+ if (gone.length >= 3 && gone.length * 2 > rows.length) {
+ log.warn?.({ gone: gone.length, of: rows.length }, 'directory check would disable most users');
+ return directoryWarning(
+ db,
+ `The directory check would disable ${gone.length} of ${rows.length} people, so it disabled nobody. Check the base DN and user filter.`,
+ );
+ }
+
+ for (const { u, subject } of rows) {
+ const e = found.get(subject);
+ if (e) {
+ await db
+ .update(userIdentities)
+ .set({ email: e.email })
+ .where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
+ await applyRole(db, log, u.id, e.admin, 'the directory', null);
+ continue;
+ }
+ if (u.role === 'admin' && u.passwordHash && (await isLastAdmin(db, u.id))) {
+ log.warn?.(
+ { userId: u.id },
+ 'kept the last admin with a password, though the directory lost them',
+ );
+ continue;
+ }
+ await db.update(users).set({ isActive: false }).where(eq(users.id, u.id));
+ await deleteUserSessions(db, u.id);
+ await audit(db, {
+ action: 'directory_disabled',
+ entityType: 'user',
+ entityId: u.id,
+ summary: `${u.email} is no longer in the directory`,
+ diff: { before: { isActive: true }, after: { isActive: false } },
+ });
+ }
+ await directoryWarning(db, null);
+}
diff --git a/apps/api/src/sso/ldap.test.ts b/apps/api/src/sso/ldap.test.ts
index 7c6fec4..763c935 100644
--- a/apps/api/src/sso/ldap.test.ts
+++ b/apps/api/src/sso/ldap.test.ts
@@ -1,6 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
+import { BerWriter } from 'ldapts';
import { describe, expect, it } from 'vitest';
-import { anyLogin, assertLdap, ldapAuthenticate, withLogin, type LdapSettings } from './ldap.ts';
+import {
+ anyLogin,
+ assertLdap,
+ byIdFilter,
+ ldapAuthenticate,
+ withLogin,
+ type LdapSettings,
+} from './ldap.ts';
describe('LDAP (12 §4)', () => {
const f = '(&(objectClass=person)(|(uid={login})(mail={login})))';
@@ -17,6 +25,19 @@ describe('LDAP (12 §4)', () => {
expect(anyLogin(f)).toBe('(&(objectClass=person)(|(uid=*)(mail=*)))');
});
+ it('finds a leaver by id, keeping the other conditions of the filter (§4.5)', () => {
+ expect(byIdFilter({ idAttribute: 'entryUUID', userFilter: f }, 'a*b').toString()).toBe(
+ '(&(entryUUID=a\\2ab)(&(objectClass=person)(|(uid=*)(mail=*))))',
+ );
+ // AD: objectGUID is stored as hex and searched as its bytes (§4.4).
+ const guid = byIdFilter({ idAttribute: 'objectGUID', userFilter: '(cn={login})' }, '00ff2a');
+ expect(guid.toString()).toBe('(&(objectGUID=\\00\\ff\\2a)(cn=*))');
+ // …and goes out as those three bytes, not U+00FF in UTF-8.
+ const ber = new BerWriter();
+ guid.write(ber);
+ expect(ber.buffer.includes(Buffer.from([0x04, 0x03, 0x00, 0xff, 0x2a]))).toBe(true);
+ });
+
it('never sends a password in clear text to another host', () => {
const ok = { startTls: false, userFilter: f, adminFilter: null };
expect(() => assertLdap({ ...ok, url: 'ldap://dc1.example.com' })).toThrow(/clear text/);
diff --git a/apps/api/src/sso/ldap.ts b/apps/api/src/sso/ldap.ts
index 1854fc6..93ef82a 100644
--- a/apps/api/src/sso/ldap.ts
+++ b/apps/api/src/sso/ldap.ts
@@ -2,7 +2,9 @@
// LDAP / LDAPS password check (12 §4) on ldapts: search the person with the service account,
// bind as them with their password, read whether the admin filter matches them.
import {
+ AndFilter,
Client,
+ EqualityFilter,
FilterParser,
InvalidCredentialsError,
NoSuchObjectError,
@@ -182,6 +184,49 @@ export async function ldapAuthenticate(
}
}
+/** `(&(=))`: still there, and still let in (§4.5). */
+export function byIdFilter(s: Pick, id: string) {
+ // An object, not a string: ldapts parses "\ff" in a string filter to U+00FF and sends it as
+ // UTF-8 (two bytes), so a binary objectGUID would never match. A Buffer goes out as is.
+ const value = s.idAttribute === 'objectGUID' ? Buffer.from(id, 'hex') : id;
+ return new AndFilter({
+ filters: [
+ new EqualityFilter({ attribute: s.idAttribute, value }),
+ FilterParser.parseString(anyLogin(s.userFilter)),
+ ],
+ });
+}
+
+/**
+ * The leaver check (12 §4.5): each id → its entry, when the directory still has it and the user
+ * filter still lets it in. One connection for all. Throws DirectoryUnavailable on any failure,
+ * so a half-done run never reads as "gone".
+ */
+export async function lookupIds(
+ s: LdapSettings,
+ ids: string[],
+): Promise> {
+ const c = await connect(s);
+ try {
+ const found = new Map();
+ for (const id of ids) {
+ const r = await c.search(s.baseDn, {
+ scope: 'sub',
+ filter: byIdFilter(s, id),
+ attributes: attributes(s),
+ explicitBufferAttributes: buffers(s),
+ sizeLimit: 2,
+ });
+ if (r.searchEntries.length === 1) found.set(id, await toEntry(c, s, r.searchEntries[0]!));
+ }
+ return found;
+ } catch (err) {
+ throw unavailable(err);
+ } finally {
+ await c.unbind().catch(() => {});
+ }
+}
+
/** Settings → Sign-in → Directory → Test (12 §4.1), step by step so it can say which failed. */
export async function testLdap(
s: LdapSettings,
diff --git a/apps/api/test/ad-live.test.ts b/apps/api/test/ad-live.test.ts
new file mode 100644
index 0000000..854cb45
--- /dev/null
+++ b/apps/api/test/ad-live.test.ts
@@ -0,0 +1,157 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// 12 S5: the Active Directory preset against a real Samba AD DC: sign-in by sAMAccountName, UPN
+// and mail, admin through a nested group (LDAP_MATCHING_RULE_IN_CHAIN), the disabled bit,
+// objectGUID as hex, and directoryCheck disabling a deleted user. Needs AD_LIVE_URL (ldaps://…,
+// with NODE_EXTRA_CA_CERTS naming its CA) and AD_LIVE_ADMIN_PASS (the Administrator's).
+// Passed against Samba 4 (diegogslomp/samba-ad-dc), its smb.conf given a throwaway certificate
+// for 127.0.0.1 (`tls keyfile`, `tls certfile`, `tls cafile`):
+// docker run -d --privileged -p 127.0.0.1:11636:636 -e REALM=EXAMPLE.COM -e DOMAIN=EXAMPLE \
+// -e ADMIN_PASS=… -e DNS_FORWARDER=1.1.1.1 -e BIND_NETWORK_INTERFACES=false \
+// diegogslomp/samba-ad-dc
+import { randomBytes } from 'node:crypto';
+import { eq } from 'drizzle-orm';
+import { Attribute, Client } from 'ldapts';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { LDAP_PRESETS } from '@selfphoneng/shared';
+import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
+import { encrypt } from '../src/crypto/secrets.ts';
+import { userIdentities, users } from '../src/db/schema.ts';
+import { setSetting } from '../src/services/settings.ts';
+import { checkDirectory } from '../src/sso/directory.ts';
+import { hasTestDb } from './db.ts';
+import { H, createTestApp } from './app.ts';
+
+const URL_ = process.env.AD_LIVE_URL;
+const ADMIN_PASS = process.env.AD_LIVE_ADMIN_PASS ?? '';
+const DC = 'DC=example,DC=com';
+const BASE = `CN=Users,${DC}`;
+const ADMIN_DN = `CN=Administrator,${BASE}`;
+
+describe.skipIf(!hasTestDb || !URL_)('Active Directory with a real Samba AD DC (12 S5)', () => {
+ const tag = randomBytes(3).toString('hex');
+ const [ann, bob, dan, cat] = ['ann', 'bob', 'dan', 'cat'].map((n) => `${n}${tag}`) as [
+ string,
+ string,
+ string,
+ string,
+ ];
+ const team = `CN=team-${tag},${BASE}`;
+ const admins = `CN=pbx-admins-${tag},${BASE}`;
+ const dn = (sam: string) => `CN=${sam},${BASE}`;
+ let t: Awaited>;
+ let ad: Client;
+
+ const login = (who: string, password: string) =>
+ t.app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ headers: H,
+ payload: { login: who, password },
+ });
+ const row = async (sam: string) =>
+ (
+ await t.db
+ .select()
+ .from(users)
+ .where(eq(users.email, `${sam}@example.com`))
+ )[0]!;
+
+ beforeAll(async () => {
+ ad = new Client({ url: URL_! });
+ await ad.bind(ADMIN_DN, ADMIN_PASS);
+ // userAccountControl 512: a normal account; 514: the same, disabled.
+ for (const [sam, uac] of [
+ [ann, '512'],
+ [bob, '512'],
+ [dan, '514'],
+ [cat, '512'],
+ ] as const)
+ await ad.add(dn(sam), [
+ ...Object.entries({
+ objectClass: ['top', 'person', 'organizationalPerson', 'user'],
+ sAMAccountName: [sam],
+ userPrincipalName: [`${sam}@example.com`],
+ mail: [`${sam}@example.com`],
+ displayName: [sam],
+ userAccountControl: [uac],
+ }).map(([type, values]) => new Attribute({ type, values })),
+ // AD's password: the quoted string in UTF-16LE, only over an encrypted connection.
+ new Attribute({
+ type: 'unicodePwd',
+ values: [Buffer.from(`"${sam}-Directory-Pw1"`, 'utf16le')],
+ }),
+ ]);
+ // ann is in team, team is in pbx-admins: only the nested-group rule finds her admin.
+ await ad.add(team, {
+ objectClass: ['top', 'group'],
+ sAMAccountName: `team-${tag}`,
+ member: dn(ann),
+ });
+ await ad.add(admins, {
+ objectClass: ['top', 'group'],
+ sAMAccountName: `pbx-admins-${tag}`,
+ member: team,
+ });
+
+ t = await createTestApp();
+ for (const sam of [ann, bob, dan, cat])
+ await t.addUser(`${sam}@example.com`, 'local-password-1');
+ await setSetting(t.db, 'auth.ldap', {
+ enabled: true,
+ preset: 'ad',
+ url: URL_!,
+ startTls: false,
+ bindDn: ADMIN_DN,
+ bindPasswordEnc: encrypt(ADMIN_PASS),
+ baseDn: BASE,
+ userFilter: LDAP_PRESETS.ad.userFilter,
+ idAttribute: LDAP_PRESETS.ad.idAttribute,
+ emailAttribute: 'mail',
+ nameAttribute: 'displayName',
+ adminFilter: `(memberOf:1.2.840.113556.1.4.1941:=${admins})`,
+ });
+ }, 60_000);
+
+ afterAll(async () => {
+ await t?.close();
+ for (const d of [admins, team, ...[ann, bob, dan, cat].map(dn)])
+ await ad?.del(d).catch(() => {});
+ await ad?.unbind();
+ });
+
+ it('signs in by sAMAccountName; a nested group makes an admin; objectGUID is stored as hex', async () => {
+ resetLoginLimiter();
+ const r = await login(ann, `${ann}-Directory-Pw1`);
+ expect(r.statusCode).toBe(200);
+ expect(r.json().user).toMatchObject({ role: 'admin', signIn: { ldap: true } });
+ const [ident] = await t.db
+ .select()
+ .from(userIdentities)
+ .where(eq(userIdentities.userId, (await row(ann)).id));
+ expect(ident!.subject).toMatch(/^[0-9a-f]{32}$/);
+ });
+
+ it('signs in by UPN and by mail; outside the group is a user', async () => {
+ resetLoginLimiter();
+ expect((await login(`${bob}@example.com`, `${bob}-Directory-Pw1`)).json().user.role).toBe(
+ 'user',
+ );
+ resetLoginLimiter();
+ expect((await login(`${ann}@example.com`, `${ann}-Directory-Pw1`)).statusCode).toBe(200);
+ });
+
+ it('a disabled account (userAccountControl bit 2) is refused', async () => {
+ resetLoginLimiter();
+ expect((await login(dan, `${dan}-Directory-Pw1`)).statusCode).toBe(401);
+ });
+
+ it('directoryCheck disables a deleted user and keeps the rest', async () => {
+ resetLoginLimiter();
+ expect((await login(cat, `${cat}-Directory-Pw1`)).statusCode).toBe(200);
+ await ad.del(dn(cat));
+ await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
+ expect((await row(cat)).isActive).toBe(false);
+ expect(await row(ann)).toMatchObject({ isActive: true, role: 'admin' });
+ expect((await row(bob)).isActive).toBe(true);
+ });
+});
diff --git a/apps/api/test/directory-check.test.ts b/apps/api/test/directory-check.test.ts
new file mode 100644
index 0000000..4391cad
--- /dev/null
+++ b/apps/api/test/directory-check.test.ts
@@ -0,0 +1,128 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// 12 §4.5: the hourly directoryCheck, against a fake directory lookup.
+import { eq, ne } from 'drizzle-orm';
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { auditLog, sessions, userIdentities, users } from '../src/db/schema.ts';
+import { getSetting, setSetting } from '../src/services/settings.ts';
+import { checkDirectory } from '../src/sso/directory.ts';
+import type { DirectoryEntry, LdapSettings } from '../src/sso/ldap.ts';
+import { hasTestDb } from './db.ts';
+import { ADMIN, H, createTestApp } from './app.ts';
+
+const log = { info: () => {}, warn: () => {} };
+const LDAP: LdapSettings = {
+ enabled: true,
+ preset: 'lldap',
+ url: 'ldaps://dir.example.com',
+ startTls: false,
+ bindDn: null,
+ bindPasswordEnc: null,
+ baseDn: 'ou=people,dc=example,dc=com',
+ userFilter: '(uid={login})',
+ idAttribute: 'entryUUID',
+ emailAttribute: 'mail',
+ nameAttribute: 'displayName',
+ adminFilter: '(memberOf=cn=pbx-admins)',
+};
+
+describe.skipIf(!hasTestDb)('directoryCheck (12 §4.5)', () => {
+ let t: Awaited>;
+ let n = 0;
+
+ /** A user here with a directory account `id`. */
+ const member = async (role: 'admin' | 'user' = 'user') => {
+ const u = await t.addUser(`dir${++n}@example.com`, 'local-password-1', role);
+ const dirId = `uuid-${n}`;
+ await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: dirId });
+ return { ...u, dirId };
+ };
+ const entry = (id: string, admin: boolean | null, email = `${id}@dir.example.com`) =>
+ [id, { dn: `uid=${id}`, id, email, name: id, admin }] as [string, DirectoryEntry];
+ const run = (found: [string, DirectoryEntry][] | Error) =>
+ checkDirectory({
+ db: t.db,
+ log,
+ lookup: () =>
+ found instanceof Error ? Promise.reject(found) : Promise.resolve(new Map(found)),
+ });
+ const user = async (id: string) => (await t.db.select().from(users).where(eq(users.id, id)))[0]!;
+ const warning = () => getSetting(t.db, 'system.directoryWarning');
+
+ beforeAll(async () => {
+ t = await createTestApp();
+ await t.clearMustChange(ADMIN.email);
+ });
+ afterAll(() => t?.close());
+ beforeEach(async () => {
+ await t.db.delete(userIdentities);
+ await t.db.update(users).set({ role: 'user' }).where(ne(users.email, ADMIN.email));
+ await setSetting(t.db, 'auth.ldap', LDAP);
+ });
+
+ it('updates who is still there, and disables who is gone', async () => {
+ const [stays, promoted, leaves] = [await member(), await member(), await member()];
+ await t.login(leaves.email, 'local-password-1');
+ await setSetting(t.db, 'system.directoryWarning', 'old');
+ await run([entry(stays.dirId, false, 'new@dir.example.com'), entry(promoted.dirId, true)]);
+
+ expect(await user(stays.id)).toMatchObject({ isActive: true, role: 'user' });
+ const [ident] = await t.db
+ .select()
+ .from(userIdentities)
+ .where(eq(userIdentities.userId, stays.id));
+ expect(ident!.email).toBe('new@dir.example.com');
+ expect((await user(promoted.id)).role).toBe('admin');
+ expect((await user(leaves.id)).isActive).toBe(false);
+ expect(await t.db.select().from(sessions).where(eq(sessions.userId, leaves.id))).toEqual([]);
+ const audits = await t.db.select().from(auditLog).where(eq(auditLog.entityId, leaves.id));
+ expect(audits.map((a) => a.action)).toContain('directory_disabled');
+ expect(await warning()).toBeNull();
+ });
+
+ it('changes nothing and warns on the dashboard when the directory is down', async () => {
+ const a = await member();
+ await run(new Error('connect ECONNREFUSED'));
+ expect((await user(a.id)).isActive).toBe(true);
+ expect(await warning()).toMatch(/couldn't reach the directory \(connect ECONNREFUSED\)/);
+ const admin = await t.login(ADMIN.email, ADMIN.password);
+ const r = await t.app.inject({
+ url: '/api/system/warnings',
+ headers: { ...H, cookie: admin },
+ });
+ expect(r.json().items).toContainEqual(
+ expect.objectContaining({ code: 'directory_check', link: '/admin/settings/sign-in' }),
+ );
+ });
+
+ it('disables nobody when most people would go (a broken filter, not a layoff)', async () => {
+ const all = [await member(), await member(), await member(), await member()];
+ await run([entry(all[0]!.dirId, null)]);
+ for (const u of all) expect((await user(u.id)).isActive).toBe(true);
+ expect(await warning()).toMatch(/would disable 3 of 4 people/);
+ });
+
+ it('two of four gone is not "most": they are disabled', async () => {
+ const all = [await member(), await member(), await member(), await member()];
+ await run([entry(all[0]!.dirId, null), entry(all[1]!.dirId, null)]);
+ expect((await user(all[2]!.id)).isActive).toBe(false);
+ expect((await user(all[3]!.id)).isActive).toBe(false);
+ });
+
+ it('keeps the last active admin with a password', async () => {
+ const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
+ await t.db.insert(userIdentities).values({ userId: me!.id, kind: 'ldap', subject: 'admin-id' });
+ await run([]);
+ expect((await user(me!.id)).isActive).toBe(true);
+ await run([entry('admin-id', false)]);
+ expect((await user(me!.id)).role).toBe('admin');
+ });
+
+ it('does nothing with the directory off', async () => {
+ const a = await member();
+ await setSetting(t.db, 'auth.ldap', { ...LDAP, enabled: false });
+ await setSetting(t.db, 'system.directoryWarning', 'old');
+ await run([]);
+ expect((await user(a.id)).isActive).toBe(true);
+ expect(await warning()).toBeNull();
+ });
+});
diff --git a/apps/api/test/ldap-live.test.ts b/apps/api/test/ldap-live.test.ts
index 8935a5f..35f838a 100644
--- a/apps/api/test/ldap-live.test.ts
+++ b/apps/api/test/ldap-live.test.ts
@@ -1,8 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-// 12 S4 against a real lldap: sign in by uid and by email, connect by email, no user made for a
-// stranger, admin by memberOf, Test, LDAPS. Needs LDAP_LIVE_URL (ldap://127.0.0.1:) and
-// LDAP_LIVE_HTTP (its web port, to make the users); LDAP_LIVE_LDAPS_URL to try LDAPS too, with
-// NODE_EXTRA_CA_CERTS naming the CA of its certificate. Admin password: LDAP_LIVE_ADMIN_PASS.
+// 12 S4-S5 against a real lldap: sign in by uid and by email, connect by email, no user made for
+// a stranger, admin by memberOf, Test, LDAPS, directoryCheck disabling a removed user. Needs
+// LDAP_LIVE_URL (ldap://127.0.0.1:) and LDAP_LIVE_HTTP (its web port, to make the users);
+// LDAP_LIVE_LDAPS_URL to try LDAPS too, with NODE_EXTRA_CA_CERTS naming the CA of its
+// certificate. Admin password: LDAP_LIVE_ADMIN_PASS.
// Passed against lldap 0.6.3:
// docker run -d -p 127.0.0.1:13890:3890 -p 127.0.0.1:16360:6360 -p 127.0.0.1:17170:17170 \
// -e LLDAP_JWT_SECRET=… -e LLDAP_KEY_SEED=… -e LLDAP_LDAP_USER_PASS=admin-pass-123 \
@@ -16,6 +17,7 @@ import { encrypt } from '../src/crypto/secrets.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { setSetting } from '../src/services/settings.ts';
+import { checkDirectory } from '../src/sso/directory.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -35,6 +37,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
const tag = randomBytes(3).toString('hex');
const ann = `ann${tag}`;
const bob = `bob${tag}`;
+ const cat = `cat${tag}`;
const group = `pbx-admins-${tag}`;
let t: Awaited>;
let jwt: string;
@@ -82,6 +85,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
for (const [id, name] of [
[ann, 'Ann Lee'],
[bob, 'Bob Roe'],
+ [cat, 'Cat Poe'],
])
await gql(
`mutation { createUser(user: { id: "${id}", email: "${id}@example.com", displayName: "${name}" }) { id } }`,
@@ -92,7 +96,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
await gql(`mutation { addUserToGroup(userId: "${ann}", groupId: ${g.createGroup.id}) { ok } }`);
const c = new Client({ url: URL_! });
await c.bind(`uid=admin,ou=people,${BASE}`, ADMIN_PASS);
- for (const id of [ann, bob])
+ for (const id of [ann, bob, cat])
await c.exop(
'1.3.6.1.4.1.4203.1.11.1',
passwordModify(`uid=${id},ou=people,${BASE}`, `${id}-directory-pw`),
@@ -103,12 +107,13 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
await t.addUser(`${ann}@example.com`, 'ann-local-pw-12');
+ await t.addUser(`${cat}@example.com`, 'cat-local-pw-12');
await setSetting(t.db, 'auth.ldap', settings());
}, 60_000);
afterAll(async () => {
await t?.close();
- for (const id of [ann, bob])
+ for (const id of [ann, bob, cat])
await gql(`mutation { deleteUser(userId: "${id}") { ok } }`).catch(() => {});
});
@@ -193,6 +198,22 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
expect(bad.json()).toMatchObject({ ok: false, error: expect.stringContaining('refused') });
});
+ it('directoryCheck disables someone removed from the directory, and only them (§4.5)', async () => {
+ resetLoginLimiter();
+ expect((await login(cat, `${cat}-directory-pw`)).statusCode).toBe(200); // connects cat
+ await gql(`mutation { deleteUser(userId: "${cat}") { ok } }`);
+ await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
+ const active = async (who: string) =>
+ (
+ await t.db
+ .select()
+ .from(users)
+ .where(eq(users.email, `${who}@example.com`))
+ )[0]!.isActive;
+ expect(await active(cat)).toBe(false);
+ expect(await active(ann)).toBe(true);
+ });
+
it.skipIf(!LDAPS)('LDAPS with a private CA (NODE_EXTRA_CA_CERTS)', async () => {
resetLoginLimiter();
await setSetting(t.db, 'auth.ldap', settings(LDAPS));
diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx
index 9708aa4..d14f95f 100644
--- a/apps/web/src/pages/admin/SettingsPage.tsx
+++ b/apps/web/src/pages/admin/SettingsPage.tsx
@@ -1640,6 +1640,16 @@ function DirectoryForm({ s }: { s: SettingsDto }) {
in with their directory username or email; the first time, they're matched to their
user here by email. Nobody gets a user just for being in the directory.
+
+ Every hour, someone no longer in the directory (or no longer matched by the user filter)
+ is disabled here, password and OpenID sign-in included. It never enables anyone again: do
+ that on Users.
+
+ {s['system.directoryWarning'] && (
+
+ {s['system.directoryWarning']}
+
+ )}
=))`. 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 email, name, and role (`adminFilter`). Gone → the user is disabled
+- 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.
@@ -453,7 +454,7 @@ For every active user with a directory account, one connection per run:
| 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 |
+| 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
@@ -526,11 +527,34 @@ For every active user with a directory account, one connection per run:
- `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 are not checked yet (S5: a
- Samba AD DC).
+ 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.
+
## 9. Decided
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts
index b57910c..4370244 100644
--- a/packages/shared/src/settings.ts
+++ b/packages/shared/src/settings.ts
@@ -151,6 +151,8 @@ export const SettingsSchemas = {
})
.nullable()
.default(null),
+ /** Why the directory couldn't be used (12 §4.2, §4.5), for the dashboard; null = fine. */
+ 'system.directoryWarning': z.string().nullable().default(null),
// Not in 02 §3's table but used by the generator (03 §3).
'system.transportHash': z.string().nullable().default(null),
};
--
2.34.1
From 97e67e347d01ca7e1e2ce1de6e4b19c17243e442 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Mon, 26 Oct 2026 09:46:23 -0500
Subject: [PATCH 7/7] auth: security audit fixes, rate limits on every route
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.env.example | 1 +
apps/api/package.json | 1 +
apps/api/src/app.ts | 12 ++-
apps/api/src/auth/guards.ts | 8 +-
apps/api/src/config.ts | 2 +
apps/api/src/routes/about.ts | 3 +-
apps/api/src/routes/auth.ts | 41 +++++---
apps/api/src/routes/contacts.ts | 92 ++++++++++--------
apps/api/src/routes/integrations.ts | 4 +-
apps/api/src/routes/me.ts | 3 +
apps/api/src/routes/oidc.ts | 117 +++++++++++++++++------
apps/api/src/routes/provisioning.ts | 3 +-
apps/api/src/routes/settings.ts | 89 +++++++++++-------
apps/api/src/routes/system.ts | 22 +++--
apps/api/src/routes/trunks.ts | 4 +-
apps/api/src/routes/users.ts | 38 +++++++-
apps/api/src/sso/directory.ts | 44 ++++++++-
apps/api/src/sso/ldap.test.ts | 42 ++++++++-
apps/api/src/sso/ldap.ts | 6 +-
apps/api/src/sso/oidcCheck.ts | 27 +++++-
apps/api/test/ad-live.test.ts | 8 +-
apps/api/test/app.ts | 1 +
apps/api/test/directory-signin.test.ts | 119 ++++++++++++++++++++++++
apps/api/test/fake-oidc.ts | 11 ++-
apps/api/test/ldap-live.test.ts | 38 ++++++--
apps/api/test/oidc-live.test.ts | 20 +++-
apps/api/test/oidc.test.ts | 85 +++++++++++++++--
apps/api/test/rate-limit.test.ts | 71 ++++++++++++++
apps/api/test/sign-in.test.ts | 119 ++++++++++++++++++++++++
apps/web/src/api/client.ts | 5 +
apps/web/src/components/SignInCard.tsx | 84 +++++++++++++++--
apps/web/src/pages/admin/UserDrawer.tsx | 13 ++-
docs/admin-guide/sign-in.md | 19 +++-
docs/spec/01-infrastructure.md | 1 +
docs/spec/04-backend-api.md | 7 +-
docs/spec/08-security-ops.md | 2 +
docs/spec/12-sso.md | 62 ++++++++++--
packages/shared/src/users.ts | 2 +
pnpm-lock.yaml | 19 ++++
39 files changed, 1059 insertions(+), 186 deletions(-)
create mode 100644 apps/api/test/directory-signin.test.ts
create mode 100644 apps/api/test/rate-limit.test.ts
diff --git a/.env.example b/.env.example
index 063e200..2deb592 100644
--- a/.env.example
+++ b/.env.example
@@ -15,6 +15,7 @@ APP_UID=1000
APP_GID=1000
LOG_LEVEL=info
TRUST_PROXY=true # true when behind Caddy/nginx
+RATE_LIMIT_PER_MINUTE=600 # requests per minute per client IP, every route; 0 = off
RTP_PORT_START=10000 # call audio (UDP), both ends included: open this range on the
RTP_PORT_END=20000 # firewall and router. Two ports per call; 200 is plenty for a small office
# Local AI (admin guide: AI). Starting values for Settings → AI; that page wins once saved.
diff --git a/apps/api/package.json b/apps/api/package.json
index 324c4e7..fc387d0 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -12,6 +12,7 @@
},
"dependencies": {
"@fastify/cookie": "^11.1.3",
+ "@fastify/rate-limit": "^11.2.1",
"@fastify/static": "^10.1.6",
"@fastify/websocket": "^11.3.4",
"@node-rs/argon2": "^2.2.2",
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 74fc3b6..496e119 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
+import fastifyRateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import { sql } from 'drizzle-orm';
@@ -11,7 +12,7 @@ import type { Db } from './db/client.ts';
import type { Generator } from './generator/index.ts';
import type { AmiClient } from './ami/client.ts';
import { settingsRoutes } from './routes/settings.ts';
-import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
+import { HttpError, errorHandler, forbidden, notFound, sendError } from './errors.ts';
import { authRoutes } from './routes/auth.ts';
import { userRoutes } from './routes/users.ts';
import { oidcRoutes } from './routes/oidc.ts';
@@ -92,6 +93,15 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.decorateRequest('sessionId', null);
app.register(fastifyCookie);
app.register(fastifyWebsocket);
+ // Every route, per client IP (08 §2.4). Before the routes, so each one gets it; routes with
+ // their own limits (login, /prov) or a stricter one say so in `config.rateLimit`.
+ if (config.RATE_LIMIT_PER_MINUTE > 0)
+ app.register(fastifyRateLimit, {
+ max: config.RATE_LIMIT_PER_MINUTE,
+ timeWindow: 60_000,
+ errorResponseBuilder: (_req, ctx) =>
+ new HttpError(429, 'rate_limited', `Too many requests, try again in ${ctx.after}`),
+ });
app.setErrorHandler(errorHandler);
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
diff --git a/apps/api/src/auth/guards.ts b/apps/api/src/auth/guards.ts
index 12c2b16..925aa73 100644
--- a/apps/api/src/auth/guards.ts
+++ b/apps/api/src/auth/guards.ts
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-// preHandler guards (04 §2.4). Routes list them in `preHandler`.
+// preHandler guards (04 §2.4). Routes list them in `preHandler`; the rate limit presets go in
+// `config`.
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { users } from '../db/schema.ts';
import { forbidden, unauthorized } from '../errors.ts';
@@ -47,3 +48,8 @@ export async function requireAdmin(req: FastifyRequest, reply: FastifyReply): Pr
await requireAuth(req, reply);
if (req.user!.role !== 'admin') throw forbidden('Admins only');
}
+
+/** Route `config` for what calls out or does real work (Test buttons, downloads): 10 a minute. */
+export const SLOW = { rateLimit: { max: 10, timeWindow: 60_000 } };
+/** Route `config` for routes with their own limit (/prov) or only Asterisk on this machine. */
+export const NO_LIMIT = { rateLimit: false as const };
diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts
index 59421f6..a41589f 100644
--- a/apps/api/src/config.ts
+++ b/apps/api/src/config.ts
@@ -35,6 +35,8 @@ const EnvSchema = z
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true),
+ /** Requests per minute per client IP, every route (08 §2.4); stricter ones per route. 0: off. */
+ RATE_LIMIT_PER_MINUTE: z.coerce.number().int().min(0).default(600),
/** Call audio (RTP) ports, both ends included: the firewall opens this UDP range (01 §3.6). */
RTP_PORT_START: z.coerce.number().int().min(1024).max(65534).default(10000),
RTP_PORT_END: z.coerce.number().int().min(1025).max(65535).default(20000),
diff --git a/apps/api/src/routes/about.ts b/apps/api/src/routes/about.ts
index b03d756..dc4a5aa 100644
--- a/apps/api/src/routes/about.ts
+++ b/apps/api/src/routes/about.ts
@@ -7,6 +7,7 @@ import { readFile, stat } from 'node:fs/promises';
import type { FastifyInstance } from 'fastify';
import type { AboutDto } from '@selfphoneng/shared';
import { notFound } from '../errors.ts';
+import { SLOW } from '../auth/guards.ts';
export const REPO_URL = 'https://git.dou.bet/spng/selfphoneng';
/** In the api's working directory (/app in the image), like ./public. */
@@ -35,7 +36,7 @@ export async function aboutRoutes(app: FastifyInstance) {
};
});
- app.get('/api/about/source', async (_req, reply) => {
+ app.get('/api/about/source', { config: SLOW }, async (_req, reply) => {
if (!(await stat(SOURCE_ARCHIVE).catch(() => null)))
throw notFound('The source archive is only in builds made with Docker');
return reply
diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts
index 4feccc6..564ea74 100644
--- a/apps/api/src/routes/auth.ts
+++ b/apps/api/src/routes/auth.ts
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/auth/* (04 §2, §7.3).
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm';
-import type { FastifyInstance } from 'fastify';
+import type { FastifyInstance, FastifyRequest } from 'fastify';
import {
ChangePasswordSchema,
LoginSchema,
@@ -140,14 +140,24 @@ export async function authRoutes(app: FastifyInstance) {
return { secret, otpauthUrl: url, qr: await QRCode.toDataURL(url, { margin: 2, width: 240 }) };
});
+ /**
+ * A signed-in person's own password, asked again (two-step on/off, recovery codes, a new
+ * password). Wrong ones count in the login limiter, so a stolen session can't guess it.
+ */
+ async function myPasswordOk(req: FastifyRequest, hash: string | null, password: string) {
+ if (isLoginBlocked(req.ip, req.user!.email)) throw rateLimited();
+ if (hash && (await verifyPassword(hash, password))) return true;
+ recordLoginFailure(req.ip, req.user!.email);
+ return false;
+ }
+
app.post('/api/auth/totp/enable', { preHandler: requireAuth }, async (req) => {
adminOnly(req.user!);
const { secret, code, password } = TotpEnableSchema.parse(req.body);
const u = await myRow(req.user!.id);
if (u.totpSecretEnc) throw conflict('Two-step sign-in is already on');
// Like turning it off: a stolen session alone can't lock the owner out with its own secret.
- if (!u.passwordHash || !(await verifyPassword(u.passwordHash, password)))
- throw badRequest('Wrong password');
+ if (!(await myPasswordOk(req, u.passwordHash, password))) throw badRequest('Wrong password');
const step = checkTotp(secret, code, null);
if (step === null)
throw badRequest('That code doesn’t match. Check the phone’s clock and try again.');
@@ -168,16 +178,15 @@ export async function authRoutes(app: FastifyInstance) {
});
/** Turning it off or making new recovery codes needs the password again (on does too). */
- async function confirmPassword(id: string, body: unknown) {
- const { password } = PasswordConfirmSchema.parse(body);
- const u = await myRow(id);
- if (!u.passwordHash || !(await verifyPassword(u.passwordHash, password)))
- throw badRequest('Wrong password');
+ async function confirmPassword(req: FastifyRequest) {
+ const { password } = PasswordConfirmSchema.parse(req.body);
+ const u = await myRow(req.user!.id);
+ if (!(await myPasswordOk(req, u.passwordHash, password))) throw badRequest('Wrong password');
return u;
}
app.post('/api/auth/totp/disable', { preHandler: requireAuth }, async (req) => {
- await confirmPassword(req.user!.id, req.body);
+ await confirmPassword(req);
await db
.update(users)
.set({ totpSecretEnc: null, totpLastStep: null, totpRecoveryHashes: [] })
@@ -194,7 +203,7 @@ export async function authRoutes(app: FastifyInstance) {
});
app.post('/api/auth/totp/recovery-codes', { preHandler: requireAuth }, async (req) => {
- const u = await confirmPassword(req.user!.id, req.body);
+ const u = await confirmPassword(req);
if (!u.totpSecretEnc) throw badRequest('Two-step sign-in is off');
const { codes, hashes } = newRecoveryCodes();
await db.update(users).set({ totpRecoveryHashes: hashes }).where(eq(users.id, u.id));
@@ -236,6 +245,16 @@ export async function authRoutes(app: FastifyInstance) {
try {
entry = await ldapAuthenticate(ldap, login, body.password);
} catch (err) {
+ // Counted like a wrong password: a 503 must not be a free guess at the local one.
+ recordLoginFailure(req.ip, login);
+ await audit(db, {
+ actorUserId: user?.id,
+ actorIp: req.ip,
+ action: 'login_failed',
+ entityType: 'user',
+ entityId: user?.id,
+ summary: `Failed login for ${login} (the directory can't be reached)`,
+ });
req.log.warn({ event: 'auth.ldap', err: (err as Error).message }, 'directory unavailable');
await directoryWarning(
db,
@@ -345,7 +364,7 @@ export async function authRoutes(app: FastifyInstance) {
const user = req.user!;
if (!user.passwordHash)
throw badRequest('You have no password here. Ask an admin to reset it to get one.');
- if (!(await verifyPassword(user.passwordHash, body.currentPassword)))
+ if (!(await myPasswordOk(req, user.passwordHash, body.currentPassword)))
throw badRequest('Current password is wrong');
if (body.newPassword === body.currentPassword)
throw badRequest('Choose a password different from the current one');
diff --git a/apps/api/src/routes/contacts.ts b/apps/api/src/routes/contacts.ts
index 01f821b..4d021e6 100644
--- a/apps/api/src/routes/contacts.ts
+++ b/apps/api/src/routes/contacts.ts
@@ -16,7 +16,7 @@ import {
type ContactShareDto,
type ListResponse,
} from '@selfphoneng/shared';
-import { requireAdmin, requireAuth } from '../auth/guards.ts';
+import { NO_LIMIT, SLOW, requireAdmin, requireAuth } from '../auth/guards.ts';
import {
addToBook,
assertBuiltin,
@@ -162,33 +162,39 @@ export async function contactRoutes(app: FastifyInstance) {
* The dialplan's caller-name lookup (11 §6 C4): only Asterisk on this machine (no proxy in
* between) with the token. Never waits on Contrack. Not logged: the token is in the query.
*/
- app.get('/api/internal/caller-name', { logLevel: 'silent' }, async (req, reply) => {
- const q = z
- .object({
- ext: z.string().regex(/^\d{1,10}$/),
- number: z.string().regex(/^\d{3,20}$/),
- token: z.string().max(100),
- })
- .safeParse(req.query);
- const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress ?? '');
- if (
- !local ||
- req.headers['x-forwarded-for'] ||
- !q.success ||
- !sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
- )
- return reply.code(404).send('');
- const [e] = await db
- .select({ userId: extensions.userId })
- .from(extensions)
- .where(eq(extensions.number, q.data.ext));
- const names = await contactNames(db, e?.userId ?? null, [q.data.number], { wait: false }).catch(
- () => new Map(),
- );
- return reply
- .type('text/plain; charset=utf-8')
- .send(phoneSafeName(names.get(q.data.number) ?? ''));
- });
+ app.get(
+ '/api/internal/caller-name',
+ { logLevel: 'silent', config: NO_LIMIT },
+ async (req, reply) => {
+ const q = z
+ .object({
+ ext: z.string().regex(/^\d{1,10}$/),
+ number: z.string().regex(/^\d{3,20}$/),
+ token: z.string().max(100),
+ })
+ .safeParse(req.query);
+ const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(
+ req.socket.remoteAddress ?? '',
+ );
+ if (
+ !local ||
+ req.headers['x-forwarded-for'] ||
+ !q.success ||
+ !sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
+ )
+ return reply.code(404).send('');
+ const [e] = await db
+ .select({ userId: extensions.userId })
+ .from(extensions)
+ .where(eq(extensions.number, q.data.ext));
+ const names = await contactNames(db, e?.userId ?? null, [q.data.number], {
+ wait: false,
+ }).catch(() => new Map());
+ return reply
+ .type('text/plain; charset=utf-8')
+ .send(phoneSafeName(names.get(q.data.number) ?? ''));
+ },
+ );
/** Copy the builtin book into Contrack (11 §5, C6): mine, or (admin) the company's. */
app.post(
@@ -214,19 +220,23 @@ export async function contactRoutes(app: FastifyInstance) {
);
/** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */
- app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => {
- const b = z
- .object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
- .parse(req.body);
- const saved = await getSetting(db, 'contacts.contrack');
- const token =
- b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
- try {
- return { ok: true, detail: await contrackCheck(b.url, token) };
- } catch (err) {
- return { ok: false, error: (err as Error).message };
- }
- });
+ app.post(
+ '/api/contacts/contrack/test',
+ { preHandler: requireAdmin, config: SLOW },
+ async (req) => {
+ const b = z
+ .object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
+ .parse(req.body);
+ const saved = await getSetting(db, 'contacts.contrack');
+ const token =
+ b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
+ try {
+ return { ok: true, detail: await contrackCheck(b.url, token) };
+ } catch (err) {
+ return { ok: false, error: (err as Error).message };
+ }
+ },
+ );
// ───── sharing: an offer each, accepted or denied by the recipient (11 §2.1) ─────
app.post('/api/me/contacts/share', { preHandler: requireAuth }, async (req) => {
diff --git a/apps/api/src/routes/integrations.ts b/apps/api/src/routes/integrations.ts
index fd2cda3..48c62c2 100644
--- a/apps/api/src/routes/integrations.ts
+++ b/apps/api/src/routes/integrations.ts
@@ -13,7 +13,7 @@ import {
type WebhookDto,
type WebhookEvent,
} from '@selfphoneng/shared';
-import { requireAdmin } from '../auth/guards.ts';
+import { SLOW, requireAdmin } from '../auth/guards.ts';
import { generateToken, hashToken } from '../auth/tokens.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { apiTokens, users, webhookDeliveries, webhooks } from '../db/schema.ts';
@@ -103,7 +103,7 @@ export async function integrationRoutes(app: FastifyInstance) {
});
/** Sends a `webhook.test` event now, not queued: the answer shows right away. */
- app.post('/api/webhooks/:id/test', async (req) => {
+ app.post('/api/webhooks/:id/test', { config: SLOW }, async (req) => {
const h = await getHook(IdParams.parse(req.params).id);
const body = JSON.stringify({
id: crypto.randomUUID(),
diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts
index 02e35c1..25a16da 100644
--- a/apps/api/src/routes/me.ts
+++ b/apps/api/src/routes/me.ts
@@ -180,6 +180,9 @@ export async function meRoutes(app: FastifyInstance) {
/** Log my calls in Contrack, with or without their summaries (11 §4.4). */
app.patch('/api/me/contrack', async (req) => {
const o = ContrackOptionsSchema.parse(req.body);
+ // An empty update would be `UPDATE … SET` with nothing to set: a 500, not a 400.
+ if (o.logCalls === undefined && o.logSummaries === undefined)
+ throw badRequest('Nothing to update');
const [u] = await db
.select({ logCalls: users.contrackLogCalls })
.from(users)
diff --git a/apps/api/src/routes/oidc.ts b/apps/api/src/routes/oidc.ts
index 1fcf4d4..5c894c4 100644
--- a/apps/api/src/routes/oidc.ts
+++ b/apps/api/src/routes/oidc.ts
@@ -5,13 +5,22 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { sessions, userIdentities, users } from '../db/schema.ts';
-import { HttpError, notFound, unauthorized } from '../errors.ts';
+import {
+ HttpError,
+ badRequest,
+ forbidden,
+ notFound,
+ rateLimited,
+ unauthorized,
+} from '../errors.ts';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
import { createSession, setSessionCookie } from '../auth/sessions.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decide, verifiedEmail } from '../sso/claims.ts';
+import { connectDirectory } from '../sso/directory.ts';
+import { ldapAuthenticate } from '../sso/ldap.ts';
import { applyClaims } from '../sso/oidcCheck.ts';
import {
finishSignIn,
@@ -24,8 +33,14 @@ import {
export const OIDC_COOKIE = 'sp_oidc';
const COOKIE_PATH = '/api/auth/oidc';
-/** Same-site paths only: never send the browser to another origin. */
-const safeNext = (p: string | undefined) => (p && /^\/(?![/\\])/.test(p) ? p : undefined);
+/**
+ * Same-site paths only: never send the browser to another origin. No backslash or control
+ * character anywhere: browsers drop tabs and newlines from URLs, so "//evil.example"
+ * would become "//evil.example".
+ */
+export const safeNext = (p: string | undefined) =>
+ // eslint-disable-next-line no-control-regex -- control characters are what it refuses
+ p && /^\/(?!\/)[^\\\x00-\x1f\x7f]*$/.test(p) ? p : undefined;
const withParam = (path: string, k: string, v: string) =>
`${path}${path.includes('?') ? '&' : '?'}${k}=${v}`;
@@ -41,34 +56,46 @@ export async function oidcRoutes(app: FastifyInstance) {
return s;
}
+ /** The sp_oidc cookie: what the callback checks the provider's answer against. */
+ async function begin(reply: FastifyReply, s: OidcSettings, next: string, connect: string | null) {
+ const { url, pending } = await startSignIn(s, host, next, connect);
+ reply.setCookie(OIDC_COOKIE, encrypt(JSON.stringify(pending)), {
+ httpOnly: true,
+ secure: true,
+ sameSite: 'lax',
+ path: COOKIE_PATH,
+ maxAge: 600,
+ });
+ return url.href;
+ }
+
app.get('/api/auth/oidc/start', async (req, reply) => {
const s = await enabled();
- const q = z
- .object({ next: z.string().max(500).optional(), connect: z.literal('1').optional() })
- .parse(req.query);
- let connectSessionId: string | null = null;
- let next = safeNext(q.next);
- if (q.connect) {
- await requireAuth(req, reply);
- if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
- connectSessionId = req.sessionId;
- next ??= req.user!.role === 'admin' ? '/admin/account' : '/me/settings';
- }
+ const q = z.object({ next: z.string().max(500).optional() }).parse(req.query);
try {
- const { url, pending } = await startSignIn(s, host, next ?? '/', connectSessionId);
- reply.setCookie(OIDC_COOKIE, encrypt(JSON.stringify(pending)), {
- httpOnly: true,
- secure: true,
- sameSite: 'lax',
- path: COOKIE_PATH,
- maxAge: 600,
- });
- return reply.redirect(url.href);
+ return reply.redirect(await begin(reply, s, safeNext(q.next) ?? '/', null));
} catch (err) {
req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'OpenID discovery failed');
- return reply.redirect(
- withParam(connectSessionId ? next! : '/login', 'sso_error', 'provider'),
- );
+ return reply.redirect(withParam('/login', 'sso_error', 'provider'));
+ }
+ });
+
+ /**
+ * Connect from My account (12 §3.3). A POST, so it needs the CSRF header: another site can't
+ * start it and connect whatever provider account the browser is signed in to. The page then
+ * sends the browser to `url`.
+ */
+ app.post('/api/auth/oidc/connect', { preHandler: requireAuth }, async (req, reply) => {
+ const s = await enabled();
+ if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
+ const q = z.object({ next: z.string().max(500).optional() }).parse(req.body ?? {});
+ const next =
+ safeNext(q.next) ?? (req.user!.role === 'admin' ? '/admin/account' : '/me/settings');
+ try {
+ return { url: await begin(reply, s, next, req.sessionId) };
+ } catch (err) {
+ req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'OpenID discovery failed');
+ throw new HttpError(502, 'provider', `${s.label} can't be reached`);
}
});
@@ -264,9 +291,41 @@ export async function oidcRoutes(app: FastifyInstance) {
}
const Kind = z.object({ kind: z.enum(['oidc', 'ldap']) });
- app.delete('/api/me/identities/:kind', { preHandler: requireAuth }, (req, reply) =>
- disconnect(req, reply, req.user!.id, Kind.parse(req.params).kind),
- );
+ app.delete('/api/me/identities/:kind', { preHandler: requireAuth }, (req, reply) => {
+ const { kind } = Kind.parse(req.params);
+ // The directory account is how a leaver gets disabled (12 §4.5): only an admin drops it.
+ if (kind === 'ldap' && req.user!.role !== 'admin')
+ throw forbidden('Ask an admin to disconnect your directory account', 'admin_only');
+ return disconnect(req, reply, req.user!.id, kind);
+ });
+
+ /**
+ * Connect my directory account (12 §4.3): its username and password, checked by the directory,
+ * while signed in here. Counted like a sign-in by the login limiter.
+ */
+ app.post('/api/me/identities/ldap', { preHandler: requireAuth }, async (req) => {
+ if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
+ const { login, password } = z
+ .object({ login: z.string().trim().min(1).max(254), password: z.string().min(1).max(200) })
+ .parse(req.body);
+ const ldap = await getSetting(db, 'auth.ldap');
+ if (!ldap?.enabled) throw notFound();
+ if (isLoginBlocked(req.ip, login)) throw rateLimited();
+ let entry;
+ try {
+ entry = await ldapAuthenticate(ldap, login, password);
+ } catch {
+ recordLoginFailure(req.ip, login);
+ throw new HttpError(503, 'directory_unavailable', 'The directory can’t be reached');
+ }
+ if (!entry) {
+ recordLoginFailure(req.ip, login);
+ throw badRequest('The directory refused that username or password');
+ }
+ if ((await connectDirectory(db, req.log, req.user!.id, entry, req.ip)) === 'taken')
+ throw new HttpError(409, 'taken', 'That directory account belongs to another user');
+ return { ok: true };
+ });
app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
const p = Kind.extend({ id: z.uuid() }).parse(req.params);
return disconnect(req, reply, p.id, p.kind);
diff --git a/apps/api/src/routes/provisioning.ts b/apps/api/src/routes/provisioning.ts
index 17529db..b129b1c 100644
--- a/apps/api/src/routes/provisioning.ts
+++ b/apps/api/src/routes/provisioning.ts
@@ -14,6 +14,7 @@ import { allowProvRequest, recordProvFailure } from '../provisioning/limiter.ts'
import { grandstream, grandstreamPhonebook } from '../provisioning/templates/grandstream.ts';
import { yealink, yealinkCommon, yealinkPhonebook } from '../provisioning/templates/yealink.ts';
import { getSetting } from '../services/settings.ts';
+import { NO_LIMIT } from '../auth/guards.ts';
/** File name → what it is. Anything else is a plain 404 (phones ask for files we don't serve). */
const FILES: {
@@ -73,7 +74,7 @@ export async function provisioningRoutes(app: FastifyInstance) {
const deny = (reply: FastifyReply, code: number, text: string) =>
reply.code(code).header('Cache-Control', 'no-store').type('text/plain').send(text);
- app.get('/prov/*', async (req, reply) => {
+ app.get('/prov/*', { config: NO_LIMIT }, async (req, reply) => {
if (!(await getSetting(db, 'provisioning.enabled'))) return deny(reply, 404, 'Not found');
const local = await isLocal(req.ip);
if ((await getSetting(db, 'provisioning.lanOnly')) && !local)
diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts
index b016179..5ab0a51 100644
--- a/apps/api/src/routes/settings.ts
+++ b/apps/api/src/routes/settings.ts
@@ -24,7 +24,7 @@ import {
import type { Db } from '../db/client.ts';
import { dids, extensions, trunks } from '../db/schema.ts';
import { HttpError, badRequest } from '../errors.ts';
-import { requireAdmin } from '../auth/guards.ts';
+import { SLOW, requireAdmin } from '../auth/guards.ts';
import { encrypt } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
import { assertDestinationExists } from '../services/destinations.ts';
@@ -152,15 +152,21 @@ export async function settingsRoutes(app: FastifyInstance) {
throw badRequest((err as Error).message);
}
const old = await getSetting(db, 'auth.ldap');
+ const url = v.url.replace(/\/$/, '');
+ // The stored password only goes back to the server and account it was typed for: else a
+ // changed address (or Test) would send it to whoever answers there.
+ const same = old?.url === url && old.bindDn === v.bindDn;
+ if (!v.bindPassword && !v.clearBindPassword && !same && v.bindDn && old?.bindPasswordEnc)
+ throw badRequest('The address or service account changed: enter its password again');
return {
enabled: v.enabled,
preset: v.preset,
- url: v.url.replace(/\/$/, ''),
+ url,
startTls: v.startTls,
bindDn: v.bindDn,
bindPasswordEnc: v.bindPassword
? encrypt(v.bindPassword)
- : v.clearBindPassword
+ : v.clearBindPassword || !same
? null
: (old?.bindPasswordEnc ?? null),
baseDn: v.baseDn,
@@ -181,6 +187,10 @@ export async function settingsRoutes(app: FastifyInstance) {
throw badRequest((err as Error).message);
}
const old = await getSetting(db, 'auth.oidc');
+ // As for the directory: the stored secret only goes to the provider it was typed for.
+ const same = old?.issuer === input.issuer && old.clientId === input.clientId;
+ if (!input.clientSecret && !input.clearClientSecret && !same && old?.clientSecretEnc)
+ throw badRequest('The issuer or client ID changed: enter the client secret again');
return {
label: input.label,
issuer: input.issuer,
@@ -189,7 +199,9 @@ export async function settingsRoutes(app: FastifyInstance) {
? encrypt(input.clientSecret)
: input.clearClientSecret
? null
- : (old?.clientSecretEnc ?? null),
+ : same
+ ? (old.clientSecretEnc ?? null)
+ : null,
scopes: input.scopes,
groupsClaim: input.groupsClaim,
adminGroups: input.adminGroups,
@@ -217,7 +229,12 @@ export async function settingsRoutes(app: FastifyInstance) {
secure: input.secure,
username: input.username,
from: input.from,
- passwordEnc: input.password ? encrypt(input.password) : (old?.passwordEnc ?? ''),
+ // Kept only for the same server and account: a new host doesn't get the old password.
+ passwordEnc: input.password
+ ? encrypt(input.password)
+ : old?.host === input.host && old.username === input.username
+ ? old.passwordEnc
+ : '',
};
} else if (key === 'contacts.contrack') {
// The company token is write-only too (11 §4.3).
@@ -311,7 +328,7 @@ export async function settingsRoutes(app: FastifyInstance) {
return toDto(db);
});
- app.post('/api/settings/smtp/test', async (req) => {
+ app.post('/api/settings/smtp/test', { config: SLOW }, async (req) => {
const { to } = z.object({ to: z.email() }).parse(req.body);
try {
await app.sendMail({
@@ -326,7 +343,7 @@ export async function settingsRoutes(app: FastifyInstance) {
});
/** Settings → Emergency → Test notifications (06 §6.5): email + ring-out, never dials 911. */
- app.post('/api/settings/emergency/test', async (req) => {
+ app.post('/api/settings/emergency/test', { config: SLOW }, async (req) => {
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
const notified = await notifyEmergency(
{ db, ami: app.ami, live: app.live, send: app.sendMail, log: req.log },
@@ -352,34 +369,42 @@ export async function settingsRoutes(app: FastifyInstance) {
* Settings → Sign-in → Directory → Test, from the unsaved form (12 §4.1). The test password
* is used once and never stored or logged.
*/
- app.post('/api/settings/auth/ldap/test', async (req): Promise => {
- const { testLogin, testPassword, ...form } = LdapInputSchema.extend({
- testLogin: z.string().trim().max(254).optional(),
- testPassword: z.string().max(200).optional(),
- }).parse(req.body);
- const s = (await ldapFromInput(form))!;
- try {
- const r = await testLdap(s, testLogin || undefined, testPassword);
- if (!r.user) return { ok: true, matching: r.matching };
- const matches = await directoryUserFor(db, r.user);
- return {
- ok: true,
- matching: r.matching,
- user: { ...r.user, matches: matches?.email ?? null },
- };
- } catch (err) {
- return { ok: false, error: (err as Error).message };
- }
- });
+ app.post(
+ '/api/settings/auth/ldap/test',
+ { config: SLOW },
+ async (req): Promise => {
+ const { testLogin, testPassword, ...form } = LdapInputSchema.extend({
+ testLogin: z.string().trim().max(254).optional(),
+ testPassword: z.string().max(200).optional(),
+ }).parse(req.body);
+ const s = (await ldapFromInput(form))!;
+ try {
+ const r = await testLdap(s, testLogin || undefined, testPassword);
+ if (!r.user) return { ok: true, matching: r.matching };
+ const matches = await directoryUserFor(db, r.user);
+ return {
+ ok: true,
+ matching: r.matching,
+ user: { ...r.user, matches: matches?.email ?? null },
+ };
+ } catch (err) {
+ return { ok: false, error: (err as Error).message };
+ }
+ },
+ );
/** Settings → Sign-in → Test, from the unsaved form (12 §3.1). */
- app.post('/api/settings/auth/oidc/test', async (req): Promise => {
- const s = await oidcFromInput(OidcInputSchema.parse(req.body));
- return testOidc(s!, app.config.PUBLIC_HOSTNAME);
- });
+ app.post(
+ '/api/settings/auth/oidc/test',
+ { config: SLOW },
+ async (req): Promise => {
+ const s = await oidcFromInput(OidcInputSchema.parse(req.body));
+ return testOidc(s!, app.config.PUBLIC_HOSTNAME);
+ },
+ );
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
- app.post('/api/settings/network/detect-public-ip', async () => {
+ app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
const ip = await detectPublicIp();
if (ip) return { ip };
throw new HttpError(502, 'unavailable', "Couldn't detect the public IP. Enter it by hand.");
@@ -401,7 +426,7 @@ export async function settingsRoutes(app: FastifyInstance) {
});
/** Settings → AI → Test (spec 10): the values in the form, saved or not. */
- app.post('/api/ai/test', async (req): Promise => {
+ app.post('/api/ai/test', { config: SLOW }, async (req): Promise => {
const { kind } = z.object({ kind: z.enum(['llm', 'stt', 'tts']) }).parse(req.body);
const ep = (kind === 'tts' ? AiTtsEndpointSchema : AiEndpointSchema).parse(
(req.body as { endpoint?: unknown }).endpoint,
diff --git a/apps/api/src/routes/system.ts b/apps/api/src/routes/system.ts
index a5fc66b..82c9208 100644
--- a/apps/api/src/routes/system.ts
+++ b/apps/api/src/routes/system.ts
@@ -13,7 +13,7 @@ import type {
SystemStatus,
} from '@selfphoneng/shared';
import type { FastifyInstance } from 'fastify';
-import { requireAdmin } from '../auth/guards.ts';
+import { SLOW, requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
import { getAllSettings } from '../services/settings.ts';
import { systemEvents } from '../db/schema.ts';
@@ -175,14 +175,18 @@ export async function systemRoutes(app: FastifyInstance) {
app.get('/api/system/diagnostics', async (): Promise => ({
checks: await insideChecks(diag()),
}));
- app.post('/api/system/diagnostics/network', async (req): Promise => {
- const { mode } = z
- .object({ mode: z.enum(['quick', 'complete']).default('quick') })
- .parse(req.body ?? {});
- if (!(await getAllSettings(app.db))['network.probeUrl'])
- throw badRequest('No network test probe set (Settings → Network)');
- return networkTest(diag(), mode);
- });
+ app.post(
+ '/api/system/diagnostics/network',
+ { config: SLOW },
+ async (req): Promise => {
+ const { mode } = z
+ .object({ mode: z.enum(['quick', 'complete']).default('quick') })
+ .parse(req.body ?? {});
+ if (!(await getAllSettings(app.db))['network.probeUrl'])
+ throw badRequest('No network test probe set (Settings → Network)');
+ return networkTest(diag(), mode);
+ },
+ );
app.get('/api/system/generated-files', async () => ({
files: await Promise.all(
diff --git a/apps/api/src/routes/trunks.ts b/apps/api/src/routes/trunks.ts
index 7844f4e..6bb3e2d 100644
--- a/apps/api/src/routes/trunks.ts
+++ b/apps/api/src/routes/trunks.ts
@@ -18,7 +18,7 @@ import {
import type { Db } from '../db/client.ts';
import { dids, outboundRoutes, trunks } from '../db/schema.ts';
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
-import { requireAdmin } from '../auth/guards.ts';
+import { SLOW, requireAdmin } from '../auth/guards.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import {
getDids,
@@ -202,7 +202,7 @@ export async function trunkRoutes(app: FastifyInstance) {
return { ok: true };
});
- app.post('/api/trunks/:id/test', async (req): Promise => {
+ app.post('/api/trunks/:id/test', { config: SLOW }, async (req): Promise => {
const { id } = IdParams.parse(req.params);
const t = await getTrunk(db, id);
if (!t.enabled) throw badRequest('Turn the trunk on first');
diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts
index 4fe8491..371d095 100644
--- a/apps/api/src/routes/users.ts
+++ b/apps/api/src/routes/users.ts
@@ -11,7 +11,7 @@ import {
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { extensions, userIdentities, users } from '../db/schema.ts';
-import { badRequest, conflict, notFound } from '../errors.ts';
+import { HttpError, badRequest, conflict, notFound } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { hashPassword } from '../auth/passwords.ts';
import { deleteUserSessions } from '../auth/sessions.ts';
@@ -32,6 +32,7 @@ export const toUserDto = (
u: UserRow,
extension: UserDto['extension'] = null,
ids: IdentityRow[] = [],
+ from: RoleSources = { oidc: null, ldap: false },
): UserDto => ({
id: u.id,
email: u.email,
@@ -44,8 +45,26 @@ export const toUserDto = (
createdAt: u.createdAt.toISOString(),
extension,
signIn: signInWays(u, ids),
+ roleFrom: roleFrom(ids, from),
});
+/** The providers that set roles now (12 §1): adminGroups / adminFilter set and in use. */
+type RoleSources = { oidc: string | null; ldap: boolean };
+async function roleSources(db: Db): Promise {
+ const [oidc, ldap, mode] = await Promise.all([
+ getSetting(db, 'auth.oidc'),
+ getSetting(db, 'auth.ldap'),
+ getSetting(db, 'auth.mode'),
+ ]);
+ return {
+ oidc: oidc && mode !== 'password' && oidc.adminGroups.length > 0 ? oidc.label : null,
+ ldap: Boolean(ldap?.enabled && ldap.adminFilter),
+ };
+}
+const roleFrom = (ids: { kind: string }[], from: RoleSources) =>
+ (from.oidc && ids.some((i) => i.kind === 'oidc') && from.oidc) ||
+ (from.ldap && ids.some((i) => i.kind === 'ldap') ? 'the directory' : null);
+
/** Connected accounts (12 §2), for one user or all of them. */
const identitiesOf = (db: Db, userId?: string): Promise =>
db
@@ -116,12 +135,14 @@ export async function userRoutes(app: FastifyInstance) {
.leftJoin(extensions, eq(extensions.userId, users.id))
.orderBy(asc(users.displayName), asc(users.id));
const ids = await identitiesOf(db);
+ const from = await roleSources(db);
return {
items: rows.map((r) =>
toUserDto(
r.u,
r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null,
ids.filter((i) => i.userId === r.u.id),
+ from,
),
),
nextCursor: null,
@@ -130,7 +151,12 @@ export async function userRoutes(app: FastifyInstance) {
app.get('/api/users/:id', async (req) => {
const { id } = IdParams.parse(req.params);
- return toUserDto(await getUser(db, id), await extOf(db, id), await identitiesOf(db, id));
+ return toUserDto(
+ await getUser(db, id),
+ await extOf(db, id),
+ await identitiesOf(db, id),
+ await roleSources(db),
+ );
});
app.post('/api/users', async (req, reply) => {
@@ -169,6 +195,12 @@ export async function userRoutes(app: FastifyInstance) {
const body = UserUpdateSchema.parse(req.body);
if (Object.keys(body).length === 0) throw badRequest('Nothing to update');
const before = await getUser(db, id);
+ const ids = await identitiesOf(db, id);
+ const from = await roleSources(db);
+ // 12 §1: a provider that sets the role would undo this at the next sign-in or check.
+ const managed = roleFrom(ids, from);
+ if (body.role !== undefined && body.role !== before.role && managed)
+ throw new HttpError(409, 'role_managed', `The role comes from ${managed}`);
const losesAdmin =
before.role === 'admin' &&
before.isActive &&
@@ -199,7 +231,7 @@ export async function userRoutes(app: FastifyInstance) {
after: Object.fromEntries(changed.map((k) => [k, after[k]])),
},
});
- return toUserDto(after, await extOf(db, id), await identitiesOf(db, id));
+ return toUserDto(after, await extOf(db, id), ids, from);
});
app.delete('/api/users/:id', async (req) => {
diff --git a/apps/api/src/sso/directory.ts b/apps/api/src/sso/directory.ts
index c116de1..efbcbe5 100644
--- a/apps/api/src/sso/directory.ts
+++ b/apps/api/src/sso/directory.ts
@@ -23,7 +23,49 @@ export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise
+ u.role !== 'admin' && (u.passwordHash === null || u.mustChangePassword);
+
+/** Connect a directory account the person proved (My settings, 12 §4.3). */
+export async function connectDirectory(
+ db: Db,
+ log: { info(o: object, m: string): void },
+ userId: string,
+ e: DirectoryEntry,
+ ip: string,
+): Promise<'connected' | 'taken'> {
+ const [owner] = await db
+ .select({ userId: userIdentities.userId })
+ .from(userIdentities)
+ .where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
+ if (owner && owner.userId !== userId) return 'taken';
+ await db.transaction(async (tx) => {
+ await tx
+ .delete(userIdentities)
+ .where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'ldap')));
+ await tx
+ .insert(userIdentities)
+ .values({ userId, kind: 'ldap', subject: e.id, email: e.email, lastUsedAt: new Date() });
+ });
+ await audit(db, {
+ actorUserId: userId,
+ actorIp: ip,
+ action: 'ldap_connected',
+ entityType: 'user',
+ entityId: userId,
+ summary: `Connected directory account ${e.dn}`,
+ });
+ await applyRole(db, log, userId, e.admin, 'the directory', ip);
+ return 'connected';
}
/**
diff --git a/apps/api/src/sso/ldap.test.ts b/apps/api/src/sso/ldap.test.ts
index 763c935..3ce48a6 100644
--- a/apps/api/src/sso/ldap.test.ts
+++ b/apps/api/src/sso/ldap.test.ts
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-import { BerWriter } from 'ldapts';
-import { describe, expect, it } from 'vitest';
+import { BerWriter, Client, UnwillingToPerformError } from 'ldapts';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import {
anyLogin,
assertLdap,
byIdFilter,
+ DirectoryUnavailable,
ldapAuthenticate,
withLogin,
type LdapSettings,
@@ -61,3 +62,40 @@ describe('LDAP (12 §4)', () => {
expect(await ldapAuthenticate(s, 'ann', '')).toBeNull();
});
});
+
+describe('a directory that answers no (12 §4.2)', () => {
+ afterEach(() => vi.restoreAllMocks());
+ const s = {
+ url: 'ldaps://dir.example.com',
+ startTls: false,
+ bindDn: null,
+ baseDn: 'dc=example,dc=com',
+ userFilter: '(uid={login})',
+ idAttribute: 'entryUUID',
+ emailAttribute: 'mail',
+ nameAttribute: 'displayName',
+ adminFilter: null,
+ } as LdapSettings;
+
+ it('a locked account (389 DS, FreeIPA: not code 49) is a refused password, not "unavailable"', async () => {
+ vi.spyOn(Client.prototype, 'search').mockResolvedValue({
+ searchEntries: [{ dn: 'uid=ann,dc=example,dc=com', entryUUID: 'id-1' }],
+ searchReferences: [],
+ });
+ vi.spyOn(Client.prototype, 'unbind').mockResolvedValue();
+ vi.spyOn(Client.prototype, 'bind').mockRejectedValue(
+ new UnwillingToPerformError('Account locked'),
+ );
+ expect(await ldapAuthenticate(s, 'ann', 'right-but-locked')).toBeNull();
+ });
+
+ it('no answer at all is "unavailable"', async () => {
+ vi.spyOn(Client.prototype, 'search').mockResolvedValue({
+ searchEntries: [{ dn: 'uid=ann,dc=example,dc=com', entryUUID: 'id-1' }],
+ searchReferences: [],
+ });
+ vi.spyOn(Client.prototype, 'unbind').mockResolvedValue();
+ vi.spyOn(Client.prototype, 'bind').mockRejectedValue(new Error('connect ECONNREFUSED'));
+ await expect(ldapAuthenticate(s, 'ann', 'pw')).rejects.toThrow(DirectoryUnavailable);
+ });
+});
diff --git a/apps/api/src/sso/ldap.ts b/apps/api/src/sso/ldap.ts
index 93ef82a..300b047 100644
--- a/apps/api/src/sso/ldap.ts
+++ b/apps/api/src/sso/ldap.ts
@@ -8,6 +8,7 @@ import {
FilterParser,
InvalidCredentialsError,
NoSuchObjectError,
+ ResultCodeError,
SizeLimitExceededError,
escapeFilter,
type Entry,
@@ -150,7 +151,10 @@ async function passwordWorks(s: LdapSettings, dn: string, password: string): Pro
await u.bind(dn, password);
return true;
} catch (err) {
- if (err instanceof InvalidCredentialsError) return false;
+ // The server answered (wrong password, locked, expired: 389 DS and FreeIPA say 19 or 53
+ // for a locked account): a refused password. Only no answer at all is "unavailable", so
+ // locking someone's account can't turn their sign-in into a 503.
+ if (err instanceof ResultCodeError) return false;
throw new DirectoryUnavailable((err as Error).message);
} finally {
await u.unbind().catch(() => {});
diff --git a/apps/api/src/sso/oidcCheck.ts b/apps/api/src/sso/oidcCheck.ts
index 32e2d64..f86ec9d 100644
--- a/apps/api/src/sso/oidcCheck.ts
+++ b/apps/api/src/sso/oidcCheck.ts
@@ -8,7 +8,7 @@ import { decrypt, encrypt } from '../crypto/secrets.ts';
import { sessions, userIdentities, users } from '../db/schema.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
-import { decide, type ClaimsDecision } from './claims.ts';
+import { claimAt, decide, type ClaimsDecision } from './claims.ts';
import { applyRole } from './role.ts';
import { oidcConfig, oidcError, type OidcSettings } from './oidc.ts';
@@ -95,9 +95,13 @@ export async function checkOidcSessions(d: { db: Db; log: Log; now?: Date }): Pr
try {
tokens = await oidc.refreshTokenGrant(config, decrypt(row.refreshEnc!));
const sub = row.subject.slice(row.subject.indexOf(' ') + 1);
- claims = config.serverMetadata().userinfo_endpoint
- ? { ...(await oidc.fetchUserInfo(config, tokens.access_token, sub)) }
- : { ...tokens.claims() };
+ // As at sign-in: the ID token's claims, userinfo's on top. Entra's app roles and
+ // "ID token only" mappers are in the ID token alone.
+ claims = {
+ ...tokens.claims(),
+ ...(config.serverMetadata().userinfo_endpoint &&
+ (await oidc.fetchUserInfo(config, tokens.access_token, sub))),
+ };
} catch (err) {
// The provider said no: disabled, signed out there, or the refresh token expired.
if (err instanceof oidc.ResponseBodyError && err.error === 'invalid_grant') {
@@ -108,6 +112,21 @@ export async function checkOidcSessions(d: { db: Db; log: Log; now?: Date }): Pr
log.warn?.({ err: oidcError(err) }, `OpenID check of a session failed`);
continue;
}
+ // No groups claim at all (the refresh brought no ID token, and userinfo doesn't carry it):
+ // nothing to decide groups on, so not "in no groups". Checked again next run.
+ const groupsKnown = claimAt(claims, s.groupsClaim) !== undefined;
+ const ruled = s.allowedGroups.length > 0 || s.adminGroups.length > 0;
+ if (ruled && !groupsKnown) {
+ log.info({ userId: row.userId }, 'OpenID check: no groups claim, role and access kept');
+ await db
+ .update(sessions)
+ .set({
+ idpCheckedAt: now,
+ ...(tokens.refresh_token && { idpRefreshEnc: encrypt(tokens.refresh_token) }),
+ })
+ .where(eq(sessions.id, row.id));
+ continue;
+ }
const verdict = decide(s, claims);
if (!verdict.allowed) {
await end(row, `no longer in ${s.label}'s allowed groups`);
diff --git a/apps/api/test/ad-live.test.ts b/apps/api/test/ad-live.test.ts
index 854cb45..4f5fe94 100644
--- a/apps/api/test/ad-live.test.ts
+++ b/apps/api/test/ad-live.test.ts
@@ -94,8 +94,14 @@ describe.skipIf(!hasTestDb || !URL_)('Active Directory with a real Samba AD DC (
});
t = await createTestApp();
- for (const sam of [ann, bob, dan, cat])
+ // New users with the temporary password: the directory email may connect them (12 §4.3).
+ for (const sam of [ann, bob, dan, cat]) {
await t.addUser(`${sam}@example.com`, 'local-password-1');
+ await t.db
+ .update(users)
+ .set({ mustChangePassword: true })
+ .where(eq(users.email, `${sam}@example.com`));
+ }
await setSetting(t.db, 'auth.ldap', {
enabled: true,
preset: 'ad',
diff --git a/apps/api/test/app.ts b/apps/api/test/app.ts
index 41a1c3c..e6acf24 100644
--- a/apps/api/test/app.ts
+++ b/apps/api/test/app.ts
@@ -66,6 +66,7 @@ export async function createTestApp(
INITIAL_ADMIN_PASSWORD: ADMIN.password,
AMI_SECRET: 'ami-secret-123',
LOG_LEVEL: 'fatal',
+ RATE_LIMIT_PER_MINUTE: '0', // the rate limit has its own test
RECORDINGS_DIR: mkdtempSync(join(tmpdir(), 'sp-rec-')),
SOUNDS_DIR: mkdtempSync(join(tmpdir(), 'sp-sounds-')),
MOH_DIR: mkdtempSync(join(tmpdir(), 'sp-moh-')),
diff --git a/apps/api/test/directory-signin.test.ts b/apps/api/test/directory-signin.test.ts
new file mode 100644
index 0000000..273f73d
--- /dev/null
+++ b/apps/api/test/directory-signin.test.ts
@@ -0,0 +1,119 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// 12 §4.3: who a directory entry may connect to by email, who may disconnect it (§4.5), and a
+// directory that's down counting as a failed sign-in (§4.2).
+import { eq } from 'drizzle-orm';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
+import { userIdentities, users } from '../src/db/schema.ts';
+import { setSetting } from '../src/services/settings.ts';
+import { directorySignIn } from '../src/sso/directory.ts';
+import type { DirectoryEntry } from '../src/sso/ldap.ts';
+import { hasTestDb } from './db.ts';
+import { ADMIN, H, createTestApp } from './app.ts';
+
+const log = { info: () => {} };
+
+describe.skipIf(!hasTestDb)('directory accounts by email (12 §4.3)', () => {
+ let t: Awaited>;
+ let n = 0;
+ const entry = (email: string): DirectoryEntry => ({
+ dn: `uid=e${++n}`,
+ id: `id-${n}`,
+ email,
+ name: email,
+ admin: null,
+ });
+ const connected = async (userId: string) =>
+ (await t.db.select().from(userIdentities).where(eq(userIdentities.userId, userId))).length;
+
+ beforeAll(async () => {
+ t = await createTestApp();
+ await t.clearMustChange(ADMIN.email);
+ });
+ afterAll(() => t?.close());
+
+ it('never connects an admin, or someone who set their own password', async () => {
+ const admin = await t.addUser('boss@example.com', 'boss-password-1', 'admin');
+ const own = await t.addUser('own@example.com', 'own-password-1');
+ for (const u of [admin, own]) {
+ expect(await directorySignIn(t.db, log, entry(u.email), '127.0.0.1')).toBeNull();
+ expect(await connected(u.id)).toBe(0);
+ }
+ });
+
+ it('connects someone with no password, or only the temporary one', async () => {
+ const fresh = await t.addUser('fresh@example.com', 'temporary-pw-1');
+ await t.db.update(users).set({ mustChangePassword: true }).where(eq(users.id, fresh.id));
+ const none = await t.addUser('none@example.com', 'unused-pw-1');
+ await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, none.id));
+ for (const u of [fresh, none]) {
+ expect((await directorySignIn(t.db, log, entry(u.email), '127.0.0.1'))?.id).toBe(u.id);
+ expect(await connected(u.id)).toBe(1);
+ }
+ });
+
+ it('only an admin can disconnect a directory account (a leaver must stay checked)', async () => {
+ const u = await t.addUser('leaver@example.com', 'leaver-password-1');
+ await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'leaver-id' });
+ const mine = await t.login(u.email, 'leaver-password-1');
+ const self = await t.app.inject({
+ method: 'DELETE',
+ url: '/api/me/identities/ldap',
+ headers: { ...H, cookie: mine },
+ });
+ expect(self.statusCode).toBe(403);
+ expect(await connected(u.id)).toBe(1);
+ const admin = await t.login(ADMIN.email, ADMIN.password);
+ const byAdmin = await t.app.inject({
+ method: 'DELETE',
+ url: `/api/users/${u.id}/identities/ldap`,
+ headers: { ...H, cookie: admin },
+ });
+ expect(byAdmin.statusCode).toBe(200);
+ expect(await connected(u.id)).toBe(0);
+ });
+
+ it('a directory that is down still counts each failed password: no free guesses', async () => {
+ resetLoginLimiter();
+ await setSetting(t.db, 'auth.ldap', {
+ enabled: true,
+ preset: 'lldap',
+ url: 'ldap://127.0.0.1:1', // nothing listens there
+ startTls: false,
+ bindDn: null,
+ bindPasswordEnc: null,
+ baseDn: 'dc=example,dc=com',
+ userFilter: '(uid={login})',
+ idAttribute: 'entryUUID',
+ emailAttribute: 'mail',
+ nameAttribute: 'displayName',
+ adminFilter: null,
+ });
+ try {
+ const codes: number[] = [];
+ for (let i = 0; i < 6; i++)
+ codes.push(
+ (
+ await t.app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ headers: H,
+ payload: { login: ADMIN.email, password: `guess-${i}` },
+ })
+ ).statusCode,
+ );
+ expect(codes).toEqual([503, 503, 503, 503, 503, 429]);
+ // Even the right password waits now: the guesses were counted.
+ const right = await t.app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ headers: H,
+ payload: { login: ADMIN.email, password: ADMIN.password },
+ });
+ expect(right.statusCode).toBe(429);
+ } finally {
+ await setSetting(t.db, 'auth.ldap', null);
+ resetLoginLimiter();
+ }
+ });
+});
diff --git a/apps/api/test/fake-oidc.ts b/apps/api/test/fake-oidc.ts
index 5930f42..8a738b2 100644
--- a/apps/api/test/fake-oidc.ts
+++ b/apps/api/test/fake-oidc.ts
@@ -11,6 +11,8 @@ export type FakeUser = {
email_verified?: boolean;
/** Only in userinfo, as Keycloak's mappers and Authelia send them. */
groups?: string[];
+ /** Only in the ID token, as Entra's app roles. */
+ roles?: string[];
};
const b64u = (b: Buffer | string) => Buffer.from(b).toString('base64url');
@@ -38,6 +40,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
/** true: the token endpoint answers 503, as a provider that's down. */
down: false,
refreshes: 0,
+ /** false: a refresh brings no new ID token (allowed by OIDC Core §12.2). */
+ refreshIdToken: true,
};
const codes = new Map();
const access = new Map();
@@ -101,7 +105,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
st.refreshes++;
if (!user || !st.refreshOk) return json(400, { error: 'invalid_grant' });
// Today's claims, as a real provider gives on refresh.
- return json(200, tokens(st.user.sub === user.sub ? { ...st.user } : user, null));
+ const t = tokens(st.user.sub === user.sub ? { ...st.user } : user, null);
+ return json(200, st.refreshIdToken ? t : { ...t, id_token: undefined });
}
const c = codes.get(form.get('code') ?? '');
codes.delete(form.get('code') ?? '');
@@ -113,7 +118,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
if (url.pathname === '/userinfo') {
const user = access.get((req.headers.authorization ?? '').replace(/^Bearer /, ''));
if (!user) return json(401, { error: 'invalid_token' });
- return json(200, { ...user, ...(st.tamper.userinfoSub && { sub: 'someone-else' }) });
+ const { roles: _, ...info } = user;
+ return json(200, { ...info, ...(st.tamper.userinfoSub && { sub: 'someone-else' }) });
}
if (url.pathname === '/logout') return json(200, {});
json(404, {});
@@ -130,6 +136,7 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
exp: t.expired ? now - 3600 : now + 300,
...(nonce && { nonce: t.nonce ? 'wrong-nonce' : nonce }),
...(user.email && { email: user.email, email_verified: user.email_verified }),
+ ...(user.roles && { roles: user.roles }),
};
const at = b64u(randomBytes(16));
const rt = b64u(randomBytes(16));
diff --git a/apps/api/test/ldap-live.test.ts b/apps/api/test/ldap-live.test.ts
index 35f838a..7446c7d 100644
--- a/apps/api/test/ldap-live.test.ts
+++ b/apps/api/test/ldap-live.test.ts
@@ -38,6 +38,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
const ann = `ann${tag}`;
const bob = `bob${tag}`;
const cat = `cat${tag}`;
+ const dee = `dee${tag}`;
const group = `pbx-admins-${tag}`;
let t: Awaited>;
let jwt: string;
@@ -86,6 +87,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
[ann, 'Ann Lee'],
[bob, 'Bob Roe'],
[cat, 'Cat Poe'],
+ [dee, 'Dee Fox'],
])
await gql(
`mutation { createUser(user: { id: "${id}", email: "${id}@example.com", displayName: "${name}" }) { id } }`,
@@ -96,7 +98,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
await gql(`mutation { addUserToGroup(userId: "${ann}", groupId: ${g.createGroup.id}) { ok } }`);
const c = new Client({ url: URL_! });
await c.bind(`uid=admin,ou=people,${BASE}`, ADMIN_PASS);
- for (const id of [ann, bob, cat])
+ for (const id of [ann, bob, cat, dee])
await c.exop(
'1.3.6.1.4.1.4203.1.11.1',
passwordModify(`uid=${id},ou=people,${BASE}`, `${id}-directory-pw`),
@@ -106,14 +108,21 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
- await t.addUser(`${ann}@example.com`, 'ann-local-pw-12');
- await t.addUser(`${cat}@example.com`, 'cat-local-pw-12');
+ // New users with the temporary password from Users: the directory email may connect them.
+ for (const who of [ann, cat]) {
+ await t.addUser(`${who}@example.com`, `${who}-local-pw-12`);
+ await t.db
+ .update(users)
+ .set({ mustChangePassword: true })
+ .where(eq(users.email, `${who}@example.com`));
+ }
+ await t.addUser(`${dee}@example.com`, 'dee-own-password-12'); // chose her own password
await setSetting(t.db, 'auth.ldap', settings());
}, 60_000);
afterAll(async () => {
await t?.close();
- for (const id of [ann, bob, cat])
+ for (const id of [ann, bob, cat, dee])
await gql(`mutation { deleteUser(userId: "${id}") { ok } }`).catch(() => {});
});
@@ -142,7 +151,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
it('signs in by email too, and the local password still works', async () => {
resetLoginLimiter();
expect((await login(`${ann}@example.com`, `${ann}-directory-pw`)).statusCode).toBe(200);
- expect((await login(`${ann}@example.com`, 'ann-local-pw-12')).statusCode).toBe(200);
+ expect((await login(`${ann}@example.com`, `${ann}-local-pw-12`)).statusCode).toBe(200);
});
it('a wrong password, an empty one, and a stranger are refused alike', async () => {
@@ -170,7 +179,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
const r = await login(ann, `${ann}-directory-pw`);
expect(r.statusCode).toBe(503);
expect(r.json().error.code).toBe('directory_unavailable');
- expect((await login(`${ann}@example.com`, 'ann-local-pw-12')).statusCode).toBe(200);
+ expect((await login(`${ann}@example.com`, `${ann}-local-pw-12`)).statusCode).toBe(200);
} finally {
await setSetting(t.db, 'auth.ldap', settings());
}
@@ -198,6 +207,23 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
expect(bad.json()).toMatchObject({ ok: false, error: expect.stringContaining('refused') });
});
+ it('someone with their own password connects on My settings, never by email alone', async () => {
+ resetLoginLimiter();
+ expect((await login(dee, `${dee}-directory-pw`)).statusCode).toBe(401);
+ const mine = await t.login(`${dee}@example.com`, 'dee-own-password-12');
+ const connect = (password: string) =>
+ t.app.inject({
+ method: 'POST',
+ url: '/api/me/identities/ldap',
+ headers: { ...H, cookie: mine },
+ payload: { login: dee, password },
+ });
+ expect((await connect('wrong-password')).statusCode).toBe(400);
+ expect((await connect(`${dee}-directory-pw`)).statusCode).toBe(200);
+ resetLoginLimiter();
+ expect((await login(dee, `${dee}-directory-pw`)).statusCode).toBe(200);
+ });
+
it('directoryCheck disables someone removed from the directory, and only them (§4.5)', async () => {
resetLoginLimiter();
expect((await login(cat, `${cat}-directory-pw`)).statusCode).toBe(200); // connects cat
diff --git a/apps/api/test/oidc-live.test.ts b/apps/api/test/oidc-live.test.ts
index 494dd2a..1b0efae 100644
--- a/apps/api/test/oidc-live.test.ts
+++ b/apps/api/test/oidc-live.test.ts
@@ -128,12 +128,22 @@ describe.skipIf(!hasTestDb || !KC)('OpenID Connect with a real Keycloak (12 S3)'
/** start → Keycloak's login form, filled in like a person would → our callback. */
async function flow(cookie?: string, query = '') {
- const start = await t.app.inject({
- url: `/api/auth/oidc/start${query}`,
- headers: cookie ? { cookie } : {},
- });
+ const connecting = query === '?connect=1';
+ // Connect is a POST from My account (with the CSRF header); sign-in a GET from the button.
+ const start = connecting
+ ? await t.app.inject({
+ method: 'POST',
+ url: '/api/auth/oidc/connect',
+ headers: { ...H, cookie: cookie! },
+ payload: {},
+ })
+ : await t.app.inject({
+ url: `/api/auth/oidc/start${query}`,
+ headers: cookie ? { cookie } : {},
+ });
+ const location = connecting ? (start.json() as { url: string }).url : start.headers.location;
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
- let res = await browser.get(start.headers.location as string);
+ let res = await browser.get(location as string);
if (res.status === 200) {
// Not signed in at Keycloak yet: post its login form.
const html = await res.text();
diff --git a/apps/api/test/oidc.test.ts b/apps/api/test/oidc.test.ts
index f881295..92fa741 100644
--- a/apps/api/test/oidc.test.ts
+++ b/apps/api/test/oidc.test.ts
@@ -3,7 +3,7 @@
// provider (test/fake-oidc.ts).
import { and, desc, eq } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
-import { encrypt } from '../src/crypto/secrets.ts';
+import { decrypt, encrypt } from '../src/crypto/secrets.ts';
import { sessions, userIdentities, users } from '../src/db/schema.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { setSetting } from '../src/services/settings.ts';
@@ -49,18 +49,29 @@ for (const alg of ['RS256', 'ES256'] as const)
idp.st.user = { sub: 'u-1', email: 'ann@example.com', email_verified: true };
idp.st.refreshOk = true;
idp.st.down = false;
+ idp.st.refreshIdToken = true;
});
/** The browser's round trip: start → provider → callback. */
async function flow(cookie?: string, query = '') {
- const start = await t.app.inject({
- url: `/api/auth/oidc/start${query}`,
- headers: cookie ? { cookie } : {},
- });
- expect(start.statusCode).toBe(302);
+ const connecting = query === '?connect=1';
+ // Connect is a POST from My account (with the CSRF header); sign-in a GET from the button.
+ const start = connecting
+ ? await t.app.inject({
+ method: 'POST',
+ url: '/api/auth/oidc/connect',
+ headers: { ...H, cookie: cookie! },
+ payload: {},
+ })
+ : await t.app.inject({
+ url: `/api/auth/oidc/start${query}`,
+ headers: cookie ? { cookie } : {},
+ });
+ const location = connecting ? (start.json() as { url: string }).url : start.headers.location;
+ expect(start.statusCode).toBe(connecting ? 200 : 302);
const pending = start.cookies.find((c) => c.name === 'sp_oidc');
if (!pending) return { start, callback: null };
- const atIdp = await fetch(start.headers.location as string, { redirect: 'manual' });
+ const atIdp = await fetch(location as string, { redirect: 'manual' });
const back = new URL(atIdp.headers.get('location')!);
expect(back.origin).toBe('https://pbx.test');
const callback = await t.app.inject({
@@ -81,6 +92,43 @@ for (const alg of ['RS256', 'ES256'] as const)
expect(r.json()).toEqual({ mode: 'mixed', ldap: false, oidc: { label: 'Keycloak' } });
});
+ it('never sends the browser to another site after sign-in (a tab, a backslash, //)', async () => {
+ await t.addUser('nextcheck@example.com', 'next-check-pw-1');
+ idp.st.user = { sub: 'u-next', email: 'nextcheck@example.com', email_verified: true };
+ const pw = await t.login('nextcheck@example.com', 'next-check-pw-1');
+ await flow(pw, '?connect=1');
+ for (const next of [
+ '/\t/evil.example',
+ '/\n/evil.example',
+ '/\\evil.example',
+ '//evil.example',
+ ])
+ expect(
+ (await flow(undefined, `?next=${encodeURIComponent(next)}`)).callback!.headers.location,
+ ).toBe('/');
+ expect((await flow(undefined, '?next=/me/calls')).callback!.headers.location).toBe(
+ '/me/calls',
+ );
+ });
+
+ it('connect is a POST with the CSRF header; a GET with connect=1 only signs in', async () => {
+ const ann = await t.login(ADMIN.email, ADMIN.password);
+ const forged = await t.app.inject({
+ method: 'POST',
+ url: '/api/auth/oidc/connect',
+ headers: { cookie: ann },
+ payload: {},
+ });
+ expect(forged.statusCode).toBe(403);
+ expect(forged.json().error.code).toBe('csrf');
+ const r = await t.app.inject({
+ url: '/api/auth/oidc/start?connect=1',
+ headers: { cookie: ann },
+ });
+ const pending = r.cookies.find((c) => c.name === 'sp_oidc')!;
+ expect(JSON.parse(decrypt(pending.value)).connectSessionId).toBeNull();
+ });
+
it('starts with PKCE S256, state and nonce, and a short-lived cookie', async () => {
const r = await t.app.inject({ url: '/api/auth/oidc/start?next=/me/calls' });
const to = new URL(r.headers.location as string);
@@ -403,4 +451,27 @@ for (const alg of ['RS256', 'ES256'] as const)
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
});
+
+ it('the session check reads roles that are only in the ID token, and keeps a session whose refresh brings no groups', async () => {
+ idp.st.user = { ...idp.st.user, roles: ['pbx-users'] }; // Entra: app roles, ID token only
+ await setSetting(t.db, 'auth.oidc', {
+ ...oidcSettings,
+ groupsClaim: 'roles',
+ allowedGroups: ['pbx-users'],
+ });
+ try {
+ const { id } = await annSession();
+ await check(20);
+ expect(await row(id)).toBeTruthy();
+ idp.st.refreshIdToken = false; // no new ID token: no roles claim at all
+ await check(40);
+ expect(await row(id)).toBeTruthy();
+ idp.st.refreshIdToken = true;
+ idp.st.user.roles = []; // the claim is there, and empty: ended
+ await check(60);
+ expect(await row(id)).toBeUndefined();
+ } finally {
+ await setSetting(t.db, 'auth.oidc', oidcSettings);
+ }
+ });
});
diff --git a/apps/api/test/rate-limit.test.ts b/apps/api/test/rate-limit.test.ts
new file mode 100644
index 0000000..700f8a8
--- /dev/null
+++ b/apps/api/test/rate-limit.test.ts
@@ -0,0 +1,71 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// 08 §2.4: every route is rate limited per client IP, Test buttons more, and a signed-in
+// person's password asked again counts like a sign-in.
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
+import { hasTestDb } from './db.ts';
+import { ADMIN, H, createTestApp } from './app.ts';
+
+describe.skipIf(!hasTestDb)('rate limits (08 §2.4)', () => {
+ let t: Awaited>;
+ let admin: string;
+ beforeAll(async () => {
+ t = await createTestApp(undefined, { RATE_LIMIT_PER_MINUTE: '30' });
+ await t.clearMustChange(ADMIN.email);
+ admin = await t.login(ADMIN.email, ADMIN.password);
+ });
+ afterAll(() => t?.close());
+
+ it('Test buttons: 10 a minute', async () => {
+ const codes: number[] = [];
+ for (let i = 0; i < 11; i++)
+ codes.push(
+ (
+ await t.app.inject({
+ method: 'POST',
+ url: '/api/settings/smtp/test',
+ headers: { ...H, cookie: admin },
+ payload: {},
+ })
+ ).statusCode,
+ );
+ expect(codes.slice(0, 10)).not.toContain(429);
+ expect(codes[10]).toBe(429);
+ });
+
+ it('a password asked again counts like a sign-in: guessing it is stopped', async () => {
+ resetLoginLimiter();
+ const codes: number[] = [];
+ for (let i = 0; i < 6; i++)
+ codes.push(
+ (
+ await t.app.inject({
+ method: 'POST',
+ url: '/api/auth/change-password',
+ headers: { ...H, cookie: admin },
+ payload: { currentPassword: `guess-${i}-wrong`, newPassword: 'another-password-1' },
+ })
+ ).statusCode,
+ );
+ expect(codes.slice(0, 5)).toEqual([400, 400, 400, 400, 400]);
+ expect(codes[5]).toBe(429);
+ resetLoginLimiter();
+ });
+
+ it('/prov keeps its own limits (07 §2), outside the global one', async () => {
+ const prov = await t.app.inject({ url: '/prov/001565000000.cfg' });
+ expect(prov.headers['x-ratelimit-limit']).toBeUndefined();
+ const api = await t.app.inject({ url: '/api/auth/methods' });
+ expect(api.headers['x-ratelimit-limit']).toBe('30');
+ });
+
+ it('every other route: the global limit per IP, as a 429 rate_limited', async () => {
+ let r;
+ for (let i = 0; i < 40; i++) {
+ r = await t.app.inject({ url: '/api/auth/methods' });
+ if (r.statusCode === 429) break;
+ }
+ expect(r!.statusCode).toBe(429);
+ expect(r!.json().error.code).toBe('rate_limited');
+ });
+});
diff --git a/apps/api/test/sign-in.test.ts b/apps/api/test/sign-in.test.ts
index bc3e0cc..5531847 100644
--- a/apps/api/test/sign-in.test.ts
+++ b/apps/api/test/sign-in.test.ts
@@ -111,4 +111,123 @@ describe.skipIf(!hasTestDb)('sign-in (12 S1)', () => {
});
expect(del.statusCode).toBe(200);
});
+
+ const patch = (payload: object, cookie = admin) =>
+ t.app.inject({ method: 'PATCH', url: '/api/settings', payload, headers: { ...H, cookie } });
+
+ it('a stored secret never goes to a new server: the directory, OpenID and email', async () => {
+ const ldap = {
+ enabled: false,
+ preset: 'lldap',
+ url: 'ldaps://dir.example.com',
+ startTls: false,
+ bindDn: 'uid=svc,dc=example,dc=com',
+ bindPassword: 'service-pw-1',
+ baseDn: 'dc=example,dc=com',
+ userFilter: '(uid={login})',
+ idAttribute: 'entryUUID',
+ emailAttribute: 'mail',
+ nameAttribute: 'displayName',
+ adminFilter: '',
+ };
+ expect((await patch({ 'auth.ldap': ldap })).statusCode).toBe(200);
+ const { bindPassword: _, ...keep } = ldap;
+ expect((await patch({ 'auth.ldap': keep })).statusCode).toBe(200); // same server: kept
+ const moved = await patch({ 'auth.ldap': { ...keep, url: 'ldaps://evil.example' } });
+ expect(moved.statusCode).toBe(400);
+ expect(moved.json().error.message).toMatch(/enter its password again/);
+ const test = await post(
+ '/api/settings/auth/ldap/test',
+ { ...keep, url: 'ldaps://evil.example' },
+ admin,
+ );
+ expect(test.statusCode).toBe(400); // Test too: it would bind there with the stored password
+ const otherDn = await patch({
+ 'auth.ldap': { ...keep, bindDn: 'uid=other,dc=example,dc=com' },
+ });
+ expect(otherDn.statusCode).toBe(400);
+
+ const oidc = {
+ label: 'Keycloak',
+ issuer: 'https://kc.example.com/realms/x',
+ clientId: 'selfphoneng',
+ clientSecret: 'client-secret-1',
+ scopes: 'openid email profile',
+ groupsClaim: 'groups',
+ adminGroups: [],
+ allowedGroups: [],
+ trustEmail: false,
+ };
+ expect((await patch({ 'auth.oidc': oidc })).statusCode).toBe(200);
+ const { clientSecret: __, ...noSecret } = oidc;
+ const newIssuer = await patch({ 'auth.oidc': { ...noSecret, issuer: 'https://evil.example' } });
+ expect(newIssuer.statusCode).toBe(400);
+ expect(newIssuer.json().error.message).toMatch(/client secret again/);
+ expect((await patch({ 'auth.oidc': noSecret })).json()['auth.oidc'].hasClientSecret).toBe(true);
+
+ const smtp = {
+ host: 'mail.example.com',
+ port: 587,
+ secure: false,
+ username: 'pbx',
+ password: 'smtp-pw-1',
+ from: 'PBX ',
+ };
+ expect((await patch({ smtp })).statusCode).toBe(200);
+ const { password: ___, ...smtpKeep } = smtp;
+ expect((await patch({ smtp: smtpKeep })).json().smtp.hasPassword).toBe(true);
+ expect(
+ (await patch({ smtp: { ...smtpKeep, host: 'evil.example' } })).json().smtp.hasPassword,
+ ).toBe(false);
+ await patch({ 'auth.ldap': null, 'auth.oidc': null, smtp: null });
+ });
+
+ it('a role set by the directory or the provider is read-only here (409 role_managed)', async () => {
+ const u = await t.addUser('managed@example.com', 'managed-pw-12');
+ await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'managed-id' });
+ const ldap = {
+ enabled: true,
+ preset: 'lldap' as const,
+ url: 'ldaps://dir.example.com',
+ startTls: false,
+ bindDn: null,
+ bindPasswordEnc: null,
+ baseDn: 'dc=example,dc=com',
+ userFilter: '(uid={login})',
+ idAttribute: 'entryUUID',
+ emailAttribute: 'mail',
+ nameAttribute: 'displayName',
+ adminFilter: null,
+ };
+ await setSetting(t.db, 'auth.ldap', ldap);
+ const setRole = (role: string) =>
+ t.app.inject({
+ method: 'PATCH',
+ url: `/api/users/${u.id}`,
+ payload: { role },
+ headers: { ...H, cookie: admin },
+ });
+ try {
+ expect((await setRole('admin')).statusCode).toBe(200); // no admin filter: set here
+ await setRole('user');
+ await setSetting(t.db, 'auth.ldap', { ...ldap, adminFilter: '(memberOf=cn=pbx-admins)' });
+ const r = await setRole('admin');
+ expect(r.statusCode).toBe(409);
+ expect(r.json().error.code).toBe('role_managed');
+ const dto = await t.app.inject({ url: `/api/users/${u.id}`, headers: { cookie: admin } });
+ expect(dto.json().roleFrom).toBe('the directory');
+ } finally {
+ await setSetting(t.db, 'auth.ldap', null);
+ }
+ });
+
+ it('an empty Contrack update is a 400, not a 500', async () => {
+ const r = await t.app.inject({
+ method: 'PATCH',
+ url: '/api/me/contrack',
+ payload: {},
+ headers: { ...H, cookie: admin },
+ });
+ expect(r.statusCode).toBe(400);
+ });
});
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index f085d09..4885981 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -172,6 +172,11 @@ export const authApi = {
methods: () => api('GET', '/api/auth/methods'),
/** 12 §3.3: disconnect my OpenID account. */
disconnectOidc: () => api<{ ok: true }>('DELETE', '/api/me/identities/oidc'),
+ /** 12 §3.3: where to send the browser to connect my OpenID account. */
+ connectOidc: (next: string) => api<{ url: string }>('POST', '/api/auth/oidc/connect', { next }),
+ /** 12 §4.3: connect my directory account with its username and password. */
+ connectLdap: (login: string, password: string) =>
+ api<{ ok: true }>('POST', '/api/me/identities/ldap', { login, password }),
changePassword: (currentPassword: string, newPassword: string) =>
api<{ ok: true }>('POST', '/api/auth/change-password', { currentPassword, newPassword }),
};
diff --git a/apps/web/src/components/SignInCard.tsx b/apps/web/src/components/SignInCard.tsx
index e476790..398e392 100644
--- a/apps/web/src/components/SignInCard.tsx
+++ b/apps/web/src/components/SignInCard.tsx
@@ -1,10 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-// My sign-in ways (12 §5): the password, and connecting or disconnecting an OpenID account.
+// My sign-in ways (12 §5): the password, connecting or disconnecting an OpenID account, and
+// connecting a directory account.
+import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useLocation, useSearchParams } from 'react-router';
import { authApi } from '../api/client.ts';
import { useMe } from '../auth/RequireAuth.tsx';
-import { Alert, Button } from './ui.tsx';
+import { Alert, Button, Field, inputClass } from './ui.tsx';
/** What `?sso_error=` means (12 §3.2), for the login page and here. */
export function ssoErrorText(code: string, label: string): string {
@@ -34,6 +36,10 @@ export function SignInCard() {
mutationFn: authApi.disconnectOidc,
onSuccess: () => qc.invalidateQueries({ queryKey: ['me'] }),
});
+ const connect = useMutation({
+ mutationFn: () => authApi.connectOidc(loc.pathname),
+ onSuccess: (r) => window.location.assign(r.url),
+ });
const ways = me.data?.signIn;
const oidc = methods.data?.oidc;
if (!ways) return null;
@@ -59,10 +65,13 @@ export function SignInCard() {
)}
- {ways.ldap && (
+ {ways.ldap ? (
- Your company directory account is connected: its password works here too.
+ Your company directory account is connected: its password works here too. Only an admin
+ can disconnect it.
+ ) : (
+ methods.data?.ldap &&
)}
{oidc && (
@@ -87,14 +96,15 @@ export function SignInCard() {
Connect your {oidc.label} account to sign in with it as well.
+ {connect.error &&
{connect.error.message} }
>
)}
@@ -102,3 +112,59 @@ export function SignInCard() {
);
}
+
+/** Connect my directory account (12 §4.3): its username and password, checked by the directory. */
+function DirectoryConnect() {
+ const qc = useQueryClient();
+ const [v, setV] = useState({ login: '', password: '' });
+ const connect = useMutation({
+ mutationFn: () => authApi.connectLdap(v.login, v.password),
+ onSuccess: () => {
+ setV({ login: '', password: '' });
+ return qc.invalidateQueries({ queryKey: ['me'] });
+ },
+ });
+ return (
+ {
+ e.preventDefault();
+ connect.mutate();
+ }}
+ >
+ Company directory
+
+ Connect your directory account to sign in with its password as well.
+
+
+ {(a) => (
+ setV({ ...v, login: e.target.value })}
+ />
+ )}
+
+
+ {(a) => (
+ setV({ ...v, password: e.target.value })}
+ />
+ )}
+
+
+
+ Connect directory account
+
+
+ {connect.error && {connect.error.message} }
+
+ );
+}
diff --git a/apps/web/src/pages/admin/UserDrawer.tsx b/apps/web/src/pages/admin/UserDrawer.tsx
index 2843d5f..5782335 100644
--- a/apps/web/src/pages/admin/UserDrawer.tsx
+++ b/apps/web/src/pages/admin/UserDrawer.tsx
@@ -192,11 +192,20 @@ function GeneralTab({
label="Role"
error={errors.role?.message}
hint={
- isSelf ? 'You cannot remove your own admin role if you are the last admin.' : undefined
+ user?.roleFrom
+ ? `From ${user.roleFrom}: change it there.`
+ : isSelf
+ ? 'You cannot remove your own admin role if you are the last admin.'
+ : undefined
}
>
{(a) => (
-
+
User
Admin
diff --git a/docs/admin-guide/sign-in.md b/docs/admin-guide/sign-in.md
index 7d68491..a9ef175 100644
--- a/docs/admin-guide/sign-in.md
+++ b/docs/admin-guide/sign-in.md
@@ -118,9 +118,22 @@ 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. The first time,
-they're matched to their user here by the directory's email; nobody gets a user just for being in
-the directory. Their SelfPhoneNG password, if they have one, keeps working too.
+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)**:
diff --git a/docs/spec/01-infrastructure.md b/docs/spec/01-infrastructure.md
index 7293db1..2e0defe 100644
--- a/docs/spec/01-infrastructure.md
+++ b/docs/spec/01-infrastructure.md
@@ -121,6 +121,7 @@ APP_UID=1000
APP_GID=1000
LOG_LEVEL=info
TRUST_PROXY=true # true when behind Caddy/nginx
+RATE_LIMIT_PER_MINUTE=600 # requests per minute per client IP, every route; 0 = off
#NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem # a private CA for the OpenID provider or LDAPS (12 §1)
```
diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md
index 5d94422..4d91d9b 100644
--- a/docs/spec/04-backend-api.md
+++ b/docs/spec/04-backend-api.md
@@ -33,7 +33,12 @@
- **CSRF:** every non-GET `/api/*` request with cookie auth must carry the header
`X-Requested-With: selfphoneng`. The web client always sends it. Browsers can't send
it cross-site without CORS, and CORS is off.
-- Login rate limit: 5 failures per IP per 15 min and 10 per email per hour → HTTP 429.
+- Login rate limit: 5 failures per IP per 15 min and 10 per email per hour → HTTP 429. A signed-in
+ person's password asked again (change password, two-step on/off, recovery codes) counts the same.
+- Every route: `@fastify/rate-limit`, `RATE_LIMIT_PER_MINUTE` (default 600) per client IP → 429
+ `rate_limited`. Test buttons, diagnostics and the source download: 10 a minute (`SLOW` in
+ `auth/guards.ts`). `/prov` (its own limits, 07 §2) and the caller-name route (Asterisk on this
+ machine) are outside it (`NO_LIMIT`).
Write `login_failed` to the audit log with the IP.
- If `must_change_password`, every route except `/api/auth/*` returns 403 `{code:'password_change_required'}`.
- Single sign-on (spec 12, as built in S1): the body is `{login, password, totp?}` (`login`: email, or a
diff --git a/docs/spec/08-security-ops.md b/docs/spec/08-security-ops.md
index 49231c5..ff21fd3 100644
--- a/docs/spec/08-security-ops.md
+++ b/docs/spec/08-security-ops.md
@@ -77,6 +77,8 @@ get the proxy's address only (below). The setting is on Settings → Security, w
`Permissions-Policy: microphone=(self), camera=()`.
- HSTS is set by the reverse proxy (Caddy does it by default; nginx example adds `add_header Strict-Transport-Security "max-age=31536000" always;`).
- `TRUST_PROXY=true` → Fastify `trustProxy: '127.0.0.1'` only.
+- Rate limits on every route (04 §2.2): `RATE_LIMIT_PER_MINUTE` per client IP, stricter on Test
+ buttons; login and a password asked again share the login limiter.
- Admin TOTP (M11): RFC 6238, 30 s, 6 digits, ±1 window, 10 single-use recovery codes (stored hashed).
- As built (M11-T1), TOTP (`auth/totp.ts`, node:crypto only, RFC 6238 test vectors in its test):
opt-in for admins on **My account** (header → Account): scan the QR or type the key, confirm one
diff --git a/docs/spec/12-sso.md b/docs/spec/12-sso.md
index 79c66b8..7634a83 100644
--- a/docs/spec/12-sso.md
+++ b/docs/spec/12-sso.md
@@ -36,7 +36,9 @@ the softphone's SIP login are untouched: a phone never talks to the identity pro
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.
+ 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.
@@ -155,7 +157,10 @@ rotation, and builds the end-session URL. We don't hand-roll any of that.
1. Login page (`mixed`, `oidc`): **Sign in with **, a plain link to
`/api/auth/oidc/start?next=` (same-site paths only, as `next` is today).
- My account: **Connect **, a link to `/api/auth/oidc/start?connect=1` (needs a session).
+ 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)`;
@@ -241,7 +246,9 @@ 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`. The provider refuses (`invalid_grant`: user disabled, session
+ 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).
@@ -346,7 +353,10 @@ accepted, so older scripts keep working).
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.
+ 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).
@@ -356,7 +366,9 @@ accepted, so older scripts keep working).
("… 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.
+ 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.
@@ -364,14 +376,20 @@ A user can have both a SelfPhoneNG password and a directory account: either pass
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 → connect it (add the row) and sign in, audit
- `ldap_connected`. The directory is the company's own and the person just proved its password,
- so its email is trusted, unlike an OpenID provider's (§3.3).
+2. Else, a user with the directory email who is **not an admin** and has **no password or only the
+ temporary one** from Users (`must_change_password`) → 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). 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).
+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
@@ -555,6 +573,32 @@ For every active user with a directory account, one connection per run:
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
diff --git a/packages/shared/src/users.ts b/packages/shared/src/users.ts
index 8ea4068..0b3fc2a 100644
--- a/packages/shared/src/users.ts
+++ b/packages/shared/src/users.ts
@@ -36,6 +36,8 @@ export type UserDto = {
createdAt: string;
extension: { id: string; number: string } | null;
signIn: SignInWays;
+ /** Who sets the role (12 §1): the provider's label, "the directory", or null (set here). */
+ roleFrom: string | null;
};
export type ListResponse = { items: T[]; nextCursor: string | null };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ddb30c9..5a9287c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -44,6 +44,9 @@ importers:
'@fastify/cookie':
specifier: ^11.1.3
version: 11.1.3
+ '@fastify/rate-limit':
+ specifier: ^11.2.1
+ version: 11.2.1
'@fastify/static':
specifier: ^10.1.6
version: 10.1.6
@@ -757,6 +760,9 @@ packages:
'@fastify/proxy-addr@5.1.2':
resolution: {integrity: sha512-uqB4QsgVoro0XDC+DIftIMGXHdBPn+lK6Ek3xXAIxPMCJfqxnbtyEoMCm2KPUVOuIosd7Cyw9HawEqMFM/UA+g==}
+ '@fastify/rate-limit@11.2.1':
+ resolution: {integrity: sha512-uTQjii3tJUnb6ZyEMcunuvKE3FDKNZEaPpxdyWADDgthHnUV1swdvwuiZfi3DDDgxm1iBrhOkQ/w0yDBQ8qJtA==}
+
'@fastify/send@4.1.2':
resolution: {integrity: sha512-PitRBXg/N4IrE5ToLuP7lITI5Mz8MmP13dv2aMtMNfaGfc5W5hNOwHqcsdQdqu0Lwm5cpDiYLkiOsZTgmhyt2Q==}
@@ -1918,6 +1924,10 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ ip-address@10.7.5:
+ resolution: {integrity: sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==}
+ engines: {node: '>= 12'}
+
ipaddr.js@2.5.0:
resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
engines: {node: '>= 10'}
@@ -3164,6 +3174,13 @@ snapshots:
'@fastify/forwarded': 3.1.1
ipaddr.js: 2.5.0
+ '@fastify/rate-limit@11.2.1':
+ dependencies:
+ '@lukeed/ms': 2.0.2
+ fastify-plugin: 6.0.1
+ ip-address: 10.7.5
+ toad-cache: 3.7.4
+
'@fastify/send@4.1.2':
dependencies:
'@lukeed/ms': 2.0.2
@@ -4105,6 +4122,8 @@ snapshots:
inherits@2.0.4: {}
+ ip-address@10.7.5: {}
+
ipaddr.js@2.5.0: {}
is-extglob@2.1.1: {}
--
2.34.1