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.phpAuthenticator —
Extensions/ModuleUsersUI/Lib/UsersUIAuthenticator.phpLDAP 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:
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 asadminsand modules are never consulted.Module hooks second. Only if the admin check fails does
authenticateViaModules()fan out to every enabled module.
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;
}You cannot override the administrator login. Because the admin check runs first, returning a session array for the admin login from your module has no effect. The hook authenticates additional (non-admin) users only.
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:
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().
Column typing rules — these are not stylistic. The primary key must stay untyped (public $id;): giving it an int type causes a fatal during save(). String columns are either left untyped or declared ?string with an empty-string (or other non-null) default — never a non-nullable typed property. LdapConfig shows both shapes side by side: untyped public $serverName;, public $baseDN; alongside public ?string $tlsMode = 'none'; and public ?string $verifyCert = '0'; (Extensions/ModuleUsersUI/Models/LdapConfig.php:32-140).
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 asCredentialsValidatordoes for the admin account.
Never short-circuit on an empty password. Most LDAP servers treat a bind with an empty password as an unauthenticated bind that returns success. Always reject an empty password before binding, as shown above.
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.
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:
This hook is not symmetric with authenticateUser(). The interface declares only the constant at line 41 — there is no public function getPasskeySessionData(string $login): array; declaration in WebUIConfigInterface, and no no-op base implementation in ConfigClass (compare authenticateUser(), which has both). So this is a method you add to your config class, not one you override; your IDE will not offer to generate it, and misspelling it fails silently.
It works at all only because PBXConfModulesProvider::hookModulesMethod() guards every dispatch with method_exists($configClassObj, $methodName) and skips modules that lack the method (Core/src/Common/Providers/PBXConfModulesProvider.php:94-115). Match the signature exactly — getPasskeySessionData(string $login): array — since nothing in the type system will check it for you.
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:
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
Related pages
Last updated
Was this helpful?