Search by

Signed access tokens and opaque refresh tokens with SQL token store and token authentication (subject + tenant), 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 Jardi

v1.8.0 2026-10-21 19:47 UTC

README

Build Status License: MIT PHP Version PHPStan Level PSR-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 signed access tokens, opaque refresh 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 packages contracts and dbquery. Pure support package.

Why This Package?

  • Nine classes to learn — SessionManager, PasswordHasher, Guard, PasswordAuthenticator, SignedTokenAuthenticator, TokenAuthenticator, DbTokenStore, TokenIssuer, TokenRedeemer. 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

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

$sessionManager = new SessionManager($tokenStore, $accessTokenKey); // 32-byte signing key, see "Session tokens"
$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->events as $event) {
    $dispatcher->dispatch($event);
}

Verify & Refresh Tokens

use JardisSupport\Auth\Data\Credential;
use JardisSupport\Auth\SignedTokenAuthenticator;

// Verify an access token — signature + expiry, no store, no database
$result = (new SignedTokenAuthenticator($accessTokenKey))->authenticate(Credential::token($accessToken));

// Refresh — rotates tokens, revokes the old refresh token
$newResult = $sessionManager->refresh($refreshToken);
// $newResult->events contains SessionCreated + SessionRefreshed

Hash & Verify Passwords

use JardisSupport\Auth\PasswordHasher;

$hasher = PasswordHasher::argon2id();

// Registration
$hash = $hasher->hash('secret-password');

// Login
$hasher->verify('secret-password', $hash); // true

// Rehash check on every login
if ($hasher->needsRehash($hash)) {
    $newHash = $hasher->hash('secret-password');
    // update stored hash
}

Authorize with RBAC

use JardisSupport\Auth\Guard;
use JardisSupport\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 = new Guard($policy);

$guard->check($session, 'article:publish');     // true/false
$guard->authorize($session, 'article:delete');  // throws UnauthorizedException

// Multi-role sessions — first matching role wins
$session = new Session(
    subject: 'user:42',
    tokenHash: $hash,
    createdAt: new DateTimeImmutable(),
    expiresAt: null,
    metadata: ['role' => ['editor', 'moderator']],
);
$guard->check($session, 'comment:delete');  // true (moderator has permission)

Authenticate with Password

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

$authenticator = new PasswordAuthenticator(
    $passwordHasher,
    $sessionManager,
    function (string $identifier): ?array {
        $user = $userRepository->findByEmail($identifier);
        if ($user === null) {
            return null;
        }
        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->events as $event) {
    $dispatcher->dispatch($event);
}

Instead of a closure, the third argument may be a UserLookupInterface (jardissupport/contracts >= 2.10, implemented by an account store such as jardissupport/identity). It returns a CredentialRecordInterface (getPasswordHash(), getSubject() as type:id, getClaims()) or null; a subject without type: becomes user:<value>. The claims are copied into the session at login (SessionClaim::TENANT, SessionClaim::ROLE).

$authenticator = new PasswordAuthenticator($passwordHasher, $sessionManager, $accountStore); // UserLookupInterface

Issue and redeem one-time tokens

Invitation, password-reset and verification links use TokenIssuer and TokenRedeemer on the same TokenStoreInterface. Only the hash is stored; the plain value is handed out once.

use JardisSupport\Auth\TokenIssuer;
use JardisSupport\Auth\TokenRedeemer;
use JardisSupport\Contract\Auth\TokenType;

$issuer = new TokenIssuer($tokenStore);              // 2nd argument: token length in bytes (default 32)
$redeemer = new TokenRedeemer($tokenStore);          // 2nd argument: VerifyToken

$issued = $issuer->issue(TokenType::PasswordReset, Subject::from('42'), 3600, ['note' => 'x']);
$issued->plain;    // send this to the user, never store it
$issued->hashed;   // HashedToken as stored

$token = $redeemer->redeem($plainFromLink, TokenType::PasswordReset);  // HashedTokenInterface
$token->getSubject();   // 'user:42'
$token->getClaims();

// Look first, consume later: same checks as redeem(), but the token stays redeemable
$token = $redeemer->peek($plainFromLink, TokenType::Invitation);

redeem() revokes the token on success, so a second call throws TokenRevokedException. peek() performs the same checks (unknown/wrong type, expiry, revocation) with the same exceptions but never revokes — use it to show an invitation or validate a reset link, and redeem() as the last step of the flow (TokenType::Invitation is a valid issued type). Unknown token or wrong type throws InvalidCredentialException (the token stays valid for its right type), an expired one TokenExpiredException.

Invalidate Sessions

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

// All sessions for a subject (logout everywhere) — returns nothing (SessionManagerInterface)
$sessionManager->invalidateAll('user:user-42');

// Exactly the session behind a session reference (see "Authenticate a Token") — other sessions
// of the same subject stay valid; idempotent, an unknown reference revokes nothing; returns nothing
$sessionManager->invalidateByReference($result->getSessionReference());

Since v1.6 invalidateAll() and invalidateByReference() return void because SessionManagerInterface (contracts 2.11) prescribes it — before, they returned the AllSessionsInvalidated / SessionInvalidated event. No package in the fleet used those return values; invalidate(SessionInterface) is not part of the interface and still returns its event. The handlers Handler/Session/InvalidateAllSessions and InvalidateSessionByReference still return the events if you need them. Also for the interface, the parameters of SessionManager::refresh() and TokenRedeemer::redeem() are now named $refreshToken and $plain/$expected (only relevant for callers using named arguments).

Revocation acts on the refresh token. invalidate(), invalidateByReference() and invalidateAll() revoke refresh tokens in the store — refresh($oldRefreshToken) afterwards throws AuthenticationException. An already issued access token stays valid until its exp (default 15 minutes), see "Session tokens". refresh() rotates the refresh token and issues a new access token with a new session reference; the refreshed session keeps the claims of the original session. Claims with a _ prefix are package-internal: they are never part of Session::metadata and never reach TenantAuthResult.

Session tokens

Breaking since v1.5.0: SessionManager takes the signing key as required second constructor argument (new SessionManager($store, $accessTokenKey)), and session access tokens are no longer stored. Authenticate them with SignedTokenAuthenticator, not TokenAuthenticator. Default access TTL is now 900 s (was 3600 s).

Token Form Storage Checked by
Access signed: base64url(payload) . '.' . base64url(mac) none SignedTokenAuthenticator — signature and exp, no database access per request
Refresh opaque random value SHA-256 hash in TokenStoreInterface SessionManager::refresh()
  • Signature: sodium_crypto_auth (HMAC-SHA512-256) over the encoded payload; no header, no alg field, no JWT library. Payload: {typ:"access", sub, sid, iat, exp, claims:{…}}.
  • sid (the session reference, getSessionReference()) is the hash of the session's refresh token: invalidateByReference($sid) revokes exactly that session's refresh.
  • Revocation happens at refresh: an issued access token remains valid until exp (TTL default 900 s, refresh TTL 604800 s). Keep the access TTL short.
  • Key: an own secret of exactly 32 bytes — in .env as AUTH_TOKEN_KEY (base64), handed to the package as raw bytes: base64_decode($_ENV['AUTH_TOKEN_KEY'], true). A key of another length throws InvalidArgumentException. Do not reuse APP_SECRET_KEY.
  • Rotation: replacing the key invalidates all issued access tokens at once (clients refresh and get new ones signed with the new key); refresh tokens are unaffected.

Token Store

DbTokenStore implements TokenStoreInterface on a DbConnectionInterface (jardissupport/contracts) using jardissupport/dbquery — no raw SQL, no own PDO. Only the SHA-256 hash of a token is stored, never the plain value.

use JardisSupport\Auth\DbTokenStore;

$store = new DbTokenStore($connection);                 // table: auth_tokens
$store = new DbTokenStore($connection, 'my_tokens');    // custom table name (plain identifier)

$sessionManager = new SessionManager($store, $accessTokenKey);

The store holds refresh tokens and one-time tokens (API keys, verification, password reset); session access tokens are signed and never stored.

Table DDL ships with the package (one file per dialect, auth_tokens, timestamps in UTC):

Dialect File
MySQL / MariaDB vendor/jardissupport/auth/resources/sql/mysql.sql
PostgreSQL vendor/jardissupport/auth/resources/sql/pgsql.sql
SQLite vendor/jardissupport/auth/resources/sql/sqlite.sql

Apply the file matching $connection->getDriverName() once in your migration/setup (all statements are IF NOT EXISTS). For another table name replace auth_tokens in the file.

Prefer your own store (Redis, ...)? Implement TokenStoreInterface yourself; an InMemoryTokenStore for tests lives in tests/Support/.

Authenticate a Token (request → subject + tenant)

Session access tokens (issued by SessionManager) are authenticated by SignedTokenAuthenticator($accessTokenKey, $tenantClaim = SessionClaim::TENANT): same result type, same reasons (Invalid token, Token expired, Unsupported credential type; never Token revoked — revocation acts at refresh), no store. getSessionReference() is the token's sid.

TokenAuthenticator resolves a stored token (API key, verification, password reset, or any TokenIssuer-issued token; $expectedType selects the type): a valid, non-revoked, non-expired token yields a successful SessionAwareAuthResultInterface with qualified subject, bare subject id, tenant and a session reference; any other token yields a failure with a reason.

use JardisSupport\Auth\Data\Credential;
use JardisSupport\Auth\TokenAuthenticator;
use JardisSupport\Contract\Auth\RequestAttribute;

// The tenant is a claim of the token, set when the session / token is created:
$session = $sessionManager->create(Subject::from('42'), ['tenant' => 'acme']);

$authenticator = new TokenAuthenticator($store);               // tenant claim: 'tenant'
$authenticator = new TokenAuthenticator($store, tenantClaim: 'org');

$result = $authenticator->authenticate(Credential::token($bearerToken));

if ($result->isSuccess()) {
    $request = $request
        ->withAttribute(RequestAttribute::SUBJECT, $result->getSubject())           // 'user:42'
        ->withAttribute(RequestAttribute::SUBJECT_ID, $result->getSubjectId())       // '42'
        ->withAttribute(RequestAttribute::TENANT, $result->getTenant())              // 'acme' | null
        ->withAttribute(RequestAttribute::SESSION, $result->getSessionReference());  // hash of the access token
} else {
    $result->getReason(); // 'Invalid token' | 'Token expired' | 'Token revoked' | 'Unsupported credential type'
}

Only TokenType::Access tokens are accepted by default (4th constructor argument changes that). The attribute names RequestAttribute::SUBJECT (jardisSubject) and RequestAttribute::TENANT (jardisTenant) live in jardissupport/contracts (>= 2.8.0); SUBJECT_ID, SESSION and SessionAwareAuthResultInterface since 2.9.0.

The session reference of a stored-token result is the SHA-256 hash of the presented token; for a signed access token it is the sid (hash of the refresh token); SessionManager::invalidateByReference() revokes exactly that session's refresh token. Any other place that needs the hash of a plain token uses the one function HashedToken::hashOf(string $plainToken): string — never hash('sha256', …) in the consumer. Subject::parse('user:42') is the inverse of Subject::toString().

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 = new PasswordHasher();

Error Handling

Exception When
AuthenticationException Authentication failed (base class)
TokenExpiredException Token has expired
TokenRevokedException Token was revoked
InvalidCredentialException Invalid credentials provided
UnauthorizedException Insufficient permissions (RBAC)
use JardisSupport\Auth\Exception\TokenExpiredException;
use JardisSupport\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 nine orchestrators. Internally, each delegates to invokable handlers:

SessionManager (Orchestrator)
  ├── Handler/Session/CreateSession         store refresh token, sign access token (sid = refresh hash) + SessionCreated event
  ├── Handler/Session/RefreshSession        rotate tokens + SessionRefreshed event
  ├── Handler/Session/InvalidateSession     revoke single session + SessionInvalidated event
  ├── Handler/Session/InvalidateSessionByReference revoke the session's refresh token (sid)
  ├── Handler/Token/SignAccessToken         payload → signed access token
  ├── Handler/Token/VerifyAccessToken       signed access token → payload (MAC, typ, exp) or null
  └── Handler/Session/InvalidateAllSessions revoke all + AllSessionsInvalidated event

PasswordAuthenticator (Orchestrator)
  ├── Handler/Authentication/LookupUser      resolve user via $userLookup closure
  ├── Handler/Authentication/LookupCredentialRecord resolve user via UserLookupInterface
  ├── Handler/Authentication/VerifyCredential verify password against hash
  └── Handler/Authentication/BuildAuthResult  assemble AuthenticationResult + events

SignedTokenAuthenticator (Orchestrator)
  └── Handler/Authentication/AuthenticateSignedToken  verify signature + exp → subject + tenant claim + sid → TenantAuthResult

TokenAuthenticator (Orchestrator)
  └── Handler/Authentication/AuthenticateToken  lookup → verify → subject + subject id + tenant claim + session reference → TenantAuthResult

TokenIssuer (Orchestrator)
  └── Handler/Token/IssueToken              create token, store hash → IssuedToken

TokenRedeemer (Orchestrator)
  ├── Handler/Token/PeekToken               find → verify (type, expiry, revocation) → stored token, no revoke
  └── Handler/Token/RedeemToken             find → verify (type, expiry, revocation) → revoke → stored token

AuthServices (bundle, no logic)
  └── sessions() · tokenIssuer() · tokenRedeemer() · hasher() — fromStore() builds the defaults

DbTokenStore (Orchestrator)
  ├── Handler/TokenStore/StoreToken · FindToken · RevokeToken · RevokeSubjectTokens · DeleteExpiredTokens
  └── Handler/TokenStore/ExecuteQuery       dbquery builder → prepared statement on the connection

PasswordHasher (Orchestrator)
  ├── Handler/Password/HashPassword         hash via password_hash()
  ├── Handler/Password/VerifyPassword       verify via password_verify()
  └── Handler/Password/CheckRehash          check via password_needs_rehash()

Rights check (closure, used without Guard)
  └── Handler/Authorization/HasRight           right in a list of granted rights (`Permission::matches`)

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, IssuedToken, 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
├── TokenIssuerTest.php                    ← src/TokenIssuer.php
├── TokenRedeemerTest.php                  ← src/TokenRedeemer.php
├── AuthContractsTest.php                  ← contracts 2.11 interfaces, AuthServices, peek, role
├── SessionExpiryContractTest.php          ← SessionInterface::getExpiresAt() (v1.7)
├── 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
├── RightsClaimTest.php                    ← claim `rights` → TenantAuthResult::getRights()
├── Handler/Authorization/
│   └── HasRightTest.php                   ← src/Handler/Authorization/HasRight.php
├── Handler/Token/
│   └── VerifyTokenTest.php                ← src/Handler/Token/VerifyToken.php
└── Support/
    └── InMemoryTokenStoreTest.php         ← tests/Support/InMemoryTokenStore.php

Contracts

Requires jardissupport/contracts ^2.16 and jardissupport/dbquery ^1.1. Defined in jardissupport/contracts:

Interface Purpose
TokenStoreInterface Token persistence: store, find, revoke, deleteExpired
PasswordHasherInterface Hash, verify, needsRehash
GuardInterface Permission check + authorize
AuthenticatorInterface Authenticate credentials, return AuthResult
TenantAwareAuthResultInterface AuthResult plus getTenant()
SessionAwareAuthResultInterface TenantAware plus getSubjectId() and getSessionReference() (returned by SignedTokenAuthenticator and TokenAuthenticator)
UserLookupInterface lookup(string $identifier): ?CredentialRecordInterface — login lookup port, accepted by PasswordAuthenticator
CredentialRecordInterface getPasswordHash(), getSubject() (type:id), getClaims()
SessionClaim Claim names TENANT / ROLE / RIGHTS (default of TokenAuthenticator, key read by Guard)
RequestAttribute Request attribute names SUBJECT / SUBJECT_ID / TENANT / SESSION / ROLE / RIGHTS for the authenticated session
SessionManagerInterface create, refresh, invalidateByReference, invalidateAll — implemented by SessionManager
TokenIssuerInterface / TokenRedeemerInterface issue / peek, redeem — implemented by TokenIssuer / TokenRedeemer
SessionResultInterface / IssuedTokenInterface getters (getAccessToken, getRefreshToken, getSession / getPlain, getHashed) — implemented by SessionResult / IssuedToken
RoleAwareAuthResultInterface getRole(): ?string — implemented by TenantAuthResult
RightsAwareAuthResultInterface getRights(): list<string> — implemented by TenantAuthResult
AuthServicesInterface sessions(), tokenIssuer(), tokenRedeemer(), hasher() — implemented by AuthServices

Interfaces (contracts 2.11)

The services implement the contract interfaces, so a kernel can wire and swap them without knowing the classes:

  • String subjects. SessionManagerInterface::create() and TokenIssuerInterface::issue() take the qualified subject string type:id (Subject::parse; a bare id is user:<id>). The classes additionally still accept a Subject object, so existing callers keep working.
  • Session expiry (v1.7, contracts 2.12). SessionInterface::getExpiresAt(): ?DateTimeImmutable (null = does not expire) is implemented by Data\Session and returns the public expiresAt property, which stays unchanged. A session from SessionManager::create()/refresh() carries the expiry of its signed access token (exp). Consumers read the expiry through the contract, never from the class.
  • peek() on the redeemer: see "Issue and redeem one-time tokens".
  • Role. The claim SessionClaim::ROLE (role) of a signed token or stored token surfaces as TenantAuthResult::getRole() (null when the claim is absent or not a non-empty string); TenantAuthResult implements RoleAwareAuthResultInterface.
  • Rights (contracts 2.16). The claim SessionClaim::RIGHTS (rights) of a signed token or stored token surfaces as TenantAuthResult::getRights(); only a list of strings counts, anything else (missing, string, map, mixed list) is []. TenantAuthResult::success(…) takes the rights as its last argument (default []) and implements RightsAwareAuthResultInterface. Elements are resource:action, resource:* or *. The check is the closure Handler\Authorization\HasRight:
use JardisSupport\Auth\Handler\Authorization\HasRight;

$hasRight = new HasRight();
$hasRight($result->getRights(), 'sales.process:approve');   // bool — '*' grants all, 'sales.process:*' grants the resource
// an unparsable right throws InvalidArgumentException (Permission::from)
  • AuthServices bundles the four services:
use JardisSupport\Auth\AuthServices;

$auth = AuthServices::fromStore($tokenStore, $accessTokenKey);   // 3rd argument: ?PasswordHasherInterface (default Argon2id)
$auth->sessions()->create('user:42', ['tenant' => 'acme', 'role' => 'admin']);
$auth->tokenIssuer()->issue(TokenType::Invitation, 'user:42', 3600);
$auth->tokenRedeemer()->peek($plain, TokenType::Invitation);
$auth->hasher()->hash($password);
// or wire your own services: new AuthServices($sessions, $issuer, $redeemer, $hasher)

fromStore() uses the package defaults and reads no environment — key and store come from the caller. The class-level SessionResult/IssuedToken keep their public properties and add the interface getters; PasswordAuthenticator takes any SessionManagerInterface.

The auth exceptions (InvalidCredentialException, TokenExpiredException, TokenRevokedException) are documented in contracts as prose only.

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: DbTokenStore on the DomainKernel database connection, or implement TokenStoreInterface yourself (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 Hashing
AUTH_HASH_ALGO=argon2id
AUTH_HASH_MEMORY=65536
AUTH_HASH_TIME=4
AUTH_HASH_THREADS=1

# Token Defaults
AUTH_TOKEN_LENGTH=32
AUTH_ACCESS_TOKEN_TTL=900
# 32 bytes, base64 (e.g. `openssl rand -base64 32`); decode before handing to SessionManager
AUTH_TOKEN_KEY=
AUTH_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 other token persistence than SQL — DbTokenStore covers MySQL/MariaDB, PostgreSQL, SQLite; anything else: implement TokenStoreInterface
  • 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 (starts a MariaDB 11.8 service, reachable inside the Compose network only)
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