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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## [6.0.0](https://github.com/anzusystems/auth-bundle/compare/5.0.0...6.0.0) (2026-07-22)

### Features
* New opt-in `personal_access_token` config section (disabled by default — upgrading without enabling it requires no schema or configuration changes): sha256-hashed bearer tokens bound to a user with expiration (default 2 months, max 1 year), revocation, a versioned auth cache with the entry lifetime capped at the token expiry, throttled `lastUsedAt` tracking and a read-only-mode guard.
* `AbstractPersonalAccessToken` mapped superclass — the host subclasses it, owns the table, indexes (`UNIQ_tokenHash`, `IDX_revokedAt_expiresAt`) and migration; the `user` relation targets the contracts `AnzuUser` via doctrine `resolve_target_entities`. Configured via `entity_class`, `user_entity_class` and `auth_cache_pool`.
* `PersonalAccessTokenAuthenticator` (`Security/Authentication`) — stateless bearer authenticator claiming only tokens with the `anzu_pat_` prefix, cache-first user lookup, uniform information-free 401 responses with a `WWW-Authenticate: Bearer` header.
* `PersonalAccessTokenVoter` + `PersonalAccessTokenPermission` — `auth_personalAccessToken_(create|read|revoke)` permissions with owner-only access and creation gated by `ROLE_MCP`.
* `PersonalAccessTokenController` (`Controller/Api`) — list own tokens, create (plaintext returned exactly once, request excluded from audit logs) and revoke; the host defines the routes.
* Console commands `anzu:personal-access-token:create` (prints the plaintext token once) and `anzu:personal-access-token:notify-expiring` (daily cron notifying owners 7 days and 1 day before expiry through `PersonalAccessTokenExpiryNotifierInterface`; no-op by default — alias your own implementation, which must be idempotent per (token, daysRemaining) pair because consecutive final-notice windows overlap).

### Changes
* BC change: `anzusystems/common-bundle` requirement floor raised to `^9.4` (uses `AuditLogResourceHelper::excludeFromAuditLogs()`), with `^12.0` allowed.
* BC change: new explicit `symfony/console` requirement `^7.3|^8.0` (invokable command support).
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,79 @@ $routes
->import('@AnzuSystemsAuthBundle/Controller/Api/JsonCredentialsAuthController.php', type: 'attribute')
->prefix('/api/auth/');
```

## Personal access tokens

Opt-in personal access token (PAT) authentication: an sha256-hashed bearer token bound to a user, with 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.

Enable it by subclassing the mapped superclass and pointing the config to it:

```php
use AnzuSystems\AuthBundle\Domain\PersonalAccessToken\Repository\PersonalAccessTokenRepository;
use AnzuSystems\AuthBundle\Entity\AbstractPersonalAccessToken;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: PersonalAccessTokenRepository::class)]
#[ORM\Table(name: 'personal_access_token')]
#[ORM\Index(name: 'IDX_revokedAt_expiresAt', fields: ['revokedAt', 'expiresAt'])]
#[ORM\UniqueConstraint(name: 'UNIQ_tokenHash', fields: ['tokenHash'])]
class PersonalAccessToken extends AbstractPersonalAccessToken
{
}
```

```yaml
anzu_systems_auth:
personal_access_token:
enabled: true
entity_class: App\Domain\PersonalAccessToken\Entity\PersonalAccessToken
user_entity_class: App\Domain\User\Entity\User
auth_cache_pool: 'some_redis.cache'
```

The `user` relation targets `AnzuSystems\Contracts\Entity\AnzuUser` — make sure doctrine
`resolve_target_entities` maps it to the project user class. Add the doctrine mapping for the bundle's `Entity`
namespace and generate the migration with `doctrine:migrations:diff`. `user_entity_class` must name the same class
as the common-bundle `settings.user_entity_class` — the authenticator and `CurrentAnzuUserProvider` would otherwise
load different user classes. The entity relies on constructor-less proxies, so use doctrine/orm 3 (lazy ghosts);
the ORM 2 legacy proxy strategy conflicts with the final entity constructor.

Wire the authenticator into a firewall protecting the API that accepts the tokens:

```yaml
security:
firewalls:
mcp:
pattern: ^/api/mcp
stateless: true
provider: app_user_provider_id
entry_point: AnzuSystems\AuthBundle\Security\Authentication\PersonalAccessTokenAuthenticator
custom_authenticators:
- AnzuSystems\AuthBundle\Security\Authentication\PersonalAccessTokenAuthenticator
```

Management API routes (list/create/revoke) are provided by
`AnzuSystems\AuthBundle\Controller\Api\PersonalAccessTokenController` attribute routes — import them with a prefix:

```php
$routes
->import('@AnzuSystemsAuthBundle/Controller/Api/PersonalAccessTokenController.php', type: 'attribute')
->prefix('/api/adm/v1');
```

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`).

Console commands:

* `anzu:personal-access-token:create <userId> --name=<label> [--expires-at=...]` — prints the plaintext token once.
* `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
(token, daysRemaining) pair — e.g. dispatch under an event name containing both.

When migrating from an app-level PAT implementation, rename existing permission grants to the
`auth_personalAccessToken_*` keys — grants stored under the old keys stop matching silently.
12 changes: 9 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
"name": "anzusystems/auth-bundle",
"description": "Anzu authorization services",
"type": "symfony-bundle",
"keywords": ["symfony", "anzusystems", "authorization"],
"keywords": [
"symfony",
"anzusystems",
"authorization"
],
"license": "Apache-2.0",
"authors": [
{
Expand All @@ -14,12 +18,14 @@
"php": ">=8.4",
"ext-json": "*",
"ext-redis": "*",
"anzusystems/common-bundle": "^9.0|^10.0|^11.0",
"anzusystems/common-bundle": "^9.4|^10.0|^11.0|^12.0",
"doctrine/common": "^3.3",
"lcobucci/clock": "^3.0",
"lcobucci/jwt": "^5.5"
"lcobucci/jwt": "^5.5",
"symfony/console": "^7.3|^8.0"
},
"require-dev": {
"doctrine/doctrine-bundle": "^2.11|^3.0",
"doctrine/orm": "^2.13|^3.0",
"nelmio/api-doc-bundle": "^5.9",
"slevomat/coding-standard": "8.20.0",
Expand Down
129 changes: 129 additions & 0 deletions src/Command/CreatePersonalAccessTokenCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

namespace AnzuSystems\AuthBundle\Command;

use AnzuSystems\AuthBundle\Domain\PersonalAccessToken\Facade\PersonalAccessTokenFacade;
use AnzuSystems\AuthBundle\Entity\AbstractPersonalAccessToken;
use AnzuSystems\CommonBundle\Exception\ValidationException;
use AnzuSystems\CommonBundle\Helper\StringHelper;
use AnzuSystems\Contracts\AnzuApp;
use AnzuSystems\Contracts\Entity\AnzuUser;
use AnzuSystems\Contracts\Exception\AppReadOnlyModeException;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Random\RandomException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
name: 'anzu:personal-access-token:create',
description: 'Create a personal access token for a user and print the plaintext token (shown only once)'
)]
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 DATETIME_FORMAT = 'Y-m-d H:i:s';

/**
* @param class-string<AnzuUser> $userEntityClass
*/
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly PersonalAccessTokenFacade $personalAccessTokenFacade,
private readonly string $userEntityClass,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->addArgument(self::ARG_USER_ID, InputArgument::REQUIRED, 'Id of the user owning the token.')
->addOption(self::OPTION_NAME, null, InputOption::VALUE_REQUIRED, 'Token label, e.g. "mcp-agent".')
->addOption(self::OPTION_EXPIRES_AT, null, InputOption::VALUE_REQUIRED, sprintf(
'Expiration date time, e.g. "2026-09-21 12:00:00". Defaults to %s, at most %s.',
AbstractPersonalAccessToken::DEFAULT_EXPIRES_AT_DATE,
AbstractPersonalAccessToken::MAX_EXPIRES_AT_DATE,
))
;
}

/**
* @throws AppReadOnlyModeException
* @throws RandomException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
AnzuApp::throwOnReadOnlyMode();

$userId = (int) $input->getArgument(self::ARG_USER_ID);
$user = $this->entityManager->find($this->userEntityClass, $userId);
if (false === ($user instanceof AnzuUser)) {
$output->writeln(sprintf('<error>User (%d) not found.</error>', $userId));

return self::FAILURE;
}

$name = (string) $input->getOption(self::OPTION_NAME);
if (StringHelper::isEmpty($name)) {
$output->writeln(sprintf('<error>Option --%s is required.</error>', self::OPTION_NAME));

return self::FAILURE;
}

try {
$expiresAt = $this->resolveExpiresAtOption($input);
} catch (Exception) {
$output->writeln(sprintf(
'<error>Invalid --%s value "%s", provide a date time, e.g. "2027-07-09 12:00:00".</error>',
self::OPTION_EXPIRES_AT,
(string) $input->getOption(self::OPTION_EXPIRES_AT),
));

return self::FAILURE;
}

try {
$result = $this->personalAccessTokenFacade->create(
user: $user,
name: $name,
expiresAt: $expiresAt,
);
} catch (ValidationException $exception) {
$output->writeln(sprintf(
'<error>Validation failed: %s</error>',
(string) json_encode($exception->getFormattedErrors()),
));

return self::FAILURE;
}

$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)
));

return self::SUCCESS;
}

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

return new DateTimeImmutable((string) $expiresAtOption);
}
}
70 changes: 70 additions & 0 deletions src/Command/NotifyExpiringPersonalAccessTokensCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace AnzuSystems\AuthBundle\Command;

use AnzuSystems\AuthBundle\Contracts\PersonalAccessTokenExpiryNotifierInterface;
use AnzuSystems\AuthBundle\Domain\PersonalAccessToken\Repository\PersonalAccessTokenRepository;
use AnzuSystems\Contracts\AnzuApp;
use AnzuSystems\Contracts\Exception\AppReadOnlyModeException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
'anzu:personal-access-token:notify-expiring',
'Notify owners of personal access tokens expiring in 7 days or in 1 day.'
)]
final class NotifyExpiringPersonalAccessTokensCommand extends Command
{
private const int EARLY_NOTICE_DAYS = 7;
private const int FINAL_NOTICE_DAYS = 1;
private const array NOTIFY_DAYS_REMAINING = [self::EARLY_NOTICE_DAYS, self::FINAL_NOTICE_DAYS];
private const string WINDOW_LENGTH = '+1 day';

public function __construct(
private readonly PersonalAccessTokenRepository $personalAccessTokenRepo,
private readonly PersonalAccessTokenExpiryNotifierInterface $expiryNotifier,
) {
parent::__construct();
}

/**
* @throws AppReadOnlyModeException
*/
public function __invoke(SymfonyStyle $io): int
{
AnzuApp::throwOnReadOnlyMode();

$notifiedCount = 0;
foreach (self::NOTIFY_DAYS_REMAINING as $daysRemaining) {
$notifiedCount += $this->notifyWindow($daysRemaining);
}

$io->writeln(sprintf('Dispatched %d expiry notification(s).', $notifiedCount));

return Command::SUCCESS;
}

private function notifyWindow(int $daysRemaining): int
{
$dayStart = AnzuApp::date(sprintf('+%d days', $daysRemaining))->setTime(0, 0);
$from = $dayStart;
if (self::FINAL_NOTICE_DAYS === $daysRemaining) {
$from = AnzuApp::date();
}
$tokens = $this->personalAccessTokenRepo->findAllActiveExpiringBetween(
from: $from,
until: $dayStart->modify(self::WINDOW_LENGTH),
);
$notifiedCount = 0;
foreach ($tokens as $personalAccessToken) {
if ($this->expiryNotifier->notifyExpiring($personalAccessToken, $daysRemaining)) {
++$notifiedCount;
}
}

return $notifiedCount;
}
}
16 changes: 16 additions & 0 deletions src/Contracts/PersonalAccessTokenExpiryNotifierInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace AnzuSystems\AuthBundle\Contracts;

use AnzuSystems\AuthBundle\Entity\AbstractPersonalAccessToken;

interface PersonalAccessTokenExpiryNotifierInterface
{
/**
* Notification windows of consecutive daily runs may overlap, the implementation
* must be idempotent per ($personalAccessToken, $daysRemaining) pair.
*/
public function notifyExpiring(AbstractPersonalAccessToken $personalAccessToken, int $daysRemaining): bool;
}
Loading
Loading