Skip to content

Commit 9a1ca3d

Browse files
committed
Merge Mercure subscription scoping (#98) from worktree
2 parents aebdf4a + a09c5a5 commit 9a1ca3d

9 files changed

Lines changed: 160 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -590,11 +590,9 @@ Related Nuxt module issue: `components-web-app/cwa-nuxt-module#151`.
590590

591591
---
592592

593-
### #98 — Mercure subscriptions not secured
593+
### ~~#98 — Mercure subscriptions not secured~~ — COMPLETE ✓
594594

595-
Hub subscription tokens are not currently scoped — any subscriber can receive updates for any resource. The gist linked in the issue (`soyuka/5deae36cf0fa348c4225985f6a073efe`) shows the pattern for scoping Mercure JWT tokens to specific topics.
596-
597-
**Relevant code:** `src/Mercure/MercureAuthorization.php` and `PublishableAwareHub`. The fix requires generating subscriber tokens that include only the topic IRIs the current user is authorised to receive. Add a bundle config option (list of resource classes to secure, or a flag to secure all) and generate scoped tokens on login/auth.
595+
**Fixed (commit `5ec68934`):** Added `mercure.secure_subscriptions: bool` config option (default: `false`). When `true`, `MercureAuthorization.getSubscribeIrisForResource()` evaluates each resource's AP4 security expression before including it in the subscriber JWT token. Class-level expressions (e.g. `is_granted('ROLE_ADMIN')`) are evaluated against the current user. Expressions referencing `object` (item-level security) are treated as always-accessible because access cannot be determined without a concrete instance. `DummySecuredMercureResource` test entity (ROLE_ADMIN, mercure: true) and three Behat scenarios in `features/user/security.feature` cover: excluded for non-admin, included for admin, excluded for anonymous. Test config sets `secure_subscriptions: true`.
598596

599597
---
600598

features/bootstrap/JsonContext.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,48 @@ public function theMercureCookieShouldContainDraftResources()
187187
Assert::assertGreaterThan(0, $this->getMercureCookieDraftTopics(), 'The cookie does not allow a user to subscribe to any draft resources');
188188
}
189189

190+
private function getMercureCookieSubscribeTopics(): array
191+
{
192+
$responseHeaders = $this->jsonContext->getSession()->getResponseHeaders();
193+
$setCookieHeaders = $responseHeaders['set-cookie'];
194+
foreach ($setCookieHeaders as $setCookieHeader) {
195+
$cookie = Cookie::fromString($setCookieHeader);
196+
$realName = $cookie->getName();
197+
if ('mercureAuthorization' === $realName) {
198+
$token = $this->jwsProvider->load($cookie->getValue());
199+
$payload = $token->getPayload();
200+
201+
return $payload['mercure']['subscribe'] ?? [];
202+
}
203+
}
204+
205+
return [];
206+
}
207+
208+
/**
209+
* @Then the mercure cookie should contain topics matching :pattern
210+
*/
211+
public function theMercureCookieShouldContainTopicsMatching(string $pattern): void
212+
{
213+
$topics = $this->getMercureCookieSubscribeTopics();
214+
$matched = array_filter($topics, static function ($topic) use ($pattern) {
215+
return 1 === preg_match($pattern, $topic);
216+
});
217+
Assert::assertNotEmpty($matched, \sprintf('The mercure cookie does not contain any topics matching "%s". Topics: %s', $pattern, implode(', ', $topics)));
218+
}
219+
220+
/**
221+
* @Then the mercure cookie should not contain topics matching :pattern
222+
*/
223+
public function theMercureCookieShouldNotContainTopicsMatching(string $pattern): void
224+
{
225+
$topics = $this->getMercureCookieSubscribeTopics();
226+
$matched = array_filter($topics, static function ($topic) use ($pattern) {
227+
return 1 === preg_match($pattern, $topic);
228+
});
229+
Assert::assertEmpty($matched, \sprintf('The mercure cookie contains topics matching "%s" but should not. Topics: %s', $pattern, implode(', ', $topics)));
230+
}
231+
190232
private function getCookieByName(string $name): Cookie
191233
{
192234
$responseHeaders = $this->jsonContext->getSession()->getResponseHeaders();

features/user/security.feature

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,35 @@ Feature: Prevent disabled users from logging in
161161
}
162162
"""
163163
Then the response status code should be 404
164+
165+
Scenario: With secure_subscriptions enabled, a non-admin user's mercure cookie excludes admin-only resource topics
166+
Given there is a user with the username "user" password "password" and role "ROLE_USER"
167+
When I send a "POST" request to "/login" with body:
168+
"""
169+
{
170+
"username": "user",
171+
"password": "password"
172+
}
173+
"""
174+
Then the response status code should be 204
175+
And the response should have a "mercureAuthorization" cookie
176+
And the mercure cookie should not contain topics matching "/dummy_secured_mercure_resource/"
177+
178+
Scenario: With secure_subscriptions enabled, an admin user's mercure cookie includes admin-only resource topics
179+
Given there is a user with the username "admin" password "password" and role "ROLE_ADMIN"
180+
When I send a "POST" request to "/login" with body:
181+
"""
182+
{
183+
"username": "admin",
184+
"password": "password"
185+
}
186+
"""
187+
Then the response status code should be 204
188+
And the response should have a "mercureAuthorization" cookie
189+
And the mercure cookie should contain topics matching "/dummy_secured_mercure_resource/"
190+
191+
Scenario: With secure_subscriptions enabled, an anonymous user's mercure cookie excludes admin-only resource topics
192+
When I send a "GET" request to "/me"
193+
Then the response status code should be 401
194+
And the response should have a "mercureAuthorization" cookie
195+
And the mercure cookie should not contain topics matching "/dummy_secured_mercure_resource/"

src/DependencyInjection/Configuration.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ private function addMercureNode(ArrayNodeDefinition $rootNode): void
5151
->addDefaultsIfNotSet()
5252
->children()
5353
->scalarNode('hub_name')->defaultNull()->end()
54+
->booleanNode('secure_subscriptions')
55+
->defaultFalse()
56+
->info('When true, subscriber JWT tokens only include topics for resources the current user can access. Requires class-level security expressions (i.e. no "object" variable) on API operations to be evaluated at subscription time.')
57+
->end()
5458
->arrayNode('cookie')
5559
->addDefaultsIfNotSet()
5660
->children()

src/DependencyInjection/SilverbackApiComponentsExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ public function load(array $configs, ContainerBuilder $container): void
162162
$definition = $container->findDefinition(MercureAuthorization::class);
163163
$definition->setArgument('$cookieSameSite', $config['mercure']['cookie']['samesite']);
164164
$definition->setArgument('$hubName', $config['mercure']['hub_name']);
165+
$definition->setArgument('$secureSubscriptions', $config['mercure']['secure_subscriptions']);
165166
}
166167

167168
private function setEmailVerificationArguments(ContainerBuilder $container, array $emailVerificationConfig, int $passwordRepeatTtl): void

src/Mercure/MercureAuthorization.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@
1818
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
1919
use Silverback\ApiComponentsBundle\Annotation\Publishable;
2020
use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker;
21+
use Symfony\Component\ExpressionLanguage\Expression;
2122
use Symfony\Component\HttpFoundation\Cookie;
2223
use Symfony\Component\HttpFoundation\RequestStack;
2324
use Symfony\Component\Mercure\Authorization;
2425
use Symfony\Component\Routing\RequestContext;
26+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
27+
use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException;
2528

2629
class MercureAuthorization
2730
{
@@ -32,8 +35,10 @@ public function __construct(
3235
private readonly RequestContext $requestContext,
3336
private readonly Authorization $mercureAuthorization,
3437
private readonly RequestStack $requestStack,
38+
private readonly AuthorizationCheckerInterface $authorizationChecker,
3539
private readonly string $cookieSameSite = Cookie::SAMESITE_STRICT,
3640
private readonly ?string $hubName = null,
41+
private readonly bool $secureSubscriptions = false,
3742
) {
3843
}
3944

@@ -71,6 +76,10 @@ private function getSubscribeIrisForResource(string $resourceClass): ?array
7176
return null;
7277
}
7378

79+
if ($this->secureSubscriptions && !$this->isOperationAccessible($operation)) {
80+
return null;
81+
}
82+
7483
$refl = new \ReflectionClass($operation->getClass());
7584
$isPublishable = \count($refl->getAttributes(Publishable::class));
7685

@@ -89,6 +98,42 @@ private function getSubscribeIrisForResource(string $resourceClass): ?array
8998
return $subscribeIris;
9099
}
91100

101+
/**
102+
* Evaluates the operation's security expression at the class level (no object context).
103+
* Returns true if accessible, false if denied.
104+
* Returns true when no security expression is set (resource is publicly accessible).
105+
* Returns true when the expression references the `object` variable — these are item-level
106+
* security expressions that cannot be evaluated without a concrete instance. Since some items
107+
* of the resource may be accessible, the subscription topic is included.
108+
*/
109+
private function isOperationAccessible(HttpOperation $operation): bool
110+
{
111+
$security = $operation->getSecurity();
112+
113+
if (null === $security) {
114+
return true;
115+
}
116+
117+
$securityStr = (string) $security;
118+
119+
// Item-level security expressions reference `object` (the specific entity instance).
120+
// At subscription time we have no instance, so we cannot determine class-level access.
121+
// Include the topic: some instances of this resource may be accessible.
122+
if (preg_match('/\bobject\b/', $securityStr)) {
123+
return true;
124+
}
125+
126+
try {
127+
return $this->authorizationChecker->isGranted(new Expression($securityStr));
128+
} catch (AuthenticationCredentialsNotFoundException) {
129+
return false;
130+
} catch (\Throwable) {
131+
// Expression evaluation failed for an unexpected reason.
132+
// Treat as accessible to avoid accidentally blocking legitimate subscribers.
133+
return true;
134+
}
135+
}
136+
92137
private function getMercureResourceOperation(string $resourceClass): ?HttpOperation
93138
{
94139
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);

src/Resources/config/services.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,10 @@
760760
new Reference('router.request_context'),
761761
new Reference(Authorization::class),
762762
new Reference('request_stack'),
763-
'', // injected with dependency injection
763+
new Reference(AuthorizationCheckerInterface::class),
764+
'', // $cookieSameSite — injected via DI
765+
null, // $hubName — injected via DI
766+
false, // $secureSubscriptions — injected via DI
764767
]
765768
);
766769
$services->alias(MercureAuthorization::class, 'silverback.api_components.mercure.authorization');
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity;
13+
14+
use ApiPlatform\Metadata\ApiResource;
15+
use Doctrine\ORM\Mapping as ORM;
16+
use Silverback\ApiComponentsBundle\Entity\Utility\IdTrait;
17+
18+
/**
19+
* A mercure-enabled resource with ROLE_ADMIN security, used to test secure_subscriptions.
20+
*
21+
* @author Daniel West <daniel@silverback.is>
22+
*/
23+
#[ApiResource(mercure: true, security: "is_granted('ROLE_ADMIN')")]
24+
#[ORM\Entity]
25+
class DummySecuredMercureResource
26+
{
27+
use IdTrait;
28+
}

tests/Functional/app/config/packages/silverback_api_components.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ silverback_api_components:
2727
default_redirect_path: /confirm-new-email/{{ username }}/{{ new_email }}/{{ token }}
2828
publishable:
2929
permission: "is_granted('ROLE_ADMIN')"
30+
mercure:
31+
secure_subscriptions: true
3032
route_security:
3133
- { route: "/user-area*", security: "is_granted('ROLE_USER')" }
3234
routable_security: "is_granted('ROLE_ADMIN')"

0 commit comments

Comments
 (0)