customsso / SSO-manager — AUTHTYPE mismatch crash: analysis & fix design

Date: 2026-09-07 (revised same-day: §4 rewritten AUTHTYPE-agnostic for the released module) Affected: any PBX where AUTHTYPE disagrees with where its admin user actually exists. Live examples: 10.0.0.136 (hit the crash after being re-added/re-paired; stopgapped with AUTHTYPE=database), 10.0.0.135 (still in the vulnerable state as of this writing). Status: analysis only — no module code changed yet. This doc is for the customsso/SSO-manager dev.


1. The error

FreePBX admin GUI throws a fatal:

Whoops\Exception\ErrorException: "Trying to access array offset on value of type bool"
  /var/www/html/admin/config.php:383

config.php:383 is:

$user_detail     = $um->getUserByUsername($_SESSION["AMP_user"]->username);   // line 382
$modules_enabled = $um->getCombinedGlobalSettingByID($user_detail['id'],'pbx_modules'); // line 383

getUserByUsername() returns false when the username isn't in User Manager, and PHP 8.2 raises a fatal on false['id']. There is no null-check on $user_detail — this is a core-FreePBX robustness gap, not our code.

The branch that reaches line 383 only runs when all three hold: - $_SESSION["AMP_user"]->getMode() === 'usermanager', AND - AUTHTYPE === 'usermanager', AND - the userman module is active.

2. Root cause

A PBX can be left with AUTHTYPE=usermanager but an empty (or admin-less) userman_users table, while the real admin (marcin) exists only in the legacy ampusers (database-mode) table. When a session in usermanager mode reaches config.php, the userman lookup returns false → crash.

Two independent contributors: - Data condition (the real root): AUTHTYPE and the admin's actual backend disagree. On a fresh/re-imaged/re-paired box the admin often exists only in ampusers while AUTHTYPE defaults to usermanager (FreePBX 17 default). - Core has no failover: the native login path (gui_auth.php, case 'usermanager') does new ampuser($username, "usermanager") rigidly, and config.php:383 dereferences the lookup without checking false. Core trusts AUTHTYPE/mode alone with no fallback to the other backend.

The SSO entry point (/var/www/html/customsso/sso_entry.php) is NOT the cause — it already does the right thing. It resolves the admin primary→secondary (AUTHTYPE backend first, then the other), downgrades authType on failover, and stamps new ampuser($resolved, $authType) with the correct mode. Its own comments cite avoiding this exact config.php crash. So logins through the SSO shim never hit it.

3. Observed behavior (2026-09-07, .136 and .135) — the diagnostic matrix

Path Result Why
SSO manager "Native UI" button (sso_entry.php) works shim fails over usermanager→database, builds a valid session
Login from a different browser, user/pass works fresh session, no stale AMP_user object
Login via HTTP port, user/pass works different/fresh session cookie; after AUTHTYPE=database native auth resolves
Login via HTTPS on the main browser crashes stale server-side PHP session holding AMP_user with mode=usermanager for a user no longer in userman_users
Clear main browser data "last 24 hours" still crashes the session cookie/server-side session predates the 24h window (or the server session file persists); clearing 24h didn't drop it
fwconsole setting AUTHTYPE database works (for NEW logins) new sessions get mode=database; but deviates from fleet usermanager standard and does not clear the pre-existing stale usermanager session

Key takeaways: - The residual trigger is a stale session, not live auth — getMode() returns the mode serialized into the session at login time; it is NOT re-derived from current AUTHTYPE. So flipping AUTHTYPE doesn't rescue an already-established mode=usermanager session; only clearing that session (or resolving the userman lookup) does. - On the VoIP Stuff fleet specifically, AUTHTYPE=usermanager was the standing convention (UCP + userman features depend on it), which made AUTHTYPE=database feel like a workaround. For the released module we do NOT privilege either value — both are first-class operator choices. The fix in §4 aligns AUTHTYPE with wherever the admin actually lives, whichever direction that is.

Philosophy (released-module design principle). Both AUTHTYPE=usermanager and AUTHTYPE=database are legitimate operator choices; this module doesn't privilege either. The crash is caused by AUTHTYPE disagreeing with where the admin actually exists — not by any particular AUTHTYPE value. The reconciler's job is to fix the MISMATCH, never to migrate admins between backends or force a specific auth mode.

4.1 PRIMARY — auto-detect AUTHTYPE at install/pair and reconcile the invariant (the requested fix)

The customsso manager's pair routine (root over SSH, before the module loads) MUST, at initial install, detect the current auth state and enforce this invariant:

AUTHTYPE MUST point at a backend that contains the target admin.

Auto-detect + reconcile (idempotent, root context, logged):

  1. Read current AUTHTYPE from freepbx_settings.
  2. Enumerate admins in both backends: userman admins (see SQL note on pbx_admin below), and full admins from ampusers (see SQL note on sections field below).

SQL note on pbx_admin (corrected in v1.0.8). v1.0.4 through v1.0.7 counted userman admins as SELECT COUNT(*) FROM userman_users on the theory that "any userman user can log in via userman mode." That's wrong — a userman row alone does NOT grant admin-GUI access. Any FreePBX with auto-provisioning creates one userman_users row per endpoint tagged "Autogenerated user on new device creation", none of which can reach the admin GUI. The naive count false-positived ok_userman on effectively every real deployment, masking the very misalignment the reconciler exists to catch.

FreePBX 15+ actually determines admin-GUI access via a global setting pbx_admin, resolved by getCombinedGlobalSettingByID(user_id, 'pbx_admin') in config.php. The combined lookup returns true if the user has a personal pbx_admin=1 in userman_users_settings OR belongs to a group with pbx_admin=1 in userman_groups_settings, with a personal pbx_admin=0 acting as a negative override. Group membership resolves via userman_users.primary_group OR JSON-array containment on userman_groups.users (v1.0.8 guards with JSON_VALID to tolerate legacy blob formats):

SELECT COUNT(DISTINCT u.id)
FROM userman_users u
WHERE
    EXISTS (
        SELECT 1 FROM userman_users_settings s
        WHERE s.uid = u.id AND s.module = 'global'
          AND s.`key` = 'pbx_admin' AND s.val IN ('1','true')
    )
    OR (
        EXISTS (
            SELECT 1 FROM userman_groups g
            JOIN userman_groups_settings gs ON gs.gid = g.id
            WHERE gs.module = 'global' AND gs.`key` = 'pbx_admin'
              AND gs.val IN ('1','true')
              AND (g.id = u.primary_group
                   OR (JSON_VALID(g.users)
                       AND JSON_CONTAINS(g.users, JSON_QUOTE(CAST(u.id AS CHAR)))))
        )
        AND NOT EXISTS (
            SELECT 1 FROM userman_users_settings s
            WHERE s.uid = u.id AND s.module = 'global'
              AND s.`key` = 'pbx_admin' AND s.val IN ('0','false')
        )
    );

Verified against a real box with 9 auto-genned users + 1 ampusers admin: pre-fix userman_admin_count=9 → ok_userman (wrong — allowed the crash-waiting-to-happen to persist); post-fix userman_admin_count=0 → flip_to_database (correct — reconciler now aligns AUTHTYPE with the ampusers admin as designed).

SQL note on sections field (corrected in v1.0.6). FreePBX stores an ampusers admin's sections as a semicolon-delimited token list where * is the "all sections" wildcard. An admin with only the wildcard has sections='*', but any admin who ALSO has explicit per-section grants ends up with a value like ;*;amd;api;999;ampusers;advancedsettings;...;weakpasswords — the wildcard is a TOKEN in the list, not the whole string.

The v1.0.4 shipped probe used WHERE sections='*' (exact match) and silently missed every semicolon-wrapped admin, driving the reconciler to no_admin_anywhere on working boxes. The corrected query in v1.0.6+ matches * as a token:

SELECT COUNT(*) FROM ampusers WHERE
    sections='*'
    OR sections LIKE '%;*;%'
    OR sections LIKE '*;%'
    OR sections LIKE '%;*'

Confirmed originally on .136 — audit log showed AUTHTYPE=database with a working login, probe recorded ampusers_admin_count=0, verdict no_admin_anywhere. Working admin, counted as zero. The sso_entry.php failover finds the admin correctly because it queries ampusers WHERE username=? FIRST (no sections filter) and only falls back to WHERE sections='*' — the reconcile probe was the only path relying solely on the sections check. 3. Identify the target admin: manager's designated target_user, else the operator's admin username as supplied at pair. 4. Reconcile using this matrix:

Current AUTHTYPE userman has target ampusers has target Action
usermanager ✓ any OK, no-op
database any ✓ OK, no-op
usermanager ✗ ✓ Set AUTHTYPE=database (align with reality)
database ✓ ✗ Set AUTHTYPE=usermanager (align with reality)
either ✓ ✓ OK, no-op (respect current AUTHTYPE; both backends would work)
either ✗ ✗ Alert manager, don't touch AUTHTYPE (box has no admin — separate problem)
  1. Emit an audit line naming the pre-state, action taken, and post-state; report back to the manager on pair.

Why no admin seeding between backends. The prior draft proposed seeding the target into userman when AUTHTYPE=usermanager but userman was empty. Dropped because ampusers stores MD5 (or bcrypt) hashes while User Manager uses PHP password_hash — the schemes are incompatible, and copying an admin across would either break the operator's known password or require a forced reset communicated out-of-band. Aligning AUTHTYPE with the admin's actual backend achieves the same goal (crash gone) without touching credentials.

"Both backends have the target" no-op — why. Some operators intentionally maintain admins in both backends (migration in progress, dual-write policy). If both backends carry the target admin but with different passwords, we can't tell which is authoritative and shouldn't try. Surface as an info-level audit line ("both backends carry target_user, respecting current AUTHTYPE=X"), not an alert.

This closes the crash trigger at the one moment we control the box (install/pair), for any AUTHTYPE the operator picked, without needing to patch core FreePBX and without migrating credential data.

4.2a sso_entry.php resolve() (corrected in v1.0.10)

sso_entry.php's admin-user resolver has two fallback paths (when the SSO token's target_user doesn't match a row directly). Both had the same false-negative bug shape that we fixed in the reconciler probe for v1.0.6/v1.0.8, and it manifested as a config.php:390 crash on every Native UI click for any AUTHTYPE=database PBX whose admin has per-section grants:

v1.0.10 aligns both fallbacks with the reconciler probe: - ampusers: match * as a semicolon-token (sections='*' OR sections LIKE '%;*;%' OR sections LIKE '*;%' OR sections LIKE '%;*') - userman: prefer users where getCombinedGlobalSettingByID('pbx_admin') is truthy, fall back to "any userman user" only for legacy schemas that lack pbx_admin metadata

Propagation to the fleet is automatic via the v1.0.0 _sync_sso_entry_if_stale() sidecar mechanism (hash-compare, SFTP push on drift). No re-pair, no fleet-wide reinstall.

4.2 Session hygiene (rebuilt in v1.0.9)

FreePBX's PHP sessions serialize an AMP_user object whose mode (usermanager|database) is frozen at login time — getMode() returns the value stored at login, not the current AUTHTYPE. Any session whose frozen mode disagrees with the current AUTHTYPE crashes on its next request in one of two places:

v1.0.9 purge criterion. Kill any session whose serialized mode differs from the post-flip AUTHTYPE. Handles both crash paths with one criterion. Falls back to the pre-1.0.9 backend-emptiness heuristic only when the probe couldn't read AUTHTYPE at all.

v1.0.9 session-dir discovery. Widened to iterate every dir found in this list:

/var/lib/php/session       (OL8 / OL9 stock)
/var/lib/php/sessions      (Debian 12 stock)
/var/lib/php7/sessions
/var/lib/php8/sessions
/tmp/systemd-private-*/tmp (PHP-FPM / apache2 hardened unit's PrivateTmp)

The PrivateTmp entries are critical on Debian 12 with php8.2-fpm.service / apache2.service under PrivateTmp=true — the actual session files land there, not in /var/lib/php/sessions. Pre-1.0.9 discovery returned the first-match /var/lib/php/session (a stale empty directory) and reported "no stale sessions" while every real session sat in a private-tmp path.

v1.0.9 hygiene sweep on ok_* verdicts. The purge now runs even when the verdict is ok_userman / ok_database / ok_both. Catches the scenario where an operator flipped AUTHTYPE via CLI on their own, and the reconciler probes later, sees an aligned state, but pre-flip sessions are still around waiting to crash. Skipped only for no_admin_anywhere (nothing safe to align to) and probe_failed (unknown state).

Manual unstick path when v1.0.9 hasn't reached a manager yet. As root on the PBX:

find /var/lib/php/session* /var/lib/php7/sessions /tmp/systemd-private-*/tmp -name 'sess_*' -delete 2>/dev/null
systemctl reload apache2   # or httpd

Then clear the browser cookie for the PBX host, or use an incognito window. New login → new session with the current AUTHTYPE's mode → both config.php branches work again.

4.2 Session hygiene — invalidate stale admin sessions when we change AUTHTYPE

Because getMode() is frozen into existing sessions, reconciling AUTHTYPE does not rescue a session that already carries the wrong mode. Whenever the reconciler flips AUTHTYPE (rows 3 and 4 of the matrix), it MUST also invalidate the sessions that would still crash on next request.

Scope: invalidate only sessions whose serialized AMP_user mode does NOT match a currently-present admin in that backend — i.e., the actually-broken subset. Live good sessions (fresh admins already authenticated in the correct backend) survive. Broad approach: iterate the configured session.save_path / DB session table, unserialize the AMP_user, drop the file/row when mode points at a backend where that username no longer exists.

Timing: at pair only, plus any subsequent reconciler-driven AUTHTYPE change. Do NOT run on every fwconsole reload or agent tick — regular operation shouldn't be blowing away sessions.

If invalidation is too invasive on some deployments, an acceptable fallback is to rotate the session cookie name via php.ini or the FreePBX config so old cookies are ignored on the next request. Document either way that a hard-stuck browser can also be cleared operator-side; the module should not depend on that.

4.1a Beyond pair time — v1.0.7 auto-reconcile + manual button

The pair-time reconciler in §4.1 only fires when a PBX is first paired (or repaired). PBXes paired BEFORE the fix landed stay in whatever misaligned state they had — the config.php:383 crash surfaces the next time an admin clicks their admin UI. v1.0.7 closes this gap with two triggers:

Manual: "Reconcile AUTHTYPE now" button on each PBX's manager Status page. Always available. Writes pbxes.authtype_reconcile_requested_at = NOW(); the sidecar detector observes non-NULL on its next status-refresh cycle (~60s), invokes the full reconcile(), clears the column, re-probes. Fires unconditionally regardless of verdict — an operator can force a session purge even on an ok_* verdict.

Auto: authtype_auto_reconcile toggle in Settings → Advanced. Default OFF to preserve pre-v1.0.7 detector-only behavior; when ON, the sidecar detector auto-invokes reconcile on any new flip_to_database or flip_to_usermanager verdict TRANSITION (prev_verdict != current). Guards against flap loops: same-verdict polls no-op. Never fires on ok_*, no_admin_anywhere, or probe_failed — those are either safe (no action needed) or unsafe to auto-fix (no target to align to, or state is inconclusive).

Both triggers call the same _authtype_run_reconcile_now() helper in the sidecar → identical authtype_reconcile.reconcile() path used at pair time (probe → fwconsole setting AUTHTYPE X → scoped session purge → re-probe → persist post-flip state). Every action lands in the audit table.

4.3 Fleet detector (manager health poll)

Add a read-only check to the SSO manager's per-PBX poll that runs the same matrix as §4.1 in detect-only mode: flag any PBX where the reconciler WOULD act (rows 3, 4, or 6). Surfaces the landmine before anyone opens the GUI. Would have flagged .135 and .136 proactively.

Alerting channel — pick from what's already wired: - Manager UI: a red banner on the per-PBX row and a rollup badge on the fleet dashboard (minimum viable — always visible when the operator opens the manager). - Audit log: one line per detection tick, so the state history is queryable. - Optional: email to the manager operator if the mismatch persists across N consecutive polls (only worth wiring if you already have SMTP set up for other alerts).

Cheap, no agent change needed — pure read-only reflection of the fleet's AUTHTYPE state against the two admin tables.

4.4 Leave the released agent module's auth path alone

sso_entry.php already failovers correctly — do not change its login logic. Its runtime philosophy ("try current AUTHTYPE backend first, fall back to the other") is the same one the reconciler applies at pair time. The new work is the install/pair-time reconcile (4.1) + session hygiene (4.2) + detector (4.3), all of which live in the manager-provisioned install step / manager server (single point of control), so they don't require re-releasing and re-signing the fleet agent for its auth behavior.

4.5 Parallel: null-check upstream

The underlying core FreePBX bug — config.php:383 dereferencing false without checking — remains regardless of §4.1. A one-line upstream PR to sangoma/freepbx adding if (!is_array($user_detail)) { … } before the ['id'] deref is free defense-in-depth against anything that reintroduces the data condition (a rogue reset, a botched restore, someone deleting a userman row). Track as a separate item — the fix in this doc is the module-side workaround that doesn't wait on upstream.

5. Recovery runbook (if it recurs before the fix ships)

The crash is GUI-only; independent escape hatches always remain: - A. Log in via the SSO manager "Native UI" button (routes through sso_entry.php failover — works even when native HTTPS login crashes). - B. fwconsole from a root shell (no GUI auth): align AUTHTYPE with wherever the admin actually lives — fwconsole setting AUTHTYPE database if the admin is in ampusers, fwconsole setting AUTHTYPE usermanager if the admin is in userman_users. Either is a legitimate end state. - C. Fresh browser / different scheme (HTTP) / cleared session — works once AUTHTYPE is aligned; on its own only if a valid admin exists in the active backend. - Then clear the stale server-side session (session file / DB row for that admin) so the origin stops replaying the frozen mode=… object.

6. Current fleet exposure