Authentication and authorization for Arcanum.
Auth splits into two distinct concerns:
- Authentication (who are you?) — transport-layer. Resolves an
Identityfrom the request. Lives in PSR-15 middleware (HTTP) orCliAuthResolver(CLI). - Authorization (can you do this?) — domain-layer. Checks permissions on the DTO before the handler runs. Lives in the Conveyor pipeline as a
Progressionmiddleware.
Handlers never know how identity was resolved. They receive a typed Identity via the container — the same interface whether it came from a session cookie, Bearer token, or CLI --token flag.
The Identity interface is the domain's representation of "who is making this request":
interface Identity
{
public function id(): string;
public function roles(): array;
}SimpleIdentity is the built-in implementation for guards that resolve from tokens or sessions. Apps with richer user models should implement Identity directly on their User class.
ActiveIdentity is the request-scoped holder (same pattern as ActiveSession). Auth middleware writes, authorization guard and handlers read.
The IdentityProvider interface is the bridge between the auth system and your user storage. Implement it once and the framework uses it everywhere — session guard, token guard, CLI auth, and the login command all go through the same provider.
interface IdentityProvider
{
public function findById(string $id): Identity|null;
public function findByToken(string $token): Identity|null;
public function findByCredentials(string ...$credentials): Identity|null;
}Every method returns null for "not found" or "invalid." Normal lookup failures (unknown ID, expired token, wrong password) are not exceptional — return null and let the guard handle it. Only throw for infrastructure failures (database down, etc.).
namespace App\Auth;
use App\Domain\User\Model\User;
use Arcanum\Auth\Identity;
use Arcanum\Auth\IdentityProvider;
use Arcanum\Auth\SimpleIdentity;
final class UserProvider implements IdentityProvider
{
public function __construct(private readonly User $users)
{
}
public function findById(string $id): Identity|null
{
$row = $this->users->findById(id: (int) $id);
return $row ? new SimpleIdentity($row->id, $row->roles) : null;
}
public function findByToken(string $token): Identity|null
{
$row = $this->users->findByToken(token: $token);
return $row ? new SimpleIdentity($row->id, $row->roles) : null;
}
public function findByCredentials(string ...$credentials): Identity|null
{
[$email, $password] = $credentials;
$row = $this->users->findByEmail(email: $email);
if ($row === null || !password_verify($password, $row->password_hash)) {
return null;
}
return new SimpleIdentity($row->id, $row->roles);
}
}Register the provider in config/auth.php:
return [
'provider' => \App\Auth\UserProvider::class,
// ...
];The provider is resolved from the container, so it can inject Forge models, database connections, or any other service.
Guards resolve an Identity from an HTTP request. They never reject — that's authorization's job.
interface Guard
{
public function resolve(ServerRequestInterface $request): Identity|null;
}Reads the identity ID from the session, calls IdentityProvider::findById() to look up the full identity:
return [
'guard' => 'session',
'provider' => \App\Auth\UserProvider::class,
];Reads a Bearer token from the Authorization header, calls IdentityProvider::findByToken():
return [
'guard' => 'token',
'provider' => \App\Auth\UserProvider::class,
];Tries multiple guards in order. First non-null identity wins. For apps serving both HTML and API:
return [
'guard' => ['session', 'token'],
'provider' => \App\Auth\UserProvider::class,
];Authorization is declared on DTOs via attributes and enforced by AuthorizationGuard in the Conveyor pipeline.
The DTO requires an authenticated identity. No identity → 401 Unauthorized.
#[RequiresAuth]
final class ViewDashboard
{
public function __construct(public readonly string $section = 'overview') {}
}The identity must have at least one of the listed roles. Missing role → 403 Forbidden. Implies RequiresAuth.
#[RequiresRole('admin', 'moderator')]
final class BanUser
{
public function __construct(public readonly string $userId) {}
}For authorization logic that depends on the DTO's data. The policy is resolved from the container.
#[RequiresPolicy(OwnsPostPolicy::class)]
final class EditPost
{
public function __construct(public readonly string $postId, public readonly string $title) {}
}
final class OwnsPostPolicy implements Policy
{
public function __construct(private PostRepository $posts) {}
public function authorize(Identity $identity, object $dto): bool
{
$post = $this->posts->find($dto->postId);
return $post->authorId === $identity->id();
}
}Multiple policies on one DTO are all checked — all must pass.
CLI uses a three-level priority chain for identity resolution:
--tokenoption (highest priority — for scripts and CI)- CLI session (from
logincommand — for interactive development) ARCANUM_TOKENenvironment variable (fallback for CI)
# One-off token
php arcanum command:admin:reset-cache --token=my-secret-token
# Environment variable (CI pipelines)
ARCANUM_TOKEN=my-secret-token php arcanum command:admin:reset-cache
# Interactive login (stays authenticated for 24 hours by default)
php arcanum login
php arcanum command:admin:reset-cache # uses stored session
php arcanum logoutThe same #[RequiresAuth] and #[RequiresRole] attributes work on CLI — AuthorizationGuard runs in the Conveyor pipeline regardless of transport.
The login command prompts for credentials (configurable fields), validates them through your app's IdentityProvider::findByCredentials(), and stores the identity in an encrypted file (files/.cli-session). Subsequent commands automatically pick up the stored identity without needing --token.
Sessions are encrypted at rest using the framework's Encryptor (your APP_KEY). They contain only the identity ID and an expiry timestamp — never the raw credentials. Sessions expire after the configured TTL (default: 24 hours).
CliSession takes an optional Hourglass\Clock constructor parameter (defaults to SystemClock) for the expiry math. Production code lets the container auto-wire it; tests pass a FrozenClock to assert expiry behavior deterministically without sleep().
php arcanum login # prompts for email + password
php arcanum logout # clears the stored session
The Prompter class provides minimal interactive input for CLI commands:
$prompter->ask('Email:'); // visible input
$prompter->secret('Password:'); // hidden input (disables terminal echo)Fields named password, secret, or token automatically use hidden input in the login command.
Request → SessionMiddleware → AuthMiddleware → CsrfMiddleware → App middleware
→ Router → DTO hydrated → Conveyor dispatches
→ AuthorizationGuard (checks #[RequiresAuth], #[RequiresRole], #[RequiresPolicy])
→ ValidationGuard → Handler
AuthMiddleware resolves identity and stores it in ActiveIdentity. It never rejects. AuthorizationGuard reads DTO attributes and enforces requirements.
CLI → CliAuthResolver (--token / session / env) → Router → DTO hydrated
→ Conveyor dispatches → AuthorizationGuard → ValidationGuard → Handler
Same AuthorizationGuard, same attributes, different identity resolution.
// config/auth.php
return [
// 'session', 'token', or ['session', 'token'] for composite
'guard' => 'session',
// Class implementing IdentityProvider — resolved from the container
'provider' => \App\Auth\UserProvider::class,
// CLI login settings
'login' => [
'fields' => ['email', 'password'], // prompt labels, in order
'ttl' => 86400, // session lifetime in seconds
],
];The IdentityProvider::findByCredentials() receives positional arguments matching the fields order. Fields named password, secret, or token use hidden input.
Bootstrap\Auth runs after Bootstrap\Sessions and before Bootstrap\Routing. It registers:
ActiveIdentityas a singletonIdentityinterface factory (resolves fromActiveIdentity)IdentityProvider(resolved from theproviderconfig key, cached in the container)Guard(configured fromconfig/auth.php) — HTTP onlyAuthMiddleware— HTTP onlyCliSession(encrypted file store) — CLI onlyCliAuthResolver(with session support) — CLI only
AuthorizationGuard is registered as Conveyor before-middleware in both Bootstrap\Routing and Bootstrap\CliRouting — after TransportGuard, before ValidationGuard.
php arcanum login # prompt for credentials, store encrypted session
php arcanum logout # clear stored session