From d051c66ed48b921deef9826a11adb71ac53038f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 17:27:51 +0200 Subject: [PATCH 01/12] First release candidate for CodeIgniter SSE. This version establishes the public package API, broker contracts, browser client, Redis transport, Mercure transport, security model, operational tooling, and documentation set intended for the initial stable `1.0.0` release. --- CHANGELOG.md | 299 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 286 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff2364..bb88d2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,291 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [v1.0.0-rc] - 2026-08-01 + +First release candidate for CodeIgniter SSE. This version establishes the +public package API, broker contracts, browser client, Redis transport, Mercure +transport, security model, operational tooling, and documentation set intended +for the initial stable `1.0.0` release. + +### Release highlights + +- Provides a framework-native CodeIgniter 4 API for publishing Server-Sent + Events through `sse()->publish(...)`. +- Supports two production transports out of the box: + - Redis Pub/Sub for direct PHP SSE streaming. + - Mercure Hub for high-concurrency deployments where long-lived browser + connections should be handled outside PHP-FPM. +- Keeps application publishing code independent of the selected transport. + Applications can start with Redis and switch to Mercure without rewriting + domain publishing calls. +- Ships a dependency-free browser ES module with broker-specific adapters, + named event handlers, lifecycle status events, JSON parsing, channel + management, and Mercure authorization refresh. +- Adds a secure default authorization model: only `public.*` channels are + allowed until the application provides explicit user and channel policy + implementations. + ### Added -- Initial CodeIgniter 4 package architecture for publishing and streaming - Server-Sent Events through Redis Pub/Sub. -- Channel authorization and authenticated-user resolution contracts. -- Streaming response support for supported CodeIgniter releases. -- Framework-independent browser `SseClient` ES module with server-selected, - broker-neutral stream bootstrap. -- Redis subscriber health PINGs, bounded reconnects, payload/RESP safety - limits, and event-ID deduplication. -- Mercure 0.x Hub publisher, exact topic mapping, private subscriber JWT - authorization, direct browser transport, token refresh, and live integration - tests. -- Installation, configuration, security, deployment, testing, and upgrade - documentation. +#### Core package + +- Added `Maniaba\CodeIgniterSse\Sse` as the main application-facing service. +- Added `sse()` helper for concise publishing from controllers, services, + listeners, Spark commands, and queue workers. +- Added semantic event publishing by channel, event name, payload, and optional + event ID. +- Added `EventInterface`, `PublishableEventInterface`, and + `EventFactory` support for applications that prefer event objects over + direct channel/name/payload calls. +- Added versioned JSON event envelopes so brokers and browser clients receive a + stable payload shape. +- Added deterministic event IDs through `EventIdGeneratorInterface` and the + built-in UUIDv7 generator. +- Added strict event validation for names, payloads, IDs, and serialized + payload size. + +#### HTTP streaming and routing + +- Added automatic CodeIgniter route registration for `GET /sse`. +- Added configurable route path, route name, controller, method, filters, and + route options through `Config\Sse::$route`. +- Added direct SSE response handling for Redis/local-style brokers. +- Added response compatibility support for current and legacy CodeIgniter + response behavior. +- Added SSE frame encoding for event names, IDs, retry hints, comments, + multiline data, and JSON payloads. +- Added heartbeat comments while streams are idle. +- Added finite stream lifetimes through `maxConnectionSeconds` so PHP workers + can rotate and deployments can drain old streams. +- Added initial `sse.connected` system event with the authorized channel list. +- Added `sse.error` emission path for recoverable stream subscription failures. +- Added strict `Accept: text/event-stream` handling for direct PHP streams. +- Added JSON preflight handling for Mercure authorization requests. + +#### Broker architecture + +- Added broker contracts for publishing, subscribing, health checks, + subscription endpoints, broker adapters, and broker adapter factories. +- Added configurable broker map through `Config\Sse::$brokers`. +- Added built-in broker keys: + - `redis` + - `mercure` + - `memory` + - `null` +- Added custom broker extension points so applications can provide their own + adapters without changing the package's public publishing API. +- Added in-memory broker for isolated one-process tests. +- Added null broker for harmless publishing in tests or disabled delivery + scenarios. + +#### Redis transport + +- Added Redis Pub/Sub broker adapter as the default transport. +- Added internal RESP2 stream-socket Redis client; PhpRedis and Predis are not + required. +- Added separate Redis publisher and subscriber sockets. +- Added Redis connection options for scheme, host, port, ACL username/password, + database selection, connect/read timeouts, polling, PING interval, reconnect + attempts, reconnect delay, client name, and PHP stream context. +- Added TLS support through PHP stream context options. +- Added application-level `channelPrefix` isolation for physical Redis Pub/Sub + channels. +- Added explicit documentation that Redis Pub/Sub is global across numbered + Redis databases and must be isolated by prefix. +- Added Redis health checking through `php spark sse:health-check`. +- Added Redis subscriber health PINGs for half-open subscribed sockets. +- Added bounded reconnect behavior for publisher and subscriber transports. +- Added payload and RESP parser safety limits: + - maximum payload bytes; + - maximum RESP array elements; + - maximum RESP nesting depth. +- Added optional Redis pattern subscriptions with secure disabled-by-default + configuration. +- Added channel/event ID deduplication for overlapping exact and pattern + subscriptions. +- Added live Redis Pub/Sub integration tests using the repository Docker + service. + +#### Mercure transport + +- Added Mercure 0.x Hub broker adapter. +- Added Mercure publishing via HTTP POST while keeping the same + `sse()->publish(...)` API used by Redis. +- Added exact logical-channel to Mercure-topic mapping through configurable + `topicPrefix`. +- Added private Mercure updates by default. +- Added publisher JWT generation with HMAC algorithms. +- Added optional externally supplied publisher JWT support. +- Added subscriber JWT generation for browser authorization. +- Added topic-scoped `mercure.subscribe` claims so browsers receive only + approved topics. +- Added topic-scoped `mercure.publish` support for publisher credentials. +- Added configurable publisher and subscriber token TTLs. +- Added configurable Hub URLs: + - `hubUrl` for PHP/server-side publishing; + - `publicHubUrl` for browser-facing EventSource connections. +- Added secure Mercure cookie configuration for the HttpOnly subscriber token. +- Added local-development support for non-secure Mercure cookies. +- Added Mercure publish payload size limits. +- Added Mercure publish error handling with Hub status/body reporting. +- Added Mercure health-check validation for configuration and `ext-curl`. +- Added live Mercure publisher/subscriber integration test against the + development Hub. +- Documented Mercure as the recommended built-in transport for larger + environments and applications with many concurrent users. + +#### Browser client + +- Added dependency-free browser ES module under `resources/js`. +- Added npm package entry for `@maniaba/codeigniter4-sse-browser`. +- Added TypeScript declaration files for the browser client and adapters. +- Added `SseClient` wrapper around native `EventSource`. +- Added frontend adapters for: + - Redis; + - Mercure; + - direct EventSource usage; + - local broker semantics; + - in-memory broker semantics. +- Added named event listeners with `on()` and `off()`. +- Added global message handling. +- Added lifecycle status notifications for connection state. +- Added safe JSON parsing with raw payload and parse-error access. +- Added channel management through `subscribe()`, `unsubscribe()`, and + `setChannels()`. +- Added automatic EventSource reconnect behavior using server retry hints. +- Added explicit `connect()` and `close()` lifecycle methods. +- Added query parameter support for application metadata. +- Added credential configuration for cookie-authenticated EventSource + connections. +- Added Mercure authorization bootstrap: the client first calls the CodeIgniter + route for JSON authorization, then opens EventSource directly against the Hub. +- Added Mercure token refresh before subscriber authorization expires. +- Added Mercure stale authorization cancellation when channel lists change. +- Added test seams through custom `eventSourceFactory` and adapter fetch + factories. + +#### Channel security and authorization + +- Added strict logical channel validation: + - 1 to 200 bytes; + - dot-separated segments; + - ASCII alphanumeric segment starts; + - `_` and `-` inside segments; + - no whitespace, slashes, empty segments, or control characters. +- Added `Channel` value object and safe segment joining helpers. +- Added maximum channel count per browser connection. +- Added duplicate channel normalization. +- Added `ChannelAuthorizerInterface`. +- Added `UserResolverInterface`. +- Added default `PublicChannelAuthorizer`, which allows only `public.*`. +- Added default `NullUserResolver`, which resolves no authenticated user. +- Added optional `ShieldUserResolver` integration for CodeIgniter Shield. +- Added all-or-nothing channel authorization: if any requested channel is + denied, the connection is rejected instead of silently subscribing to a + subset. +- Added CORS origin allowlist and credential support for cross-origin + frontends. +- Documented secure cookie usage and why bearer tokens should not be placed in + SSE URLs. +- Added Mercure-specific authorization flow where approved channels become + exact topic selectors in an HttpOnly subscriber JWT. +- Added expanded Mercure authorization tests for public, user, tenant, project, + admin, duplicate, and denied channel combinations. + +#### Developer tooling and installation + +- Added `php spark sse:install` command to publish: + - application config; + - browser ES module; + - TypeScript declarations; + - broker adapter browser modules. +- Added `php spark sse:health-check` command for broker/config validation. +- Added CodeIgniter service definitions for package services. +- Added Composer package discovery integration for config, routes, services, + commands, and toolbar registration. +- Added CodeIgniter Debug Toolbar `SSE Events` collector for publish metadata. +- Added traceable publisher decorator used by the toolbar. +- Added coverage upload to Coveralls through GitHub Actions. +- Added CI checks for Composer validation/audit, PHPUnit, PHPStan, Rector, + coding standards, browser-client tests, documentation build, Redis + integration, and Mercure integration. + +#### Documentation + +- Added complete installation guide. +- Added quick-start guide. +- Added configuration reference for route, stream behavior, toolbar, broker, + Redis, Mercure, authorization, CORS, and credentials. +- Added Redis vs Mercure deployment guidance. +- Added Mercure Hub guide with Docker Compose, keys, cookies, CORS, TLS, + reverse proxy, replay, and health-check notes. +- Added browser client guide with imports, adapters, channels, named events, + system events, message shape, error handling, fallback behavior, and + TypeScript usage. +- Added channels and authorization guide with secure defaults, user resolver, + authorizer, dependencies, pattern subscriptions, and browser authentication. +- Added streaming/deployment guide for headers, heartbeats, Nginx, Apache, + PHP-FPM capacity, session locking, Redis networking, CDN/load balancers, and + larger deployments. +- Added custom broker guide for implementing adapter factories and endpoints. +- Added architecture, events, examples, testing, troubleshooting, upgrade, and + module-structure documentation. + +#### Testing + +- Added PHPUnit test suite for core event, broker, HTTP, authorization, + configuration, stream, support, helper, toolbar, and factory behavior. +- Added browser-client tests with Node's test runner. +- Added Redis integration tests gated by `SSE_REDIS_INTEGRATION=1`. +- Added Mercure integration tests gated by `SSE_MERCURE_INTEGRATION=1`. +- Added recording and fake test support utilities for deterministic broker and + stream assertions. +- Added Coveralls Clover coverage generation through PHPUnit. + +### Security + +- Private/user-specific channels are denied by default. +- Every requested channel is treated as untrusted browser input and must pass + syntax validation and application authorization. +- Channel authorization is required even when route authentication filters are + configured. +- Mercure subscriber JWTs are written to HttpOnly cookies and are not exposed + to JavaScript. +- Redis channel prefixes isolate applications sharing the same Redis instance. +- Debug Toolbar collector records publish metadata only and does not display + event payload values. + +### Operational notes + +- Redis Pub/Sub is live broadcast only. It does not store events, replay missed + messages, or guarantee delivery to disconnected clients. +- Business-critical state should remain in the application's database or another + durable system; SSE events should normally notify the browser that state + changed. +- Mercure can replay retained Hub history through `Last-Event-ID` when the Hub + transport is configured for history, but it is not a replacement for + authoritative application state. +- Redis is the simplest starting point for bounded concurrency. Mercure is + recommended when many users keep dashboards or notification streams open and + PHP-FPM workers should remain available for normal requests. + +### Requirements + +- PHP 8.2 or newer. +- CodeIgniter 4.7 or newer. +- `ext-json`. +- Redis server for the Redis adapter, or Mercure Hub for the Mercure adapter. +- `ext-curl` when using Mercure publishing. + +### Upgrade notes + +- This is the first release candidate. There are no migrations from an earlier + stable release. +- Applications should publish the config with `php spark sse:install`, review + `Config\Sse`, set a unique `channelPrefix`, and implement authorization before + using private channels. +- For production Redis deployments, configure proxy buffering, PHP-FPM + capacity, TLS/ACLs where needed, heartbeats, and finite stream lifetime. +- For production Mercure deployments, configure separate publisher/subscriber + keys, secure cookies, exact CORS origins, HTTPS, and a browser-facing Hub URL. From 0e68a5db967ffb00f1d53e6768922af71bafe7a4 Mon Sep 17 00:00:00 2001 From: maniaba <61078470+maniaba@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:43:03 +0200 Subject: [PATCH 02/12] Fix indentation in diagram in index.md Signed-off-by: maniaba <61078470+maniaba@users.noreply.github.com> --- docs/index.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index 7ee7967..5a939b0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,9 +5,9 @@ applications without coupling application code to broker sockets, Hub HTTP requests, or streaming details. ```text - ┌─ Redis Pub/Sub ── PHP SSE response ─┐ -Application publisher ──┤ ├─ EventSource - └─ Mercure Hub ──────────────────────┘ + ┌─ Redis Pub/Sub ── PHP SSE response ─┐ +Application publisher ──┤ ├─ EventSource + └─ Mercure Hub ───────────────────────┘ ``` ## What it provides From 0ceb496fc88741f56d89b3c6af2fa7451a360ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:36:10 +0200 Subject: [PATCH 03/12] Bump version to 1.0.0-rc js --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f318241..db98aa6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@maniaba/codeigniter4-sse-browser", - "version": "1.0.0", + "version": "1.0.0-rc", "description": "Dependency-free browser EventSource client for maniaba/codeigniter4-sse.", "license": "MIT", "author": { From 97dac1c022dcfff7629bf0886a6c6e683450ed55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:38:33 +0200 Subject: [PATCH 04/12] Improve Mercure JWT signing key validation and add tests --- docs/configuration.md | 7 +++-- docs/mercure.md | 11 +++---- src/Broker/Mercure/MercureJwtFactory.php | 9 ++++-- .../Mercure/MercureBrokerAdapterTest.php | 4 +-- tests/Broker/Mercure/MercureConfigTest.php | 10 +++---- .../Broker/Mercure/MercureJwtFactoryTest.php | 29 +++++++++++++++++-- tests/Broker/Mercure/MercurePublisherTest.php | 4 +-- .../MercureSubscriptionFactoryTest.php | 4 +-- tests/Config/ServicesTest.php | 4 +-- tests/HTTP/MercureAuthorizationTest.php | 4 +-- tests/HTTP/SseControllerTest.php | 4 +-- tests/HTTP/SubscriptionEndpointTest.php | 4 +-- 12 files changed, 62 insertions(+), 32 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 066c77c..b2a38fd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -298,9 +298,10 @@ For local plain HTTP only, also set: sse.mercure.cookie.secure = false ``` -Keep Hub signing keys out of source control. The package validates Mercure -configuration when the Mercure adapter factory builds the broker. The full key -reference and deployment guidance are in [Mercure Hub](mercure.md). +Keep Hub signing keys out of source control. HMAC JWT signing keys must be at +least 32 bytes. The package validates Mercure configuration when the Mercure +adapter factory builds the broker. The full key reference and deployment +guidance are in [Mercure Hub](mercure.md). ## Redis connection diff --git a/docs/mercure.md b/docs/mercure.md index e7a59ac..4f2962a 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -323,11 +323,12 @@ pod. Do not expose the entire Caddy admin API publicly. | `maxPayloadBytes` | `1048576` | Maximum serialized event size. | | `cookie` | secure Mercure defaults | Subscriber cookie attributes. | -The built-in JWT issuer supports HMAC algorithms. `publisherJwt` can contain a -token issued by an external system, but dynamic subscriber authorization still -requires the configured HMAC subscriber key. Applications using an external -OAuth/JWKS issuer should replace the authorization controller rather than -exposing signing keys to the browser. +The built-in JWT issuer supports HMAC algorithms and requires HMAC signing keys +to be at least 32 bytes. `publisherJwt` can contain a token issued by an +external system, but dynamic subscriber authorization still requires the +configured HMAC subscriber key. Applications using an external OAuth/JWKS issuer +should replace the authorization controller rather than exposing signing keys to +the browser. The adapter currently maps exact logical channels. Redis glob patterns are not accepted by the Mercure transport; pattern selectors remain a Redis adapter diff --git a/src/Broker/Mercure/MercureJwtFactory.php b/src/Broker/Mercure/MercureJwtFactory.php index 146a3ef..d4ce87e 100644 --- a/src/Broker/Mercure/MercureJwtFactory.php +++ b/src/Broker/Mercure/MercureJwtFactory.php @@ -9,7 +9,8 @@ final class MercureJwtFactory { - private const HASH_ALGORITHMS = [ + private const MINIMUM_KEY_BYTES = 32; + private const HASH_ALGORITHMS = [ 'HS256' => 'sha256', 'HS384' => 'sha384', 'HS512' => 'sha512', @@ -25,8 +26,10 @@ public function create( int $ttl = 300, ?int $issuedAt = null, ): string { - if ($key === '') { - throw new MercureConfigurationException('The Mercure JWT signing key must not be empty.'); + if (strlen($key) < self::MINIMUM_KEY_BYTES) { + throw new MercureConfigurationException( + sprintf('The Mercure JWT signing key must be at least %d bytes.', self::MINIMUM_KEY_BYTES), + ); } $hash = self::HASH_ALGORITHMS[$algorithm] ?? null; diff --git a/tests/Broker/Mercure/MercureBrokerAdapterTest.php b/tests/Broker/Mercure/MercureBrokerAdapterTest.php index acf88d9..9783b1e 100644 --- a/tests/Broker/Mercure/MercureBrokerAdapterTest.php +++ b/tests/Broker/Mercure/MercureBrokerAdapterTest.php @@ -95,8 +95,8 @@ private function config(): Sse $config->mercure = [ 'hubUrl' => 'http://mercure/.well-known/mercure', 'publicHubUrl' => 'https://example.test/.well-known/mercure', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', ]; return $config; diff --git a/tests/Broker/Mercure/MercureConfigTest.php b/tests/Broker/Mercure/MercureConfigTest.php index cc1e530..63b8d24 100644 --- a/tests/Broker/Mercure/MercureConfigTest.php +++ b/tests/Broker/Mercure/MercureConfigTest.php @@ -94,7 +94,7 @@ public static function provideRejectsInvalidMercureConfig(): iterable yield 'missing publisher credentials' => [ static function (Sse $config): void { $config->mercure = [ - 'subscriberKey' => 'subscriber-test-secret', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', ]; }, ]; @@ -102,7 +102,7 @@ static function (Sse $config): void { yield 'missing subscriber key' => [ static function (Sse $config): void { $config->mercure = [ - 'publisherKey' => 'publisher-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', ]; }, ]; @@ -112,7 +112,7 @@ static function (Sse $config): void { $config->mercure = [ 'private' => true, 'authorizeSubscribers' => false, - 'publisherKey' => 'publisher-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', ]; }, ]; @@ -120,8 +120,8 @@ static function (Sse $config): void { yield 'publisher selectors must be a list' => [ static function (Sse $config): void { $config->mercure = [ - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', 'publisherTopicSelectors' => '*', ]; }, diff --git a/tests/Broker/Mercure/MercureJwtFactoryTest.php b/tests/Broker/Mercure/MercureJwtFactoryTest.php index f98dc08..3bdc310 100644 --- a/tests/Broker/Mercure/MercureJwtFactoryTest.php +++ b/tests/Broker/Mercure/MercureJwtFactoryTest.php @@ -4,6 +4,7 @@ namespace Tests\Broker\Mercure; +use Maniaba\CodeIgniterSse\Broker\Mercure\Exception\MercureConfigurationException; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureJwtFactory; use PHPUnit\Framework\TestCase; @@ -12,11 +13,13 @@ */ final class MercureJwtFactoryTest extends TestCase { + private const SIGNING_KEY = 'test-secret-that-is-long-enough!'; + public function testCreatesSignedMercureClaimsWithExpiration(): void { $token = (new MercureJwtFactory())->create( ['subscribe' => ['urn:sse:users.42']], - 'test-secret-that-is-long-enough', + self::SIGNING_KEY, 'HS256', 600, 1_700_000_000, @@ -27,7 +30,7 @@ public function testCreatesSignedMercureClaimsWithExpiration(): void $expectedSignature = hash_hmac( 'sha256', $encodedHeader . '.' . $encodedClaims, - 'test-secret-that-is-long-enough', + self::SIGNING_KEY, true, ); @@ -44,6 +47,28 @@ public function testCreatesSignedMercureClaimsWithExpiration(): void ); } + public function testRejectsSigningKeysShorterThan32Bytes(): void + { + $this->expectException(MercureConfigurationException::class); + $this->expectExceptionMessage('The Mercure JWT signing key must be at least 32 bytes.'); + + (new MercureJwtFactory())->create( + ['subscribe' => ['urn:sse:users.42']], + str_repeat('a', 31), + ); + } + + public function testAllowsSigningKeyWith32Bytes(): void + { + $token = (new MercureJwtFactory())->create( + ['subscribe' => ['urn:sse:users.42']], + str_repeat('a', 32), + issuedAt: 1_700_000_000, + ); + + $this->assertCount(3, explode('.', $token)); + } + /** * @return array */ diff --git a/tests/Broker/Mercure/MercurePublisherTest.php b/tests/Broker/Mercure/MercurePublisherTest.php index cc39971..cba57fa 100644 --- a/tests/Broker/Mercure/MercurePublisherTest.php +++ b/tests/Broker/Mercure/MercurePublisherTest.php @@ -163,8 +163,8 @@ private function config(): Sse 'hubUrl' => 'http://mercure/.well-known/mercure', 'publicHubUrl' => 'https://example.test/.well-known/mercure', 'topicPrefix' => 'urn:example:sse:', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', 'cookie' => [ 'secure' => true, ], diff --git a/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php b/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php index 956454b..5d033e0 100644 --- a/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php +++ b/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php @@ -20,8 +20,8 @@ public function testSubscriberTokenIsRestrictedToRequestedTopics(): void $config->mercure = [ 'publicHubUrl' => 'https://example.test/.well-known/mercure', 'topicPrefix' => 'urn:example:sse:', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', 'subscriberTokenTtl' => 600, ]; diff --git a/tests/Config/ServicesTest.php b/tests/Config/ServicesTest.php index 730f266..fe9cad8 100644 --- a/tests/Config/ServicesTest.php +++ b/tests/Config/ServicesTest.php @@ -129,8 +129,8 @@ public function testMercurePublisherUsesTheConfiguredBrokerAdapter(): void $config = new Sse(); $config->broker = 'mercure'; $config->mercure = [ - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', ]; $publisher = Services::ssePublisher($config, false); diff --git a/tests/HTTP/MercureAuthorizationTest.php b/tests/HTTP/MercureAuthorizationTest.php index 5897b58..b545b44 100644 --- a/tests/HTTP/MercureAuthorizationTest.php +++ b/tests/HTTP/MercureAuthorizationTest.php @@ -361,8 +361,8 @@ private function mercureConfig(): Sse 'hubUrl' => 'http://mercure/.well-known/mercure', 'publicHubUrl' => 'https://example.test/.well-known/mercure', 'topicPrefix' => 'urn:example:sse:', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', 'cookie' => [ 'name' => 'mercureAuthorization', 'secure' => true, diff --git a/tests/HTTP/SseControllerTest.php b/tests/HTTP/SseControllerTest.php index 5dcd0ad..c44acae 100644 --- a/tests/HTTP/SseControllerTest.php +++ b/tests/HTTP/SseControllerTest.php @@ -132,8 +132,8 @@ public function testMercureRouteAuthorizesChannelsWithoutOpeningAPhpStream(): vo 'hubUrl' => 'http://mercure/.well-known/mercure', 'publicHubUrl' => 'https://example.test/.well-known/mercure', 'topicPrefix' => 'urn:example:sse:', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', 'cookie' => [ 'name' => 'mercureAuthorization', 'secure' => true, diff --git a/tests/HTTP/SubscriptionEndpointTest.php b/tests/HTTP/SubscriptionEndpointTest.php index f6e4560..4926980 100644 --- a/tests/HTTP/SubscriptionEndpointTest.php +++ b/tests/HTTP/SubscriptionEndpointTest.php @@ -305,8 +305,8 @@ private function mercureConfig(array $mercure = []): Sse 'hubUrl' => 'http://mercure/.well-known/mercure', 'publicHubUrl' => 'https://example.test/.well-known/mercure', 'topicPrefix' => 'urn:example:sse:', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', 'cookie' => [ 'name' => 'mercureAuthorization', 'secure' => true, From b3605fb738b94cfe44688b81a0abacf007e8913f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:40:56 +0200 Subject: [PATCH 05/12] Refactor JWT creation to include issuer, audience, subject, and unique ID claims, and add new tests --- docs/mercure.md | 11 ++--- src/Broker/Mercure/MercureJwtFactory.php | 38 ++++++++++++++++++ .../Broker/Mercure/MercureJwtFactoryTest.php | 40 +++++++++++++++++++ .../MercureSubscriptionFactoryTest.php | 4 ++ tests/HTTP/MercureAuthorizationTest.php | 4 ++ 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/docs/mercure.md b/docs/mercure.md index 4f2962a..b51c374 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -324,11 +324,12 @@ pod. Do not expose the entire Caddy admin API publicly. | `cookie` | secure Mercure defaults | Subscriber cookie attributes. | The built-in JWT issuer supports HMAC algorithms and requires HMAC signing keys -to be at least 32 bytes. `publisherJwt` can contain a token issued by an -external system, but dynamic subscriber authorization still requires the -configured HMAC subscriber key. Applications using an external OAuth/JWKS issuer -should replace the authorization controller rather than exposing signing keys to -the browser. +to be at least 32 bytes. Generated JWTs include `iss`, `aud`, `sub`, `jti`, +`iat`, and `exp` claims plus the Mercure scope. `publisherJwt` can contain a +token issued by an external system, but dynamic subscriber authorization still +requires the configured HMAC subscriber key. Applications using an external +OAuth/JWKS issuer should replace the authorization controller rather than +exposing signing keys to the browser. The adapter currently maps exact logical channels. Redis glob patterns are not accepted by the Mercure transport; pattern selectors remain a Redis adapter diff --git a/src/Broker/Mercure/MercureJwtFactory.php b/src/Broker/Mercure/MercureJwtFactory.php index d4ce87e..dab4db1 100644 --- a/src/Broker/Mercure/MercureJwtFactory.php +++ b/src/Broker/Mercure/MercureJwtFactory.php @@ -6,6 +6,7 @@ use JsonException; use Maniaba\CodeIgniterSse\Broker\Mercure\Exception\MercureConfigurationException; +use Random\RandomException; final class MercureJwtFactory { @@ -15,6 +16,10 @@ final class MercureJwtFactory 'HS384' => 'sha384', 'HS512' => 'sha512', ]; + private const ISSUER = 'maniaba/codeigniter4-sse'; + private const AUDIENCE = 'mercure'; + private const PUBLISHER_SUBJECT = 'mercure-publisher'; + private const SUBSCRIBER_SUBJECT = 'mercure-subscriber'; /** * @param array{publish?: list, subscribe?: list} $mercure @@ -47,6 +52,10 @@ public function create( $issuedAt ??= time(); $header = $this->encode(['alg' => $algorithm, 'typ' => 'JWT']); $claims = $this->encode([ + 'iss' => self::ISSUER, + 'aud' => self::AUDIENCE, + 'sub' => self::subject($mercure), + 'jti' => self::jwtId(), 'iat' => $issuedAt, 'exp' => $issuedAt + $ttl, 'mercure' => $mercure, @@ -75,6 +84,35 @@ private function encode(array $value): string } } + /** + * @param array{publish?: list, subscribe?: list} $mercure + */ + private static function subject(array $mercure): string + { + if (isset($mercure['publish']) && ! isset($mercure['subscribe'])) { + return self::PUBLISHER_SUBJECT; + } + + if (isset($mercure['subscribe']) && ! isset($mercure['publish'])) { + return self::SUBSCRIBER_SUBJECT; + } + + return 'mercure-token'; + } + + private static function jwtId(): string + { + try { + return bin2hex(random_bytes(16)); + } catch (RandomException $exception) { + throw new MercureConfigurationException( + 'The Mercure JWT ID could not be generated.', + 0, + $exception, + ); + } + } + private static function base64UrlEncode(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); diff --git a/tests/Broker/Mercure/MercureJwtFactoryTest.php b/tests/Broker/Mercure/MercureJwtFactoryTest.php index 3bdc310..00a6bd6 100644 --- a/tests/Broker/Mercure/MercureJwtFactoryTest.php +++ b/tests/Broker/Mercure/MercureJwtFactoryTest.php @@ -35,6 +35,10 @@ public function testCreatesSignedMercureClaimsWithExpiration(): void ); $this->assertSame(['alg' => 'HS256', 'typ' => 'JWT'], $header); + $this->assertSame('maniaba/codeigniter4-sse', $claims['iss']); + $this->assertSame('mercure', $claims['aud']); + $this->assertSame('mercure-subscriber', $claims['sub']); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $claims['jti']); $this->assertSame(1_700_000_000, $claims['iat']); $this->assertSame(1_700_000_600, $claims['exp']); $this->assertSame( @@ -47,6 +51,42 @@ public function testCreatesSignedMercureClaimsWithExpiration(): void ); } + public function testCreatesPublisherSubjectForPublisherTokens(): void + { + $token = (new MercureJwtFactory())->create( + ['publish' => ['*']], + self::SIGNING_KEY, + issuedAt: 1_700_000_000, + ); + [, $encodedClaims] = explode('.', $token); + $claims = $this->decode($encodedClaims); + + $this->assertSame('mercure-publisher', $claims['sub']); + } + + public function testCreatesUniqueJwtIds(): void + { + $factory = new MercureJwtFactory(); + $first = $factory->create( + ['subscribe' => ['urn:sse:users.42']], + self::SIGNING_KEY, + issuedAt: 1_700_000_000, + ); + $second = $factory->create( + ['subscribe' => ['urn:sse:users.42']], + self::SIGNING_KEY, + issuedAt: 1_700_000_000, + ); + + [, $firstClaims] = explode('.', $first); + [, $secondClaims] = explode('.', $second); + + $this->assertNotSame( + $this->decode($firstClaims)['jti'], + $this->decode($secondClaims)['jti'], + ); + } + public function testRejectsSigningKeysShorterThan32Bytes(): void { $this->expectException(MercureConfigurationException::class); diff --git a/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php b/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php index 5d033e0..f873ebe 100644 --- a/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php +++ b/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php @@ -42,6 +42,10 @@ public function testSubscriberTokenIsRestrictedToRequestedTopics(): void $this->assertCount(3, $parts); $claims = $this->decode($parts[1]); + $this->assertSame('maniaba/codeigniter4-sse', $claims['iss']); + $this->assertSame('mercure', $claims['aud']); + $this->assertSame('mercure-subscriber', $claims['sub']); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $claims['jti']); $this->assertSame( ['subscribe' => $subscription->topics], $claims['mercure'], diff --git a/tests/HTTP/MercureAuthorizationTest.php b/tests/HTTP/MercureAuthorizationTest.php index b545b44..fa84b93 100644 --- a/tests/HTTP/MercureAuthorizationTest.php +++ b/tests/HTTP/MercureAuthorizationTest.php @@ -298,6 +298,10 @@ private function assertMercureAuthorizationResponse( $this->assertCount(3, $tokenParts); $claims = $this->decodeJwtPayload($tokenParts[1]); + $this->assertSame('maniaba/codeigniter4-sse', $claims['iss']); + $this->assertSame('mercure', $claims['aud']); + $this->assertSame('mercure-subscriber', $claims['sub']); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $claims['jti']); $this->assertSame( ['subscribe' => $topics], $claims['mercure'], From 060762d937aa0fb510c0cf76d13319f4f45b2608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:44:31 +0200 Subject: [PATCH 06/12] Ensure `topicPrefix` is a literal absolute IRI prefix without wildcards or URI-template characters --- docs/configuration.md | 5 +++++ docs/mercure.md | 2 +- src/Broker/Mercure/MercureConfig.php | 25 +++++++++++++--------- tests/Broker/Mercure/MercureConfigTest.php | 12 +++++++++++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index b2a38fd..935a4fe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -285,6 +285,11 @@ public array $mercure = [ ]; ``` +Use a literal absolute IRI prefix such as `urn:herceg:sse:`. Prefixes +containing wildcard or URI-template characters, such as +`https://example.com/{topic}`, are rejected because they can broaden Mercure +topic selectors. + Nested `.env` overrides are supported: ```dotenv diff --git a/docs/mercure.md b/docs/mercure.md index b51c374..d6a98a2 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -306,7 +306,7 @@ pod. Do not expose the entire Caddy admin API publicly. |---|---|---| | `hubUrl` | `http://127.0.0.1:3000/.well-known/mercure` | Server-side publish URL. | | `publicHubUrl` | same local URL | Browser-facing subscription URL. | -| `topicPrefix` | `urn:codeigniter4-sse:` | Absolute IRI prefix added to logical channels. | +| `topicPrefix` | `urn:codeigniter4-sse:` | Literal absolute IRI prefix added to logical channels. Wildcard and URI-template characters are rejected. | | `private` | `true` | Mark published updates as private. | | `authorizeSubscribers` | `true` | Issue a topic-restricted subscriber JWT cookie. | | `publisherJwt` | `null` | Optional pre-generated publisher JWT. | diff --git a/src/Broker/Mercure/MercureConfig.php b/src/Broker/Mercure/MercureConfig.php index c8e33f7..0ebbf52 100644 --- a/src/Broker/Mercure/MercureConfig.php +++ b/src/Broker/Mercure/MercureConfig.php @@ -41,16 +41,7 @@ public function __construct( ) { $this->assertUrl($this->hubUrl, 'server-side Hub'); $this->assertUrl($this->publicHubUrl, 'public Hub'); - - if ( - $this->topicPrefix === '' - || strpbrk($this->topicPrefix, "\r\n\0") !== false - || preg_match('/^[A-Za-z][A-Za-z0-9+.-]*:/D', $this->topicPrefix) !== 1 - ) { - throw new MercureConfigurationException( - 'Mercure topicPrefix must be a non-empty absolute IRI prefix.', - ); - } + $this->assertTopicPrefix($this->topicPrefix); if ($this->publisherJwt === null && $this->publisherKey === null) { throw new MercureConfigurationException( @@ -141,6 +132,20 @@ public function __construct( } } + private function assertTopicPrefix(string $prefix): void + { + if ( + $prefix === '' + || strpbrk($prefix, "\r\n\0{}*?[]") !== false + || preg_match('/^[A-Za-z][A-Za-z0-9+.-]*:/D', $prefix) !== 1 + ) { + throw new MercureConfigurationException( + 'Mercure topicPrefix must be a literal absolute IRI prefix ' + . 'without wildcard or URI-template characters.', + ); + } + } + public function publisherToken(MercureJwtFactory $tokens, ?int $issuedAt = null): string { if ($this->publisherJwt !== null) { diff --git a/tests/Broker/Mercure/MercureConfigTest.php b/tests/Broker/Mercure/MercureConfigTest.php index 63b8d24..dda95e1 100644 --- a/tests/Broker/Mercure/MercureConfigTest.php +++ b/tests/Broker/Mercure/MercureConfigTest.php @@ -126,5 +126,17 @@ static function (Sse $config): void { ]; }, ]; + + foreach (['{', '}', '*', '?', '[', ']'] as $character) { + yield sprintf('topic prefix rejects "%s"', $character) => [ + static function (Sse $config) use ($character): void { + $config->mercure = [ + 'topicPrefix' => 'https://example.test/sse/' . $character . 'topic', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + } } } From c129abb6aee9ae6ced7d2ea0099bb48ff9248b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:47:36 +0200 Subject: [PATCH 07/12] Refactor ChannelRequestParser to reject oversized raw channels parameter before splitting --- docs/configuration.md | 2 +- docs/troubleshooting.md | 3 +- src/HTTP/ChannelRequestParser.php | 46 +++++++++++++++++++++---- tests/HTTP/ChannelRequestParserTest.php | 8 +++++ 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 935a4fe..9ba6f0e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -95,7 +95,7 @@ then connects directly to the authorized Hub URL. | `retryMilliseconds` | `3000` | SSE reconnect delay hint sent to the browser. | | `heartbeatInterval` | `15` | Seconds between heartbeat comments while idle. | | `maxConnectionSeconds` | `300` | Finite lifetime of one HTTP stream. | -| `maxChannelsPerConnection` | `20` | Maximum unique requested logical channels. | +| `maxChannelsPerConnection` | `20` | Maximum unique requested logical channels. The raw `channels` query input is also bounded from this value before splitting. | | `emitConnectedEvent` | `true` | Send `sse.connected` after opening the stream. | The browser automatically opens a new stream after the server reaches diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c475fe1..df722d3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -50,7 +50,8 @@ Valid requests include: /sse?channels[]=users.42&channels[]=orders.918 ``` -Check the channel syntax and `maxChannelsPerConnection`. Patterns are rejected +Check the channel syntax and `maxChannelsPerConnection`. Oversized raw +`channels` query input is rejected before splitting. Patterns are rejected unless explicitly enabled. ## The endpoint returns 403 diff --git a/src/HTTP/ChannelRequestParser.php b/src/HTTP/ChannelRequestParser.php index f8f1073..d184c56 100644 --- a/src/HTTP/ChannelRequestParser.php +++ b/src/HTTP/ChannelRequestParser.php @@ -10,6 +10,8 @@ final readonly class ChannelRequestParser { + private const MAXIMUM_CHANNEL_BYTES = 200; + public function __construct( private int $maximumChannels = 20, private ?ChannelSelectorValidatorInterface $validator = null, @@ -32,22 +34,25 @@ public function parse(array|string|null $input): array $values = is_array($input) ? $input : [$input]; $parts = []; + $bytes = 0; foreach ($values as $value) { if (! is_string($value)) { throw new InvalidChannelRequestException('The channels query parameter must contain strings.'); } - foreach (explode(',', $value) as $part) { - $part = trim($part); + $bytes += strlen($value); - if ($part !== '') { - $parts[] = $part; - } + if ($bytes > $this->maximumInputBytes()) { + throw new InvalidChannelRequestException( + sprintf('The channels query parameter must not exceed %d bytes.', $this->maximumInputBytes()), + ); } + + $this->parseValue($value, $parts); } - $parts = array_values(array_unique($parts)); + $parts = array_keys($parts); if ($parts === []) { throw new InvalidChannelRequestException('At least one channel is required.'); @@ -67,4 +72,33 @@ public function parse(array|string|null $input): array return $parts; } + + /** + * @param array $parts + */ + private function parseValue(string $value, array &$parts): void + { + $length = strlen($value); + $start = 0; + + for ($offset = 0; $offset <= $length; $offset++) { + if ($offset !== $length && $value[$offset] !== ',') { + continue; + } + + $part = trim(substr($value, $start, $offset - $start)); + + if ($part !== '') { + $parts[$part] = true; + } + + $start = $offset + 1; + } + } + + private function maximumInputBytes(): int + { + return $this->maximumChannels * self::MAXIMUM_CHANNEL_BYTES + + max(0, $this->maximumChannels - 1); + } } diff --git a/tests/HTTP/ChannelRequestParserTest.php b/tests/HTTP/ChannelRequestParserTest.php index 265c7d0..46b1c26 100644 --- a/tests/HTTP/ChannelRequestParserTest.php +++ b/tests/HTTP/ChannelRequestParserTest.php @@ -32,6 +32,14 @@ public function testChannelLimitIsEnforced(): void (new ChannelRequestParser(1))->parse('public.news,public.alerts'); } + public function testOversizedRawChannelsParameterIsRejectedBeforeSplitting(): void + { + $this->expectException(InvalidChannelRequestException::class); + $this->expectExceptionMessage('The channels query parameter must not exceed 200 bytes.'); + + (new ChannelRequestParser(1))->parse(str_repeat('public.news,', 20) . 'public.news'); + } + public function testPatternsAreOptIn(): void { $this->expectException(InvalidChannelException::class); From 4971dfa3eb6443bf3a2e10f6ea4d8977848fe83c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:52:50 +0200 Subject: [PATCH 08/12] Added default topic selector and global publisher selector options for Mercure configuration. Updated tests to reflect these changes. --- docs/configuration.md | 3 + docs/mercure.md | 6 +- src/Broker/Mercure/MercureConfig.php | 11 ++++ src/Broker/Mercure/MercureConfigFactory.php | 17 +++++- src/Config/Sse.php | 37 ++++++------ tests/Broker/Mercure/MercureConfigTest.php | 63 +++++++++++++++------ 6 files changed, 99 insertions(+), 38 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9ba6f0e..0d72562 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -273,6 +273,9 @@ public array $mercure = [ 'publisherKey' => null, 'subscriberKey' => null, + // Defaults to topicPrefix . '{channel}'. + 'publisherTopicSelectors' => null, + 'allowGlobalPublisherSelector' => false, 'cookie' => [ 'name' => 'mercureAuthorization', diff --git a/docs/mercure.md b/docs/mercure.md index d6a98a2..c392a77 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -98,6 +98,9 @@ final class Sse extends BaseSse 'publisherKey' => null, 'subscriberKey' => null, + // Defaults to topicPrefix . '{channel}'. + 'publisherTopicSelectors' => null, + 'allowGlobalPublisherSelector' => false, 'cookie' => [ 'name' => 'mercureAuthorization', @@ -316,7 +319,8 @@ pod. Do not expose the entire Caddy admin API publicly. | `subscriberAlgorithm` | `HS256` | Subscriber JWT algorithm: HS256, HS384, or HS512. | | `publisherTokenTtl` | `300` | Generated publisher token lifetime in seconds. | | `subscriberTokenTtl` | `3600` | Browser token lifetime in seconds. | -| `publisherTopicSelectors` | `['*']` | Topics the generated publisher JWT may publish. | +| `publisherTopicSelectors` | `null` | Topics the generated publisher JWT may publish. `null` becomes `topicPrefix . '{channel}'`. | +| `allowGlobalPublisherSelector` | `false` | Allows `publisherTopicSelectors` to contain `*`. Enable only for an intentionally global publisher. | | `connectTimeout` | `2.5` | Hub connection timeout in seconds. | | `timeout` | `5.0` | Complete publish request timeout in seconds. | | `verifyTls` | `true` | TLS verification flag or CA bundle path. | diff --git a/src/Broker/Mercure/MercureConfig.php b/src/Broker/Mercure/MercureConfig.php index 0ebbf52..7593b69 100644 --- a/src/Broker/Mercure/MercureConfig.php +++ b/src/Broker/Mercure/MercureConfig.php @@ -27,6 +27,7 @@ public function __construct( public int $publisherTokenTtl, public int $subscriberTokenTtl, public array $publisherTopicSelectors, + public bool $allowGlobalPublisherSelector, public float $connectTimeout, public float $timeout, public bool|string $verifyTls, @@ -82,6 +83,16 @@ public function __construct( ); } + if ( + in_array('*', $this->publisherTopicSelectors, true) + && ! $this->allowGlobalPublisherSelector + ) { + throw new MercureConfigurationException( + 'The global Mercure publisher selector "*" is disabled. ' + . 'Enable it explicitly only when required.', + ); + } + foreach ($this->publisherTopicSelectors as $selector) { if ($selector === '' || strpbrk($selector, "\r\n\0") !== false) { throw new MercureConfigurationException( diff --git a/src/Broker/Mercure/MercureConfigFactory.php b/src/Broker/Mercure/MercureConfigFactory.php index e65dab7..1c3f75c 100644 --- a/src/Broker/Mercure/MercureConfigFactory.php +++ b/src/Broker/Mercure/MercureConfigFactory.php @@ -26,7 +26,8 @@ public function create(Sse $config): MercureConfig subscriberAlgorithm: strtoupper((string) $mercure['subscriberAlgorithm']), publisherTokenTtl: (int) $mercure['publisherTokenTtl'], subscriberTokenTtl: (int) $mercure['subscriberTokenTtl'], - publisherTopicSelectors: self::stringList($mercure['publisherTopicSelectors'] ?? null), + publisherTopicSelectors: self::publisherTopicSelectors($mercure), + allowGlobalPublisherSelector: (bool) $mercure['allowGlobalPublisherSelector'], connectTimeout: (float) $mercure['connectTimeout'], timeout: (float) $mercure['timeout'], verifyTls: is_string($mercure['verifyTls']) @@ -48,6 +49,20 @@ private static function nullableString(mixed $value): ?string return is_string($value) && $value !== '' ? $value : null; } + /** + * @param array $mercure + * + * @return list + */ + private static function publisherTopicSelectors(array $mercure): array + { + if (($mercure['publisherTopicSelectors'] ?? null) === null) { + return [(string) $mercure['topicPrefix'] . '{channel}']; + } + + return self::stringList($mercure['publisherTopicSelectors']); + } + /** * @return list */ diff --git a/src/Config/Sse.php b/src/Config/Sse.php index de7df4e..3562a86 100644 --- a/src/Config/Sse.php +++ b/src/Config/Sse.php @@ -50,24 +50,25 @@ class Sse extends BaseConfig * @var array */ private const DEFAULT_MERCURE = [ - 'hubUrl' => 'http://127.0.0.1:3000/.well-known/mercure', - 'publicHubUrl' => 'http://127.0.0.1:3000/.well-known/mercure', - 'topicPrefix' => 'urn:codeigniter4-sse:', - 'private' => true, - 'authorizeSubscribers' => true, - 'publisherJwt' => null, - 'publisherKey' => null, - 'subscriberKey' => null, - 'publisherAlgorithm' => 'HS256', - 'subscriberAlgorithm' => 'HS256', - 'publisherTokenTtl' => 300, - 'subscriberTokenTtl' => 3600, - 'publisherTopicSelectors' => ['*'], - 'connectTimeout' => 2.5, - 'timeout' => 5.0, - 'verifyTls' => true, - 'maxPayloadBytes' => 1_048_576, - 'cookie' => [ + 'hubUrl' => 'http://127.0.0.1:3000/.well-known/mercure', + 'publicHubUrl' => 'http://127.0.0.1:3000/.well-known/mercure', + 'topicPrefix' => 'urn:codeigniter4-sse:', + 'private' => true, + 'authorizeSubscribers' => true, + 'publisherJwt' => null, + 'publisherKey' => null, + 'subscriberKey' => null, + 'publisherAlgorithm' => 'HS256', + 'subscriberAlgorithm' => 'HS256', + 'publisherTokenTtl' => 300, + 'subscriberTokenTtl' => 3600, + 'publisherTopicSelectors' => null, + 'allowGlobalPublisherSelector' => false, + 'connectTimeout' => 2.5, + 'timeout' => 5.0, + 'verifyTls' => true, + 'maxPayloadBytes' => 1_048_576, + 'cookie' => [ 'name' => 'mercureAuthorization', 'domain' => '', 'path' => '/.well-known/mercure', diff --git a/tests/Broker/Mercure/MercureConfigTest.php b/tests/Broker/Mercure/MercureConfigTest.php index dda95e1..281539c 100644 --- a/tests/Broker/Mercure/MercureConfigTest.php +++ b/tests/Broker/Mercure/MercureConfigTest.php @@ -20,24 +20,25 @@ public function testFactoryMapsMercureOptions(): void $config = new Sse(); $config->retryMilliseconds = 1250; $config->mercure = [ - 'hubUrl' => 'http://mercure/.well-known/mercure', - 'publicHubUrl' => 'https://example.test/.well-known/mercure', - 'topicPrefix' => 'urn:example:sse:', - 'private' => false, - 'authorizeSubscribers' => false, - 'publisherJwt' => 'static-publisher-token', - 'publisherKey' => null, - 'subscriberKey' => null, - 'publisherAlgorithm' => 'hs384', - 'subscriberAlgorithm' => 'hs512', - 'publisherTokenTtl' => 90, - 'subscriberTokenTtl' => 600, - 'publisherTopicSelectors' => ['*', 'users.*', 17], - 'connectTimeout' => 1.5, - 'timeout' => 4.5, - 'verifyTls' => '/etc/ssl/certs/ca.pem', - 'maxPayloadBytes' => 4096, - 'cookie' => [ + 'hubUrl' => 'http://mercure/.well-known/mercure', + 'publicHubUrl' => 'https://example.test/.well-known/mercure', + 'topicPrefix' => 'urn:example:sse:', + 'private' => false, + 'authorizeSubscribers' => false, + 'publisherJwt' => 'static-publisher-token', + 'publisherKey' => null, + 'subscriberKey' => null, + 'publisherAlgorithm' => 'hs384', + 'subscriberAlgorithm' => 'hs512', + 'publisherTokenTtl' => 90, + 'subscriberTokenTtl' => 600, + 'publisherTopicSelectors' => ['*', 'users.*', 17], + 'allowGlobalPublisherSelector' => true, + 'connectTimeout' => 1.5, + 'timeout' => 4.5, + 'verifyTls' => '/etc/ssl/certs/ca.pem', + 'maxPayloadBytes' => 4096, + 'cookie' => [ 'name' => 'mercureAuth', 'domain' => 'example.test', 'path' => '/mercure', @@ -62,6 +63,7 @@ public function testFactoryMapsMercureOptions(): void $this->assertSame(90, $mercure->publisherTokenTtl); $this->assertSame(600, $mercure->subscriberTokenTtl); $this->assertSame(['*', 'users.*'], $mercure->publisherTopicSelectors); + $this->assertTrue($mercure->allowGlobalPublisherSelector); $this->assertSame(1.5, $mercure->connectTimeout); $this->assertSame(4.5, $mercure->timeout); $this->assertSame('/etc/ssl/certs/ca.pem', $mercure->verifyTls); @@ -75,6 +77,21 @@ public function testFactoryMapsMercureOptions(): void $this->assertSame('Lax', $mercure->cookieSameSite); } + public function testDefaultsPublisherSelectorsFromTopicPrefix(): void + { + $config = new Sse(); + $config->mercure = [ + 'topicPrefix' => 'urn:herceg:sse:', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + + $mercure = (new MercureConfigFactory())->create($config); + + $this->assertSame(['urn:herceg:sse:{channel}'], $mercure->publisherTopicSelectors); + $this->assertFalse($mercure->allowGlobalPublisherSelector); + } + #[DataProvider('provideRejectsInvalidMercureConfig')] public function testRejectsInvalidMercureConfig(callable $configure): void { @@ -127,6 +144,16 @@ static function (Sse $config): void { }, ]; + yield 'global publisher selector requires opt in' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + 'publisherTopicSelectors' => ['*'], + ]; + }, + ]; + foreach (['{', '}', '*', '?', '[', ']'] as $character) { yield sprintf('topic prefix rejects "%s"', $character) => [ static function (Sse $config) use ($character): void { From a9f1545bac2a3c2c97a1e19d91e24ab84dff0150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:57:46 +0200 Subject: [PATCH 09/12] Add Mercure JWT codec implementation and tests --- docs/mercure.md | 12 +- src/Broker/Mercure/MercureConfig.php | 62 ++++++++ src/Broker/Mercure/MercureJwtCodec.php | 91 +++++++++++ src/Broker/Mercure/MercureJwtFactory.php | 36 +---- tests/Broker/Mercure/MercureConfigTest.php | 153 ++++++++++++++++++- tests/Broker/Mercure/MercureJwtCodecTest.php | 43 ++++++ 6 files changed, 358 insertions(+), 39 deletions(-) create mode 100644 src/Broker/Mercure/MercureJwtCodec.php create mode 100644 tests/Broker/Mercure/MercureJwtCodecTest.php diff --git a/docs/mercure.md b/docs/mercure.md index c392a77..0e1a787 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -330,10 +330,14 @@ pod. Do not expose the entire Caddy admin API publicly. The built-in JWT issuer supports HMAC algorithms and requires HMAC signing keys to be at least 32 bytes. Generated JWTs include `iss`, `aud`, `sub`, `jti`, `iat`, and `exp` claims plus the Mercure scope. `publisherJwt` can contain a -token issued by an external system, but dynamic subscriber authorization still -requires the configured HMAC subscriber key. Applications using an external -OAuth/JWKS issuer should replace the authorization controller rather than -exposing signing keys to the browser. +token issued by an external system. The package does not cryptographically +verify pre-generated publisher tokens, but it rejects malformed JWTs, unsupported +`alg` values, missing or expired `exp` claims, missing `mercure.publish` rights, +and publish selectors outside `topicPrefix` unless the global `*` selector was +explicitly enabled. Dynamic subscriber authorization still requires the +configured HMAC subscriber key. Applications using an external OAuth/JWKS issuer +should replace the authorization controller rather than exposing signing keys to +the browser. The adapter currently maps exact logical channels. Redis glob patterns are not accepted by the Mercure transport; pattern selectors remain a Redis adapter diff --git a/src/Broker/Mercure/MercureConfig.php b/src/Broker/Mercure/MercureConfig.php index 7593b69..0a90999 100644 --- a/src/Broker/Mercure/MercureConfig.php +++ b/src/Broker/Mercure/MercureConfig.php @@ -101,6 +101,10 @@ public function __construct( } } + if ($this->publisherJwt !== null) { + $this->assertPublisherJwt($this->publisherJwt); + } + if ($this->connectTimeout <= 0.0 || $this->timeout <= 0.0) { throw new MercureConfigurationException( 'Mercure HTTP timeouts must be greater than zero.', @@ -143,6 +147,64 @@ public function __construct( } } + private function assertPublisherJwt(string $jwt): void + { + $codec = new MercureJwtCodec(); + [$encodedHeader, $encodedClaims] = $codec->split($jwt, 'publisherJwt'); + $header = $codec->decodeJsonObjectSegment($encodedHeader, 'publisherJwt header'); + $claims = $codec->decodeJsonObjectSegment($encodedClaims, 'publisherJwt payload'); + + $algorithm = $header['alg'] ?? null; + + if (! is_string($algorithm) || ! in_array($algorithm, self::ALGORITHMS, true)) { + throw new MercureConfigurationException( + sprintf('Mercure publisherJwt alg must be one of %s.', implode(', ', self::ALGORITHMS)), + ); + } + + $expiresAt = $claims['exp'] ?? null; + + if (! is_int($expiresAt) && ! is_float($expiresAt)) { + throw new MercureConfigurationException('Mercure publisherJwt must contain an exp claim.'); + } + + if ($expiresAt <= time()) { + throw new MercureConfigurationException('Mercure publisherJwt has expired.'); + } + + $mercure = $claims['mercure'] ?? null; + $publish = is_array($mercure) ? ($mercure['publish'] ?? null) : null; + + if (! is_array($publish) || $publish === []) { + throw new MercureConfigurationException('Mercure publisherJwt must contain mercure.publish rights.'); + } + + foreach ($publish as $selector) { + if (! is_string($selector) || $selector === '' || strpbrk($selector, "\r\n\0") !== false) { + throw new MercureConfigurationException( + 'Mercure publisherJwt publish selectors must be non-empty single-line strings.', + ); + } + + if ($selector === '*') { + if (! $this->allowGlobalPublisherSelector) { + throw new MercureConfigurationException( + 'The global Mercure publisher selector "*" is disabled. ' + . 'Enable it explicitly only when required.', + ); + } + + continue; + } + + if (! str_starts_with($selector, $this->topicPrefix)) { + throw new MercureConfigurationException( + 'Mercure publisherJwt publish selectors must stay within topicPrefix.', + ); + } + } + } + private function assertTopicPrefix(string $prefix): void { if ( diff --git a/src/Broker/Mercure/MercureJwtCodec.php b/src/Broker/Mercure/MercureJwtCodec.php new file mode 100644 index 0000000..8e6d94e --- /dev/null +++ b/src/Broker/Mercure/MercureJwtCodec.php @@ -0,0 +1,91 @@ + $header + * @param array $claims + */ + public function unsigned(array $header, array $claims): string + { + return $this->encodeJsonObject($header, 'header') + . '.' . $this->encodeJsonObject($claims, 'payload'); + } + + /** + * @return array{0: string, 1: string, 2: string} + */ + public function split(string $jwt, string $label = 'JWT'): array + { + $parts = explode('.', $jwt); + + if (count($parts) !== 3 || in_array('', $parts, true)) { + throw new MercureConfigurationException( + sprintf('Mercure %s must be a compact JWT with three segments.', $label), + ); + } + + return [$parts[0], $parts[1], $parts[2]]; + } + + /** + * @return array + */ + public function decodeJsonObjectSegment(string $segment, string $label): array + { + $decoded = base64_decode( + strtr($segment . str_repeat('=', (4 - strlen($segment) % 4) % 4), '-_', '+/'), + true, + ); + + if ($decoded === false) { + throw new MercureConfigurationException(sprintf('Mercure JWT %s is not valid base64url.', $label)); + } + + try { + $value = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new MercureConfigurationException( + sprintf('Mercure JWT %s is not valid JSON.', $label), + 0, + $exception, + ); + } + + if (! is_array($value)) { + throw new MercureConfigurationException(sprintf('Mercure JWT %s must be a JSON object.', $label)); + } + + return $value; + } + + public function encodeBytes(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + /** + * @param array $value + */ + public function encodeJsonObject(array $value, string $label): string + { + try { + return $this->encodeBytes( + json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + ); + } catch (JsonException $exception) { + throw new MercureConfigurationException( + sprintf('Mercure JWT %s could not be encoded.', $label), + 0, + $exception, + ); + } + } +} diff --git a/src/Broker/Mercure/MercureJwtFactory.php b/src/Broker/Mercure/MercureJwtFactory.php index dab4db1..97c9b22 100644 --- a/src/Broker/Mercure/MercureJwtFactory.php +++ b/src/Broker/Mercure/MercureJwtFactory.php @@ -4,7 +4,6 @@ namespace Maniaba\CodeIgniterSse\Broker\Mercure; -use JsonException; use Maniaba\CodeIgniterSse\Broker\Mercure\Exception\MercureConfigurationException; use Random\RandomException; @@ -21,6 +20,11 @@ final class MercureJwtFactory private const PUBLISHER_SUBJECT = 'mercure-publisher'; private const SUBSCRIBER_SUBJECT = 'mercure-subscriber'; + public function __construct( + private readonly ?MercureJwtCodec $codec = null, + ) { + } + /** * @param array{publish?: list, subscribe?: list} $mercure */ @@ -50,8 +54,8 @@ public function create( } $issuedAt ??= time(); - $header = $this->encode(['alg' => $algorithm, 'typ' => 'JWT']); - $claims = $this->encode([ + $codec = $this->codec ?? new MercureJwtCodec(); + $unsigned = $codec->unsigned(['alg' => $algorithm, 'typ' => 'JWT'], [ 'iss' => self::ISSUER, 'aud' => self::AUDIENCE, 'sub' => self::subject($mercure), @@ -60,28 +64,9 @@ public function create( 'exp' => $issuedAt + $ttl, 'mercure' => $mercure, ]); - $unsigned = $header . '.' . $claims; $signature = hash_hmac($hash, $unsigned, $key, true); - return $unsigned . '.' . self::base64UrlEncode($signature); - } - - /** - * @param array $value - */ - private function encode(array $value): string - { - try { - return self::base64UrlEncode( - json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), - ); - } catch (JsonException $exception) { - throw new MercureConfigurationException( - 'The Mercure JWT claims could not be encoded.', - 0, - $exception, - ); - } + return $unsigned . '.' . $codec->encodeBytes($signature); } /** @@ -112,9 +97,4 @@ private static function jwtId(): string ); } } - - private static function base64UrlEncode(string $value): string - { - return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); - } } diff --git a/tests/Broker/Mercure/MercureConfigTest.php b/tests/Broker/Mercure/MercureConfigTest.php index 281539c..354ce60 100644 --- a/tests/Broker/Mercure/MercureConfigTest.php +++ b/tests/Broker/Mercure/MercureConfigTest.php @@ -6,6 +6,7 @@ use Maniaba\CodeIgniterSse\Broker\Mercure\Exception\MercureConfigurationException; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureConfigFactory; +use Maniaba\CodeIgniterSse\Broker\Mercure\MercureJwtCodec; use Maniaba\CodeIgniterSse\Config\Sse; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -20,12 +21,18 @@ public function testFactoryMapsMercureOptions(): void $config = new Sse(); $config->retryMilliseconds = 1250; $config->mercure = [ - 'hubUrl' => 'http://mercure/.well-known/mercure', - 'publicHubUrl' => 'https://example.test/.well-known/mercure', - 'topicPrefix' => 'urn:example:sse:', - 'private' => false, - 'authorizeSubscribers' => false, - 'publisherJwt' => 'static-publisher-token', + 'hubUrl' => 'http://mercure/.well-known/mercure', + 'publicHubUrl' => 'https://example.test/.well-known/mercure', + 'topicPrefix' => 'urn:example:sse:', + 'private' => false, + 'authorizeSubscribers' => false, + 'publisherJwt' => self::publisherJwt( + ['alg' => 'HS384', 'typ' => 'JWT'], + [ + 'exp' => time() + 3600, + 'mercure' => ['publish' => ['*']], + ], + ), 'publisherKey' => null, 'subscriberKey' => null, 'publisherAlgorithm' => 'hs384', @@ -55,7 +62,7 @@ public function testFactoryMapsMercureOptions(): void $this->assertSame('urn:example:sse:', $mercure->topicPrefix); $this->assertFalse($mercure->privateUpdates); $this->assertFalse($mercure->authorizeSubscribers); - $this->assertSame('static-publisher-token', $mercure->publisherJwt); + $this->assertIsString($mercure->publisherJwt); $this->assertNull($mercure->publisherKey); $this->assertNull($mercure->subscriberKey); $this->assertSame('HS384', $mercure->publisherAlgorithm); @@ -92,6 +99,29 @@ public function testDefaultsPublisherSelectorsFromTopicPrefix(): void $this->assertFalse($mercure->allowGlobalPublisherSelector); } + public function testAcceptsScopedPublisherJwt(): void + { + $jwt = self::publisherJwt( + ['alg' => 'HS256', 'typ' => 'JWT'], + [ + 'exp' => time() + 3600, + 'mercure' => ['publish' => ['urn:herceg:sse:{channel}']], + ], + ); + + $config = new Sse(); + $config->mercure = [ + 'topicPrefix' => 'urn:herceg:sse:', + 'publisherJwt' => $jwt, + 'publisherKey' => null, + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + + $mercure = (new MercureConfigFactory())->create($config); + + $this->assertSame($jwt, $mercure->publisherJwt); + } + #[DataProvider('provideRejectsInvalidMercureConfig')] public function testRejectsInvalidMercureConfig(callable $configure): void { @@ -154,6 +184,104 @@ static function (Sse $config): void { }, ]; + yield 'publisher jwt must have three segments' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => 'not-a-jwt', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + + yield 'publisher jwt rejects invalid algorithm' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => self::publisherJwt( + ['alg' => 'none'], + [ + 'exp' => time() + 3600, + 'mercure' => ['publish' => ['urn:codeigniter4-sse:{channel}']], + ], + ), + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + + yield 'publisher jwt requires exp' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => self::publisherJwt( + ['alg' => 'HS256'], + [ + 'mercure' => ['publish' => ['urn:codeigniter4-sse:{channel}']], + ], + ), + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + + yield 'publisher jwt rejects expired token' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => self::publisherJwt( + ['alg' => 'HS256'], + [ + 'exp' => time() - 1, + 'mercure' => ['publish' => ['urn:codeigniter4-sse:{channel}']], + ], + ), + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + + yield 'publisher jwt requires publish rights' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => self::publisherJwt( + ['alg' => 'HS256'], + [ + 'exp' => time() + 3600, + 'mercure' => ['subscribe' => ['urn:codeigniter4-sse:{channel}']], + ], + ), + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + + yield 'publisher jwt publish rights must stay inside topic prefix' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => self::publisherJwt( + ['alg' => 'HS256'], + [ + 'exp' => time() + 3600, + 'mercure' => ['publish' => ['urn:other:sse:{channel}']], + ], + ), + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + + yield 'publisher jwt global publish rights require opt in' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => self::publisherJwt( + ['alg' => 'HS256'], + [ + 'exp' => time() + 3600, + 'mercure' => ['publish' => ['*']], + ], + ), + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + foreach (['{', '}', '*', '?', '[', ']'] as $character) { yield sprintf('topic prefix rejects "%s"', $character) => [ static function (Sse $config) use ($character): void { @@ -166,4 +294,15 @@ static function (Sse $config) use ($character): void { ]; } } + + /** + * @param array $header + * @param array $claims + */ + private static function publisherJwt(array $header, array $claims): string + { + $codec = new MercureJwtCodec(); + + return $codec->unsigned($header, $claims) . '.signature'; + } } diff --git a/tests/Broker/Mercure/MercureJwtCodecTest.php b/tests/Broker/Mercure/MercureJwtCodecTest.php new file mode 100644 index 0000000..db5a44f --- /dev/null +++ b/tests/Broker/Mercure/MercureJwtCodecTest.php @@ -0,0 +1,43 @@ +unsigned( + ['alg' => 'HS256', 'typ' => 'JWT'], + ['exp' => 1_700_000_000, 'mercure' => ['publish' => ['urn:sse:{channel}']]], + ); + + [$header, $claims, $signature] = $codec->split($unsigned . '.signature'); + + $this->assertSame('signature', $signature); + $this->assertSame( + ['alg' => 'HS256', 'typ' => 'JWT'], + $codec->decodeJsonObjectSegment($header, 'header'), + ); + $this->assertSame( + ['exp' => 1_700_000_000, 'mercure' => ['publish' => ['urn:sse:{channel}']]], + $codec->decodeJsonObjectSegment($claims, 'payload'), + ); + } + + public function testRejectsNonCompactJwt(): void + { + $this->expectException(MercureConfigurationException::class); + + (new MercureJwtCodec())->split('not-a-jwt'); + } +} From 25a9282617f4bb8a4016dba7b17a5e39c8d2f2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 23:09:54 +0200 Subject: [PATCH 10/12] Add option to reject cross-site bootstrap requests in Mercure authorization --- docs/configuration.md | 7 ++ docs/mercure.md | 5 ++ .../Mercure/MercureSubscriptionEndpoint.php | 14 ++++ src/Config/Sse.php | 15 ++-- src/HTTP/SseController.php | 14 +++- tests/Config/SseConfigTest.php | 5 ++ tests/HTTP/SseControllerTest.php | 79 +++++++++++++++++++ 7 files changed, 128 insertions(+), 11 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 0d72562..35c9922 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -501,6 +501,7 @@ Both classes must implement their package contracts. See |---|---:|---| | `allowedOrigins` | `[]` | Exact cross-origin frontend origins. | | `withCredentials` | `true` | Allow credentialed CORS responses. | +| `rejectCrossSiteBootstrap` | `true` | Reject Mercure authorization bootstrap requests with `Sec-Fetch-Site: cross-site`. | Same-origin requests do not require an allowlist entry because browsers omit the cross-origin `Origin` case this policy is intended to control. @@ -518,3 +519,9 @@ public bool $withCredentials = true; When credentials are enabled, `*` is not a valid allowed origin. Cookie domain, `Secure`, and `SameSite` settings must also permit the browser to send the session cookie. + +`rejectCrossSiteBootstrap` adds a Fetch Metadata check for browsers that send +`Sec-Fetch-Site`. It is an extra defense for the Mercure authorization request +that sets the HttpOnly subscriber cookie; it does not replace CORS, session +authentication, or channel authorization. Disable it only for trusted legacy or +non-browser clients that cannot send Fetch Metadata headers. diff --git a/docs/mercure.md b/docs/mercure.md index 0e1a787..82343c5 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -275,6 +275,11 @@ For a cross-origin Hub: - serve both endpoints over HTTPS; - allow the Hub origin in Content Security Policy `connect-src`. +The CodeIgniter authorization route also rejects +`Sec-Fetch-Site: cross-site` bootstrap requests by default before issuing the +subscriber cookie. Set `rejectCrossSiteBootstrap = false` only for trusted +legacy or non-browser clients that cannot send Fetch Metadata headers. + An application on `app.example.com` can set `domain = '.example.com'` for a Hub on `hub.example.com`. An application cannot set a cookie for an unrelated site. Use a same-origin reverse proxy in that case. diff --git a/src/Broker/Mercure/MercureSubscriptionEndpoint.php b/src/Broker/Mercure/MercureSubscriptionEndpoint.php index 12dd40d..0e3ae43 100644 --- a/src/Broker/Mercure/MercureSubscriptionEndpoint.php +++ b/src/Broker/Mercure/MercureSubscriptionEndpoint.php @@ -10,6 +10,7 @@ use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorInterface; use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorProviderInterface; use Maniaba\CodeIgniterSse\Contracts\PreflightSubscriptionEndpointInterface; +use Maniaba\CodeIgniterSse\Exception\InvalidOriginException; use Maniaba\CodeIgniterSse\Factory\MercureSubscriptionFactory; use Maniaba\CodeIgniterSse\HTTP\AcceptHeaderNegotiator; use Maniaba\CodeIgniterSse\Support\ChannelNameValidator; @@ -61,6 +62,10 @@ public function respond( ResponseInterface $response, array $channels, ): ResponseInterface { + if ($this->config->rejectCrossSiteBootstrap) { + $this->assertSameSiteRequest($request); + } + $subscriptions = $this->subscriptions ?? new MercureSubscriptionFactory(mercure: $this->mercure); $subscription = $subscriptions->create($this->config, $channels); $mercure = $this->mercure ?? ($this->configs ?? new MercureConfigFactory())->create($this->config); @@ -100,4 +105,13 @@ public function respond( return $response; } + + private function assertSameSiteRequest(RequestInterface $request): void + { + if (strtolower($request->getHeaderLine('Sec-Fetch-Site')) === 'cross-site') { + throw new InvalidOriginException( + 'Cross-site SSE authorization requests are not allowed.', + ); + } + } } diff --git a/src/Config/Sse.php b/src/Config/Sse.php index 3562a86..a2bfafd 100644 --- a/src/Config/Sse.php +++ b/src/Config/Sse.php @@ -141,13 +141,14 @@ class Sse extends BaseConfig ], ]; - public string $channelPrefix = 'app:sse:'; - public int $retryMilliseconds = 3000; - public int $heartbeatInterval = 15; - public int $maxConnectionSeconds = 300; - public int $maxChannelsPerConnection = 20; - public bool $emitConnectedEvent = true; - public bool $requireAcceptHeader = true; + public string $channelPrefix = 'app:sse:'; + public int $retryMilliseconds = 3000; + public int $heartbeatInterval = 15; + public int $maxConnectionSeconds = 300; + public int $maxChannelsPerConnection = 20; + public bool $emitConnectedEvent = true; + public bool $requireAcceptHeader = true; + public bool $rejectCrossSiteBootstrap = true; /** * CodeIgniter Debug Toolbar publisher tracing. diff --git a/src/HTTP/SseController.php b/src/HTTP/SseController.php index 62f6611..f059f0f 100644 --- a/src/HTTP/SseController.php +++ b/src/HTTP/SseController.php @@ -59,10 +59,16 @@ public function stream(): ResponseInterface ); } - return $cors->apply( - $endpoint->respond($this->request, $this->response, $channels), - $origin, - ); + try { + $response = $endpoint->respond($this->request, $this->response, $channels); + } catch (InvalidOriginException $exception) { + return $cors->apply( + $this->error(403, 'origin_forbidden', $exception->getMessage()), + $origin, + ); + } + + return $cors->apply($response, $origin); } /** diff --git a/tests/Config/SseConfigTest.php b/tests/Config/SseConfigTest.php index 863ec91..0f877a0 100644 --- a/tests/Config/SseConfigTest.php +++ b/tests/Config/SseConfigTest.php @@ -14,6 +14,11 @@ */ final class SseConfigTest extends TestCase { + public function testRejectsCrossSiteBootstrapByDefault(): void + { + $this->assertTrue((new Sse())->rejectCrossSiteBootstrap); + } + /** * @param callable(Sse):void $configure */ diff --git a/tests/HTTP/SseControllerTest.php b/tests/HTTP/SseControllerTest.php index c44acae..eb89e03 100644 --- a/tests/HTTP/SseControllerTest.php +++ b/tests/HTTP/SseControllerTest.php @@ -155,6 +155,7 @@ public function testMercureRouteAuthorizesChannelsWithoutOpeningAPhpStream(): vo $superglobals->setGetArray(['channels' => 'public.news,public.status']); $request->removeHeader('Origin'); $request->removeHeader('Accept'); + $request->removeHeader('Sec-Fetch-Site'); try { FrameworkServices::injectMock( @@ -201,6 +202,26 @@ public function testMercureRouteAuthorizesChannelsWithoutOpeningAPhpStream(): vo } } + public function testMercureRouteRejectsCrossSiteBootstrapRequest(): void + { + $result = $this->mercureBootstrapResponse('cross-site'); + $body = $result->getBody(); + + $this->assertSame(403, $result->getStatusCode()); + $this->assertIsString($body); + $this->assertStringContainsString('origin_forbidden', $body); + $this->assertStringContainsString('Cross-site SSE authorization requests are not allowed.', $body); + $this->assertNull($result->getCookie('mercureAuthorization')); + } + + public function testMercureRouteAllowsCrossSiteBootstrapWhenFetchMetadataCheckIsDisabled(): void + { + $result = $this->mercureBootstrapResponse('cross-site', rejectCrossSiteBootstrap: false); + + $this->assertSame(200, $result->getStatusCode()); + $this->assertInstanceOf(Cookie::class, $result->getCookie('mercureAuthorization')); + } + private function controllerResponse( ?string $origin, ?string $accept, @@ -216,6 +237,7 @@ private function controllerResponse( $request->removeHeader('Origin'); $request->removeHeader('Accept'); + $request->removeHeader('Sec-Fetch-Site'); if ($origin !== null) { $request->setHeader('Origin', $origin); @@ -240,4 +262,61 @@ private function controllerResponse( } } } + + private function mercureBootstrapResponse( + ?string $secFetchSite, + bool $rejectCrossSiteBootstrap = true, + ): ResponseInterface { + $config = new Sse(); + $config->broker = 'mercure'; + $config->rejectCrossSiteBootstrap = $rejectCrossSiteBootstrap; + $config->mercure = [ + 'hubUrl' => 'http://mercure/.well-known/mercure', + 'publicHubUrl' => 'https://example.test/.well-known/mercure', + 'topicPrefix' => 'urn:example:sse:', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + 'cookie' => [ + 'name' => 'mercureAuthorization', + 'secure' => true, + 'httpOnly' => true, + 'sameSite' => 'Lax', + ], + ]; + $request = single_service('request'); + $response = single_service('response'); + $logger = service('logger'); + + $this->assertInstanceOf(RequestInterface::class, $request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertInstanceOf(LoggerInterface::class, $logger); + + $superglobals = service('superglobals'); + $this->assertInstanceOf(Superglobals::class, $superglobals); + $previousGet = $superglobals->getGetArray(); + $superglobals->setGetArray(['channels' => 'public.news']); + $request->removeHeader('Origin'); + $request->removeHeader('Accept'); + $request->removeHeader('Sec-Fetch-Site'); + + if ($secFetchSite !== null) { + $request->setHeader('Sec-Fetch-Site', $secFetchSite); + } + + try { + FrameworkServices::injectMock( + 'sseBrokerAdapter', + new BasicBrokerAdapter(endpoint: new MercureSubscriptionEndpoint($config)), + ); + + $controller = new SseController(); + $controller->initController($request, $response, $logger); + + return $controller->stream(); + } finally { + $superglobals->setGetArray($previousGet); + $request->removeHeader('Sec-Fetch-Site'); + FrameworkServices::resetSingle('sseBrokerAdapter'); + } + } } From c0b5356d1bf8df55782ebbf37e34357433306cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 23:19:34 +0200 Subject: [PATCH 11/12] Refactor Mercure route handling by removing redundant code and streamlining test setup --- tests/HTTP/SseControllerTest.php | 104 +++++++++---------------------- 1 file changed, 31 insertions(+), 73 deletions(-) diff --git a/tests/HTTP/SseControllerTest.php b/tests/HTTP/SseControllerTest.php index eb89e03..56b1c9e 100644 --- a/tests/HTTP/SseControllerTest.php +++ b/tests/HTTP/SseControllerTest.php @@ -126,80 +126,37 @@ public function testAuthorizedPublicChannelProducesAStreamingResponse(): void public function testMercureRouteAuthorizesChannelsWithoutOpeningAPhpStream(): void { - $config = new Sse(); - $config->broker = 'mercure'; - $config->mercure = [ - 'hubUrl' => 'http://mercure/.well-known/mercure', - 'publicHubUrl' => 'https://example.test/.well-known/mercure', - 'topicPrefix' => 'urn:example:sse:', - 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', - 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', - 'cookie' => [ - 'name' => 'mercureAuthorization', - 'secure' => true, - 'httpOnly' => true, - 'sameSite' => 'Lax', - ], - ]; - $request = single_service('request'); - $response = single_service('response'); - $logger = service('logger'); - - $this->assertInstanceOf(RequestInterface::class, $request); - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertInstanceOf(LoggerInterface::class, $logger); - - $superglobals = service('superglobals'); - $this->assertInstanceOf(Superglobals::class, $superglobals); - $previousGet = $superglobals->getGetArray(); - $superglobals->setGetArray(['channels' => 'public.news,public.status']); - $request->removeHeader('Origin'); - $request->removeHeader('Accept'); - $request->removeHeader('Sec-Fetch-Site'); - - try { - FrameworkServices::injectMock( - 'sseBrokerAdapter', - new BasicBrokerAdapter(endpoint: new MercureSubscriptionEndpoint($config)), - ); - - $controller = new SseController(); - $controller->initController($request, $response, $logger); - $result = $controller->stream(); - $body = $result->getBody(); + $result = $this->mercureBootstrapResponse(null, channels: 'public.news,public.status'); + $body = $result->getBody(); - $this->assertSame(200, $result->getStatusCode()); - $this->assertStringStartsWith('application/json', $result->getHeaderLine('Content-Type')); - $this->assertStringContainsString('private', $result->getHeaderLine('Cache-Control')); - $this->assertStringContainsString('no-store', $result->getHeaderLine('Cache-Control')); - $this->assertSame( - '; rel="mercure"', - $result->getHeaderLine('Link'), - ); - $this->assertIsString($body); - $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - $this->assertSame( - 'https://example.test/.well-known/mercure', - $decoded['hub'], - ); - $this->assertSame( - [ - 'urn:example:sse:public.news', - 'urn:example:sse:public.status', - ], - $decoded['topics'], - ); - $this->assertIsInt($decoded['expiresAt']); + $this->assertSame(200, $result->getStatusCode()); + $this->assertStringStartsWith('application/json', $result->getHeaderLine('Content-Type')); + $this->assertStringContainsString('private', $result->getHeaderLine('Cache-Control')); + $this->assertStringContainsString('no-store', $result->getHeaderLine('Cache-Control')); + $this->assertSame( + '; rel="mercure"', + $result->getHeaderLine('Link'), + ); + $this->assertIsString($body); + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame( + 'https://example.test/.well-known/mercure', + $decoded['hub'], + ); + $this->assertSame( + [ + 'urn:example:sse:public.news', + 'urn:example:sse:public.status', + ], + $decoded['topics'], + ); + $this->assertIsInt($decoded['expiresAt']); - $cookie = $result->getCookie('mercureAuthorization'); - $this->assertInstanceOf(Cookie::class, $cookie); - $this->assertTrue($cookie->isSecure()); - $this->assertTrue($cookie->isHTTPOnly()); - $this->assertSame('Lax', $cookie->getSameSite()); - } finally { - $superglobals->setGetArray($previousGet); - FrameworkServices::resetSingle('sseBrokerAdapter'); - } + $cookie = $result->getCookie('mercureAuthorization'); + $this->assertInstanceOf(Cookie::class, $cookie); + $this->assertTrue($cookie->isSecure()); + $this->assertTrue($cookie->isHTTPOnly()); + $this->assertSame('Lax', $cookie->getSameSite()); } public function testMercureRouteRejectsCrossSiteBootstrapRequest(): void @@ -266,6 +223,7 @@ private function controllerResponse( private function mercureBootstrapResponse( ?string $secFetchSite, bool $rejectCrossSiteBootstrap = true, + string $channels = 'public.news', ): ResponseInterface { $config = new Sse(); $config->broker = 'mercure'; @@ -294,7 +252,7 @@ private function mercureBootstrapResponse( $superglobals = service('superglobals'); $this->assertInstanceOf(Superglobals::class, $superglobals); $previousGet = $superglobals->getGetArray(); - $superglobals->setGetArray(['channels' => 'public.news']); + $superglobals->setGetArray(['channels' => $channels]); $request->removeHeader('Origin'); $request->removeHeader('Accept'); $request->removeHeader('Sec-Fetch-Site'); From 55388bca9da3a327f35a16ba9768145bd85bcb8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 23:21:29 +0200 Subject: [PATCH 12/12] chore: bump version to 1.0.0-rc2 Release candidate focused on Mercure hardening, JWT validation, safer request parsing, and test cleanup before the initial stable `1.0.0` release. --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb88d2a..1ff76e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,58 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [v1.0.0-rc2] - 2026-08-01 + +Second release candidate focused on Mercure hardening, JWT validation, safer +request parsing, and test cleanup before the initial stable `1.0.0` release. + +### Added + +- Added Mercure JWT standard claims for generated publisher and subscriber + tokens: `iss`, `aud`, `sub`, and unique `jti`. +- Added `MercureJwtCodec` for compact JWT splitting, base64url encoding, + unsigned JWT creation, and JSON header/payload decoding. +- Added structural validation for configured `publisherJwt` values: + - compact JWT format with three segments; + - supported HMAC `alg`; + - required and non-expired `exp`; + - required `mercure.publish` rights; + - publish selectors constrained to `topicPrefix` unless the global selector + is explicitly enabled. +- Added `allowGlobalPublisherSelector` Mercure option. The global publisher + selector `*` is now allowed only when explicitly opted in. +- Added `rejectCrossSiteBootstrap` option. Mercure authorization bootstrap + requests with `Sec-Fetch-Site: cross-site` are rejected by default before a + subscriber cookie is issued. + +### Changed + +- Changed the default Mercure publisher selector from global `*` to a scoped + selector derived from `topicPrefix`: `topicPrefix . '{channel}'`. +- Refactored Mercure JWT encoding/decoding into a dedicated codec used by both + generated JWT creation and configured publisher JWT validation. +- Refactored Mercure route tests to remove duplicated setup and share the + bootstrap response helper. + +### Security + +- Enforced a minimum 32-byte Mercure JWT HMAC signing key. +- Rejected Mercure `topicPrefix` values containing wildcard or URI-template + characters such as `{`, `}`, `*`, `?`, `[`, and `]`. +- Bounded the raw `channels` query input before parsing so oversized requests + are rejected before channel splitting/deduplication work. +- Kept unauthorized channel responses generic while logging server-side audit + metadata for denied channel authorization attempts. +- Added Fetch Metadata protection for Mercure subscriber authorization cookie + bootstrap requests. + +### Tests + +- Added and updated tests for Mercure JWT key length, standard JWT claims, JWT + codec behavior, configured publisher JWT validation, scoped publisher + selectors, `topicPrefix` safety, raw `channels` limits, and cross-site + bootstrap rejection. + ## [v1.0.0-rc] - 2026-08-01 First release candidate for CodeIgniter SSE. This version establishes the