ad3n/jwt-refresh-token-bundle
Symfony bundle to manage JWT refresh tokens alongside LexikJWTAuthenticationBundle. Supports Doctrine ORM or MongoDB ODM, adds refresh token generation/rotation and storage, plus endpoints and security integration for renewing access tokens securely.
Installation:
composer require gesdinet/jwt-refresh-token-bundle
Add to config/bundles.php:
return [
// ...
Gesdinet\JWTRefreshTokenBundle\GesdinetJWTRefreshTokenBundle::class => ['all' => true],
];
Configuration:
Update config/packages/gesdinet_jwt_refresh_token.yaml:
gesdinet_jwt_refresh_token:
ttl: 3600 # Refresh token TTL in seconds (default: 3600)
blacklist_ttl: 300 # Blacklist TTL (default: 300)
storage: doctrine # Options: doctrine, redis, or custom
doctrine:
entity: App\Entity\RefreshToken # Custom entity (if using Doctrine)
Entity Setup (Doctrine):
Create a RefreshToken entity (or use the default):
php bin/console make:entity RefreshToken
Add fields:
// src/Entity/RefreshToken.php
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class RefreshToken {
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $token;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $revokedAt = null;
// Getters/setters...
}
First Use Case:
Generate Refresh Token: Extend your authentication logic to issue a refresh token alongside the JWT:
use Gesdinet\JWTRefreshTokenBundle\Services\RefreshTokenManager;
// In your auth controller/service
$refreshTokenManager = $this->container->get(RefreshTokenManager::class);
$refreshToken = $refreshTokenManager->createRefreshToken($user);
Validate Refresh Token:
Use the RefreshTokenManager to validate and revoke tokens:
$isValid = $refreshTokenManager->validateRefreshToken($refreshToken);
if ($isValid) {
$newJwt = $this->jwtManager->create($user); // LexikJWT
}
Token Issuance Flow:
// Example in a login controller
$user = $authenticator->authenticate($credentials);
$jwt = $this->jwtManager->create($user);
$refreshToken = $this->refreshTokenManager->createRefreshToken($user);
return new JsonResponse([
'access_token' => $jwt,
'refresh_token' => $refreshToken->getToken(),
]);
Refresh Token Flow:
/refresh endpoint.// src/Controller/RefreshTokenController.php
use Gesdinet\JWTRefreshTokenBundle\Services\RefreshTokenManager;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
#[Route('/refresh', name: 'refresh_token', methods: ['POST'])]
public function refresh(
Request $request,
RefreshTokenManager $refreshTokenManager,
JWTTokenManagerInterface $jwtManager
): JsonResponse {
$refreshToken = $request->request->get('refresh_token');
$user = $refreshTokenManager->getUserByRefreshToken($refreshToken);
if (!$user) {
throw new \RuntimeException('Invalid refresh token');
}
$newJwt = $jwtManager->create($user);
return new JsonResponse(['access_token' => $newJwt]);
}
Token Revocation:
$refreshTokenManager->revokeRefreshToken($refreshToken);
$this->jwtManager->invalidateToken($jwt); // LexikJWT
LexikJWT Integration:
Ensure LexikJWTAuthenticationBundle is configured to blacklist tokens (via blacklist_ttl in lexik_jwt_authentication.yaml).
lexik_jwt_authentication:
blacklist_ttl: 0 # Disable Lexik's blacklist if using this bundle
Redis Storage:
Configure Redis for storage in gesdinet_jwt_refresh_token.yaml:
gesdinet_jwt_refresh_token:
storage: redis
redis:
dsn: 'redis://localhost:6379'
Custom Storage:
Implement Gesdinet\JWTRefreshTokenBundle\Storage\RefreshTokenStorageInterface for custom backends (e.g., DynamoDB).
Event Listeners:
Listen to refresh_token.created and refresh_token.revoked events for auditing/logging:
use Gesdinet\JWTRefreshTokenBundle\Event\RefreshTokenEvent;
#[AsEventListener(event: RefreshTokenEvent::REFRESH_TOKEN_CREATED)]
public function onRefreshTokenCreated(RefreshTokenEvent $event) {
// Log or notify
}
Testing:
Use RefreshTokenManager in tests to mock token validation:
$this->refreshTokenManager->expects($this->once())
->method('validateRefreshToken')
->with('test_token')
->willReturn(true);
Token Storage Mismatch:
storage is set to doctrine but the RefreshToken entity is missing or misconfigured, the bundle will throw EntityManager errors.RefreshTokenManager is autowired correctly.Blacklist Conflicts:
LexikJWTAuthenticationBundle and this bundle are configured to blacklist tokens, tokens may be double-blacklisted.blacklist_ttl: 0) if using this bundle.TTL Misconfiguration:
ttl too low (e.g., < 60) may cause refresh tokens to expire too quickly, leading to poor UX.3600 (1 hour) is reasonable; adjust based on security needs.Race Conditions:
Redis Connection Issues:
Predis\Connection\ConnectionException.Token Validation Failures:
$refreshToken = $refreshTokenManager->findRefreshToken('token_value');
var_dump($refreshToken); // null if not found
revokedAt is null for valid tokens.Doctrine Queries:
doctrine:
dbal:
logging: true
Event Debugging:
#[AsEventListener(event: RefreshTokenEvent::class)]
public function debugEvents(RefreshTokenEvent $event) {
error_log('RefreshTokenEvent: ' . $event->getName() . ' - ' . $event->getRefreshToken()->getToken());
}
Custom Token Generation:
Gesdinet\JWTRefreshTokenBundle\Services\RefreshTokenManager to customize token generation (e.g., add metadata):
class CustomRefreshTokenManager extends RefreshTokenManager {
public function createRefreshToken(UserInterface $user): RefreshToken {
$token = parent::createRefreshToken($user);
$token->setMetadata(['ip' => $this->getClientIp()]);
return $token;
}
}
services:
Gesdinet\JWTRefreshTokenBundle\Services\RefreshTokenManager:
class: App\Services\CustomRefreshTokenManager
Custom Storage:
RefreshTokenStorageInterface for non-Doctrine/Redis backends:
class CustomStorage implements RefreshTokenStorageInterface {
public function save(RefreshToken $refreshToken): void {
// Custom logic (e.g., DynamoDB)
}
public function find(string $token): ?RefreshToken {
// Custom logic
}
public function revoke(string $token): void {
How can I help you explore Laravel packages today?