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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,12 @@ The plugin supports multiple JWT generation strategies:

**1. Secret-based (default):**

> [!IMPORTANT]
> When using HMAC algorithms, ensure your secret meets minimum lengths:
> - `HS256`: at least 32 bytes
> - `HS384`: at least 48 bytes
> - `HS512`: at least 64 bytes

```php
'jwt' => [
'secret' => env('MERCURE_JWT_SECRET'),
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
"require": {
"php": ">=8.2",
"cakephp/cakephp": "^5.0.1",
"firebase/php-jwt": "^6.11"
"firebase/php-jwt": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^11.1.3 || ^12.0",
"phpunit/phpunit": "^11.1.3 || ^12.0 || ^13.0",
"cakephp/plugin-installer": "^2.0.1",
"cakephp/cakephp-codesniffer": "^5.2",
"phpstan/phpstan": "^2.1",
Expand Down
5 changes: 5 additions & 0 deletions config/mercure.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
*
* Configure JWT authentication for the Mercure hub. Multiple strategies supported:
*
* Important for HMAC algorithms:
* - HS256 requires at least 32-byte secret
* - HS384 requires at least 48-byte secret
* - HS512 requires at least 64-byte secret
*
* 1. Secret-based (recommended):
* - 'secret' => Your JWT signing secret
* - 'algorithm' => Signing algorithm (default: HS256)
Expand Down
1 change: 0 additions & 1 deletion phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
>
<php>
<ini name="memory_limit" value="-1"/>
<ini name="apc.enable_cli" value="1"/>
</php>

<!-- Add any additional test suites you want to run here -->
Expand Down
14 changes: 12 additions & 2 deletions src/Authorization.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use Cake\Http\Response;
use Cake\Http\ServerRequest;
use InvalidArgumentException;
use Mercure\Exception\MercureException;
use Mercure\Internal\ConfigurationHelper;
use Mercure\Jwt\FirebaseTokenFactory;
Expand Down Expand Up @@ -57,13 +58,22 @@ public static function create(): AuthorizationInterface
throw new MercureException(sprintf("Token factory class '%s' not found", $factoryClass));
}

$factory = new $factoryClass($secret, $algorithm);
try {
$factory = new $factoryClass($secret, $algorithm);
} catch (InvalidArgumentException $e) {
throw new MercureException($e->getMessage(), $e->getCode(), previous: $e);
}

if (!$factory instanceof TokenFactoryInterface) {
throw new MercureException('Token factory must implement TokenFactoryInterface');
}
} else {
// Use default Firebase factory
$factory = new FirebaseTokenFactory($secret, $algorithm);
try {
$factory = new FirebaseTokenFactory($secret, $algorithm);
} catch (InvalidArgumentException $e) {
throw new MercureException($e->getMessage(), $e->getCode(), previous: $e);
}
}

self::$instance = new AuthorizationService($factory, $cookieConfig);
Expand Down
34 changes: 34 additions & 0 deletions src/Jwt/FirebaseTokenFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
namespace Mercure\Jwt;

use Firebase\JWT\JWT;
use InvalidArgumentException;

/**
* Firebase Token Factory
Expand All @@ -13,6 +14,17 @@
*/
class FirebaseTokenFactory implements TokenFactoryInterface
{
/**
* Minimum key length in bytes for HMAC algorithms.
*
* @var array<string, int>
*/
private const MIN_HMAC_KEY_LENGTH = [
'HS256' => 32,
'HS384' => 48,
'HS512' => 64,
];

/**
* Constructor
*
Expand All @@ -23,6 +35,7 @@ public function __construct(
private string $secret,
private string $algorithm = 'HS256',
) {
$this->validateSymmetricKeyLength();
}

/**
Expand Down Expand Up @@ -60,4 +73,25 @@ public function create(

return JWT::encode($payload, $this->secret, $this->algorithm);
}

/**
* Validate HMAC key lengths to match the minimum security requirements.
*
* @throws \InvalidArgumentException When the configured key is too short
*/
private function validateSymmetricKeyLength(): void
{
$minimumLength = self::MIN_HMAC_KEY_LENGTH[$this->algorithm] ?? null;
if ($minimumLength === null) {
return;
}

if (strlen($this->secret) < $minimumLength) {
throw new InvalidArgumentException(sprintf(
'JWT secret is too short for %s. Expected at least %d bytes.',
$this->algorithm,
$minimumLength,
));
}
}
}
14 changes: 12 additions & 2 deletions src/Publisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace Mercure;

use InvalidArgumentException;
use Mercure\Exception\MercureException;
use Mercure\Internal\ConfigurationHelper;
use Mercure\Jwt\FactoryTokenProvider;
Expand Down Expand Up @@ -97,13 +98,22 @@ private static function createTokenProvider(): TokenProviderInterface
throw new MercureException(sprintf("Token factory class '%s' not found", $factoryClass));
}

$factory = new $factoryClass($secret, $algorithm);
try {
$factory = new $factoryClass($secret, $algorithm);
} catch (InvalidArgumentException $e) {
throw new MercureException($e->getMessage(), $e->getCode(), previous: $e);
}

if (!$factory instanceof TokenFactoryInterface) {
throw new MercureException('Token factory must implement TokenFactoryInterface');
}
} else {
// Option 3b: Default Firebase factory
$factory = new FirebaseTokenFactory($secret, $algorithm);
try {
$factory = new FirebaseTokenFactory($secret, $algorithm);
} catch (InvalidArgumentException $e) {
throw new MercureException($e->getMessage(), $e->getCode(), previous: $e);
}
}

return new FactoryTokenProvider($factory, $publish, $subscribe, $additionalClaims);
Expand Down
2 changes: 2 additions & 0 deletions src/View/Helper/MercureHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
* $this->Mercure->addTopic('/user/123/messages');
* $this->Mercure->addTopics(['/books/456', '/comments/789']);
* ```
*
* @extends \Cake\View\Helper<\Cake\View\View>
*/
class MercureHelper extends Helper
{
Expand Down
21 changes: 18 additions & 3 deletions tests/TestCase/AuthorizationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ protected function setUp(): void
Configure::write('Mercure', [
'url' => 'https://mercure.example.com/.well-known/mercure',
'jwt' => [
'secret' => 'test-secret-key',
'secret' => 'test-secret-key-with-32-bytes-minimum!!',
'algorithm' => 'HS256',
],
'cookie' => [
Expand Down Expand Up @@ -143,7 +143,7 @@ public function testGetInstanceThrowsExceptionWithNonExistentFactory(): void
*/
public function testSetInstanceAllowsCustomInstance(): void
{
$tokenFactory = new FirebaseTokenFactory('custom-secret', 'HS256');
$tokenFactory = new FirebaseTokenFactory('custom-secret-key-with-32-bytes-min!!', 'HS256');
$customService = new AuthorizationService($tokenFactory, ['name' => 'customCookie']);

Authorization::setInstance($customService);
Expand Down Expand Up @@ -434,7 +434,7 @@ public function testAddDiscoveryHeaderThrowsExceptionWhenNoUrlConfigured(): void
'url' => '',
'public_url' => '',
'jwt' => [
'secret' => 'test-secret-key',
'secret' => 'test-secret-key-with-32-bytes-minimum!!',
'algorithm' => 'HS256',
],
]);
Expand Down Expand Up @@ -530,4 +530,19 @@ public function testAddDiscoveryHeaderWithOptionalParametersDelegatesToService()
$mercureHeader = array_filter($linkHeaders, fn(string $h): bool => str_contains($h, 'rel="mercure"'));
$this->assertNotEmpty($mercureHeader);
}

/**
* Test getInstance wraps invalid JWT secret length in MercureException
*/
public function testGetInstanceThrowsMercureExceptionWhenJwtSecretTooShort(): void
{
Configure::write('Mercure.jwt.secret', 'too-short-secret');
Configure::write('Mercure.jwt.algorithm', 'HS256');
Authorization::clear();

$this->expectException(MercureException::class);
$this->expectExceptionMessage('JWT secret is too short for HS256');

Authorization::create();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ protected function setUp(): void
'url' => 'https://mercure.example.com/.well-known/mercure',
'public_url' => 'https://public.mercure.example.com/.well-known/mercure',
'jwt' => [
'secret' => 'test-secret-key',
'secret' => 'test-secret-key-with-32-bytes-minimum!!',
'algorithm' => 'HS256',
],
'cookie' => [
Expand Down
26 changes: 13 additions & 13 deletions tests/TestCase/Http/Middleware/MercureDiscoveryMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ protected function setUp(): void
'url' => 'https://mercure.example.com/.well-known/mercure',
'public_url' => 'https://public.mercure.example.com/.well-known/mercure',
'jwt' => [
'secret' => 'test-secret-key',
'secret' => 'test-secret-key-with-32-bytes-minimum!!',
'algorithm' => 'HS256',
],
]);
Expand Down Expand Up @@ -90,7 +90,7 @@ public function testMiddlewareUsesPublicUrl(): void
$request = new ServerRequest();
$response = new Response();

$handler = $this->createMock(RequestHandlerInterface::class);
$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$result = $this->middleware->process($request, $handler);
Expand All @@ -111,7 +111,7 @@ public function testMiddlewareFallsBackToUrl(): void
$request = new ServerRequest();
$response = new Response();

$handler = $this->createMock(RequestHandlerInterface::class);
$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$result = $this->middleware->process($request, $handler);
Expand All @@ -129,7 +129,7 @@ public function testMiddlewareThrowsExceptionWhenNoUrlConfigured(): void
'url' => '',
'public_url' => '',
'jwt' => [
'secret' => 'test-secret-key',
'secret' => 'test-secret-key-with-32-bytes-minimum!!',
'algorithm' => 'HS256',
],
]);
Expand All @@ -138,7 +138,7 @@ public function testMiddlewareThrowsExceptionWhenNoUrlConfigured(): void
$request = new ServerRequest();
$response = new Response();

$handler = $this->createMock(RequestHandlerInterface::class);
$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$this->expectException(MercureException::class);
Expand All @@ -156,7 +156,7 @@ public function testMiddlewarePreservesExistingHeaders(): void
$response = new Response();
$response = $response->withHeader('X-Custom-Header', 'custom-value');

$handler = $this->createMock(RequestHandlerInterface::class);
$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$result = $this->middleware->process($request, $handler);
Expand All @@ -174,7 +174,7 @@ public function testMiddlewareCanCombineWithExistingLinkHeaders(): void
$response = new Response();
$response = $response->withAddedHeader('Link', '<https://example.com/other>; rel="other"');

$handler = $this->createMock(RequestHandlerInterface::class);
$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$result = $this->middleware->process($request, $handler);
Expand All @@ -193,7 +193,7 @@ public function testMiddlewarePreservesResponseStatusCode(): void
$request = new ServerRequest();
$response = new Response(['status' => 201]);

$handler = $this->createMock(RequestHandlerInterface::class);
$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$result = $this->middleware->process($request, $handler);
Expand All @@ -211,7 +211,7 @@ public function testMiddlewarePreservesResponseBody(): void
$response = new Response();
$response->getBody()->write('Test response body');

$handler = $this->createMock(RequestHandlerInterface::class);
$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$result = $this->middleware->process($request, $handler);
Expand All @@ -233,7 +233,7 @@ public function testMiddlewareWorksWithDifferentRequestMethods(): void
$request = new ServerRequest(['environment' => ['REQUEST_METHOD' => $method]]);
$response = new Response();

$handler = $this->createMock(RequestHandlerInterface::class);
$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$result = $this->middleware->process($request, $handler);
Expand All @@ -251,9 +251,9 @@ public function testMiddlewareHandlesNonCakePHPResponseGracefully(): void
$request = new ServerRequest();

// Create a mock PSR-7 ResponseInterface that is NOT a CakePHP Response
$response = $this->createMock(ResponseInterface::class);
$response = $this->createStub(ResponseInterface::class);

$handler = $this->createMock(RequestHandlerInterface::class);
$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$result = $this->middleware->process($request, $handler);
Expand Down Expand Up @@ -284,7 +284,7 @@ public function testMiddlewareWithCookiesInResponse(): void
$response = new Response();
$response = Authorization::setCookie($response, ['/feeds/123']);

$handler = $this->createMock(RequestHandlerInterface::class);
$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$result = $this->middleware->process($request, $handler);
Expand Down
Loading