Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
## [Unreleased]

### Features
* `AbstractPersonalAccessToken::expiresAt` is nullable — `NULL` means the token never expires (active in `findOneActiveByTokenHash()`, skipped by `anzu:personal-access-token:notify-expiring`). New `rateLimit` column (`?int`, not serialized) — a per-token MCP rate limit overriding the configured default. Hosts must add a migration: `expires_at DATETIME DEFAULT NULL`, `rate_limit INT UNSIGNED DEFAULT NULL`, `user_id` foreign key `ON DELETE CASCADE`.
* `anzu:personal-access-token:create` gained `--never-expires` (mutually exclusive with `--expires-at`) and `--rate-limit=N`; `PersonalAccessTokenFacade::create()` gained `?int $rateLimit` and `bool $neverExpires`. The management API keeps creating expiring tokens without a rate limit.
* `PersonalAccessTokenFacade::deleteByUser()` + `PersonalAccessTokenManager::delete()` remove all tokens of a user and invalidate their auth cache entries — call it before deleting the user (the only reliable path — `created_by`/`modified_by` of the user's own tokens still reference the user); the `user` join column additionally declares `onDelete: CASCADE` as a best-effort database-level cleanup (hosts must update the foreign key in their migration).
* `PersonalAccessTokenAuthenticator` sets `McpRateLimiter::TOKEN_ATTRIBUTE_KEY` (`pat_<id>`) and `McpRateLimiter::TOKEN_ATTRIBUTE_LIMIT` on the security token; `PersonalAccessTokenAuthCache` caches a `CachedPersonalAccessToken` (token id, user id, rate limit) under a new key prefix instead of the bare user id.

### Changes
* BC change: `anzusystems/common-bundle` requirement raised to `^11.5` (the authenticator uses `McpRateLimiter::TOKEN_ATTRIBUTE_*`).
* BC change: `PersonalAccessTokenFacade` constructor gained `PersonalAccessTokenRepository $repository` (autowired).
* BC change: `AbstractPersonalAccessToken::getExpiresAt()` returns `?DateTimeImmutable`, `setExpiresAt()` accepts `null`; `PersonalAccessTokenAuthCache::getUserId()/storeUserId()` replaced by `getToken()/storeToken()`.
* A cached token whose user entity no longer exists now fails authentication instead of falling back to the database lookup.

## [6.0.0](https://github.com/anzusystems/auth-bundle/compare/5.0.0...6.0.0) (2026-07-22)

### Features
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ $routes

## Personal access tokens

Opt-in personal access token (PAT) authentication: an sha256-hashed bearer token bound to a user, with expiration,
Opt-in personal access token (PAT) authentication: an sha256-hashed bearer token bound to a user, with optional expiration,
revocation, cached authentication, expiry notifications and management API. Disabled by default — a project that does
not enable it needs no schema or configuration changes after a bundle upgrade.

Expand Down Expand Up @@ -130,13 +130,25 @@ $routes
->prefix('/api/adm/v1');
```

The authenticator sets the `McpRateLimiter::TOKEN_ATTRIBUTE_KEY` (`pat_<id>`) and `McpRateLimiter::TOKEN_ATTRIBUTE_LIMIT`
(the token's `rateLimit`) attributes on the security token, so the common-bundle MCP rate limiter (`anzusystems/common-bundle`
`>=11.5`) buckets requests per personal access token and honours the per-token limit.

Authorization uses the `auth_personalAccessToken_(create|read|revoke)` permissions (see
`AnzuSystems\AuthBundle\Security\PersonalAccessTokenPermission`); creation additionally requires the role
configured via `create_role` (default `ROLE_MCP`).

Deleting a user: call `PersonalAccessTokenFacade::deleteByUser($user)` before removing the user entity — it deletes the
user's tokens in one flush and invalidates their auth cache entries. The `user` join column also declares `onDelete: CASCADE`,
but that is best-effort only: the `created_by`/`modified_by` columns of the user's own tokens still reference the user, so
the explicit `deleteByUser()` call is the contract.

Console commands:

* `anzu:personal-access-token:create <userId> --name=<label> [--expires-at=...]` — prints the plaintext token once.
* `anzu:personal-access-token:create <userId> --name=<label> [--expires-at=...] [--never-expires] [--rate-limit=N]` —
prints the plaintext token once. `--never-expires` creates a token with `expiresAt = NULL` (skipped by the expiry
notifications, mutually exclusive with `--expires-at`); `--rate-limit` stores a per-token MCP rate limit overriding
the configured default (`null` = default). Both are command-only — the management API never sets them.
* `anzu:personal-access-token:notify-expiring` — daily cron; notifies owners of tokens expiring in 7 days or 1 day
through `PersonalAccessTokenExpiryNotifierInterface` (no-op by default — alias your own implementation). The
final-notice windows of consecutive runs overlap, so the implementation must be idempotent per
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"php": ">=8.4",
"ext-json": "*",
"ext-redis": "*",
"anzusystems/common-bundle": "^9.4|^10.0|^11.0|^12.0",
"anzusystems/common-bundle": "^11.5 || dev-85893_mcp_log_search_by_id",
"doctrine/common": "^3.3",
"lcobucci/clock": "^3.0",
"lcobucci/jwt": "^5.5",
Expand Down
66 changes: 61 additions & 5 deletions src/Command/CreatePersonalAccessTokenCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ final class CreatePersonalAccessTokenCommand extends Command
private const string ARG_USER_ID = 'userId';
private const string OPTION_NAME = 'name';
private const string OPTION_EXPIRES_AT = 'expires-at';
private const string OPTION_NEVER_EXPIRES = 'never-expires';
private const string OPTION_RATE_LIMIT = 'rate-limit';
private const string EXPIRES_AT_NEVER = 'never';
private const string RATE_LIMIT_DEFAULT = 'default';
private const int RATE_LIMIT_MIN = 1;
private const string DATETIME_FORMAT = 'Y-m-d H:i:s';

/**
Expand All @@ -54,6 +59,16 @@ protected function configure(): void
AbstractPersonalAccessToken::DEFAULT_EXPIRES_AT_DATE,
AbstractPersonalAccessToken::MAX_EXPIRES_AT_DATE,
))
->addOption(self::OPTION_NEVER_EXPIRES, null, InputOption::VALUE_NONE, sprintf(
'Create a token without expiration (mutually exclusive with --%s).',
self::OPTION_EXPIRES_AT,
))
->addOption(
self::OPTION_RATE_LIMIT,
null,
InputOption::VALUE_REQUIRED,
'Requests per rate limit interval overriding the configured default, e.g. 600.',
)
;
}

Expand All @@ -80,6 +95,28 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return self::FAILURE;
}

$neverExpires = (bool) $input->getOption(self::OPTION_NEVER_EXPIRES);
if ($neverExpires && StringHelper::isNotEmpty((string) $input->getOption(self::OPTION_EXPIRES_AT))) {
$output->writeln(sprintf(
'<error>Options --%s and --%s are mutually exclusive.</error>',
self::OPTION_EXPIRES_AT,
self::OPTION_NEVER_EXPIRES,
));

return self::FAILURE;
}

$rateLimit = $this->resolveRateLimitOption($input);
if (false === $rateLimit) {
$output->writeln(sprintf(
'<error>Invalid --%s value "%s", provide a positive integer.</error>',
self::OPTION_RATE_LIMIT,
(string) $input->getOption(self::OPTION_RATE_LIMIT),
));

return self::FAILURE;
}

try {
$expiresAt = $this->resolveExpiresAtOption($input);
} catch (Exception) {
Expand All @@ -97,6 +134,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
user: $user,
name: $name,
expiresAt: $expiresAt,
rateLimit: $rateLimit,
neverExpires: $neverExpires,
);
} catch (ValidationException $exception) {
$output->writeln(sprintf(
Expand All @@ -110,20 +149,37 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$output->writeln(sprintf('Token (shown only once): <info>%s</info>', $result->token));
$output->writeln(sprintf(
'Expires at: %s',
$result->personalAccessToken->getExpiresAt()
->format(self::DATETIME_FORMAT)
$result->personalAccessToken->getExpiresAt()?->format(self::DATETIME_FORMAT) ?? self::EXPIRES_AT_NEVER,
));
$output->writeln(sprintf(
'Rate limit: %s',
$result->personalAccessToken->getRateLimit() ?? self::RATE_LIMIT_DEFAULT,
));

return self::SUCCESS;
}

private function resolveRateLimitOption(InputInterface $input): int|false|null
{
$rateLimitOption = $input->getOption(self::OPTION_RATE_LIMIT);
if (null === $rateLimitOption) {
return null;
}

return filter_var($rateLimitOption, FILTER_VALIDATE_INT, [
'options' => [
'min_range' => self::RATE_LIMIT_MIN,
],
]);
}

private function resolveExpiresAtOption(InputInterface $input): ?DateTimeImmutable
{
$expiresAtOption = $input->getOption(self::OPTION_EXPIRES_AT);
if (null === $expiresAtOption) {
$expiresAtOption = (string) $input->getOption(self::OPTION_EXPIRES_AT);
if (StringHelper::isEmpty($expiresAtOption)) {
return null;
}

return new DateTimeImmutable((string) $expiresAtOption);
return new DateTimeImmutable($expiresAtOption);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace AnzuSystems\AuthBundle\Domain\PersonalAccessToken\Cache;

use AnzuSystems\AuthBundle\Domain\PersonalAccessToken\Model\CachedPersonalAccessToken;
use AnzuSystems\Contracts\AnzuApp;
use DateTimeImmutable;
use Psr\Cache\CacheItemInterface;
Expand All @@ -13,7 +14,7 @@
{
public const int USER_ID_EXPIRE_TIME = 300;

private const string CACHE_KEY_PREFIX = 'pat_auth_user_id';
private const string CACHE_KEY_PREFIX = 'pat_auth_token';
private const string VERSION_KEY_PREFIX = 'pat_auth_version';
private const int VERSION_INITIAL = 1;
private const int VERSION_INCREMENT = 1;
Expand All @@ -29,29 +30,33 @@ public function getInvalidationVersion(string $tokenHash): int
return $this->readVersion($this->patAuthCache->getItem($this->getVersionKey($tokenHash)));
}

public function getUserId(string $tokenHash, int $version): ?int
public function getToken(string $tokenHash, int $version): ?CachedPersonalAccessToken
{
$cacheItem = $this->patAuthCache->getItem($this->getCacheKey($tokenHash, $version));
if (false === $cacheItem->isHit()) {
return null;
}
$userId = $cacheItem->get();
if (is_int($userId)) {
return $userId;
$token = $cacheItem->get();
if ($token instanceof CachedPersonalAccessToken) {
return $token;
}

return null;
}

public function storeUserId(string $tokenHash, int $version, int $userId, DateTimeImmutable $expiresAt): void
{
$secondsToExpiry = $expiresAt->getTimestamp() - AnzuApp::date()->getTimestamp();
if ($secondsToExpiry <= 0) {
public function storeToken(
string $tokenHash,
int $version,
CachedPersonalAccessToken $token,
?DateTimeImmutable $expiresAt,
): void {
$expireTime = $this->resolveExpireTime($expiresAt);
if ($expireTime <= 0) {
return;
}
$cacheItem = $this->patAuthCache->getItem($this->getCacheKey($tokenHash, $version));
$cacheItem->set($userId);
$cacheItem->expiresAfter(min(self::USER_ID_EXPIRE_TIME, $secondsToExpiry));
$cacheItem->set($token);
$cacheItem->expiresAfter($expireTime);
$this->patAuthCache->save($cacheItem);
}

Expand All @@ -63,6 +68,15 @@ public function invalidate(string $tokenHash): void
$this->patAuthCache->save($cacheItem);
}

private function resolveExpireTime(?DateTimeImmutable $expiresAt): int
{
if (null === $expiresAt) {
return self::USER_ID_EXPIRE_TIME;
}

return min(self::USER_ID_EXPIRE_TIME, $expiresAt->getTimestamp() - AnzuApp::date()->getTimestamp());
}

private function readVersion(CacheItemInterface $cacheItem): int
{
if (false === $cacheItem->isHit()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
use AnzuSystems\AuthBundle\Domain\PersonalAccessToken\Cache\PersonalAccessTokenAuthCache;
use AnzuSystems\AuthBundle\Domain\PersonalAccessToken\Manager\PersonalAccessTokenManager;
use AnzuSystems\AuthBundle\Domain\PersonalAccessToken\Model\PersonalAccessTokenCreateResult;
use AnzuSystems\AuthBundle\Domain\PersonalAccessToken\Repository\PersonalAccessTokenRepository;
use AnzuSystems\AuthBundle\Entity\AbstractPersonalAccessToken;
use AnzuSystems\CommonBundle\Exception\ValidationException;
use AnzuSystems\CommonBundle\Validator\Validator;
use AnzuSystems\Contracts\Entity\AnzuUser;
use DateTimeImmutable;
use InvalidArgumentException;
use Random\RandomException;

final readonly class PersonalAccessTokenFacade
Expand All @@ -24,6 +26,7 @@
public function __construct(
private Validator $validator,
private PersonalAccessTokenManager $manager,
private PersonalAccessTokenRepository $repository,
private PersonalAccessTokenAuthCache $authCache,
private string $entityClass,
) {
Expand All @@ -32,28 +35,50 @@ public function __construct(
/**
* @throws ValidationException
* @throws RandomException
* @throws InvalidArgumentException
*/
public function create(
AnzuUser $user,
string $name,
?DateTimeImmutable $expiresAt = null,
?int $rateLimit = null,
bool $neverExpires = false,
): PersonalAccessTokenCreateResult {
if ($neverExpires && $expiresAt instanceof DateTimeImmutable) {
throw new InvalidArgumentException('A never expiring personal access token cannot have an expiration date.');
}
$plainToken = AbstractPersonalAccessToken::TOKEN_PREFIX . bin2hex(random_bytes(self::TOKEN_BYTES_LENGTH));
$personalAccessToken = new $this->entityClass();
$personalAccessToken
->setUser($user)
->setName($name)
->setTokenHash(AbstractPersonalAccessToken::hashToken($plainToken))
->setRateLimit($rateLimit)
;
if ($expiresAt instanceof DateTimeImmutable) {
$personalAccessToken->setExpiresAt($expiresAt);
}
if ($neverExpires) {
$personalAccessToken->setExpiresAt(null);
}
$this->validator->validate($personalAccessToken);
$this->manager->create($personalAccessToken);

return new PersonalAccessTokenCreateResult($plainToken, $personalAccessToken);
}

public function deleteByUser(AnzuUser $user): void
{
$personalAccessTokens = $this->repository->findByUser($user);
foreach ($personalAccessTokens as $personalAccessToken) {
$this->manager->delete($personalAccessToken, false);
}
$this->manager->flush();
foreach ($personalAccessTokens as $personalAccessToken) {
$this->authCache->invalidate($personalAccessToken->getTokenHash());
}
}

public function revoke(AbstractPersonalAccessToken $personalAccessToken): AbstractPersonalAccessToken
{
if ($personalAccessToken->isRevoked()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ public function revoke(AbstractPersonalAccessToken $personalAccessToken, bool $f
return $personalAccessToken;
}

public function delete(AbstractPersonalAccessToken $personalAccessToken, bool $flush = true): void
{
$this->entityManager->remove($personalAccessToken);
$this->flush($flush);
}

public function updateLastUsedAt(AbstractPersonalAccessToken $personalAccessToken, bool $flush = true): AbstractPersonalAccessToken
{
$personalAccessToken->setLastUsedAt(AnzuApp::date());
Expand Down
27 changes: 27 additions & 0 deletions src/Domain/PersonalAccessToken/Model/CachedPersonalAccessToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace AnzuSystems\AuthBundle\Domain\PersonalAccessToken\Model;

use AnzuSystems\AuthBundle\Entity\AbstractPersonalAccessToken;

final readonly class CachedPersonalAccessToken
{
public function __construct(
public int $personalAccessTokenId,
public int $userId,
public ?int $rateLimit,
) {
}

public static function fromEntity(AbstractPersonalAccessToken $personalAccessToken): self
{
return new self(
(int) $personalAccessToken->getId(),
(int) $personalAccessToken->getUser()
->getId(),
$personalAccessToken->getRateLimit(),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function findOneActiveByTokenHash(string $tokenHash): ?AbstractPersonalAc
return $this->createQueryBuilder('personalAccessToken')
->where('personalAccessToken.tokenHash = :tokenHash')
->andWhere('personalAccessToken.revokedAt IS NULL')
->andWhere('personalAccessToken.expiresAt > :now')
->andWhere('personalAccessToken.expiresAt IS NULL OR personalAccessToken.expiresAt > :now')
->setParameter('tokenHash', $tokenHash)
->setParameter('now', AnzuApp::date())
->getQuery()
Expand Down
Loading
Loading