Identity & User Store
Glueful's core is provider-agnostic about who your users are. The framework owns the security spine — sessions, tokens, API keys, auth middleware, authorization — but the concrete user store (the users table, password hashing, account lifecycle) lives in an extension behind a small contract. The first-party store is glueful/users; you can swap your own (LDAP, an existing database, a SaaS directory) by implementing one interface.
Core ships no user store. Without one enabled, core binds a fail-closed
NullUserProviderand authentication is disabled by design — every credential check returnsnull. A fresh app must enable a store; the api-skeleton enablesglueful/usersby default. (The old in-coreUser/UserRepositoryandAuthenticatedUserwere removed in 1.50 — everything now flows throughUserIdentity+UserProviderInterface.)
The pieces
| Type | Role |
|---|---|
Glueful\Auth\UserIdentity | The one canonical runtime identity — identity facts + claims. Immutable, final. |
Glueful\Auth\Contracts\UserProviderInterface | Looks users up and verifies credentials. Implemented by a user store. |
Glueful\Auth\NullUserProvider | Fail-closed default binding — every lookup returns null. |
Glueful\Auth\IdentityResolver | Post-auth: applies the account-status gate and folds in claims providers. |
Glueful\Auth\Contracts\IdentityClaimsProviderInterface | Decorates an identity with claims (e.g. roles). Implemented by RBAC such as glueful/aegis. |
Glueful\Auth\Contracts\TwoFactorServiceInterface | Optional 2FA, provided by an extension. |
The login flow
POST /auth/login
→ AuthenticationService::verifyCredentials()
→ UserProviderInterface::verifyCredentials($identifier, $password) // the store checks the hash
→ IdentityResolver::resolve($identity)
→ status gate (allowed_login_statuses)
→ fold each IdentityClaimsProvider::enrich() (roles / permissions / …)
→ session/token layer attaches sessionUuid + provider, persists identity + claims
If no user store is installed, verifyCredentials() returns null (the NullUserProvider) and login fails closed.
UserIdentity
The authenticated identity plus its runtime claims — not a database row. It carries identity facts (uuid, email, username, status), runtime context (session uuid, provider), and an open claims bag (roles, scopes, permissions, …). It's immutable; with*() methods return copies. Accessors are methods:
$user = $this->currentUser; // ?Glueful\Auth\UserIdentity (or $requestUserContext->getUser())
$user->uuid(); // 'u-abc123'
$user->email(); // ?string
$user->status(); // ?string ('active', …)
$user->roles(); // list<string> — typed claim accessor
$user->scopes(); // list<string>
$user->claim('permissions', []); // arbitrary claim with a default
$user->attr('tenant_id'); // non-claim attribute
$user->toArray(); // array shape used for session/user_data
Identity facts vs. claims: a claims provider can change what a user can do (claims), never who they are (identity facts) — the resolver re-pins the facts after enrichment.
Enabling a user store
Install and enable the first-party store, then migrate (users/profiles ship with the extension; the auth spine ships with core):
composer require glueful/users
// config/extensions.php
'enabled' => [
'Glueful\\Extensions\\Users\\UsersServiceProvider',
],
php glueful migrate:run
Account endpoints
With glueful/users enabled you also get the account lifecycle and read endpoints it ships:
| Endpoint | Purpose |
|---|---|
GET /me | The current authenticated user. |
GET /users/{uuid} | Look up a user by uuid. Opt-in — USERS_USER_LOOKUP_ENABLED. |
GET /users | Paginated user list. Opt-in — USERS_USER_LIST_ENABLED (email filtering via USERS_USER_LIST_ALLOW_EMAIL_FILTER). |
POST /auth/verify-email, /auth/verify-otp, /auth/resend-otp | Email / OTP verification. |
POST /auth/forgot-password, /auth/reset-password | Password recovery. |
POST /2fa/enable, /2fa/verify, /2fa/disable | Email-PIN two-factor. |
The lookup and list endpoints are off by default and require the users.read permission — enable them with the flags above and grant the permission (e.g. php glueful aegis:bootstrap-admin --user=<uuid> if you use glueful/aegis).
Writing your own user store
Implement UserProviderInterface — three methods, authentication only (registration/profile writes belong to the store) — and alias it to the contract so it replaces NullUserProvider:
interface UserProviderInterface
{
public function findByUuid(string $uuid): ?UserIdentity;
public function findByLogin(string $identifier): ?UserIdentity; // email/username/etc.
public function verifyCredentials(string $identifier, string $password): ?UserIdentity;
}
// in your extension's services()
use Glueful\Auth\Contracts\UserProviderInterface;
MyUserProvider::class => [
'class' => MyUserProvider::class,
'shared' => true,
'arguments' => ['@' . MyDirectoryClient::class],
'alias' => [UserProviderInterface::class], // rebinds the contract away from NullUserProvider
],
Return UserIdentity instances from lookups; for verifyCredentials() return the identity on a correct password and null otherwise. Treat the uuid as an opaque principal id.
Adding claims (roles, permissions, …)
A claims provider enriches every authenticated identity after login. Implement IdentityClaimsProviderInterface and tag it identity.claims_provider — the IdentityResolver collects and invokes all of them, additively:
use Glueful\Auth\Contracts\IdentityClaimsProviderInterface;
use Glueful\Auth\UserIdentity;
final class MyRoleClaims implements IdentityClaimsProviderInterface
{
public function enrich(UserIdentity $identity): UserIdentity
{
$roles = $this->roleStore->rolesFor($identity->uuid()); // list<string>
return $roles === []
? $identity // never fabricate membership
: $identity->withClaims([
'roles' => array_values(array_unique([...$identity->roles(), ...$roles])),
]);
}
}
// services()
MyRoleClaims::class => [
'class' => MyRoleClaims::class,
'arguments' => ['@' . MyRoleStore::class],
'shared' => true,
'tags' => ['identity.claims_provider'],
],
This is exactly how glueful/aegis adds RBAC role claims. Enrichment is additive only — a claims provider can grant capabilities, never change who the user is.
Account-status gate
After credentials verify, IdentityResolver rejects any user whose status isn't permitted to log in:
// config/security.php
'auth' => [
'allowed_login_statuses' => ['active'], // others are rejected at login
],
Optional: two-factor
If an extension registers a TwoFactorServiceInterface implementation, the login flow routes through it (isEnabled() / beginLogin()); with none registered, 2FA is skipped entirely. glueful/users provides one.
Next steps
- Authentication — the login, token, and refresh flows that sit on top of identity.
- Extensions — the
glueful/usersstore and RBAC viaglueful/aegis.