Authentication providers
SkillSecurityReference for OrangeHRM's pluggable authentication system — `AuthProviderChain` (DI-registered as `Services::AUTH_PROVIDER_CHAIN`, holds an ordered list of `AbstractAuthProvider` implementations and dispatches `authenticate()` to the highest-priority one that accepts), `AbstractAuthProvider` (`authenticate(AuthParams): bool` + `getPriority(): int`), `LocalAuthProvider` (password verification against the bcrypt hash in `users.user_password`), `LDAPAuthProvider` (queries an LDAP server + syncs users via `LDAPService` and `LDAPSyncService`), the OAuth2 server (`orangehrmCoreOAuthPlugin` based on `league/oauth2-server`, separate from interactive login), and OpenID Connect / SSO (`orangehrmOpenidAuthenticationPlugin` based on `jumbojett/openid-connect-php`, handles Google / Microsoft / generic OIDC providers via the `AuthProviderExtraDetails` entity). Use whenever the user is wiring a new auth provider, debugging "why won't this user log in", asking about LDAP sync, configuring OAuth clients for an external integration, or integrating an SSO provider. Companion to `security-primitives` (password hashing via `PasswordHash`, OAuth client_secret encryption), `authorization` (different concept — this skill is about *who you are*, authorization is about *what you can do*), `events` (auth providers can dispatch events but currently don't), `scheduled-jobs` (LDAP sync runs as a scheduled task).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Authentication providers skill
What this skill tells your AI
The instructions your AI receives, as published by orangehrm/orangehrm in .agents/skills/auth-providers/SKILL.md and read by ahel’s review.
OrangeHRM has a pluggable chain of authentication providers. Login attempts are routed through each provider in priority order until one accepts. Three providers ship in-tree:
LocalAuthProvider— username + password against the bcrypt hash inusers.user_password(the default). Provided byorangehrmAuthenticationPlugin.LDAPAuthProvider— username + password against an LDAP/AD server with on-the-fly user sync. Provided byorangehrmLDAPAuthenticationPlugin.- OAuth2 server (different model — see below) and OpenID Connect (OIDC) SSO — these are auxiliary auth paths, not direct password providers.
This skill covers the provider chain, the three concrete providers, and the patterns for adding new ones. Authentication is "who you are"; for "what you can do" see authorization. For password hashing, see security-primitives.
The provider chain — AuthProviderChain
OrangeHRM\Authentication\Auth\AuthProviderChain. Registered in the DI container by Framework::configureContainer() as Services::AUTH_PROVIDER_CHAIN.
class AuthProviderChain
{
public function addProvider(AbstractAuthProvider $authProvider): void
{
// … rejects duplicate provider classes
// … rejects conflicting priorities
$this->providers[] = $authProvider;
$this->priorities[] = $authProvider->getPriority();
}
public function authenticate(AuthParamsInterface $authParams): bool
{
array_multisort($this->priorities, SORT_DESC, $this->providers);
foreach ($this->providers as $authProvider) {
if ($authProvider->authenticate($authParams)) {
return true;
}
}
return false;
}
}
Rules:
- Providers are registered by plugins during
PluginConfiguration::initialize(). - Each provider has a unique priority (the chain throws if two providers share one).
- Higher priority runs first.
- Authentication short-circuits on first success — once a provider returns true, the chain stops.
- A provider returning false means "I don't accept these credentials" — the next provider tries.
AbstractAuthProvider — the contract
abstract class AbstractAuthProvider
{
abstract public function authenticate(AuthParamsInterface $authParams): bool;
abstract public function getPriority(): int;
}
Two methods to implement:
-
authenticate(AuthParams): bool— try to authenticate. Returntrueif successful (the user is now logged in, the provider's own side effects have run). Returnfalseif these credentials aren't for this provider. ThrowAuthenticationExceptionif something is wrong with the provider itself (e.g. LDAP server down) — the chain doesn't catch this, so it propagates up. -
getPriority(): int— higher = checked first. Existing providers:LocalAuthProviderpriority:0(lowest, fallback)LDAPAuthProviderpriority: typically500(when LDAP is enabled, it's tried first)
The pattern is specialized auth first, local last: if LDAP is configured, try it; failing that, fall back to local. If neither is configured, only local is registered.
Provider registration — in plugin initialize()
Each auth plugin registers its provider with the chain. The AuthenticationPluginConfiguration always registers LocalAuthProvider:
namespace OrangeHRM\Authentication;
use OrangeHRM\Authentication\Auth\AuthProviderChain;
use OrangeHRM\Authentication\Auth\LocalAuthProvider;
use OrangeHRM\Framework\Services;
class AuthenticationPluginConfiguration implements PluginConfigurationInterface
{
use ServiceContainerTrait;
public function initialize(Request $request): void
{
/** @var AuthProviderChain $authProviderChain */
$authProviderChain = $this->getContainer()->get(Services::AUTH_PROVIDER_CHAIN);
$authProviderChain->addProvider(new LocalAuthProvider());
// … plus other subscribers and config
}
}
And LDAPAuthenticationPluginConfiguration adds the LDAP one conditionally on LDAP being enabled (so we don't try LDAP on instances that don't use it):
class LDAPAuthenticationPluginConfiguration implements
PluginConfigurationInterface,
ConsoleConfigurationInterface,
SchedulerConfigurationInterface
{
use ConfigServiceTrait;
use ServiceContainerTrait;
public function initialize(Request $request): void
{
$ldapSettings = $this->getConfigService()->getLDAPSetting();
if ($ldapSettings instanceof LDAPSetting && $ldapSettings->isEnable()) {
/** @var AuthProviderChain $authProviderChain */
$authProviderChain = $this->getContainer()->get(Services::AUTH_PROVIDER_CHAIN);
$authProviderChain->addProvider(new LDAPAuthProvider());
}
// …
}
}
The pattern: adding a provider is a one-liner in initialize(), gated on configuration so disabled providers don't waste cycles.
Provider 1 — LocalAuthProvider (the default)
OrangeHRM\Authentication\Auth\LocalAuthProvider. The everyday password-based login.
class LocalAuthProvider extends AbstractAuthProvider
{
use ConfigServiceTrait;
use PasswordStrengthServiceTrait;
use I18NHelperTrait;
public function authenticate(AuthParamsInterface $authParams): bool
{
// … queries User by username
// … (new PasswordHash())->verify($plainPassword, $user->getUserPassword())
// … starts the session if valid
// … checks password strength against policy; if weak, throws PasswordEnforceException
// to redirect to "must change password" screen
// returns true on success, false on bad credentials
}
public function getPriority(): int
{
return 0; // fallback
}
}
Storage: bcrypt hash via Utility/PasswordHash (see security-primitives skill). The users.user_password column stores the hash.
Side effects:
- Sets session attributes (
AuthUser::userId, etc.) - May throw
PasswordEnforceExceptionif the configured password policy has changed and the user's current password no longer meets it — the login flow catches this and redirects to "must change password" screen - Increments login-attempt counter (used by lockout features, if enabled)
Provider 2 — LDAPAuthProvider (when LDAP is enabled)
OrangeHRM\LDAP\Auth\LDAPAuthProvider. When the org uses Active Directory or another LDAP-compliant directory.
class LDAPAuthProvider extends AbstractAuthProvider
{
use LoggerTrait;
public function authenticate(AuthParamsInterface $authParams): bool
{
// 1. Bind to LDAP server with the provided credentials
// (via LDAPService::authenticate)
// 2. If bind succeeds:
// - Sync the user (LDAPSyncService) — create local User row if missing, update fields
// - Mark this user as "comes from LDAP" via UserAuthProvider entity
// - Start the session
// - Return true
// 3. If bind fails: return false (chain falls through to local provider)
}
public function getPriority(): int
{
// typically higher than LocalAuthProvider, so LDAP is tried first
}
}
Two services do the work:
| Service | Use |
|---|---|
LDAPService | Low-level LDAP bind + search. Wraps symfony/ldap. |
LDAPSyncService | On successful auth, syncs the LDAP user into the local users + hs_hr_employee tables. Creates a Doctrine User if one doesn't exist, updates name/email/etc. if it does. |
The UserAuthProvider entity tracks the per-user mapping — a users row can have one or more rows in ohrm_user_auth_provider indicating which providers it has linked credentials with. Lets a user authenticate via LDAP and also (separately) via OIDC if both are configured.
LDAP sync also runs as a scheduled task (see scheduled-jobs skill — orangehrm:ldap-sync-user runs hourly by default). This pre-creates User rows for LDAP users so they exist before they first try to log in.
Settings storage: KEY_LDAP_SETTINGS in hs_hr_config (see config skill) — a JSON blob containing host, port, bind DN, search base, user mapping, etc. Configured via Admin → Configuration → LDAP Configuration UI.
LDAP-specific commands:
php bin/console orangehrm:ldap-sync-user— manual sync trigger (also scheduled hourly)- See
console-commandsfor how the command is registered
OAuth2 server — orangehrmCoreOAuthPlugin
Different model from the providers above. OAuth2 is for third-party application access to OrangeHRM's REST API, not interactive user login. Uses league/oauth2-server.
External app OrangeHRM (this codebase)
↓
GET /oauth2/authorize?client_id=...&response_type=code&redirect_uri=...
↓ (user grants permission)
Authorization code returned
↓
POST /oauth2/token (with auth code + client_secret)
↓
Access token returned
↓
External app uses access token for /api/v2/... requests
Three main components:
| Piece | Role |
|---|---|
Server/ directory | Wraps league/oauth2-server configuration: authorization-code grant, refresh-token grant, scopes. |
OAuthClient entity (ohrm_oauth_client) | Each registered third-party app. client_id, client_secret (encrypted), redirect_uri, etc. |
AccessToken, RefreshToken, AuthCode entities | The tokens. Stored in DB so revocation is possible. |
OAuthSubscriber | Intercepts KernelEvents::REQUEST for the /oauth2/* endpoints. |
AuthorizationController (public, see authorization skill) | The user-facing consent screen ("Allow X to access your data?"). |
The interactive user is still logged in via LocalAuthProvider / LDAPAuthProvider — OAuth2 just adds a separate token-based access path for non-interactive callers. The OAuth flow uses the user's existing session for the consent step.
Token TTLs are config-driven (see config skill):
oauth.access_token_ttl— typically 3600soauth.refresh_token_ttl— longer, configurableoauth.auth_code_ttl— typically 600s
Encryption keys for OAuth (separate from the field-encryption key — see security-primitives):
oauth.encryption_keyandoauth.token_encryption_keyinhs_hr_config. Used by the OAuth server for token signing.
Admin UI: Admin → Configuration → Register OAuth Client → set up client_id / client_secret / redirect_uri.
When integrating a new external app, you typically:
- Register the client via the UI (gets you
client_id+client_secret) - External app starts the auth flow at
/oauth2/authorize - User logs in (via Local / LDAP), consents, gets the auth code
- External app exchanges for an access token
OpenID Connect / SSO — orangehrmOpenidAuthenticationPlugin
For "log in with Google / Microsoft / generic OIDC provider" instead of password. Uses jumbojett/openid-connect-php.
User clicks "Log in with Google" on /auth/login
→ Redirect to Google's OIDC authorization endpoint
→ User authenticates with Google
→ Google redirects back with an ID token
→ OpenidAuthenticationController exchanges the token, looks up the local user
→ User is logged in (session started, same as Local/LDAP)
Key bits:
| Piece | Role |
|---|---|
AuthProviderExtraDetails entity (ohrm_auth_provider_extra_details) | Stores OIDC client config: client_secret (encrypted), discovery URL, scopes per provider. |
OpenidProvider entity (ohrm_openid_provider) | The registered providers ("Google", "Microsoft Azure", etc.). |
SocialMediaAuthenticationService | The login flow's coordinator. |
LoginController | Renders the "Log in with X" buttons from the configured providers. |
Configuration: Admin → Configuration → OpenID Connect → Add Provider → name, client_id, client_secret, discovery URL.
A user authenticating via OIDC gets a UserAuthProvider row marking which OIDC provider they used. The first OIDC login auto-creates a User row (matching by email); subsequent logins just authenticate.
KEY_OPENID_PROVIDER_ADDED config flag signals to the login page whether to render the "Log in with…" buttons. When false (no providers configured), the buttons are hidden.
Choosing the right auth approach
| Scenario | Use |
|---|---|
| Interactive user login with username + password | LocalAuthProvider (always available; the default) |
| Interactive login authenticated against AD/LDAP directory | LDAPAuthProvider + LDAP plugin enabled |
| Interactive login via Google / Microsoft / OIDC SSO | OpenID plugin + provider configured |
| Third-party app / integration accessing the REST API | OAuth2 server + client registered |
| API client that already has a username + password | They hit /api/v2/... with credentials in HTTP Basic header (rare); typically OAuth2 is preferred |
The four approaches coexist — an OrangeHRM instance can have local + LDAP + OIDC + OAuth all enabled at once. A single user can have credentials in multiple (e.g. their AD username, plus they linked their Google account).
Where authentication state lives
After a successful authenticate(), the session has:
AuthUser::SESSION_USER_ID— the user'sUser.idAuthUser::SESSION_EMPLOYEE_NUMBER— theiremp_numberAuthUser::SESSION_AUTHENTICATED—trueAuthUser::SESSION_USER_ROLE— their static role (Admin or ESS — dynamic roles like Supervisor are computed per request, seeauthorizationskill)- Plus assorted flash attributes (login errors, redirect URLs)
Accessed via AuthUserTrait::getAuthUser() from anywhere in the request lifecycle (see helpers skill).
AuthenticationSubscriber (see authorization skill) is the runtime gate that checks getAuthUser()->isAuthenticated() on every request and forces re-login when the session is invalid.
Recipes
Recipe 1 — Add a new auth provider
Goal: integrate with a hypothetical SAML provider.
- Define the provider class in your plugin's
Auth/directory:
namespace OrangeHRM\X\Auth;
use OrangeHRM\Authentication\Auth\AbstractAuthProvider;
use OrangeHRM\Authentication\Dto\AuthParamsInterface;
class SamlAuthProvider extends AbstractAuthProvider
{
public function authenticate(AuthParamsInterface $authParams): bool
{
// … your SAML validation logic
// … on success: start session via AuthenticationService, return true
// … on "not for me": return false
// … on broken-state: throw AuthenticationException
}
public function getPriority(): int
{
return 700; // higher than LDAP (500), lower than admin override
}
}
- Register in your plugin's
<Plugin>PluginConfiguration::initialize():
public function initialize(Request $request): void
{
if ($this->getConfigService()->isSamlEnabled()) {
/** @var AuthProviderChain $chain */
$chain = $this->getContainer()->get(Services::AUTH_PROVIDER_CHAIN);
$chain->addProvider(new SamlAuthProvider());
}
}
- Make sure your provider's
authenticate()returns false (not throws) when the credentials are for someone else (e.g. they're plain username/password, not a SAML assertion). The chain falls through to the next provider on false.
Recipe 2 — Register a new OAuth2 client (for an external integration)
For a third-party app to consume the OrangeHRM API:
- Admin → Configuration → Register OAuth Client → set name, redirect URI
- Save → get
client_id+client_secret(the secret is shown once; record it) - External app uses the credentials in the standard OAuth2 authorization-code flow
For programmatic client setup, write to ohrm_oauth_client directly (rare — the UI is the canonical path). client_secret is encrypted at rest via the column-level encryption pattern (see security-primitives).
Recipe 3 — Configure LDAP (operator side)
Admin → Configuration → LDAP Configuration:
- Host, port, encryption (TLS / SSL / none)
- Bind credentials (or anonymous if directory allows)
- Search base, user filter
- User mapping (which LDAP attribute → which
usersfield) - Sync interval (1-23 hours; drives the scheduled
orangehrm:ldap-sync-usertask)
Save → triggers LDAPSyncService to do an initial sync. New User rows are created with users.user_password = NULL (LDAP-only auth — no local password). UserAuthProvider rows mark them as LDAP-sourced.
Recipe 4 — Debug "user can't log in"
| Symptom | Check |
|---|---|
| "Invalid credentials" on a known good password | Local provider: is the password the right one? Use password_verify($plain, $hash) manually. LDAP provider: are LDAP settings correct? Try the bind via ldapsearch from the same host. |
| User exists in LDAP but doesn't get logged in | Is LDAPAuthenticationPluginConfiguration::initialize() adding the provider? Is ldapSettings->isEnable() true? Is the LDAP server reachable from the OrangeHRM host? |
| OAuth client_id rejected | Is the ohrm_oauth_client row enabled? Has the client_secret been mistyped? Decrypt and compare. |
| OIDC button doesn't appear | Is KEY_OPENID_PROVIDER_ADDED true in hs_hr_config? Is at least one OpenidProvider row enabled? |
| Password policy rejection on otherwise-valid creds | LocalAuthProvider throws PasswordEnforceException if the current password doesn't meet the configured policy. User must visit "weak password reset" — the login flow handles this redirect. |
Checklists
Add a new auth provider
- Provider class extends
AbstractAuthProvider; implementsauthenticate(AuthParams): bool+getPriority(): int - On "credentials match but invalid", throw
AuthenticationException— don't return false (that means "not for me") - On "credentials are for someone else" (different protocol, different format), return false — chain falls through
- On success, start the session via
AuthenticationService, set theUserlookup, return true - Unique priority — verify it doesn't collide with
LocalAuthProvider (0)/LDAPAuthProvider - Register in the plugin's
<Plugin>PluginConfiguration::initialize()via$chain->addProvider(new Provider()) - If config-gated: read from
ConfigServicefirst; only register when enabled - Add a
UserAuthProviderrow pattern if users need to be tagged as "authenticated via this provider"
Configure LDAP for a new deployment
- Admin → Configuration → LDAP → enter host/port/credentials/search base
- Test bind via "Test Connection" button (uses
LDAPService::testConnection) - Configure user mapping (LDAP attribute → user field)
- Set sync interval (1-23 hours)
- Verify
php bin/console orangehrm:ldap-sync-userruns successfully - Verify host cron is set up (see
scheduled-jobs) so the periodic sync actually happens - Have a non-LDAP admin account available as fallback in case LDAP misconfiguration locks people out
Register an OAuth2 client for an external integration
- Admin → Configuration → Register OAuth Client
- Choose grant type (authorization code is most common)
- Set redirect URI(s)
- Record the
client_idand one-time-shownclient_secret - (External app side) implement the OAuth2 authorization-code flow against
/oauth2/authorize+/oauth2/token - Verify token issuance: a successful login should produce rows in
ohrm_oauth_access_token
Things that bite
- Provider priorities must be unique —
AuthProviderChain::addProviderthrows if two providers share a priority. If you copy-paste agetPriority()and forget to change it, the plugin breaks oninitialize(). authenticate()returning false vs throwing is the difference between "next provider, please" and "stop and show this error." Get it wrong and the user sees confusing behavior.- The
UserAuthProvidertable is per-user-per-provider, not per-session. Multiple rows per user are fine and indicate "this user can authenticate via these providers." - LDAP-synced users have NULL
users.user_password— they can't log in via Local becausepassword_verify($plain, null)returns false. Don't try to "fix" the null; it's intentional and correct. - OAuth2 access tokens are stored encrypted in
ohrm_oauth_access_token— but they're not column-encrypted viaCryptographer. The OAuth2 server uses its ownoauth.encryption_key(seeconfig) for signing/encryption. Don't confuse the two encryption layers. - OIDC
client_secretIS column-encrypted (AuthProviderExtraDetails::clientSecretvia an EntityListener) — seesecurity-primitives. The key file is shared with employee SSN encryption. - LDAP sync runs hourly by default — a brand-new LDAP user can't log in until the next sync (or you trigger it manually). The auth provider's just-in-time sync handles this on login, but only if LDAP bind succeeds; if your provider has a "must exist before auth" model, users get locked out between syncs.
- The chain doesn't catch exceptions from
authenticate(). A thrownAuthenticationExceptionpropagates up to the login controller, which renders the appropriate error page. Don't catch and swallow in your provider — let the chain propagate. AuthenticationException::sessionExpired()vs invalid creds are different — sessions get force-logged-out byAuthenticationSubscriber(seeauthorization), not by providers. Providers handle initial login only.
Signals
- GitHub stars
- 1k
- Forks
- 749
- Last commit
- Jun 2026
Advanced
- Catalog kind
- skill
- Gateway key
auth-providers- Source
- github.com/orangehrm/orangehrm