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/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/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/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..4bb10390e --- /dev/null +++ b/tests/Unit/Mercure/Service/HubServiceTest.php @@ -0,0 +1,115 @@ +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); + } + + /** + * 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( + $this->makeEmpty(TokenProviderInterface::class, ['getJwt' => 'jwt']), + $this->makeEmpty(UrlServiceInterface::class, ['getClientSideUrl' => 'https://example.com/hub']), + $cookieLifetime + ); + } +}