Skip to content

Repository files navigation

Jardis Auth

Build StatusLicense: MITPHP VersionPHPStan LevelPSR-12

Part of Jardis — the Domain-Driven Design platform for PHP. You model your domain; Jardis generates the production-ready hexagonal code (DTOs, Command/Query handlers, repositories, persistence). This package is part of the open-source foundation that generated code runs on.

Authentication and authorization without framework coupling. A focused RBAC for PHP covering opaque tokens, session management, password hashing, and role-based access control — designed for DDD applications. No HTTP layer, no JWT, no third-party runtime dependencies beyond PHP built-ins and the Jardis contract. Pure support package.


Why This Package?

  • Four classes to learnSessionManager, PasswordHasher, Guard, PasswordAuthenticator. Everything else is data
  • Opaque tokens — server-side state, SHA-256 hashed storage, no JWT complexity
  • Token rotation — automatic refresh with old-token revocation
  • RBAC as Value Objects — policies are immutable, defined in code, not in a database
  • No third-party runtime dependencies — PHP built-ins (password_hash, random_bytes, hash, hash_equals) plus the Jardis contract

Installation

composer require jardissupport/auth

Quick Start

Create a Session

useJardisSupport\Auth\SessionManager;
useJardisSupport\Auth\Data\Subject;
$sessionManager = newSessionManager($tokenStore);
$subject = Subject::from('user-42', 'user');
$result = $sessionManager->create($subject, ['role' => 'editor']);
$accessToken = $result->accessToken; // send to client$refreshToken = $result->refreshToken; // store securely on client$session = $result->session; // use server-side// Dispatch events (optional — use your EventDispatcher)foreach ($result->eventsas$event) {
$dispatcher->dispatch($event);
}

Verify & Refresh Tokens

useJardisSupport\Auth\Handler\Token\VerifyToken;
useJardisSupport\Auth\Data\TokenType;
// Verify an access token$verifier = newVerifyToken();
$hash = hash('sha256', $accessToken);
$stored = $tokenStore->find($hash);
$verifier($accessToken, $stored, TokenType::Access);
// throws TokenExpiredException or TokenRevokedException// Refresh — rotates tokens, revokes the old refresh token$newResult = $sessionManager->refresh($refreshToken);
// $newResult->events contains SessionCreated + SessionRefreshed

Hash & Verify Passwords

useJardisSupport\Auth\PasswordHasher;
$hasher = PasswordHasher::argon2id();
// Registration$hash = $hasher->hash('secret-password');
// Login$hasher->verify('secret-password', $hash); // true// Rehash check on every loginif ($hasher->needsRehash($hash)) {
$newHash = $hasher->hash('secret-password');
// update stored hash
}

Authorize with RBAC

useJardisSupport\Auth\Guard;
useJardisSupport\Auth\Data\Policy;
$policy = Policy::create()
->role('admin')->allow('*')
->role('editor')
->allow('article:read', 'article:write', 'article:publish')
->deny('article:delete')
->role('viewer')->allow('article:read')
->role('moderator')->includes('editor')->allow('comment:delete')
->build();
$guard = newGuard($policy);
$guard->check($session, 'article:publish'); // true/false$guard->authorize($session, 'article:delete'); // throws UnauthorizedException// Multi-role sessions — first matching role wins$session = newSession(
subject: 'user:42',
tokenHash: $hash,
createdAt: newDateTimeImmutable(),
expiresAt: null,
metadata: ['role' => ['editor', 'moderator']],
);
$guard->check($session, 'comment:delete'); // true (moderator has permission)

Authenticate with Password

useJardisSupport\Auth\PasswordAuthenticator;
useJardisSupport\Auth\Data\Credential;
$authenticator = newPasswordAuthenticator(
$passwordHasher,
$sessionManager,
function (string$identifier): ?array {
$user = $userRepository->findByEmail($identifier);
if ($user === null) {
returnnull;
}
return [
'hash' => $user->passwordHash,
'subject' => Subject::from($user->id, 'user'),
'claims' => ['role' => $user->role],
];
},
);
$credential = Credential::password('john@example.com', 'secret123');
$result = $authenticator->authenticate($credential);
if ($result->isSuccess()) {
$session = $result->session;
$accessToken = $result->accessToken;
}
// All events in one place: SessionCreated + AuthenticationSucceeded (or AuthenticationFailed)foreach ($result->eventsas$event) {
$dispatcher->dispatch($event);
}

Invalidate Sessions

// Single session (logout) — returns SessionInvalidated event$event = $sessionManager->invalidate($session);
// All sessions for a subject (logout everywhere) — returns AllSessionsInvalidated event$event = $sessionManager->invalidateAll('user:user-42');

Token Store

The package defines TokenStoreInterface — you implement it in your infrastructure layer:

useJardisSupport\Contract\Auth\TokenStoreInterface;
useJardisSupport\Auth\Data\HashedToken;
class DatabaseTokenStore implements TokenStoreInterface
{
publicfunction__construct(privatePDO$pdo) {}
publicfunctionstore(HashedToken$token): void { /* INSERT */ }
publicfunctionfind(string$hash): ?HashedToken { /* SELECT */ }
publicfunctionrevoke(string$hash): void { /* UPDATE revoked = true */ }
publicfunctionrevokeAllForSubject(string$subject): void { /* UPDATE WHERE subject = ? */ }
publicfunctiondeleteExpired(): int { /* DELETE WHERE expires_at < NOW() */ }
}

An InMemoryTokenStore is included in tests/Support/ for testing.


Password Hashing

// Argon2id (default, recommended)$hasher = PasswordHasher::argon2id(memoryCost: 65536, timeCost: 4, threads: 1);
// Bcrypt (fallback)$hasher = PasswordHasher::bcrypt(cost: 12);
// Default constructor uses Argon2id$hasher = newPasswordHasher();

Error Handling

ExceptionWhen
AuthenticationExceptionAuthentication failed (base class)
TokenExpiredExceptionToken has expired
TokenRevokedExceptionToken was revoked
InvalidCredentialExceptionInvalid credentials provided
UnauthorizedExceptionInsufficient permissions (RBAC)
useJardisSupport\Auth\Exception\TokenExpiredException;
useJardisSupport\Auth\Exception\UnauthorizedException;
try {
$verifier($token, $storedToken, TokenType::Access);
} catch (TokenExpiredException$e) {
// Token expired — client should use refresh token
}
try {
$guard->authorize($session, 'admin:delete');
} catch (UnauthorizedException$e) {
// Access denied
}

Architecture

The user sees four orchestrators. Internally, each delegates to invokable handlers:

SessionManager (Orchestrator)
├── Handler/Session/CreateSession create session + token pair + SessionCreated event
├── Handler/Session/RefreshSession rotate tokens + SessionRefreshed event
├── Handler/Session/InvalidateSession revoke single session + SessionInvalidated event
└── Handler/Session/InvalidateAllSessions revoke all + AllSessionsInvalidated event
PasswordAuthenticator (Orchestrator)
├── Handler/Authentication/LookupUser resolve user via $userLookup closure
├── Handler/Authentication/VerifyCredential verify password against hash
└── Handler/Authentication/BuildAuthResult assemble AuthenticationResult + events
PasswordHasher (Orchestrator)
├── Handler/Password/HashPassword hash via password_hash()
├── Handler/Password/VerifyPassword verify via password_verify()
└── Handler/Password/CheckRehash check via password_needs_rehash()
Guard (Orchestrator)
├── Handler/Authorization/CheckPermission check role(s) against policy
└── Handler/Authorization/AuthorizePermission check + throw on failure
Data (Value Objects, Enums, Builder, Events)
├── Token, HashedToken, TokenType
├── Session, SessionResult
├── Subject, Credential, CredentialType, AuthResult, AuthenticationResult
├── Permission, Policy, PolicyBuilder
└── Event/ (AuthenticationSucceeded, AuthenticationFailed, SessionCreated,
SessionRefreshed, SessionInvalidated, AllSessionsInvalidated)

Each handler is an invokable object (__invoke) — independently testable, replaceable, composable. The orchestrators contain no business logic, only delegation.

Test Structure

Tests mirror the src/ directory:

tests/Integration/
├── GuardTest.php ← src/Guard.php
├── SessionManagerTest.php ← src/SessionManager.php
├── PasswordHasherTest.php ← src/PasswordHasher.php
├── PasswordAuthenticatorTest.php ← src/PasswordAuthenticator.php
├── Data/
│ ├── AuthResultTest.php ← src/Data/AuthResult.php
│ ├── CredentialTest.php ← src/Data/Credential.php
│ ├── SubjectTest.php ← src/Data/Subject.php
│ ├── PermissionTest.php ← src/Data/Permission.php
│ ├── PolicyTest.php ← src/Data/Policy.php
│ ├── TokenTest.php ← src/Data/Token.php
│ └── HashedTokenTest.php ← src/Data/HashedToken.php
├── Handler/Token/
│ └── VerifyTokenTest.php ← src/Handler/Token/VerifyToken.php
└── Support/
└── InMemoryTokenStoreTest.php ← tests/Support/InMemoryTokenStore.php

Contracts

Defined in jardissupport/contracts — implement these in your infrastructure:

InterfacePurpose
TokenStoreInterfaceToken persistence: store, find, revoke, deleteExpired
PasswordHasherInterfaceHash, verify, needsRehash
GuardInterfacePermission check + authorize
AuthenticatorInterfaceAuthenticate credentials, return AuthResult

Kernel Integration

Auth is wired directly — constructor injection, no framework hook. DomainKernel (the Koffer in jardiscore/kernel) exposes 11 accessors for cross-cutting infrastructure (cache, logger, database, HTTP client, mailer, filesystem, event dispatcher/registry), but no auth(): Auth is a support package you instantiate and wire yourself in your bounded context.

(Earlier docs described this as "no service hook in DomainApp" — DomainApp was removed in the Kernel-Entkopplung refactor. The fact is unchanged, only the vocabulary: today's Koffer is DomainKernel, consumed by the generated {Domain}Context.)

  • TokenStore: Implement in infrastructure (database, Redis)
  • Policy: Define as value object in application layer
  • Guard: Instantiate in application layer, inject Policy

ENV Variables (optional)

This package does not read the process environment itself — no getenv/$_ENV/DotEnv call exists in src/. The names below are a suggested convention for values you read yourself in the consuming application and pass explicitly into the constructors/methods (e.g. AUTH_HASH_ALGO → the $algorithm argument of PasswordHasher, AUTH_TOKEN_LENGTH → the length argument when generating a Token).

# Password HashingAUTH_HASH_ALGO=argon2idAUTH_HASH_MEMORY=65536AUTH_HASH_TIME=4AUTH_HASH_THREADS=1# Token DefaultsAUTH_TOKEN_LENGTH=32AUTH_ACCESS_TOKEN_TTL=3600AUTH_REFRESH_TOKEN_TTL=604800

What This Package Does NOT Do

  • No JWT — opaque tokens only. JWT comes in v2 at the earliest
  • No OAuth2/OIDC — no authorization server, no PKCE
  • No HTTP layer — no cookies, no middleware, no session_start()
  • No user management — no user model, no registration flow
  • No rate limiting — brute-force protection is infrastructure concern
  • No token persistence — only the interface. You implement the store
  • No event dispatching — events are returned to the caller, not dispatched internally

Development

cp .env.example .env # One-time setup
make install # Install dependencies
make phpunit # Run tests
make phpstan # Static analysis (Level 8)
make phpcs # Coding standards (PSR-12)

Documentation

Full documentation, guides, and API reference:

docs.jardis.io/en/support/auth


License

MIT License — free for any use, including commercial.

AI-Assisted Development

This package ships with a skill for Claude Code, Cursor, Continue, and Aider. Install it in your consuming project:

composer require --dev jardis/dev-skills

More details: https://docs.jardis.io/en/skills

About

Opaque token management, session handling, password hashing, and role-based access control for PHP DDD applications — framework-free, no JWT, no third-party runtime dependencies beyond PHP built-ins and the Jardis contract

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages