diff --git a/Command/ClearExpiredTokensCommand.php b/Command/ClearExpiredTokensCommand.php
new file mode 100644
index 00000000..13e4725e
--- /dev/null
+++ b/Command/ClearExpiredTokensCommand.php
@@ -0,0 +1,89 @@
+accessTokenManager = $accessTokenManager;
+ $this->refreshTokenManager = $refreshTokenManager;
+ }
+
+ protected function configure(): void
+ {
+ $this
+ ->setDescription('Clears all expired access and/or refresh tokens')
+ ->addOption(
+ 'access-tokens-only',
+ 'a',
+ InputOption::VALUE_NONE,
+ 'Clear only access tokens.'
+ )
+ ->addOption(
+ 'refresh-tokens-only',
+ 'r',
+ InputOption::VALUE_NONE,
+ 'Clear only refresh tokens.'
+ )
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ $clearExpiredAccessTokens = !$input->getOption('refresh-tokens-only');
+ $clearExpiredRefreshTokens = !$input->getOption('access-tokens-only');
+
+ if (!$clearExpiredAccessTokens && !$clearExpiredRefreshTokens) {
+ $io->error('Please chose only one of the following options: "access-tokens-only", "refresh-tokens-only".');
+
+ return 1;
+ }
+
+ if (true === $clearExpiredAccessTokens) {
+ $numOfClearedAccessTokens = $this->accessTokenManager->clearExpired();
+ $io->success(sprintf(
+ 'Cleared %d expired access token%s.',
+ $numOfClearedAccessTokens,
+ 1 === $numOfClearedAccessTokens ? '' : 's'
+ ));
+ }
+
+ if (true === $clearExpiredRefreshTokens) {
+ $numOfClearedRefreshTokens = $this->refreshTokenManager->clearExpired();
+ $io->success(sprintf(
+ 'Cleared %d expired refresh token%s.',
+ $numOfClearedRefreshTokens,
+ 1 === $numOfClearedRefreshTokens ? '' : 's'
+ ));
+ }
+
+ return 0;
+ }
+}
diff --git a/Manager/AccessTokenManagerInterface.php b/Manager/AccessTokenManagerInterface.php
index a8ae8257..3bdd09ff 100644
--- a/Manager/AccessTokenManagerInterface.php
+++ b/Manager/AccessTokenManagerInterface.php
@@ -9,4 +9,6 @@ interface AccessTokenManagerInterface
public function find(string $identifier): ?AccessToken;
public function save(AccessToken $accessToken): void;
+
+ public function clearExpired(): int;
}
diff --git a/Manager/Doctrine/AccessTokenManager.php b/Manager/Doctrine/AccessTokenManager.php
index e91acc24..1d5c0aaf 100644
--- a/Manager/Doctrine/AccessTokenManager.php
+++ b/Manager/Doctrine/AccessTokenManager.php
@@ -34,4 +34,13 @@ public function save(AccessToken $accessToken): void
$this->entityManager->persist($accessToken);
$this->entityManager->flush();
}
+
+ public function clearExpired(): int
+ {
+ return $this->entityManager->createQueryBuilder()
+ ->delete(AccessToken::class, 'at')
+ ->where('at.expiry < CURRENT_TIMESTAMP()')
+ ->getQuery()
+ ->execute();
+ }
}
diff --git a/Manager/Doctrine/RefreshTokenManager.php b/Manager/Doctrine/RefreshTokenManager.php
index 41225d02..2fedb861 100644
--- a/Manager/Doctrine/RefreshTokenManager.php
+++ b/Manager/Doctrine/RefreshTokenManager.php
@@ -34,4 +34,13 @@ public function save(RefreshToken $refreshToken): void
$this->entityManager->persist($refreshToken);
$this->entityManager->flush();
}
+
+ public function clearExpired(): int
+ {
+ return $this->entityManager->createQueryBuilder()
+ ->delete(RefreshToken::class, 'rt')
+ ->where('rt.expiry < CURRENT_TIMESTAMP()')
+ ->getQuery()
+ ->execute();
+ }
}
diff --git a/Manager/InMemory/AccessTokenManager.php b/Manager/InMemory/AccessTokenManager.php
index 2a887aa5..81127b3c 100644
--- a/Manager/InMemory/AccessTokenManager.php
+++ b/Manager/InMemory/AccessTokenManager.php
@@ -2,6 +2,7 @@
namespace Trikoder\Bundle\OAuth2Bundle\Manager\InMemory;
+use DateTime;
use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface;
use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken;
@@ -27,4 +28,16 @@ public function save(AccessToken $accessToken): void
{
$this->accessTokens[$accessToken->getIdentifier()] = $accessToken;
}
+
+ public function clearExpired(): int
+ {
+ $count = \count($this->accessTokens);
+
+ $now = new DateTime();
+ $this->accessTokens = array_filter($this->accessTokens, function (AccessToken $accessToken) use ($now): bool {
+ return $accessToken->getExpiry() >= $now;
+ });
+
+ return $count - \count($this->accessTokens);
+ }
}
diff --git a/Manager/InMemory/RefreshTokenManager.php b/Manager/InMemory/RefreshTokenManager.php
index 8797889d..03f8cdf7 100644
--- a/Manager/InMemory/RefreshTokenManager.php
+++ b/Manager/InMemory/RefreshTokenManager.php
@@ -2,6 +2,7 @@
namespace Trikoder\Bundle\OAuth2Bundle\Manager\InMemory;
+use DateTime;
use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface;
use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken;
@@ -27,4 +28,16 @@ public function save(RefreshToken $refreshToken): void
{
$this->refreshTokens[$refreshToken->getIdentifier()] = $refreshToken;
}
+
+ public function clearExpired(): int
+ {
+ $count = \count($this->refreshTokens);
+
+ $now = new DateTime();
+ $this->refreshTokens = array_filter($this->refreshTokens, function (RefreshToken $refreshToken) use ($now): bool {
+ return $refreshToken->getExpiry() >= $now;
+ });
+
+ return $count - \count($this->refreshTokens);
+ }
}
diff --git a/Manager/RefreshTokenManagerInterface.php b/Manager/RefreshTokenManagerInterface.php
index 6e17d5a7..b0042c22 100644
--- a/Manager/RefreshTokenManagerInterface.php
+++ b/Manager/RefreshTokenManagerInterface.php
@@ -9,4 +9,6 @@ interface RefreshTokenManagerInterface
public function find(string $identifier): ?RefreshToken;
public function save(RefreshToken $refreshToken): void;
+
+ public function clearExpired(): int;
}
diff --git a/Resources/config/services.xml b/Resources/config/services.xml
index 7e50064c..1a41500e 100644
--- a/Resources/config/services.xml
+++ b/Resources/config/services.xml
@@ -90,6 +90,11 @@
+
+
+
+
+
diff --git a/Tests/Acceptance/ClearExpiredTokensCommandTest.php b/Tests/Acceptance/ClearExpiredTokensCommandTest.php
new file mode 100644
index 00000000..6c33b4f0
--- /dev/null
+++ b/Tests/Acceptance/ClearExpiredTokensCommandTest.php
@@ -0,0 +1,108 @@
+client->getContainer()->get(ScopeManagerInterface::class),
+ $this->client->getContainer()->get(ClientManagerInterface::class),
+ $this->client->getContainer()->get(AccessTokenManagerInterface::class),
+ $this->client->getContainer()->get(RefreshTokenManagerInterface::class)
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ timecop_return();
+
+ parent::tearDown();
+ }
+
+ public function testClearExpiredAccessAndRefreshTokens(): void
+ {
+ $command = $this->command();
+ $commandTester = new CommandTester($command);
+
+ $exitCode = $commandTester->execute([
+ 'command' => $command->getName(),
+ ]);
+
+ $this->assertSame(0, $exitCode);
+
+ $output = $commandTester->getDisplay();
+ $this->assertStringContainsString('Cleared 1 expired access token.', $output);
+ $this->assertStringContainsString('Cleared 1 expired refresh token.', $output);
+ }
+
+ public function testClearExpiredAccessTokens(): void
+ {
+ $command = $this->command();
+ $commandTester = new CommandTester($command);
+
+ $exitCode = $commandTester->execute([
+ 'command' => $command->getName(),
+ '--access-tokens-only' => true,
+ ]);
+
+ $this->assertSame(0, $exitCode);
+
+ $output = $commandTester->getDisplay();
+ $this->assertStringContainsString('Cleared 1 expired access token.', $output);
+ $this->assertStringNotContainsString('Cleared 1 expired refresh token.', $output);
+ }
+
+ public function testClearExpiredRefreshTokens(): void
+ {
+ $command = $this->command();
+ $commandTester = new CommandTester($command);
+
+ $exitCode = $commandTester->execute([
+ 'command' => $command->getName(),
+ '--refresh-tokens-only' => true,
+ ]);
+
+ $this->assertSame(0, $exitCode);
+
+ $output = $commandTester->getDisplay();
+ $this->assertStringNotContainsString('Cleared 1 expired access token.', $output);
+ $this->assertStringContainsString('Cleared 1 expired refresh token.', $output);
+ }
+
+ public function testErrorWhenBothOptionsAreUsed(): void
+ {
+ $command = $this->command();
+ $commandTester = new CommandTester($command);
+
+ $exitCode = $commandTester->execute([
+ 'command' => $command->getName(),
+ '--access-tokens-only' => true,
+ '--refresh-tokens-only' => true,
+ ]);
+
+ $this->assertSame(1, $exitCode);
+
+ $output = $commandTester->getDisplay();
+ $this->assertStringContainsString('Please chose only one of the following options:', $output);
+ }
+
+ private function command(): Command
+ {
+ return $this->application->find('trikoder:oauth2:clear-expired-tokens');
+ }
+}
diff --git a/Tests/Unit/InMemoryAccessTokenManagerTest.php b/Tests/Unit/InMemoryAccessTokenManagerTest.php
new file mode 100644
index 00000000..eebc7ca0
--- /dev/null
+++ b/Tests/Unit/InMemoryAccessTokenManagerTest.php
@@ -0,0 +1,66 @@
+buildClearExpiredTestData();
+
+ foreach ($testData['input'] as $token) {
+ $inMemoryAccessTokenManager->save($token);
+ }
+ $this->assertSame(3, $inMemoryAccessTokenManager->clearExpired());
+
+ timecop_return();
+
+ $reflectionProperty = new ReflectionProperty(InMemoryAccessTokenManager::class, 'accessTokens');
+ $reflectionProperty->setAccessible(true);
+
+ $this->assertSame($testData['output'], $reflectionProperty->getValue($inMemoryAccessTokenManager));
+ }
+
+ private function buildClearExpiredTestData(): array
+ {
+ $validAccessTokens = [
+ '1111' => $this->buildAccessToken('1111', '+1 day'),
+ '2222' => $this->buildAccessToken('2222', '+1 hour'),
+ '3333' => $this->buildAccessToken('3333', '+1 second'),
+ '4444' => $this->buildAccessToken('4444', 'now'),
+ ];
+
+ $expiredAccessTokens = [
+ '5555' => $this->buildAccessToken('5555', '-1 day'),
+ '6666' => $this->buildAccessToken('6666', '-1 hour'),
+ '7777' => $this->buildAccessToken('7777', '-1 second'),
+ ];
+
+ return [
+ 'input' => $validAccessTokens + $expiredAccessTokens,
+ 'output' => $validAccessTokens,
+ ];
+ }
+
+ private function buildAccessToken(string $identifier, string $modify): AccessToken
+ {
+ return new AccessToken(
+ $identifier,
+ (new DateTime())->modify($modify),
+ new Client('client', 'secret'),
+ null,
+ []
+ );
+ }
+}
diff --git a/Tests/Unit/InMemoryRefreshTokenManagerTest.php b/Tests/Unit/InMemoryRefreshTokenManagerTest.php
new file mode 100644
index 00000000..015abc05
--- /dev/null
+++ b/Tests/Unit/InMemoryRefreshTokenManagerTest.php
@@ -0,0 +1,72 @@
+buildClearExpiredTestData();
+
+ foreach ($testData['input'] as $token) {
+ $inMemoryRefreshTokenManager->save($token);
+ }
+
+ $this->assertSame(3, $inMemoryRefreshTokenManager->clearExpired());
+
+ timecop_return();
+
+ $reflectionProperty = new ReflectionProperty(InMemoryRefreshTokenManager::class, 'refreshTokens');
+ $reflectionProperty->setAccessible(true);
+
+ $this->assertSame($testData['output'], $reflectionProperty->getValue($inMemoryRefreshTokenManager));
+ }
+
+ private function buildClearExpiredTestData(): array
+ {
+ $validRefreshTokens = [
+ '1111' => $this->buildRefreshToken('1111', '+1 day'),
+ '2222' => $this->buildRefreshToken('2222', '+1 hour'),
+ '3333' => $this->buildRefreshToken('3333', '+1 second'),
+ '4444' => $this->buildRefreshToken('4444', 'now'),
+ ];
+
+ $expiredRefreshTokens = [
+ '5555' => $this->buildRefreshToken('5555', '-1 day'),
+ '6666' => $this->buildRefreshToken('6666', '-1 hour'),
+ '7777' => $this->buildRefreshToken('7777', '-1 second'),
+ ];
+
+ return [
+ 'input' => $validRefreshTokens + $expiredRefreshTokens,
+ 'output' => $validRefreshTokens,
+ ];
+ }
+
+ private function buildRefreshToken(string $identifier, string $modify): RefreshToken
+ {
+ return new RefreshToken(
+ $identifier,
+ (new DateTime())->modify($modify),
+ new AccessToken(
+ $identifier,
+ (new DateTime('+1 day')),
+ new Client('client', 'secret'),
+ null,
+ []
+ )
+ );
+ }
+}
diff --git a/docs/basic-setup.md b/docs/basic-setup.md
index 6b4fa8a4..fdf0fe88 100644
--- a/docs/basic-setup.md
+++ b/docs/basic-setup.md
@@ -153,6 +153,24 @@ There are two possible reasons for the authentication server to reject a request
- Provided token is expired or invalid (HTTP response 401 `Unauthorized`)
- Provided token is valid but scopes are insufficient (HTTP response 403 `Forbidden`)
+## Clearing expired access & refresh tokens
+
+To clear expired access & refresh tokens you can use the `trikoder:oauth2:clear-expired-tokens` command.
+
+The command removes all tokes whose expiry time is lesser than the current.
+
+```sh
+Description:
+ Clears all expired access and/or refresh tokens
+
+Usage:
+ trikoder:oauth2:clear-expired-tokens [options]
+
+Options:
+ -a, --access-tokens-only Clear only access tokens.
+ -r, --refresh-tokens-only Clear only refresh tokens.
+```
+
## CORS requests
For CORS handling, use [NelmioCorsBundle](https://github.com/nelmio/NelmioCorsBundle)
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index d689c7eb..ef848162 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -15,6 +15,9 @@
+
+ ./Tests/Unit
+
./Tests/Acceptance