Skip to content

Auth

Four classes, opaque tokens, RBAC as Value Objects, authentication without framework coupling.

Introduction

Authentication in PHP applications often means: a framework-specific auth system with sessions in $_SESSION, JWTs with algorithm-confusion risks, and RBAC in the database. Whoever wants to switch the auth system ends up rebuilding half the application.

jardissupport/auth takes a different approach. Four orchestrators (SessionManager, PasswordHasher, PasswordAuthenticator, Guard) that can be used together or independently:

  • Opaque tokens instead of JWT — random bytes, stored server-side as SHA-256 hash. No signature verification, no algorithm confusion, no payload decoding
  • Token rotation — every refresh revokes the old token and issues a new pair
  • RBAC as Value Objects — policies are built in code, not loaded from the database. Immutable, testable, versionable
  • Events returned, not dispatched — every mutating operation returns domain events. The caller decides whether and how they are dispatched
  • Zero runtime dependencies — only PHP builtins: password_hash, random_bytes, hash_equals, hash_hmac

Installation

bash
composer require jardissupport/auth

GitHub: jardisSupport/auth

Session Management

Creating Sessions

php
use JardisSupport\Auth\SessionManager;
use JardisSupport\Auth\Data\Subject;

$sessionManager = new SessionManager(
    tokenStore: $tokenStore,        // TokenStoreInterface — your own implementation
    accessTokenTtl: 3600,           // 1 hour
    refreshTokenTtl: 604800,        // 7 days
);

$subject = Subject::from('42', 'user');
$result = $sessionManager->create($subject, ['role' => 'editor', 'tenant' => 'acme']);

$result->accessToken;    // Plain-text token (64 hex characters)
$result->refreshToken;   // Plain-text refresh token
$result->session;        // Session object with metadata
$result->events;         // [SessionCreated]

The plain-text token is returned exactly once. Only the SHA-256 hash is stored: a compromised token store yields no usable tokens.

Token Refresh with Rotation

php
$refreshed = $sessionManager->refresh($result->refreshToken);

$refreshed->accessToken;    // New access token
$refreshed->refreshToken;   // New refresh token (old one is revoked)
$refreshed->events;         // [SessionCreated, SessionRefreshed]

The old refresh token is immediately revoked. A stolen refresh token works only once: on the second attempt TokenRevokedException is thrown.

Invalidating Sessions

php
// Single session (logout)
$event = $sessionManager->invalidate($session);
// SessionInvalidated

// All sessions for a subject (logout everywhere)
$event = $sessionManager->invalidateAll('user:42');
// AllSessionsInvalidated

Token Verification

php
use JardisSupport\Auth\Handler\Token\VerifyToken;
use JardisSupport\Auth\Exception\TokenExpiredException;
use JardisSupport\Auth\Exception\TokenRevokedException;

$verifier = new VerifyToken();

try {
    $valid = $verifier($plainToken, $storedHashedToken, TokenType::Access);
} catch (TokenRevokedException) {
    // Token was revoked (logout)
} catch (TokenExpiredException) {
    // TTL expired → use refresh token
}

TokenStoreInterface

Implementing the token store is the application's responsibility. The interface:

php
interface TokenStoreInterface
{
    public function store(HashedTokenInterface $token): void;
    public function find(string $hash): ?HashedTokenInterface;
    public function revoke(string $hash): void;
    public function revokeAllForSubject(string $subject): void;
    public function deleteExpired(): int;
}

Typical implementations: Redis (TTL-based), database (with cleanup job), or in-memory for tests.

Password Hashing

Argon2id (Default)

php
use JardisSupport\Auth\PasswordHasher;

$hasher = PasswordHasher::argon2id(
    memoryCost: 65536,
    timeCost: 4,
    threads: 1,
);

$hash = $hasher->hash('s3cret!');        // $argon2id$...
$hasher->verify('s3cret!', $hash);       // true
$hasher->needsRehash($hash);             // false (options unchanged)

Bcrypt

php
$hasher = PasswordHasher::bcrypt(cost: 12);

$hash = $hasher->hash('s3cret!');        // $2y$12$...

Rehash on Login

php
if ($hasher->verify($password, $storedHash)) {
    if ($hasher->needsRehash($storedHash)) {
        $newHash = $hasher->hash($password);
        // Persist the new hash
    }
}

Password Authentication

The PasswordAuthenticator combines user lookup, password verification and session creation in a single call:

php
use JardisSupport\Auth\PasswordAuthenticator;
use JardisSupport\Auth\Data\Credential;
use JardisSupport\Auth\Data\Subject;

$authenticator = new PasswordAuthenticator(
    passwordHasher: $hasher,
    sessionManager: $sessionManager,
    userLookup: function (string $identifier): ?array {
        $user = $userRepository->findByEmail($identifier);
        if ($user === null) {
            return null;
        }
        return [
            'hash'    => $user->getPasswordHash(),
            'subject' => Subject::from($user->getId(), 'user'),
            'claims'  => ['role' => $user->getRole(), 'tenant' => $user->getTenantId()],
        ];
    },
);

$result = $authenticator->authenticate(
    Credential::password('john@example.com', 's3cret!')
);

if ($result->isSuccess()) {
    $result->getSubject();          // 'user:42'
    $result->session->metadata;     // ['role' => 'admin', 'tenant' => 'acme']
    $result->accessToken;           // Plain-text access token
    $result->refreshToken;          // Plain-text refresh token
    $result->events;                // [SessionCreated, AuthenticationSucceeded]
} else {
    $result->getReason();           // 'Invalid credentials'
    $result->events;                // [AuthenticationFailed]
}

Secure Error Messages

getReason() always returns "Invalid credentials", regardless of whether the user was not found or the password was wrong. The specific cause is only in the internal AuthenticationFailed event (for logging).

Credential Types

php
Credential::password('john@example.com', 's3cret!');   // Login
Credential::apiKey('service-a', 'key-abc123');          // API key
Credential::token('bearer-token-value');                // Token-based

The PasswordAuthenticator only processes CredentialType::Password. For other types, a custom authenticator can be implemented.

RBAC — Guard

Defining a Policy

php
use JardisSupport\Auth\Data\Policy;

$policy = Policy::create()
    ->role('viewer')
        ->allow('article:read', 'comment:read')
    ->role('editor')
        ->allow('article:read', 'article:write', 'article:publish')
        ->deny('article:delete')
    ->role('moderator')
        ->includes('editor')              // Inherits editor permissions
        ->allow('comment:delete')
    ->role('admin')
        ->allow('*')                      // Wildcard: everything allowed
    ->build();

Checking Permissions

php
use JardisSupport\Auth\Guard;

$guard = new Guard($policy);

// Soft check (bool)
$guard->check($session, 'article:write');    // true/false

// Hard check (Exception)
$guard->authorize($session, 'article:delete');
// → UnauthorizedException if not allowed

Permission Format

Permissions follow the resource:action pattern:

PermissionMatches
article:readExactly article:read
article:*All actions on article
*Everything

Multi-Role Sessions

php
$session = new Session(
    subject: 'user:1',
    tokenHash: hash('sha256', 'token'),
    createdAt: new DateTimeImmutable(),
    expiresAt: null,
    metadata: ['role' => ['viewer', 'moderator']],
);

$guard->check($session, 'comment:delete');   // true (moderator)
$guard->check($session, 'article:write');    // true (moderator inherits editor)

The role is read from $session->getMetadata()['role']: as string (single role) or array<string> (multi role). The first role that grants the permission wins.

Role Inheritance

php
$policy = Policy::create()
    ->role('editor')
        ->allow('article:read', 'article:write')
        ->deny('article:delete')
    ->role('moderator')
        ->includes('editor')
        ->allow('comment:delete')
    ->build();

$policy->isAllowed('moderator', 'article:write');    // true  (inherited)
$policy->isAllowed('moderator', 'article:delete');   // false (deny in editor)
$policy->isAllowed('moderator', 'comment:delete');   // true  (own permission)

Deny applies per role

deny() only applies within the role it is defined in. A role that inherits another role via includes() also inherits its deny rules, but evaluation always happens in the context of the respective role.

Domain Events

Every mutating operation returns events. The caller decides how to proceed:

EventTriggered byProperties
SessionCreatedcreate(), refresh()subject, tokenHash, timestamp
SessionRefreshedrefresh()subject, oldTokenHash, newTokenHash, timestamp
SessionInvalidatedinvalidate()subject, tokenHash, timestamp
AllSessionsInvalidatedinvalidateAll()subject, timestamp
AuthenticationSucceededauthenticate()subject, timestamp
AuthenticationFailedauthenticate()credentialType, reason, timestamp
php
// Forward events to the event dispatcher
foreach ($result->events as $event) {
    $dispatcher->dispatch($event);
}

Error Handling

ExceptionCause
AuthenticationExceptionInvalid refresh token
TokenExpiredExceptionToken TTL expired
TokenRevokedExceptionToken was revoked
InvalidCredentialExceptionWrong credentials (for custom authenticators)
UnauthorizedExceptionRBAC check failed

Architecture

Four orchestrators in the Closure-Orchestrator-Pattern, each delegates entirely to handlers:

SessionManager                         ← Orchestrator
├── CreateSession                      ← Generate + store token
├── RefreshSession                     ← Revoke old token, issue new pair
├── InvalidateSession                  ← Revoke single token
└── InvalidateAllSessions              ← Revoke all tokens for a subject

PasswordHasher                         ← Orchestrator
├── HashPassword                       ← password_hash()
├── VerifyPassword                     ← password_verify()
└── CheckRehash                        ← password_needs_rehash()

PasswordAuthenticator                  ← Orchestrator
├── LookupUser                         ← User lookup closure
├── VerifyCredential                   ← Password verification
└── BuildAuthResult                    ← Assemble result

Guard                                  ← Orchestrator
├── CheckPermission                    ← RBAC check (bool)
└── AuthorizePermission                ← RBAC enforce (Exception)

Directory Structure

src/
├── SessionManager.php
├── PasswordHasher.php
├── PasswordAuthenticator.php
├── Guard.php
├── Data/
│   ├── Session.php
│   ├── SessionResult.php
│   ├── Subject.php
│   ├── Token.php
│   ├── HashedToken.php
│   ├── Credential.php
│   ├── AuthResult.php
│   ├── AuthenticationResult.php
│   ├── Permission.php
│   ├── Policy.php
│   ├── PolicyBuilder.php
│   └── Event/
│       ├── SessionCreated.php
│       ├── SessionRefreshed.php
│       ├── SessionInvalidated.php
│       ├── AllSessionsInvalidated.php
│       ├── AuthenticationSucceeded.php
│       └── AuthenticationFailed.php
├── Handler/
│   ├── Session/
│   ├── Token/
│   ├── Password/
│   ├── Authentication/
│   └── Authorization/
└── Exception/
    ├── AuthenticationException.php
    ├── TokenExpiredException.php
    ├── TokenRevokedException.php
    ├── InvalidCredentialException.php
    └── UnauthorizedException.php

API Reference

SessionManager

MethodSignatureDescription
createcreate(Subject $subject, array $claims = []): SessionResultCreate session
refreshrefresh(string $refreshToken): SessionResultToken rotation
invalidateinvalidate(SessionInterface $session): SessionInvalidatedLogout
invalidateAllinvalidateAll(string $subject): AllSessionsInvalidatedLogout everywhere

PasswordHasher

MethodSignatureDescription
argon2idstatic argon2id(int $memoryCost, int $timeCost, int $threads): selfArgon2id factory
bcryptstatic bcrypt(int $cost): selfBcrypt factory
hashhash(string $password): stringHash password
verifyverify(string $password, string $hash): boolVerify password
needsRehashneedsRehash(string $hash): boolRehash needed?

PasswordAuthenticator

MethodSignatureDescription
authenticateauthenticate(CredentialInterface $credential): AuthenticationResultLogin flow

Guard

MethodSignatureDescription
checkcheck(SessionInterface $session, string $permission): boolSoft check
authorizeauthorize(SessionInterface $session, string $permission): voidHard check

Complete Example

Login, session usage and RBAC in one application:

php
use JardisSupport\Auth\Guard;
use JardisSupport\Auth\PasswordAuthenticator;
use JardisSupport\Auth\PasswordHasher;
use JardisSupport\Auth\SessionManager;
use JardisSupport\Auth\Data\Credential;
use JardisSupport\Auth\Data\Policy;
use JardisSupport\Auth\Data\Subject;

// Setup
$hasher = PasswordHasher::argon2id();
$sessionManager = new SessionManager($tokenStore);

$authenticator = new PasswordAuthenticator(
    $hasher,
    $sessionManager,
    fn(string $id) => $userRepo->findForAuth($id),
);

$policy = Policy::create()
    ->role('editor')->allow('article:read', 'article:write')
    ->role('admin')->allow('*')
    ->build();

$guard = new Guard($policy);

// Login
$result = $authenticator->authenticate(
    Credential::password('john@example.com', 's3cret!')
);

if ($result->isSuccess()) {
    // Send tokens to client
    setcookie('access_token', $result->accessToken, httponly: true, secure: true);
    setcookie('refresh_token', $result->refreshToken, httponly: true, secure: true);

    // Dispatch events
    foreach ($result->events as $event) {
        $dispatcher->dispatch($event);
    }
}

// Middleware: validate token and check RBAC
$guard->authorize($session, 'article:write');
// → UnauthorizedException if not allowed

// Refresh
$refreshed = $sessionManager->refresh($refreshToken);
setcookie('access_token', $refreshed->accessToken, httponly: true, secure: true);
setcookie('refresh_token', $refreshed->refreshToken, httponly: true, secure: true);

// Logout
$sessionManager->invalidate($session);