For the complete documentation index, see llms.txt. This page is also available as Markdown.

External authentication

Integrate LDAP/OAuth login via the authenticateUser hook.

MikoPBX delegates web login to modules through a single hook. By implementing authenticateUser() on your module's ConfigClass, you can authenticate the operator against an LDAP directory, an OAuth/OpenID Connect identity provider, or any external IdP — and still fall back to MikoPBX's built-in credential check when your module does not recognise the user.

Throughout this recipe the running example is ModuleBlackList (config class BlackListConf). The factual anchors, however, are the production module ModuleUsersUI, which ships a complete external-auth implementation:

  • Config class — Extensions/ModuleUsersUI/Lib/UsersUIConf.php

  • Authenticator — Extensions/ModuleUsersUI/Lib/UsersUIAuthenticator.php

  • LDAP settings model — Extensions/ModuleUsersUI/Models/LdapConfig.php

Where the hook fits in the login flow

Web login and the REST login action both funnel through one place: MikoPBX\Common\Library\Auth\CredentialsValidator. Its authenticate() method runs two checks, in order:

  1. Admin credentials first. checkAdminCredentials() compares the submitted login/password against the system administrator account (PbxSettings::WEB_ADMIN_LOGIN / WEB_ADMIN_PASSWORD). If that matches, the user logs in as admins and modules are never consulted.

  2. Module hooks second. Only if the admin check fails does authenticateViaModules() fan out to every enabled module.

Core/src/Common/Library/Auth/CredentialsValidator.php
public static function authenticateViaModules(string $login, string $password): ?array
{
    // Try to authenticate via module hooks
    $moduleResults = PBXConfModulesProvider::hookModulesMethod(
        WebUIConfigInterface::AUTHENTICATE_USER,
        [$login, $password]
    );

    foreach ($moduleResults as $sessionData) {
        if (!empty($sessionData) && is_array($sessionData)) {
            return $sessionData;
        }
    }

    return null;
}

The hook is iterated across all modules and the first non-empty result wins. The contract for each module is therefore:

  • return a non-empty session-data array to log the user in with that role, or

  • return [] to say "not my user" — the loop continues to the next module, and ultimately authentication fails with a normal "invalid credentials" error.

The constant and the method signature are both fixed by the interface:

ConfigClass ships a no-op implementation at Core/src/Modules/Config/ConfigClass.php:405, so authenticateUser() is a method you override, and every module already satisfies the interface whether or not it cares about login.

The session-data array

The array you return becomes the user's session. The keys are defined as constants on MikoPBX\AdminCabinet\Controllers\SessionController — always use the constants, never bare strings:

Constant
Wire value
Meaning

SessionController::ROLE

role

ACL role this user gets. For module users this is a custom role string registered in onAfterACLPrepared().

SessionController::HOME_PAGE

homePage

Path the operator is redirected to after login.

SessionController::USER_NAME

userName

Display login stored in the session.

The role string ties this recipe to the ACL recipe: the role you return here must be one your module declared. ModuleUsersUI builds it from a per-module prefix constant — Constants::MODULE_ROLE_PREFIX (its real value is 'UsersUIRoleID') — plus the access-group id, e.g. UsersUIRoleID42. Define your own prefix constant; do not hardcode another module's value.

See Roles, rights and ACL for how to register the role itself, and the Hooks reference for the full list of web-UI hooks.

Step 1 — Store external-IdP configuration in a module Model

Authentication settings (LDAP server, base DN, bind account, OAuth client id and secret) belong in a module-owned table, not in code. Create a model that extends MikoPBX\Modules\Models\ModulesModelsBase and pins its table name in initialize().

Store only the service/bind account credentials and connection parameters here. End-user passwords are never stored: for LDAP you verify them by binding to the directory, and for OAuth you never see them at all. The table holding the bind/service secret should be reachable only through your module's protected controllers.

The production reference is Extensions/ModuleUsersUI/Models/LdapConfig.php, whose table is m_ModuleUsersUI_LDAP_Config. It carries the full set of LDAP fields — serverName, serverPort, tlsMode (none/starttls/ldaps), verifyCert, caCertificate, administrativeLogin, administrativePassword, baseDN, userFilter, userIdAttribute, organizationalUnit and ldapType.

Step 2 — Implement authenticateUser() on the ConfigClass

Read your configuration, then branch on the IdP type. The two common branches:

  • LDAP — bind to the directory with the supplied login/password. There is no local hash to compare; a successful bind is the proof of identity.

  • Local fallback / password hash — if you keep a local password hash, verify it with Phalcon\Encryption\Security::checkHash(), exactly as CredentialsValidator does for the admin account.

How ModuleUsersUI does it (real example)

Extensions/ModuleUsersUI/Lib/UsersUIConf.php keeps authenticateUser() thin and delegates to a dedicated authenticator:

UsersUIAuthenticator::authenticate() (in Extensions/ModuleUsersUI/Lib/UsersUIAuthenticator.php) looks the login up in its own UsersCredentials/AccessGroups tables, then either binds against LDAP (LdapConfig::findFirst() + an LDAP helper) or verifies a locally stored hash, and on success returns the role / homePage / userName array.

The production authenticator still routes its local-password branch through a legacy version-shim helper. On MikoPBX 2025.1.1+ you do not need that shim — verify a stored hash directly with Phalcon\Encryption\Security:

OAuth / OpenID Connect variant

For OAuth/OIDC the browser typically completes the provider's flow first and your module receives an authorization code or an already-validated token. In that case authenticateUser() validates the token (introspection or signature/userinfo check), maps the verified subject/claims to a MikoPBX role, and returns the same session-data array. If no token is present, return [] so the standard form login still works.

Step 3 — WebAuthn passkeys: getPasskeySessionData()

Passkey (WebAuthn) login is a separate path. The cryptographic challenge is verified by the Core action Core/src/PBXCoreREST/Lib/Passkeys/AuthenticationFinishAction.php. By the time your module is called, the user's identity is already proven — so the hook takes only the login and no password:

The Core action fans the login out to modules to build the session:

Your implementation returns the same session-data shape, by login alone:

ModuleUsersUI implements the same idea by reusing its authenticator's "resolve session by login, no password" path:

Passkeys themselves are stored centrally by Core (the passkey credential is bound to a login). Your module only maps that login to a role and home page — it does not store the credential.

How the session is consumed afterwards

Once login succeeds, MikoPBX issues a JWT and the role you returned drives every subsequent ACL decision in MikoPBX\AdminCabinet\Plugins\SecurityPlugin. On each request SecurityPlugin extracts the role (from the Authorization: Bearer header on AJAX calls, or from the refreshToken cookie mapped through Redis on page loads) and calls isAllowedAction($controller, $action) against the ACL. So the role string from authenticateUser() must be one your module registered in onAfterACLPrepared() — otherwise the user logs in but is denied everywhere.

The same JWT/role machinery backs the REST API; see REST API for how external clients obtain and present tokens.

Checklist

Last updated

Was this helpful?