Skip to content
Merged

rc2 #11

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
351 changes: 338 additions & 13 deletions CHANGELOG.md

Large diffs are not rendered by default.

24 changes: 20 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -273,6 +273,9 @@ public array $mercure = [

'publisherKey' => null,
'subscriberKey' => null,
// Defaults to topicPrefix . '{channel}'.
'publisherTopicSelectors' => null,
'allowGlobalPublisherSelector' => false,

'cookie' => [
'name' => 'mercureAuthorization',
Expand All @@ -285,6 +288,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
Expand All @@ -298,9 +306,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

Expand Down Expand Up @@ -492,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.
Expand All @@ -509,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.
6 changes: 3 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 22 additions & 7 deletions docs/mercure.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ final class Sse extends BaseSse

'publisherKey' => null,
'subscriberKey' => null,
// Defaults to topicPrefix . '{channel}'.
'publisherTopicSelectors' => null,
'allowGlobalPublisherSelector' => false,

'cookie' => [
'name' => 'mercureAuthorization',
Expand Down Expand Up @@ -272,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.
Expand Down Expand Up @@ -306,7 +314,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. |
Expand All @@ -316,18 +324,25 @@ 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. |
| `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. 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. 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
Expand Down
3 changes: 2 additions & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
98 changes: 88 additions & 10 deletions src/Broker/Mercure/MercureConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -41,16 +42,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(
Expand Down Expand Up @@ -91,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(
Expand All @@ -99,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.',
Expand Down Expand Up @@ -141,6 +147,78 @@ 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 (
$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) {
Expand Down
17 changes: 16 additions & 1 deletion src/Broker/Mercure/MercureConfigFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -48,6 +49,20 @@ private static function nullableString(mixed $value): ?string
return is_string($value) && $value !== '' ? $value : null;
}

/**
* @param array<string, mixed> $mercure
*
* @return list<string>
*/
private static function publisherTopicSelectors(array $mercure): array
{
if (($mercure['publisherTopicSelectors'] ?? null) === null) {
return [(string) $mercure['topicPrefix'] . '{channel}'];
}

return self::stringList($mercure['publisherTopicSelectors']);
}

/**
* @return list<string>
*/
Expand Down
Loading
Loading