-
Notifications
You must be signed in to change notification settings - Fork 18
Tell the client how long the Mercure authorization cookie lives #2012
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
fashxp
wants to merge
2
commits into
2026.2
Choose a base branch
from
fix/pv346-mercure-cookie-renewal-2026.2
base: 2026.2
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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,
jwtLifetimeresolves tosession.cookie_lifetimeor 3600, entirely independently ofmercure_settings.cookie_lifetime. Settingcookie_lifetime: 7200produced a cookie stamped+7200saround 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.
ClientTokenServicenow takes the configured lifetime and passes an explicitexpclaim, whichLcobucciFactory::create()honours over its own default:Deliberately not wired into the shared
TokenFactoryInterfaceservice:ServerTokenServiceuses the same factory for the publisher token, and a shortcookie_lifetimewould then shorten that one too -PublishServicecaches itsHubwith aStaticTokenProvider, 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".