diff --git a/README.md b/README.md index e2667ca..faf3537 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ api_platform_extras: enabled: false jwt_refresh: enabled: false + auto_refresh_cookie: false + auto_refresh_header: false + ignored_routes: [] + ignored_paths: [] + allowed_firewalls: [] iri_template_generator: enabled: false schema_processor: @@ -24,3 +29,58 @@ api_platform_extras: ``` Enable features by setting the corresponding flag to true. + +## JWT Refresh Feature + +`jwt_refresh` is active only when: + +- `api_platform_extras.features.jwt_refresh.enabled: true` +- at least one of: + - `api_platform_extras.features.jwt_refresh.auto_refresh_cookie: true` + - `api_platform_extras.features.jwt_refresh.auto_refresh_header: true` + +If both auto-refresh flags are `false`, behavior is effectively the same as feature disabled. + +### Related bundle config + +JWT/refresh token names and header prefix are taken from Lexik/Gesdinet config (with bundle defaults): + +- `lexik_jwt_authentication.token_extractors.authorization_header.prefix` (default: `Bearer`) +- `lexik_jwt_authentication.token_extractors.authorization_header.name` (default: `Authorization`) +- `lexik_jwt_authentication.token_extractors.cookie.name` (default: `BEARER`) +- `gesdinet_jwt_refresh_token.token_parameter_name` (default: `refresh_token`) + +When Lexik extractor parameters are not exposed as container parameters, values are read from Lexik extractor service definition arguments. + +## Logout Configuration + +Recommended config to invalidate both tokens and clear cookies with no custom app logic: + +```yaml +# config/packages/lexik_jwt_authentication.yaml +lexik_jwt_authentication: + blocklist_token: + enabled: true +``` + +```yaml +# config/packages/security.yaml +security: + firewalls: + api: + logout: + path: app_logout + delete_cookies: + # JWT cookie configured in lexik_jwt_authentication.token_extractors.cookie.name + jwt-bearer: ~ + # Refresh cookie configured in gesdinet_jwt_refresh_token.token_parameter_name + refresh-token: ~ + refresh-jwt: + invalidate_token_on_logout: true +``` + +Notes: + +- `invalidate_token_on_logout: true` (Gesdinet) deletes refresh token on logout. +- `blocklist_token.enabled: true` (Lexik) blacklists JWT on logout. +- This bundle normalizes Gesdinet `400 No refresh_token found.` to `200 Logged out.` for idempotent logout responses. diff --git a/src/DependencyInjection/CompilerPass/JwtRefreshCompilerPass.php b/src/DependencyInjection/CompilerPass/JwtRefreshCompilerPass.php new file mode 100644 index 0000000..ed98019 --- /dev/null +++ b/src/DependencyInjection/CompilerPass/JwtRefreshCompilerPass.php @@ -0,0 +1,312 @@ +resolveBoolParameter($container, sprintf('%s.enabled', self::BASE_FEATURE_PATH), false); + $autoRefreshCookie = $this->resolveBoolParameter($container, sprintf('%s.auto_refresh_cookie', self::BASE_FEATURE_PATH), false); + $autoRefreshHeader = $this->resolveBoolParameter($container, sprintf('%s.auto_refresh_header', self::BASE_FEATURE_PATH), false); + if ($featureEnabled === false) { + return; + } + + $this->registerLogoutSubscriber($container); + + if ($autoRefreshCookie === false && $autoRefreshHeader === false) { + return; + } + + $refreshTokenManagerId = $this->firstAvailable($container, [ + 'gesdinet_jwt_refresh_token.refresh_token_manager', + 'gesdinet.jwtrefreshtoken.refresh_token_manager', + ]); + + $refreshTokenGeneratorId = $this->firstAvailable($container, [ + 'gesdinet_jwt_refresh_token.refresh_token_generator', + 'gesdinet.jwtrefreshtoken.refresh_token_generator', + ]); + + $refreshTokenExtractorId = $this->firstAvailable($container, [ + 'gesdinet_jwt_refresh_token.request.extractor.chain', + 'gesdinet.jwtrefreshtoken.request.extractor.chain', + ]); + + if ( + $refreshTokenExtractorId === null + || $refreshTokenGeneratorId === null + || $refreshTokenManagerId === null + || !$container->has('lexik_jwt_authentication.jwt_manager') + ) { + return; + } + + // Lexik token extractor config is usually applied by replacing service definition arguments, + // not by exposing dedicated container parameters. We read parameters first and only fall back + // to those service arguments when the parameter is missing. + $jwtAuthorizationHeaderPrefix = $this->resolveStringParameterOrServiceArgument( + $container, + 'lexik_jwt_authentication.token_extractors.authorization_header.prefix', + 'lexik_jwt_authentication.extractor.authorization_header_extractor', + 0, + 'Bearer', + ); + $jwtAuthorizationHeaderName = $this->resolveStringParameterOrServiceArgument( + $container, + 'lexik_jwt_authentication.token_extractors.authorization_header.name', + 'lexik_jwt_authentication.extractor.authorization_header_extractor', + 1, + 'Authorization', + ); + $jwtCookieName = $this->resolveStringParameterOrServiceArgument( + $container, + 'lexik_jwt_authentication.token_extractors.cookie.name', + 'lexik_jwt_authentication.extractor.cookie_extractor', + 0, + 'BEARER', + ); + $refreshTokenName = $this->resolveStringParameter($container, 'gesdinet_jwt_refresh_token.token_parameter_name', 'refresh_token'); + $refreshTtl = $this->resolveIntParameter($container, 'gesdinet_jwt_refresh_token.ttl', 2592000); + $refreshSingleUse = $this->resolveBoolParameter($container, 'gesdinet_jwt_refresh_token.single_use', false); + $refreshCookieConfig = $this->resolveArrayParameter($container, 'gesdinet_jwt_refresh_token.cookie', []); + + $userProviderLocatorReference = ServiceLocatorTagPass::register( + $container, + $this->resolveUserProviderReferences($container), + ); + + $container->setDefinition( + RequestHeaderExtractor::class, + new Definition(RequestHeaderExtractor::class), + ); + + if ($container->hasDefinition($refreshTokenExtractorId)) { + $container + ->getDefinition($refreshTokenExtractorId) + ->addMethodCall('addExtractor', [new Reference(RequestHeaderExtractor::class)]); + } elseif ($container->hasAlias($refreshTokenExtractorId)) { + $targetId = (string) $container->getAlias($refreshTokenExtractorId); + if ($container->hasDefinition($targetId)) { + $container + ->getDefinition($targetId) + ->addMethodCall('addExtractor', [new Reference(RequestHeaderExtractor::class)]); + } + } + + $container->setDefinition( + RequestTokenResolver::class, + new Definition(RequestTokenResolver::class), + )->setArguments([ + new Reference($refreshTokenExtractorId), + $refreshTokenName, + ]); + + $container->setDefinition( + TokenRefreshService::class, + new Definition(TokenRefreshService::class), + )->setArguments([ + new Reference($refreshTokenManagerId), + new Reference($refreshTokenGeneratorId), + new Reference('lexik_jwt_authentication.jwt_manager'), + $userProviderLocatorReference, + new Reference( + sprintf('lexik_jwt_authentication.cookie_provider.%s', $jwtCookieName), + ContainerInterface::NULL_ON_INVALID_REFERENCE, + ), + $refreshTtl, + $refreshSingleUse, + $jwtAuthorizationHeaderName, + $jwtAuthorizationHeaderPrefix, + $jwtCookieName, + $refreshCookieConfig, + ]); + + $container->setDefinition( + JwtRefreshSubscriber::class, + new Definition(JwtRefreshSubscriber::class), + ) + ->addTag('kernel.event_subscriber') + ->setArguments([ + new Reference('lexik_jwt_authentication.extractor.chain_extractor'), + new Reference(RequestTokenResolver::class), + new Reference(TokenRefreshService::class), + new Reference('security.firewall.map'), + $this->resolveArrayParameter($container, sprintf('%s.allowed_firewalls', self::BASE_FEATURE_PATH), []), + $this->resolveArrayParameter($container, sprintf('%s.ignored_routes', self::BASE_FEATURE_PATH), []), + $this->resolveArrayParameter($container, sprintf('%s.ignored_paths', self::BASE_FEATURE_PATH), []), + $autoRefreshCookie, + $autoRefreshHeader, + ]); + } + + private function resolveStringParameter(ContainerBuilder $container, string $name, string $default): string + { + if (!$container->hasParameter($name)) { + return $default; + } + + $value = $container->getParameter($name); + + return is_scalar($value) ? (string) $value : $default; + } + + private function resolveStringParameterOrServiceArgument( + ContainerBuilder $container, + string $parameterName, + string $serviceId, + int $argumentIndex, + string $default, + ): string { + if ($container->hasParameter($parameterName)) { + return $this->resolveStringParameter($container, $parameterName, $default); + } + + $definition = $this->resolveDefinition($container, $serviceId); + if (!$definition instanceof Definition) { + return $default; + } + + $arguments = $definition->getArguments(); + if (!array_key_exists($argumentIndex, $arguments)) { + return $default; + } + + $value = $arguments[$argumentIndex]; + + return is_scalar($value) ? (string) $value : $default; + } + + private function resolveDefinition(ContainerBuilder $container, string $id): ?Definition + { + if ($container->hasDefinition($id)) { + return $container->getDefinition($id); + } + + if (!$container->hasAlias($id)) { + return null; + } + + $targetId = (string) $container->getAlias($id); + + return $container->hasDefinition($targetId) ? $container->getDefinition($targetId) : null; + } + + private function resolveIntParameter(ContainerBuilder $container, string $name, int $default): int + { + if (!$container->hasParameter($name)) { + return $default; + } + + return (int) $container->getParameter($name); + } + + private function resolveBoolParameter(ContainerBuilder $container, string $name, bool $default): bool + { + if (!$container->hasParameter($name)) { + return $default; + } + + return (bool) $container->getParameter($name); + } + + /** + * @param mixed[] $default + * + * @return mixed[] + */ + private function resolveArrayParameter(ContainerBuilder $container, string $name, array $default): array + { + if (!$container->hasParameter($name)) { + return $default; + } + + $value = $container->getParameter($name); + + return is_array($value) ? $value : $default; + } + + /** + * @return array + */ + private function resolveUserProviderReferences(ContainerBuilder $container): array + { + $references = []; + + foreach ($container->getDefinitions() as $id => $_definition) { + if (str_starts_with($id, 'security.user.provider.concrete.')) { + $references[$id] = new Reference($id); + } + } + + foreach ($container->getAliases() as $id => $alias) { + if (!str_starts_with($id, 'security.user.provider.concrete.')) { + continue; + } + + $targetId = (string) $alias; + $references[$id] = new Reference($id); + $references[$targetId] = new Reference($targetId); + } + + return $references; + } + + /** + * @param string[] $ids + */ + private function firstAvailable(ContainerBuilder $container, array $ids): ?string + { + foreach ($ids as $id) { + if ($container->has($id)) { + return $id; + } + } + + return null; + } + + private function registerLogoutSubscriber(ContainerBuilder $container): void + { + foreach ($container->getDefinitions() as $id => $_definition) { + if (!str_starts_with($id, 'security.event_dispatcher.')) { + continue; + } + + $container->setDefinition( + sprintf('%s.%s', LogoutSubscriber::class, $id), + (new Definition(LogoutSubscriber::class)) + ->addTag('kernel.event_listener', [ + 'event' => LogoutEvent::class, + 'method' => 'onLogout', + 'dispatcher' => $id, + 'priority' => -128, + ]), + ); + } + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index ef9be7a..dc43ff4 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -42,6 +42,32 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->arrayNode('jwt_refresh') ->canBeEnabled() + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('auto_refresh_cookie') + ->defaultFalse() + ->info('Will refresh jwt cookie during request cycle if valid refresh token present and enabled.') + ->end() + ->booleanNode('auto_refresh_header') + ->defaultFalse() + ->info('Will refresh jwt header during request cycle if valid refresh token present and enabled.') + ->end() + ->arrayNode('ignored_routes') + ->scalarPrototype()->end() + ->defaultValue([]) + ->info('Skip auto refresh if route matches by name.') + ->end() + ->arrayNode('ignored_paths') + ->scalarPrototype()->end() + ->defaultValue([]) + ->info('Skip auto refresh if route matches path.') + ->end() + ->arrayNode('allowed_firewalls') + ->scalarPrototype()->end() + ->defaultValue([]) + ->info('Skip auto refresh if resolved firewall not matching allowed.') + ->end() + ->end() ->end() ->arrayNode('iri_template_generator') ->canBeEnabled() diff --git a/src/DependencyInjection/NetgenApiPlatformExtrasExtension.php b/src/DependencyInjection/NetgenApiPlatformExtrasExtension.php index 42db295..f75f49c 100644 --- a/src/DependencyInjection/NetgenApiPlatformExtrasExtension.php +++ b/src/DependencyInjection/NetgenApiPlatformExtrasExtension.php @@ -8,10 +8,17 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; +use function in_array; use function is_array; final class NetgenApiPlatformExtrasExtension extends Extension { + private const array SCALAR_ARRAY_PARAMS = [ + 'ignored_routes', + 'ignored_paths', + 'allowed_firewalls', + ]; + /** * @param mixed[] $configs */ @@ -41,7 +48,7 @@ private function setParameters( foreach ($config as $key => $value) { $paramName = "{$alias}.{$key}"; - if (is_array($value)) { + if (is_array($value) && !in_array($key, self::SCALAR_ARRAY_PARAMS, true)) { $this->setParameters($container, $value, $paramName); } else { $container->setParameter($paramName, $value); diff --git a/src/EventSubscriber/JwtRefreshSubscriber.php b/src/EventSubscriber/JwtRefreshSubscriber.php new file mode 100644 index 0000000..b5f1ca9 --- /dev/null +++ b/src/EventSubscriber/JwtRefreshSubscriber.php @@ -0,0 +1,100 @@ + ['onKernelRequest', 10], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + if ($this->cookieAutoRefreshEnabled === false && $this->headerAutoRefreshEnabled === false) { + return; + } + + $request = $event->getRequest(); + $jwtToken = $this->jwtTokenExtractor->extract($request); + if ($jwtToken !== false) { + return; + } + + $refreshToken = $this->tokensResolver->resolveRefreshTokens($request); + if (!$refreshToken instanceof RefreshToken) { + return; + } + + if (method_exists($this->firewallMap, 'getFirewallConfig')) { + $firewallConfig = $this->firewallMap->getFirewallConfig($request); + + if (!$firewallConfig instanceof FirewallConfig) { + return; + } + + if ($this->allowedFirewalls !== [] && !in_array($firewallConfig->getName(), $this->allowedFirewalls, true)) { + return; + } + } else { + return; + } + + if (array_find( + $this->ignoredPaths, + static fn (string $path) => str_starts_with($request->getPathInfo(), $path), + ) !== null) { + return; + } + + if (array_find( + $this->ignoredRoutes, + static fn (string $route) => $request->attributes->get('_route') === $route, + ) !== null) { + return; + } + + $this->tokenRefreshService->refresh( + $request, + $refreshToken, + $this->headerAutoRefreshEnabled, + $this->cookieAutoRefreshEnabled, + $firewallConfig->getProvider(), + ); + } +} diff --git a/src/EventSubscriber/LogoutSubscriber.php b/src/EventSubscriber/LogoutSubscriber.php new file mode 100644 index 0000000..7b70d84 --- /dev/null +++ b/src/EventSubscriber/LogoutSubscriber.php @@ -0,0 +1,52 @@ +getResponse(); + if (!$response instanceof JsonResponse || $response->getStatusCode() !== Response::HTTP_BAD_REQUEST) { + return; + } + + $content = $response->getContent(); + if (!is_string($content) || $content === '') { + return; + } + + $payload = json_decode($content, true); + if (!is_array($payload) || ($payload['message'] ?? null) !== 'No refresh_token found.') { + return; + } + + $event->setResponse( + new JsonResponse( + [ + 'code' => Response::HTTP_OK, + 'message' => 'Logged out.', + ], + Response::HTTP_OK, + ), + ); + } +} diff --git a/src/Gesdinet/JWTRefreshTokenBundle/Request/Extractor/RequestHeaderExtractor.php b/src/Gesdinet/JWTRefreshTokenBundle/Request/Extractor/RequestHeaderExtractor.php new file mode 100644 index 0000000..afbd1cf --- /dev/null +++ b/src/Gesdinet/JWTRefreshTokenBundle/Request/Extractor/RequestHeaderExtractor.php @@ -0,0 +1,16 @@ +headers->get($parameter); + } +} diff --git a/src/JwtRefresh/TokenSourceType.php b/src/JwtRefresh/TokenSourceType.php new file mode 100644 index 0000000..63a2f92 --- /dev/null +++ b/src/JwtRefresh/TokenSourceType.php @@ -0,0 +1,14 @@ +source) { + TokenSourceType::Attribute, TokenSourceType::Header, TokenSourceType::Payload, TokenSourceType::Query => TokenSourceType::Header, + TokenSourceType::Cookie => TokenSourceType::Cookie, + }; + } +} diff --git a/src/NetgenApiPlatformExtrasBundle.php b/src/NetgenApiPlatformExtrasBundle.php index 16f059c..d532f9c 100644 --- a/src/NetgenApiPlatformExtrasBundle.php +++ b/src/NetgenApiPlatformExtrasBundle.php @@ -5,6 +5,7 @@ namespace Netgen\ApiPlatformExtras; use Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass\IriTemplateGeneratorCompilerPass; +use Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass\JwtRefreshCompilerPass; use Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass\SchemaDecorationCompilerPass; use Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass\SchemaProcessorCompilerPass; use Netgen\ApiPlatformExtras\OpenApi\Processor\OpenApiProcessorInterface; @@ -24,6 +25,9 @@ public function build(ContainerBuilder $container): void ) ->addCompilerPass( new SchemaDecorationCompilerPass(), + ) + ->addCompilerPass( + new JwtRefreshCompilerPass(), ); $container->registerForAutoconfiguration(OpenApiProcessorInterface::class) diff --git a/src/Service/RequestTokenResolver.php b/src/Service/RequestTokenResolver.php new file mode 100644 index 0000000..ea5261c --- /dev/null +++ b/src/Service/RequestTokenResolver.php @@ -0,0 +1,45 @@ +extractor->getRefreshToken($request, $this->refreshTokenName); + + if ($value === null) { + return null; + } + + $source = $this->detectSource($request); + + return $source !== null + ? new RefreshToken($source, $this->refreshTokenName, $value) + : null; + } + + private function detectSource(Request $request): ?TokenSourceType + { + return match (true) { + $request->cookies->has($this->refreshTokenName) => TokenSourceType::Cookie, + $request->headers->has($this->refreshTokenName) => TokenSourceType::Header, + $request->request->has($this->refreshTokenName) => TokenSourceType::Payload, + $request->query->has($this->refreshTokenName) => TokenSourceType::Query, + $request->attributes->has($this->refreshTokenName) => TokenSourceType::Attribute, + default => null, + }; + } +} diff --git a/src/Service/TokenRefreshService.php b/src/Service/TokenRefreshService.php new file mode 100644 index 0000000..2120ef5 --- /dev/null +++ b/src/Service/TokenRefreshService.php @@ -0,0 +1,190 @@ +> $providerLocator + * @param array $refreshCookieSettings + */ + public function __construct( + private RefreshTokenManagerInterface $refreshTokenManager, + private RefreshTokenGeneratorInterface $refreshTokenGenerator, + private JWTTokenManagerInterface $jwtTokenManager, + private ServiceLocator $providerLocator, + private ?JWTCookieProvider $jwtCookieProvider, + private int $refreshTtl, + private bool $refreshSingleUse, + private string $jwtHeaderName, + private string $jwtHeaderPrefix, + private string $jwtCookieName, + private array $refreshCookieSettings, + ) { + $this->refreshCookieSettings = array_merge([ + 'enabled' => false, + 'same_site' => 'lax', + 'path' => '/', + 'domain' => null, + 'http_only' => true, + 'secure' => true, + 'remove_token_from_body' => true, + 'partitioned' => false, + ], $this->refreshCookieSettings); + } + + public function refresh( + Request $request, + RefreshToken $token, + bool $setHeader, + bool $setCookie, + ?string $providerId = null, + ): void { + if ($setHeader === false && $setCookie === false) { + return; + } + + if ($token->value === '') { + return; + } + + if ($setCookie === false && $token->getSourceToResponseMapping() === TokenSourceType::Cookie) { + return; + } + + if ($setHeader === false && $token->getSourceToResponseMapping() === TokenSourceType::Header) { + return; + } + + if ( + $providerId === null + || $providerId === '' + || !$this->providerLocator->has($providerId) + ) { + return; + } + + $provider = $this->providerLocator->get($providerId); + + $refreshToken = $this->refreshTokenManager->get($token->value); + if (!$refreshToken instanceof RefreshTokenInterface || !$refreshToken->isValid()) { + return; + } + + $username = $refreshToken->getUsername(); + if (!is_string($username) || $username === '') { + return; + } + + try { + $user = $provider->loadUserByIdentifier($username); + } catch (Throwable) { + return; + } + + $jwt = $this->jwtTokenManager->create($user); + $refreshTokenValue = $refreshToken->getRefreshToken(); + if (!is_string($refreshTokenValue) || $refreshTokenValue === '') { + return; + } + + if ($this->refreshSingleUse) { + $this->refreshTokenManager->delete($refreshToken); + + $rotatedRefreshToken = $this->refreshTokenGenerator->createForUserWithTtl($user, $this->refreshTtl); + $this->refreshTokenManager->save($rotatedRefreshToken); + + $rotatedValue = $rotatedRefreshToken->getRefreshToken(); + if (is_string($rotatedValue) && $rotatedValue !== '') { + $refreshTokenValue = $rotatedValue; + } + } + + $response = new Response(); + + $this->handleRefresh($token->getSourceToResponseMapping(), $request, $response, $token, $jwt, $refreshTokenValue); + + $response->sendHeaders(); + } + + private function createJwtCookie(string $jwt): ?Cookie + { + if (!$this->jwtCookieProvider instanceof JWTCookieProvider) { + return null; + } + + return $this->jwtCookieProvider->createCookie($jwt); + } + + private function createRefreshCookie(string $name, string $tokenValue): Cookie + { + return new Cookie( + $name, + $tokenValue, + time() + $this->refreshTtl, + $this->refreshCookieSettings['path'], + $this->refreshCookieSettings['domain'], + $this->refreshCookieSettings['secure'], + $this->refreshCookieSettings['http_only'], + false, + $this->refreshCookieSettings['same_site'], + $this->refreshCookieSettings['partitioned'], + ); + } + + private function handleRefresh( + TokenSourceType $responseType, + Request $request, + Response $response, + RefreshToken $token, + string $jwtVal, + string $refreshVal, + ): void { + switch ($responseType) { + case TokenSourceType::Header: + $headerValue = $this->jwtHeaderPrefix !== '' ? sprintf('%s %s', $this->jwtHeaderPrefix, $jwtVal) : $jwtVal; + $request->headers->set($this->jwtHeaderName, $headerValue); + $response->headers->set($this->jwtHeaderName, $headerValue); + $request->headers->set($token->name, $refreshVal); + $response->headers->set($token->name, $refreshVal); + + break; + + case TokenSourceType::Cookie: + $request->cookies->set($this->jwtCookieName, $jwtVal); + $jwtCookie = $this->createJwtCookie($jwtVal); + if ($jwtCookie !== null) { + $response->headers->setCookie($jwtCookie); + } + $request->cookies->set($token->name, $refreshVal); + $response->headers->setCookie($this->createRefreshCookie($token->name, $refreshVal)); + + break; + + default: + break; + } + } +}