From ec36b357258dec4500ec6e2450f805b3d029d016 Mon Sep 17 00:00:00 2001 From: Christian Fasching Date: Thu, 20 Aug 2026 16:33:53 +0200 Subject: [PATCH 1/2] Tell the client how long the Mercure authorization cookie lives The hub authorises a subscription only at connect time, so a client that cannot know when its cookie expires has no way to renew before it does. mercure/auth now returns the configured lifetime next to the cookie it sets. Co-Authored-By: Claude Opus 5 (1M context) --- src/Mercure/Controller/JwtController.php | 14 +++- src/Mercure/Schema/Authorization.php | 47 +++++++++++++ src/Mercure/Service/HubService.php | 5 ++ src/Mercure/Service/HubServiceInterface.php | 6 ++ tests/Unit/Mercure/Service/HubServiceTest.php | 68 +++++++++++++++++++ 5 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 src/Mercure/Schema/Authorization.php create mode 100644 tests/Unit/Mercure/Service/HubServiceTest.php diff --git a/src/Mercure/Controller/JwtController.php b/src/Mercure/Controller/JwtController.php index 77b9580c3..dc3fee694 100644 --- a/src/Mercure/Controller/JwtController.php +++ b/src/Mercure/Controller/JwtController.php @@ -13,8 +13,10 @@ namespace Pimcore\Bundle\StudioBackendBundle\Mercure\Controller; +use OpenApi\Attributes\JsonContent; use OpenApi\Attributes\Post; use Pimcore\Bundle\StudioBackendBundle\Controller\AbstractApiController; +use Pimcore\Bundle\StudioBackendBundle\Mercure\Schema\Authorization; use Pimcore\Bundle\StudioBackendBundle\Mercure\Service\HubServiceInterface; use Pimcore\Bundle\StudioBackendBundle\OpenApi\Attribute\Response\DefaultResponses; use Pimcore\Bundle\StudioBackendBundle\OpenApi\Attribute\Response\SuccessResponse; @@ -45,15 +47,21 @@ public function __construct( )] #[SuccessResponse( description: 'mercure_create_cookie_success_response', + content: new JsonContent(ref: Authorization::class) )] #[DefaultResponses] public function auth(): Response { - $res = new Response(); - $res->headers->setCookie( + // The cookie authorises the subscription; the body tells the client when to come back for + // a new one. The hub checks authorisation once, at connect time, so a client that lets the + // cookie lapse reconnects anonymously and loses every private update without any error. + $response = $this->jsonResponse( + new Authorization($this->hubService->getCookieLifetime()) + ); + $response->headers->setCookie( $this->hubService->createCookie() ); - return $res; + return $response; } } diff --git a/src/Mercure/Schema/Authorization.php b/src/Mercure/Schema/Authorization.php new file mode 100644 index 000000000..e08228efb --- /dev/null +++ b/src/Mercure/Schema/Authorization.php @@ -0,0 +1,47 @@ +cookieLifetime; + } +} diff --git a/src/Mercure/Service/HubService.php b/src/Mercure/Service/HubService.php index 717362bf1..b9efed9e1 100644 --- a/src/Mercure/Service/HubService.php +++ b/src/Mercure/Service/HubService.php @@ -31,6 +31,11 @@ public function __construct( ) { } + public function getCookieLifetime(): int + { + return $this->cookieLifetime; + } + public function createCookie(): Cookie { $urlParts = parse_url($this->urlService->getClientSideUrl()); diff --git a/src/Mercure/Service/HubServiceInterface.php b/src/Mercure/Service/HubServiceInterface.php index 0c8e6a09b..e671472f9 100644 --- a/src/Mercure/Service/HubServiceInterface.php +++ b/src/Mercure/Service/HubServiceInterface.php @@ -21,4 +21,10 @@ interface HubServiceInterface { public function createCookie(): Cookie; + + /** + * Lifetime of the cookie returned by createCookie(), in seconds. Clients need it to + * renew their authorization before the hub stops accepting it. + */ + public function getCookieLifetime(): int; } diff --git a/tests/Unit/Mercure/Service/HubServiceTest.php b/tests/Unit/Mercure/Service/HubServiceTest.php new file mode 100644 index 000000000..640379b90 --- /dev/null +++ b/tests/Unit/Mercure/Service/HubServiceTest.php @@ -0,0 +1,68 @@ +assertSame( + self::CUSTOM_LIFETIME, + $this->createHubService(self::CUSTOM_LIFETIME)->getCookieLifetime() + ); + } + + public function testGetCookieLifetimeDefaultsToOneHour(): void + { + $service = new HubService( + $this->makeEmpty(TokenProviderInterface::class, ['getJwt' => 'jwt']), + $this->makeEmpty(UrlServiceInterface::class, ['getClientSideUrl' => 'https://example.com/hub']), + ); + + $this->assertSame(3600, $service->getCookieLifetime()); + } + + /** + * The lifetime a client renews on and the lifetime the cookie actually expires on must be the + * same number. If they drift apart, a client that renews "in time" still reconnects with an + * expired cookie, which the hub accepts as anonymous, silently dropping every private update. + */ + public function testCookieExpiryMatchesTheAdvertisedLifetime(): void + { + $service = $this->createHubService(self::CUSTOM_LIFETIME); + + $before = time(); + $expiresAt = $service->createCookie()->getExpiresTime(); + $after = time(); + + $this->assertGreaterThanOrEqual($before + $service->getCookieLifetime(), $expiresAt); + $this->assertLessThanOrEqual($after + $service->getCookieLifetime(), $expiresAt); + } + + private function createHubService(int $cookieLifetime): HubService + { + return new HubService( + $this->makeEmpty(TokenProviderInterface::class, ['getJwt' => 'jwt']), + $this->makeEmpty(UrlServiceInterface::class, ['getClientSideUrl' => 'https://example.com/hub']), + $cookieLifetime + ); + } +} From 78ad8abe89fa7cfe13c01d0a18c2e8dccc8a93e4 Mon Sep 17 00:00:00 2001 From: Christian Fasching Date: Fri, 21 Aug 2026 11:55:01 +0200 Subject: [PATCH 2/2] Stamp the client token with the lifetime the cookie advertises The factory derived `exp` from `session.cookie_lifetime` (or 3600) instead, so a configured `cookie_lifetime` above that told the client to renew long after the hub had stopped accepting its token. Co-Authored-By: Claude Opus 5 (1M context) --- config/mercure.yaml | 4 +- src/Mercure/Service/ClientTokenService.php | 10 +++- tests/Unit/Mercure/Service/HubServiceTest.php | 47 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/config/mercure.yaml b/config/mercure.yaml index b9af31a96..1e9a071c0 100644 --- a/config/mercure.yaml +++ b/config/mercure.yaml @@ -13,7 +13,9 @@ services: Pimcore\Bundle\StudioBackendBundle\Mercure\Service\ServerTokenService: ~ - Pimcore\Bundle\StudioBackendBundle\Mercure\Service\ClientTokenService: ~ + Pimcore\Bundle\StudioBackendBundle\Mercure\Service\ClientTokenService: + arguments: + $cookieLifetime: '%pimcore_studio_backend.mercure_settings.cookie_lifetime%' Pimcore\Bundle\StudioBackendBundle\Mercure\Service\HubServiceInterface: class: Pimcore\Bundle\StudioBackendBundle\Mercure\Service\HubService diff --git a/src/Mercure/Service/ClientTokenService.php b/src/Mercure/Service/ClientTokenService.php index 2127cdf18..528a14b44 100644 --- a/src/Mercure/Service/ClientTokenService.php +++ b/src/Mercure/Service/ClientTokenService.php @@ -13,6 +13,7 @@ namespace Pimcore\Bundle\StudioBackendBundle\Mercure\Service; +use DateTimeImmutable; use Pimcore\Bundle\StudioBackendBundle\Mercure\Model\TopicCollection; use Pimcore\Bundle\StudioBackendBundle\Mercure\Service\Loader\TopicLoaderInterface; use Symfony\Component\Mercure\Jwt\TokenFactoryInterface; @@ -25,7 +26,8 @@ { public function __construct( private TopicLoaderInterface $topicLoader, - private TokenFactoryInterface $tokenFactory + private TokenFactoryInterface $tokenFactory, + private int $cookieLifetime = 3600 ) { } @@ -39,6 +41,12 @@ public function getJwt(): string return $this->tokenFactory->create( $this->getTopicCollection()->getClientSubscribableTopics(), $this->getTopicCollection()->getClientPublishableTopics(), + // Without an explicit claim the factory derives `exp` from `session.cookie_lifetime` + // (or 3600), which has nothing to do with the lifetime the cookie is stamped with and + // the client is told to renew on. Configuring a longer `cookie_lifetime` would then + // leave a window where the browser still sends a cookie the hub already rejects, which + // is the dead-authorization state this whole mechanism exists to avoid. + ['exp' => new DateTimeImmutable('+' . $this->cookieLifetime . ' seconds')] ); } } diff --git a/tests/Unit/Mercure/Service/HubServiceTest.php b/tests/Unit/Mercure/Service/HubServiceTest.php index 640379b90..4bb10390e 100644 --- a/tests/Unit/Mercure/Service/HubServiceTest.php +++ b/tests/Unit/Mercure/Service/HubServiceTest.php @@ -14,14 +14,20 @@ namespace Pimcore\Bundle\StudioBackendBundle\Tests\Unit\Mercure\Service; use Codeception\Test\Unit; +use Pimcore\Bundle\StudioBackendBundle\Mercure\Model\TopicCollection; +use Pimcore\Bundle\StudioBackendBundle\Mercure\Service\ClientTokenService; use Pimcore\Bundle\StudioBackendBundle\Mercure\Service\HubService; +use Pimcore\Bundle\StudioBackendBundle\Mercure\Service\Loader\TopicLoaderInterface; use Pimcore\Bundle\StudioBackendBundle\Mercure\Service\UrlServiceInterface; +use Symfony\Component\Mercure\Jwt\LcobucciFactory; use Symfony\Component\Mercure\Jwt\TokenProviderInterface; final class HubServiceTest extends Unit { private const int CUSTOM_LIFETIME = 900; + private const string JWT_KEY = 'a-test-secret-that-is-long-enough-for-hmac-sha256'; + public function testGetCookieLifetimeReturnsConfiguredValue(): void { $this->assertSame( @@ -57,6 +63,47 @@ public function testCookieExpiryMatchesTheAdvertisedLifetime(): void $this->assertLessThanOrEqual($after + $service->getCookieLifetime(), $expiresAt); } + /** + * The cookie carries a JWT with its own `exp`, and the hub rejects the subscription as soon as + * that claim has passed - regardless of how long the browser keeps sending the cookie. So the + * advertised lifetime has to match the TOKEN, not just the outer cookie: `LcobucciFactory` + * otherwise derives `exp` from `session.cookie_lifetime` (or 3600), and a `cookie_lifetime` + * configured above that would leave the client renewing long after the hub stopped accepting + * its token. + */ + public function testAdvertisedLifetimeMatchesTheTokenExpiry(): void + { + $lifetime = 7200; + $service = new HubService( + new ClientTokenService( + $this->makeEmpty(TopicLoaderInterface::class, [ + 'loadTopics' => new TopicCollection([], [], [], ['studio-backend-default']), + ]), + new LcobucciFactory(self::JWT_KEY), + $lifetime + ), + $this->makeEmpty(UrlServiceInterface::class, ['getClientSideUrl' => 'https://example.com/hub']), + $lifetime + ); + + $cookie = $service->createCookie(); + $claims = $this->decodeClaims((string) $cookie->getValue()); + + $this->assertSame($lifetime, $service->getCookieLifetime()); + $this->assertEqualsWithDelta($lifetime, $claims['exp'] - time(), 5); + $this->assertEqualsWithDelta($claims['exp'], $cookie->getExpiresTime(), 5); + } + + /** + * @return array + */ + private function decodeClaims(string $jwt): array + { + $payload = explode('.', $jwt)[1]; + + return json_decode(base64_decode(strtr($payload, '-_', '+/')), true, 512, JSON_THROW_ON_ERROR); + } + private function createHubService(int $cookieLifetime): HubService { return new HubService(