Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config/mercure.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions src/Mercure/Controller/JwtController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
47 changes: 47 additions & 0 deletions src/Mercure/Schema/Authorization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

namespace Pimcore\Bundle\StudioBackendBundle\Mercure\Schema;

use OpenApi\Attributes\Property;
use OpenApi\Attributes\Schema;

/**
* @internal
*/
#[Schema(
title: 'MercureAuthorization',
required: [
'cookieLifetime',
],
type: 'object'
)]
final readonly class Authorization
{
public function __construct(
#[Property(
description: 'Lifetime of the authorization cookie in seconds. A client has to request a new ' .
'cookie before it elapses: the hub authorises a subscription once, at connect time, so an ' .
'expired cookie leaves every reconnect anonymous and silently drops all private updates.',
type: 'integer',
example: 3600
)]
private int $cookieLifetime
) {
}

public function getCookieLifetime(): int
{
return $this->cookieLifetime;
}
}
10 changes: 9 additions & 1 deletion src/Mercure/Service/ClientTokenService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,7 +26,8 @@
{
public function __construct(
private TopicLoaderInterface $topicLoader,
private TokenFactoryInterface $tokenFactory
private TokenFactoryInterface $tokenFactory,
private int $cookieLifetime = 3600
) {
}

Expand All @@ -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')]
);
}
}
5 changes: 5 additions & 0 deletions src/Mercure/Service/HubService.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ public function __construct(
) {
}

public function getCookieLifetime(): int
{
return $this->cookieLifetime;
Comment on lines +34 to +36

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and verified: with the factory given only the secret, jwtLifetime resolves to session.cookie_lifetime or 3600, entirely independently of mercure_settings.cookie_lifetime. Setting cookie_lifetime: 7200 produced a cookie stamped +7200s around a token expiring at +3600s, so the endpoint would have advertised 7200 and the client would have renewed 36 minutes after the hub stopped accepting the token - the exact dead-authorization window this PR is meant to remove.

Fixed in 78ad8ab. ClientTokenService now takes the configured lifetime and passes an explicit exp claim, which LcobucciFactory::create() honours over its own default:

[$exp => new DateTimeImmutable($"+{$this->cookieLifetime} seconds")]

Deliberately not wired into the shared TokenFactoryInterface service: ServerTokenService uses the same factory for the publisher token, and a short cookie_lifetime would then shorten that one too - PublishService caches its Hub with a StaticTokenProvider, so a long-running messenger worker would start publishing with an expired token.

The regression test now decodes the JWT out of the cookie and asserts exp, the cookie expiry and the advertised lifetime all agree ("testAdvertisedLifetimeMatchesTheTokenExpiry"). Confirmed it fails without the fix: "Failed asserting that 3600.2 matches expected 7200".

}

public function createCookie(): Cookie
{
$urlParts = parse_url($this->urlService->getClientSideUrl());
Expand Down
6 changes: 6 additions & 0 deletions src/Mercure/Service/HubServiceInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
115 changes: 115 additions & 0 deletions tests/Unit/Mercure/Service/HubServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

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(
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<string, mixed>
*/
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
);
}
}
Loading