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
composer require jardissupport/authGitHub: jardisSupport/auth
Session Management
Creating Sessions
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
$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
// Single session (logout)
$event = $sessionManager->invalidate($session);
// SessionInvalidated
// All sessions for a subject (logout everywhere)
$event = $sessionManager->invalidateAll('user:42');
// AllSessionsInvalidatedToken Verification
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:
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)
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
$hasher = PasswordHasher::bcrypt(cost: 12);
$hash = $hasher->hash('s3cret!'); // $2y$12$...Rehash on Login
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:
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
Credential::password('john@example.com', 's3cret!'); // Login
Credential::apiKey('service-a', 'key-abc123'); // API key
Credential::token('bearer-token-value'); // Token-basedThe PasswordAuthenticator only processes CredentialType::Password. For other types, a custom authenticator can be implemented.
RBAC — Guard
Defining a Policy
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
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 allowedPermission Format
Permissions follow the resource:action pattern:
| Permission | Matches |
|---|---|
article:read | Exactly article:read |
article:* | All actions on article |
* | Everything |
Multi-Role Sessions
$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
$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:
| Event | Triggered by | Properties |
|---|---|---|
SessionCreated | create(), refresh() | subject, tokenHash, timestamp |
SessionRefreshed | refresh() | subject, oldTokenHash, newTokenHash, timestamp |
SessionInvalidated | invalidate() | subject, tokenHash, timestamp |
AllSessionsInvalidated | invalidateAll() | subject, timestamp |
AuthenticationSucceeded | authenticate() | subject, timestamp |
AuthenticationFailed | authenticate() | credentialType, reason, timestamp |
// Forward events to the event dispatcher
foreach ($result->events as $event) {
$dispatcher->dispatch($event);
}Error Handling
| Exception | Cause |
|---|---|
AuthenticationException | Invalid refresh token |
TokenExpiredException | Token TTL expired |
TokenRevokedException | Token was revoked |
InvalidCredentialException | Wrong credentials (for custom authenticators) |
UnauthorizedException | RBAC 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.phpAPI Reference
SessionManager
| Method | Signature | Description |
|---|---|---|
create | create(Subject $subject, array $claims = []): SessionResult | Create session |
refresh | refresh(string $refreshToken): SessionResult | Token rotation |
invalidate | invalidate(SessionInterface $session): SessionInvalidated | Logout |
invalidateAll | invalidateAll(string $subject): AllSessionsInvalidated | Logout everywhere |
PasswordHasher
| Method | Signature | Description |
|---|---|---|
argon2id | static argon2id(int $memoryCost, int $timeCost, int $threads): self | Argon2id factory |
bcrypt | static bcrypt(int $cost): self | Bcrypt factory |
hash | hash(string $password): string | Hash password |
verify | verify(string $password, string $hash): bool | Verify password |
needsRehash | needsRehash(string $hash): bool | Rehash needed? |
PasswordAuthenticator
| Method | Signature | Description |
|---|---|---|
authenticate | authenticate(CredentialInterface $credential): AuthenticationResult | Login flow |
Guard
| Method | Signature | Description |
|---|---|---|
check | check(SessionInterface $session, string $permission): bool | Soft check |
authorize | authorize(SessionInterface $session, string $permission): void | Hard check |
Complete Example
Login, session usage and RBAC in one application:
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);