Essentials

Identity & User Store

How Glueful models who your users are — the provider-agnostic identity contract, the pluggable user store, and claims.

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 NullUserProvider and authentication is disabled by design — every credential check returns null. A fresh app must enable a store; the api-skeleton enables glueful/users by default. (The old in-core User/UserRepository and AuthenticatedUser were removed in 1.50 — everything now flows through UserIdentity + UserProviderInterface.)

The pieces

TypeRole
Glueful\Auth\UserIdentityThe one canonical runtime identity — identity facts + claims. Immutable, final.
Glueful\Auth\Contracts\UserProviderInterfaceLooks users up and verifies credentials. Implemented by a user store.
Glueful\Auth\NullUserProviderFail-closed default binding — every lookup returns null.
Glueful\Auth\IdentityResolverPost-auth: applies the account-status gate and folds in claims providers.
Glueful\Auth\Contracts\IdentityClaimsProviderInterfaceDecorates an identity with claims (e.g. roles). Implemented by RBAC such as glueful/aegis.
Glueful\Auth\Contracts\TwoFactorServiceInterfaceOptional 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:

EndpointPurpose
GET /meThe current authenticated user.
GET /users/{uuid}Look up a user by uuid. Opt-inUSERS_USER_LOOKUP_ENABLED.
GET /usersPaginated user list. Opt-inUSERS_USER_LIST_ENABLED (email filtering via USERS_USER_LIST_ALLOW_EMAIL_FILTER).
POST /auth/verify-email, /auth/verify-otp, /auth/resend-otpEmail / OTP verification.
POST /auth/forgot-password, /auth/reset-passwordPassword recovery.
POST /2fa/enable, /2fa/verify, /2fa/disableEmail-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/users store and RBAC via glueful/aegis.