Skip to content

Commit aac2735

Browse files
authored
feat: add Twig helper generating hub URLs and setting authorization cookies (#62)
* feat: add Twig helper generating hub URLs and setting authorization cookies * fix: support for multiple hubs * clean request attributes * fix @chalasr's comments * add tests * compat with PHP 7.1 * fix tests
1 parent c4bb384 commit aac2735

File tree

12 files changed

+347
-21
lines changed

12 files changed

+347
-21
lines changed

.github/workflows/static-analysis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
- name: "installing PHP"
1616
uses: "shivammathur/setup-php@v2"
1717
with:
18-
php-version: "7.4"
18+
php-version: "8.0"
1919
ini-values: memory_limit=-1
2020
tools: composer:v2, phpstan, cs2pr
2121

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
/.php_cs.cache
2-
/.php_cs
1+
/.php-cs-fixer.cache
2+
/.php-cs-fixer.php
33
/.phpunit.result.cache
44
/composer.phar
55
/composer.lock
File renamed without changes.

composer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,11 @@
4646
},
4747
"require-dev": {
4848
"lcobucci/jwt": "^3.4|^4.0",
49+
"symfony/event-dispatcher": "^4.4|^5.0|^6.0",
50+
"symfony/http-kernel": "^4.4|^5.0|^6.0",
4951
"symfony/phpunit-bridge": "^5.2|^6.0",
50-
"symfony/stopwatch": "^4.4|^5.0|^6.0"
52+
"symfony/stopwatch": "^4.4|^5.0|^6.0",
53+
"twig/twig": "^2.0|^3.0"
5154
},
5255
"minimum-stability": "dev"
5356
}

src/Authorization.php

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515

1616
use Symfony\Component\HttpFoundation\Cookie;
1717
use Symfony\Component\HttpFoundation\Request;
18-
use Symfony\Component\HttpFoundation\Response;
1918
use Symfony\Component\Mercure\Exception\InvalidArgumentException;
2019
use Symfony\Component\Mercure\Exception\RuntimeException;
2120

21+
/**
22+
* Manages the "mercureAuthorization" cookies.
23+
*/
2224
final class Authorization
2325
{
2426
private const MERCURE_AUTHORIZATION_COOKIE_NAME = 'mercureAuthorization';
@@ -36,7 +38,30 @@ public function __construct(HubRegistry $registry, ?int $cookieLifetime = null)
3638
}
3739

3840
/**
39-
* Create Authorization cookie for the given hub.
41+
* Sets mercureAuthorization cookie for the given hub.
42+
*
43+
* @param string[] $subscribe a list of topics that the authorization cookie will allow subscribing to
44+
* @param string[] $publish a list of topics that the authorization cookie will allow publishing to
45+
* @param mixed[] $additionalClaims an array of additional claims for the JWT
46+
* @param string|null $hub the hub to generate the cookie for
47+
*/
48+
public function setCookie(Request $request, array $subscribe = [], array $publish = [], array $additionalClaims = [], ?string $hub = null): void
49+
{
50+
$this->updateCookies($request, $hub, $this->createCookie($request, $subscribe, $publish, $additionalClaims, $hub));
51+
}
52+
53+
/**
54+
* Clears the mercureAuthorization cookie for the given hub.
55+
*
56+
* @param string|null $hub the hub to clear the cookie for
57+
*/
58+
public function clearCookie(Request $request, ?string $hub = null): void
59+
{
60+
$this->updateCookies($request, $hub, $this->createClearCookie($request, $hub));
61+
}
62+
63+
/**
64+
* Creates mercureAuthorization cookie for the given hub.
4065
*
4166
* @param string[] $subscribe a list of topics that the authorization cookie will allow subscribing to
4267
* @param string[] $publish a list of topics that the authorization cookie will allow publishing to
@@ -48,7 +73,7 @@ public function createCookie(Request $request, array $subscribe = [], array $pub
4873
$hubInstance = $this->registry->getHub($hub);
4974
$tokenFactory = $hubInstance->getFactory();
5075
if (null === $tokenFactory) {
51-
throw new InvalidArgumentException(sprintf('The "%s" hub does not contain a token factory.', $hub ? '"'.$hub.'"' : 'default'));
76+
throw new InvalidArgumentException(sprintf('The %s hub does not contain a token factory.', $hub ? "\"$hub\"" : 'default'));
5277
}
5378

5479
$cookieLifetime = $this->cookieLifetime;
@@ -82,18 +107,26 @@ public function createCookie(Request $request, array $subscribe = [], array $pub
82107
);
83108
}
84109

85-
public function clearCookie(Request $request, Response $response, ?string $hub = null): void
110+
/**
111+
* Clears the mercureAuthorization cookie for the given hub.
112+
*
113+
* @param string|null $hub the hub to clear the cookie for
114+
*/
115+
public function createClearCookie(Request $request, ?string $hub = null): Cookie
86116
{
87117
$hubInstance = $this->registry->getHub($hub);
88118
/** @var array $urlComponents */
89119
$urlComponents = parse_url($hubInstance->getPublicUrl());
90120

91-
$response->headers->clearCookie(
121+
return Cookie::create(
92122
self::MERCURE_AUTHORIZATION_COOKIE_NAME,
123+
null,
124+
1,
93125
$urlComponents['path'] ?? '/',
94126
$this->getCookieDomain($request, $urlComponents),
95127
'http' !== strtolower($urlComponents['scheme'] ?? 'https'),
96128
true,
129+
false,
97130
Cookie::SAMESITE_STRICT
98131
);
99132
}
@@ -119,4 +152,15 @@ private function getCookieDomain(Request $request, array $urlComponents): ?strin
119152

120153
return $cookieDomain;
121154
}
155+
156+
private function updateCookies(Request $request, ?string $hub, Cookie $cookie): void
157+
{
158+
$cookies = $request->attributes->get('_mercure_authorization_cookies', []);
159+
if (\array_key_exists($hub, $cookies)) {
160+
throw new RuntimeException(sprintf('The "mercureAuthorization" cookie for the %s has already been set. You cannot set it two times during the same request.', $hub ? "\"$hub\" hub" : 'default hub'));
161+
}
162+
163+
$cookies[$hub] = $cookie;
164+
$request->attributes->set('_mercure_authorization_cookies', $cookies);
165+
}
122166
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Mercure Component project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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+
declare(strict_types=1);
13+
14+
namespace Symfony\Component\Mercure\EventSubscriber;
15+
16+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
17+
use Symfony\Component\HttpKernel\Event\ResponseEvent;
18+
use Symfony\Component\HttpKernel\KernelEvents;
19+
20+
/**
21+
* Sets the cookies created by the Authorization helper class.
22+
*
23+
* @author Kévin Dunglas <kevin@dunglas.fr>
24+
*/
25+
final class SetCookieSubscriber implements EventSubscriberInterface
26+
{
27+
public function onKernelResponse(ResponseEvent $event): void
28+
{
29+
$mainRequest = method_exists($event, 'isMainRequest') ? $event->isMainRequest() : $event->isMasterRequest();
30+
if (
31+
!($mainRequest) ||
32+
null === $cookies = ($request = $event->getRequest())->attributes->get('_mercure_authorization_cookies')) {
33+
return;
34+
}
35+
36+
$request->attributes->remove('_mercure_authorization_cookies');
37+
38+
$response = $event->getResponse();
39+
foreach ($cookies as $cookie) {
40+
$response->headers->setCookie($cookie);
41+
}
42+
}
43+
44+
/**
45+
* {@inheritdoc}
46+
*/
47+
public static function getSubscribedEvents(): array
48+
{
49+
return [KernelEvents::RESPONSE => 'onKernelResponse'];
50+
}
51+
}

src/Publisher.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public function __invoke(Update $update): string
8282
private function validateJwt(string $jwt): void
8383
{
8484
if (!preg_match('/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/', $jwt)) {
85-
throw new Exception\InvalidArgumentException('The provided JWT is not valid');
85+
throw new Exception\InvalidArgumentException('The provided JWT is not valid.');
8686
}
8787
}
8888
}

src/Twig/MercureExtension.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Mercure Component project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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+
declare(strict_types=1);
13+
14+
namespace Symfony\Component\Mercure\Twig;
15+
16+
use Symfony\Component\HttpFoundation\RequestStack;
17+
use Symfony\Component\Mercure\Authorization;
18+
use Symfony\Component\Mercure\HubRegistry;
19+
use Twig\Extension\AbstractExtension;
20+
use Twig\TwigFunction;
21+
22+
/**
23+
* Registers the Twig helper function.
24+
*
25+
* @author Kévin Dunglas <kevin@dunglas.fr>
26+
*/
27+
final class MercureExtension extends AbstractExtension
28+
{
29+
private $hubRegistry;
30+
private $authorization;
31+
private $requestStack;
32+
33+
public function __construct(HubRegistry $hubRegistry, ?Authorization $authorization = null, ?RequestStack $requestStack = null)
34+
{
35+
$this->hubRegistry = $hubRegistry;
36+
$this->authorization = $authorization;
37+
$this->requestStack = $requestStack;
38+
}
39+
40+
public function getFunctions(): array
41+
{
42+
return [new TwigFunction('mercure', [$this, 'mercure'])];
43+
}
44+
45+
/**
46+
* @param string|string[]|null $topics A topic or an array of topics to subscribe for. If this parameter is omitted or `null` is passed, the URL of the hub will be returned (useful for publishing in JavaScript).
47+
*
48+
* @return string The URL of the hub with the appropriate "topic" query parameters (if any)
49+
*/
50+
public function mercure($topics = null, array $options = []): string
51+
{
52+
$hub = $options['hub'] ?? null;
53+
$url = $this->hubRegistry->getHub($hub)->getPublicUrl();
54+
if (null !== $topics) {
55+
// We cannot use http_build_query() because this method doesn't support generating multiple query parameters with the same name without the [] suffix
56+
$separator = '?';
57+
foreach ((array) $topics as $topic) {
58+
$url .= $separator.'topic='.rawurlencode($topic);
59+
if ('?' === $separator) {
60+
$separator = '&';
61+
}
62+
}
63+
}
64+
65+
if (
66+
null === $this->authorization ||
67+
null === $this->requestStack ||
68+
(!isset($options['subscribe']) && !isset($options['publish']) && !isset($options['additionalClaims'])) ||
69+
/* @phpstan-ignore-next-line */
70+
null === $request = method_exists($this->requestStack, 'getMainRequest') ? $this->requestStack->getMainRequest() : $this->requestStack->getMasterRequest()
71+
) {
72+
return $url;
73+
}
74+
75+
$this->authorization->setCookie($request, $options['subscribe'] ?? [], $options['publish'] ?? [], $options['additionalClaims'] ?? [], $hub);
76+
77+
return $url;
78+
}
79+
}

src/Update.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ final class Update
3333
private $retry;
3434

3535
/**
36-
* @param array|string $topics
36+
* @param string|string[] $topics
3737
*/
3838
public function __construct($topics, string $data = '', bool $private = false, string $id = null, string $type = null, int $retry = null)
3939
{
4040
if (!\is_array($topics) && !\is_string($topics)) {
41-
throw new \InvalidArgumentException('$topics must be an array of strings or a string');
41+
throw new \InvalidArgumentException('$topics must be an array of strings or a string.');
4242
}
4343

4444
$this->topics = (array) $topics;

tests/AuthorizationTest.php

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
use Lcobucci\JWT\Signer\Key\InMemory;
1717
use PHPUnit\Framework\TestCase;
1818
use Symfony\Component\HttpFoundation\Request;
19-
use Symfony\Component\HttpFoundation\Response;
2019
use Symfony\Component\Mercure\Authorization;
2120
use Symfony\Component\Mercure\Exception\RuntimeException;
2221
use Symfony\Component\Mercure\HubRegistry;
@@ -52,6 +51,31 @@ function (Update $u): string { return 'dummy'; },
5251
$this->assertIsNumeric($payload['exp']);
5352
}
5453

54+
public function testSetCookie(): void
55+
{
56+
$tokenFactory = $this->createMock(TokenFactoryInterface::class);
57+
$tokenFactory
58+
->expects($this->once())
59+
->method('create')
60+
->with($this->equalTo(['foo']), $this->equalTo(['bar']), $this->arrayHasKey('x-foo'))
61+
;
62+
63+
$registry = new HubRegistry(new MockHub(
64+
'https://example.com/.well-known/mercure',
65+
new StaticTokenProvider('foo.bar.baz'),
66+
function (Update $u): string { return 'dummy'; },
67+
$tokenFactory
68+
));
69+
70+
$request = Request::create('https://example.com');
71+
$authorization = new Authorization($registry, 0);
72+
$authorization->setCookie($request, ['foo'], ['bar'], ['x-foo' => 'bar']);
73+
74+
$cookie = $request->attributes->get('_mercure_authorization_cookies')[null];
75+
$this->assertNotNull($cookie->getValue());
76+
$this->assertSame(0, $cookie->getExpiresTime());
77+
}
78+
5579
public function testClearCookie(): void
5680
{
5781
$registry = new HubRegistry(new MockHub(
@@ -67,15 +91,12 @@ public function create(array $subscribe = [], array $publish = [], array $additi
6791
));
6892

6993
$authorization = new Authorization($registry);
70-
$cookie = $authorization->createCookie($request = Request::create('https://example.com'));
71-
72-
$response = new Response();
73-
$response->headers->setCookie($cookie);
94+
$request = Request::create('https://example.com');
95+
$authorization->clearCookie($request);
7496

75-
$authorization->clearCookie($request, $response);
76-
77-
$this->assertNull($response->headers->getCookies()[0]->getValue());
78-
$this->assertSame(1, $response->headers->getCookies()[0]->getExpiresTime());
97+
$cookie = $request->attributes->get('_mercure_authorization_cookies')[null];
98+
$this->assertNull($cookie->getValue());
99+
$this->assertSame(1, $cookie->getExpiresTime());
79100
}
80101

81102
/**
@@ -137,4 +158,26 @@ public function provideNonApplicableCookieDomains(): iterable
137158
yield ['https://demo.mercure.com', 'https://example.com'];
138159
yield ['https://mercure.internal.com', 'https://external.com'];
139160
}
161+
162+
public function testSetMultipleCookies(): void
163+
{
164+
$this->expectException(RuntimeException::class);
165+
166+
$registry = new HubRegistry(new MockHub(
167+
'https://example.com/.well-known/mercure',
168+
new StaticTokenProvider('foo.bar.baz'),
169+
function (Update $u): string { return 'dummy'; },
170+
new class() implements TokenFactoryInterface {
171+
public function create(array $subscribe = [], array $publish = [], array $additionalClaims = []): string
172+
{
173+
return '';
174+
}
175+
}
176+
));
177+
178+
$authorization = new Authorization($registry);
179+
$request = Request::create('https://example.com');
180+
$authorization->setCookie($request);
181+
$authorization->clearCookie($request);
182+
}
140183
}

0 commit comments

Comments
 (0)