Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/static-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- name: "installing PHP"
uses: "shivammathur/setup-php@v2"
with:
php-version: "7.4"
php-version: "8.0"
ini-values: memory_limit=-1
tools: composer:v2, phpstan, cs2pr

Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/.php_cs.cache
/.php_cs
/.php-cs-fixer.cache
/.php-cs-fixer.php
/.phpunit.result.cache
/composer.phar
/composer.lock
Expand Down
File renamed without changes.
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@
},
"require-dev": {
"lcobucci/jwt": "^3.4|^4.0",
"symfony/event-dispatcher": "^4.4|^5.0|^6.0",
"symfony/http-kernel": "^4.4|^5.0|^6.0",
"symfony/phpunit-bridge": "^5.2|^6.0",
"symfony/stopwatch": "^4.4|^5.0|^6.0"
"symfony/stopwatch": "^4.4|^5.0|^6.0",
"twig/twig": "^2.0|^3.0"
},
"minimum-stability": "dev"
}
54 changes: 49 additions & 5 deletions src/Authorization.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@

use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Exception\InvalidArgumentException;
use Symfony\Component\Mercure\Exception\RuntimeException;

/**
* Manages the "mercureAuthorization" cookies.
*/
final class Authorization
{
private const MERCURE_AUTHORIZATION_COOKIE_NAME = 'mercureAuthorization';
Expand All @@ -36,7 +38,30 @@ public function __construct(HubRegistry $registry, ?int $cookieLifetime = null)
}

/**
* Create Authorization cookie for the given hub.
* Sets mercureAuthorization cookie for the given hub.
*
* @param string[] $subscribe a list of topics that the authorization cookie will allow subscribing to
* @param string[] $publish a list of topics that the authorization cookie will allow publishing to
* @param mixed[] $additionalClaims an array of additional claims for the JWT
* @param string|null $hub the hub to generate the cookie for
*/
public function setCookie(Request $request, array $subscribe = [], array $publish = [], array $additionalClaims = [], ?string $hub = null): void
{
$this->updateCookies($request, $hub, $this->createCookie($request, $subscribe, $publish, $additionalClaims, $hub));
}

/**
* Clears the mercureAuthorization cookie for the given hub.
*
* @param string|null $hub the hub to clear the cookie for
*/
public function clearCookie(Request $request, ?string $hub = null): void
{
$this->updateCookies($request, $hub, $this->createClearCookie($request, $hub));
}

/**
* Creates mercureAuthorization cookie for the given hub.
*
* @param string[] $subscribe a list of topics that the authorization cookie will allow subscribing to
* @param string[] $publish a list of topics that the authorization cookie will allow publishing to
Expand All @@ -48,7 +73,7 @@ public function createCookie(Request $request, array $subscribe = [], array $pub
$hubInstance = $this->registry->getHub($hub);
$tokenFactory = $hubInstance->getFactory();
if (null === $tokenFactory) {
throw new InvalidArgumentException(sprintf('The "%s" hub does not contain a token factory.', $hub ? '"'.$hub.'"' : 'default'));
throw new InvalidArgumentException(sprintf('The %s hub does not contain a token factory.', $hub ? "\"$hub\"" : 'default'));
}

$cookieLifetime = $this->cookieLifetime;
Expand Down Expand Up @@ -82,18 +107,26 @@ public function createCookie(Request $request, array $subscribe = [], array $pub
);
}

public function clearCookie(Request $request, Response $response, ?string $hub = null): void
/**
* Clears the mercureAuthorization cookie for the given hub.
*
* @param string|null $hub the hub to clear the cookie for
*/
public function createClearCookie(Request $request, ?string $hub = null): Cookie
{
$hubInstance = $this->registry->getHub($hub);
/** @var array $urlComponents */
$urlComponents = parse_url($hubInstance->getPublicUrl());

$response->headers->clearCookie(
return Cookie::create(
self::MERCURE_AUTHORIZATION_COOKIE_NAME,
null,
1,
$urlComponents['path'] ?? '/',
$this->getCookieDomain($request, $urlComponents),
'http' !== strtolower($urlComponents['scheme'] ?? 'https'),
true,
false,
Cookie::SAMESITE_STRICT
);
}
Expand All @@ -119,4 +152,15 @@ private function getCookieDomain(Request $request, array $urlComponents): ?strin

return $cookieDomain;
}

private function updateCookies(Request $request, ?string $hub, Cookie $cookie): void
{
$cookies = $request->attributes->get('_mercure_authorization_cookies', []);
if (\array_key_exists($hub, $cookies)) {
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'));
}

$cookies[$hub] = $cookie;
$request->attributes->set('_mercure_authorization_cookies', $cookies);
}
}
51 changes: 51 additions & 0 deletions src/EventSubscriber/SetCookieSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

/*
* This file is part of the Mercure Component project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Symfony\Component\Mercure\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* Sets the cookies created by the Authorization helper class.
*
* @author Kévin Dunglas <[email protected]>
*/
final class SetCookieSubscriber implements EventSubscriberInterface
{
public function onKernelResponse(ResponseEvent $event): void
{
$mainRequest = method_exists($event, 'isMainRequest') ? $event->isMainRequest() : $event->isMasterRequest();
if (
!($mainRequest) ||
null === $cookies = ($request = $event->getRequest())->attributes->get('_mercure_authorization_cookies')) {
return;
}

$request->attributes->remove('_mercure_authorization_cookies');

$response = $event->getResponse();
foreach ($cookies as $cookie) {
$response->headers->setCookie($cookie);
}
}

/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array
{
return [KernelEvents::RESPONSE => 'onKernelResponse'];
}
}
2 changes: 1 addition & 1 deletion src/Publisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public function __invoke(Update $update): string
private function validateJwt(string $jwt): void
{
if (!preg_match('/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/', $jwt)) {
throw new Exception\InvalidArgumentException('The provided JWT is not valid');
throw new Exception\InvalidArgumentException('The provided JWT is not valid.');
}
}
}
79 changes: 79 additions & 0 deletions src/Twig/MercureExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

/*
* This file is part of the Mercure Component project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Symfony\Component\Mercure\Twig;

use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mercure\Authorization;
use Symfony\Component\Mercure\HubRegistry;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

/**
* Registers the Twig helper function.
*
* @author Kévin Dunglas <[email protected]>
*/
final class MercureExtension extends AbstractExtension
{
private $hubRegistry;
private $authorization;
private $requestStack;

public function __construct(HubRegistry $hubRegistry, ?Authorization $authorization = null, ?RequestStack $requestStack = null)
{
$this->hubRegistry = $hubRegistry;
$this->authorization = $authorization;
$this->requestStack = $requestStack;
}

public function getFunctions(): array
{
return [new TwigFunction('mercure', [$this, 'mercure'])];
}

/**
* @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).
*
* @return string The URL of the hub with the appropriate "topic" query parameters (if any)
*/
public function mercure($topics = null, array $options = []): string
{
$hub = $options['hub'] ?? null;
$url = $this->hubRegistry->getHub($hub)->getPublicUrl();
if (null !== $topics) {
// We cannot use http_build_query() because this method doesn't support generating multiple query parameters with the same name without the [] suffix
$separator = '?';
foreach ((array) $topics as $topic) {
$url .= $separator.'topic='.rawurlencode($topic);
if ('?' === $separator) {
$separator = '&';
}
}
}

if (
null === $this->authorization ||
null === $this->requestStack ||
(!isset($options['subscribe']) && !isset($options['publish']) && !isset($options['additionalClaims'])) ||
/* @phpstan-ignore-next-line */
null === $request = method_exists($this->requestStack, 'getMainRequest') ? $this->requestStack->getMainRequest() : $this->requestStack->getMasterRequest()
) {
return $url;
}

$this->authorization->setCookie($request, $options['subscribe'] ?? [], $options['publish'] ?? [], $options['additionalClaims'] ?? [], $hub);

return $url;
}
}
4 changes: 2 additions & 2 deletions src/Update.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ final class Update
private $retry;

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

$this->topics = (array) $topics;
Expand Down
61 changes: 52 additions & 9 deletions tests/AuthorizationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
use Lcobucci\JWT\Signer\Key\InMemory;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Authorization;
use Symfony\Component\Mercure\Exception\RuntimeException;
use Symfony\Component\Mercure\HubRegistry;
Expand Down Expand Up @@ -52,6 +51,31 @@ function (Update $u): string { return 'dummy'; },
$this->assertIsNumeric($payload['exp']);
}

public function testSetCookie(): void
{
$tokenFactory = $this->createMock(TokenFactoryInterface::class);
$tokenFactory
->expects($this->once())
->method('create')
->with($this->equalTo(['foo']), $this->equalTo(['bar']), $this->arrayHasKey('x-foo'))
;

$registry = new HubRegistry(new MockHub(
'https://example.com/.well-known/mercure',
new StaticTokenProvider('foo.bar.baz'),
function (Update $u): string { return 'dummy'; },
$tokenFactory
));

$request = Request::create('https://example.com');
$authorization = new Authorization($registry, 0);
$authorization->setCookie($request, ['foo'], ['bar'], ['x-foo' => 'bar']);

$cookie = $request->attributes->get('_mercure_authorization_cookies')[null];
$this->assertNotNull($cookie->getValue());
$this->assertSame(0, $cookie->getExpiresTime());
}

public function testClearCookie(): void
{
$registry = new HubRegistry(new MockHub(
Expand All @@ -67,15 +91,12 @@ public function create(array $subscribe = [], array $publish = [], array $additi
));

$authorization = new Authorization($registry);
$cookie = $authorization->createCookie($request = Request::create('https://example.com'));

$response = new Response();
$response->headers->setCookie($cookie);
$request = Request::create('https://example.com');
$authorization->clearCookie($request);

$authorization->clearCookie($request, $response);

$this->assertNull($response->headers->getCookies()[0]->getValue());
$this->assertSame(1, $response->headers->getCookies()[0]->getExpiresTime());
$cookie = $request->attributes->get('_mercure_authorization_cookies')[null];
$this->assertNull($cookie->getValue());
$this->assertSame(1, $cookie->getExpiresTime());
}

/**
Expand Down Expand Up @@ -137,4 +158,26 @@ public function provideNonApplicableCookieDomains(): iterable
yield ['https://demo.mercure.com', 'https://example.com'];
yield ['https://mercure.internal.com', 'https://external.com'];
}

public function testSetMultipleCookies(): void
{
$this->expectException(RuntimeException::class);

$registry = new HubRegistry(new MockHub(
'https://example.com/.well-known/mercure',
new StaticTokenProvider('foo.bar.baz'),
function (Update $u): string { return 'dummy'; },
new class() implements TokenFactoryInterface {
public function create(array $subscribe = [], array $publish = [], array $additionalClaims = []): string
{
return '';
}
}
));

$authorization = new Authorization($registry);
$request = Request::create('https://example.com');
$authorization->setCookie($request);
$authorization->clearCookie($request);
}
}
Loading