Skip to content
Open
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
8 changes: 6 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@
"resend/resend-php": "^1.0",
"symfony/cache": "^7.4.0 || ^8.0.0",
"symfony/http-client": "^7.4.0 || ^8.0.0",
"symfony/mercure": "^0.8.0",
"symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0",
"symfony/translation": "^7.4.0 || ^8.0.0"
"symfony/translation": "^7.4.0 || ^8.0.0",
"web-token/jwt-library": "^4.1"
},
"replace": {
"illuminate/auth": "self.version",
Expand Down Expand Up @@ -177,8 +179,10 @@
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).",
"symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).",
"symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).",
"symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).",
"symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).",
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)."
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).",
"web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)."
},
"minimum-stability": "dev",
"prefer-stable": true,
Expand Down
280 changes: 280 additions & 0 deletions src/Illuminate/Broadcasting/BroadcastManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use GuzzleHttp\Client as GuzzleClient;
use Illuminate\Broadcasting\Broadcasters\AblyBroadcaster;
use Illuminate\Broadcasting\Broadcasters\LogBroadcaster;
use Illuminate\Broadcasting\Broadcasters\MercureBroadcaster;
use Illuminate\Broadcasting\Broadcasters\NullBroadcaster;
use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster;
use Illuminate\Broadcasting\Broadcasters\RedisBroadcaster;
Expand All @@ -28,6 +29,14 @@
use Pusher\Pusher;
use ReflectionException;
use RuntimeException;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Mercure\FrankenPhpHub;
use Symfony\Component\Mercure\Hub;
use Symfony\Component\Mercure\Jwt\DefaultClaimsTokenFactory;
use Symfony\Component\Mercure\Jwt\FactoryTokenProvider;
use Symfony\Component\Mercure\Jwt\Grant;
use Symfony\Component\Mercure\Jwt\WebTokenFactory;
use Symfony\Component\Mercure\ProtocolVersion;
use Throwable;

use function Illuminate\Support\enum_value;
Expand Down Expand Up @@ -436,6 +445,277 @@ protected function createLogDriver(array $config)
);
}

/**
* Create an instance of the driver.
*
* @param array $config

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might type the config array:

Suggested change
* @param array $config
* @param array{topic_prefix?: string, client_events?: bool, public_url?: string, url?: string, publish_expiration?: non-negative-int, publish_algorithm?: 'HS256'|'HS384'|'HS512'|'ES256'|'ES384'|'ES512'|'RS256'|'RS384'|'RS512'|'PS256'|'PS384'|'PS512'|'EdDSA', algorithm?: 'HS256'|'HS384'|'HS512'|'ES256'|'ES384'|'ES512'|'RS256'|'RS384'|'RS512'|'PS256'|'PS384'|'PS512'|'EdDSA', publish_passphrase?: string, passphrase?: string, client_options?: array{auth_basic?: string|array{0: string, 1?: string}, auth_bearer?: string, query?: string[], headers?: iterable|string[]|string[][], body?: array|string|resource|\Traversable|\Closure, json?: mixed, user_data?: mixed, max_redirects?: int, http_version?: string, base_uri?: string, buffer?: bool|resource|\Closure(array<string, list<string>> $headers): (bool|resource), on_progress: callable(int, int, array): mixed, resolve?: string[], proxy?: string, timeout?: float, max_duration?: float, max_connect_duration?: float, bindto?: string, verify_peer?: bool, verify_host?: bool, cafile?: string, capath?: string, local_cert?: string, local_pk?: string, passphrase?: string, ciphers?: string, peer_fingerprint?: string|array<string, string>, capture_peer_cert_chain?: bool, crypto_method?: 9|17|33|65, extra?: array{use_persistent_connections: bool}}, cookie_name: string, subscribe_expiration?: int, subscribe_secret?: string, secret?: string, subscribe_algorithm?: string, subscribe_passphrase?: string, encryption_key?: string, claims?: array{aud?: string, iss?: string, client_id?: string}} $config

or

Suggested change
* @param array $config
* @param array{
* topic_prefix?: string,
* client_events?: bool,
* public_url?: string,
* url?: string,
* publish_expiration?: non-negative-int,
* publish_algorithm?: 'HS256'|'HS384'|'HS512'|'ES256'|'ES384'|'ES512'|'RS256'|'RS384'|'RS512'|'PS256'|'PS384'|'PS512'|'EdDSA',
* algorithm?: 'HS256'|'HS384'|'HS512'|'ES256'|'ES384'|'ES512'|'RS256'|'RS384'|'RS512'|'PS256'|'PS384'|'PS512'|'EdDSA',
* publish_passphrase?: string,
* passphrase?: string,
* client_options?: array{
* auth_basic?: string|array{0: string, 1?: string},
* auth_bearer?: string,
* query?: string[],
* headers?: iterable|string[]|string[][],
* body?: array|string|resource|\Traversable|\Closure,
* json?: mixed,
* user_data?: mixed,
* max_redirects?: int,
* http_version?: string,
* base_uri?: string,
* buffer?: bool|resource|\Closure(array<string, list<string>> $headers): (bool|resource),
* on_progress: callable(int, int, array): mixed,
* resolve?: string[],
* proxy?: string,
* timeout?: float,
* max_duration?: float,
* max_connect_duration?: float,
* bindto?: string,
* verify_peer?: bool,
* verify_host?: bool,
* cafile?: string,
* capath?: string,
* local_cert?: string,
* local_pk?: string,
* passphrase?: string,
* ciphers?: string,
* peer_fingerprint?: string|array<string, string>,
* capture_peer_cert_chain?: bool,
* crypto_method?: 9|17|33|65,
* extra?: array{use_persistent_connections?: bool}
* },
* cookie_name: string,
* subscribe_expiration?: int,
* subscribe_secret?: string,
* secret?: string,
* subscribe_algorithm?: string,
* subscribe_passphrase?: string,
* encryption_key?: string,
* claims?: array{aud?: string, iss?: string, client_id?: string}
* } $config

This would have to go to the other methods as well

* @return \Illuminate\Contracts\Broadcasting\Broadcaster
*/
protected function createMercureDriver(array $config)
{
$expiration = $this->mercureSubscribeExpiration($config);

if ($expiration <= 0) {
throw new InvalidArgumentException('The Mercure "subscribe_expiration" configuration value must be a positive number of minutes.');
}

$hub = $this->mercure($config);

if ($hub->getFactory() === null) {
throw new InvalidArgumentException('The Mercure broadcasting connection requires a "secret" (or "subscribe_secret") configuration value.');
}

return new MercureBroadcaster(
$hub,
$expiration,
$this->mercureChannelEncrypter($config),
(string) (($config['topic_prefix'] ?? null) ?: 'https://laravel.alt/echo/'),
(bool) ($config['client_events'] ?? true),
);
}

/**
* Get a Mercure hub instance for the given configuration.
*
* The hub's token factory is the subscriber one, feeding the Mercure
* Authorization helper; the publish token is minted by the hub's
* token provider.
*
* @param array $config
* @return \Symfony\Component\Mercure\HubInterface
*/
public function mercure(array $config)
{
if (empty($config['url'])) {
return $this->frankenPhpMercure($config);
}

// 0 (the default) delegates the lifetime to the hub's token factory:
// "session.cookie_lifetime", or an hour when that setting is 0. A
// set value must survive the truncation to whole seconds, so a
// sub-second lifetime can't silently become the default one.
$publishExpiration = (int) (($config['publish_expiration'] ?? 0) * 60);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A publish_expiration under one minute truncates to 0 here and the < 0 guard passes, so WebTokenFactory reads it as the default lifetime (session.cookie_lifetime or one hour) while the subscribe side rejects the same truncation in createMercureDriver(). You could apply the same positive-seconds check when the value is set, and reserve 0 for the explicit default.


if ($publishExpiration < 0 || ($publishExpiration === 0 && ! empty($config['publish_expiration']))) {
throw new InvalidArgumentException('The Mercure "publish_expiration" configuration value must be a positive number of minutes, or 0 to use the default lifetime.');
}

$publishTokenFactory = new DefaultClaimsTokenFactory(
WebTokenFactory::fromSecret(
$this->mercureSecret($config, 'publish'),
$config['publish_algorithm'] ?? $config['algorithm'] ?? 'HS256',
$publishExpiration,
$config['publish_passphrase'] ?? $config['passphrase'] ?? '',
),
$this->mercurePublishClaims($config),
);

return new Hub(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cookie name, Secure flag and domain come from public_url (falling back to url) inside Authorization::createCookie() alone, so a plain http:// hub URL or an internal hostname such as https://mercure/.well-known/mercure resolves without error and then turns every broadcasting/auth call into a 500 (__Secure- cookie name over HTTP, or different second-level domain). You could validate those two conditions in createMercureDriver() so the failure points at the configuration.

$config['url'],
new CachingTokenProvider(new FactoryTokenProvider($publishTokenFactory, [new Grant([Grant::ACTION_PUBLISH], ['*'])])),
$this->mercureSubscribeFactory($config),
($config['public_url'] ?? null) ?: null,
empty($config['client_options']) ? null : HttpClient::create($config['client_options']),
($config['cookie_name'] ?? null) ?: null,
ProtocolVersion::V1,
);
}

/**
* Get a Mercure hub instance backed by FrankenPHP's built-in hub.
*
* FrankenPHP publishes in-process, so no publisher JWT, token provider
* or HTTP client is involved; only the subscriber token factory is.
*
* @param array $config
* @return \Symfony\Component\Mercure\HubInterface
*/
protected function frankenPhpMercure(array $config)
{
if (! function_exists('mercure_publish')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This holds on every FrankenPHP build: the nomercure build defines mercure_publish() as a stub that fails with error 3, and a build without the mercure directive defines it as well, so a missing url resolves without error and the first broadcast throws a bare RuntimeException('No Mercure hub configured').

I don't know if you want to handle this case or if we consider FrankenPHP without mercure an exotic config?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intended!

This tag is an exotic setup requiring a custom compilation of FrankenPHP.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright and I guess there's no easy way to not declare at all the function when nomercure is used? Just out of curiosity, I think the current solution can be good enough

throw new InvalidArgumentException('The Mercure broadcasting connection requires a "url" configuration value, unless the application is served by FrankenPHP with its built-in Mercure hub enabled.');
}

return new FrankenPhpHub(
($config['public_url'] ?? null) ?: '/.well-known/mercure',
$this->mercureSubscribeFactory($config),
($config['cookie_name'] ?? null) ?: null,
ProtocolVersion::V1,
);
}

/**
* Get the subscriber token (and cookie) lifetime in whole seconds.
*
* Shared by the driver and the subscriber factory so the cookie and the
* token it carries always expire together. Cast to int up front: a
* fractional value would otherwise truncate silently downstream, and a
* 0-second lifetime is treated by the hub as one hour.
*
* @param array $config
* @return int
*/
protected function mercureSubscribeExpiration(array $config)
{
return (int) (($config['subscribe_expiration'] ?? 5) * 60);
}

/**
* Get the subscriber token factory for the given Mercure configuration,
* or null when no subscriber secret is configured. The broadcast driver
* itself rejects a factory-less hub; publish-only hubs are only
* reachable through the public mercure() helper.
*
* @param array $config
* @return \Symfony\Component\Mercure\Jwt\TokenFactoryInterface|null
*/
protected function mercureSubscribeFactory(array $config)
{
if (empty($config['subscribe_secret']) && empty($config['secret'])) {
return null;
}

$claims = $this->mercureClaims($config);
// RFC 9068 requires "sub" unconditionally, even for a guest joining
// public-only channels; an authenticated user's identity overrides
// this default at call time (see MercureBroadcaster::makeAuthorizationCookie()).
$claims['sub'] = ($claims['sub'] ?? null) ?: 'anonymous';

return new DefaultClaimsTokenFactory(
WebTokenFactory::fromSecret(
$this->mercureSecret($config, 'subscribe'),
$config['subscribe_algorithm'] ?? $config['algorithm'] ?? 'HS256',
$this->mercureSubscribeExpiration($config),
$config['subscribe_passphrase'] ?? $config['passphrase'] ?? '',
),
$claims,
);
}

/**
* Get the end-to-end channel encrypter for the given Mercure
* configuration, or null when no "encryption_key" is configured
* (end-to-end encrypted channels then throw when used).
*
* Built eagerly so a missing "web-token/jwt-library" package or a
* malformed key surfaces at driver resolution rather than mid-broadcast.
Comment on lines +600 to +601

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe, here, too:

Suggested change
* Built eagerly so a missing "web-token/jwt-library" package or a
* malformed key surfaces at driver resolution rather than mid-broadcast.
* Built eagerly so a missing "[web-token/jwt-library](https://packagist.org/packages/web-token/jwt-library)"
* package or a malformed key surfaces at driver resolution rather
* than mid-broadcast.

*
* @param array $config
* @return \Illuminate\Broadcasting\MercureChannelEncrypter|null
*/
protected function mercureChannelEncrypter(array $config)
{
if (empty($config['encryption_key'])) {
return null;
}

$encodedKey = $config['encryption_key'];

// Support the framework's key convention (what "key:generate
// --show" produces), so an APP_KEY-style value works as-is.
if (str_starts_with($encodedKey, 'base64:')) {
$encodedKey = substr($encodedKey, 7);
}

$key = base64_decode($encodedKey, true);

if ($key === false || strlen($key) !== 32) {
throw new InvalidArgumentException('The Mercure "encryption_key" configuration value must be a base64-encoded 32-byte key. You may generate one with: php -r "echo base64_encode(random_bytes(32));"');
}

return new MercureChannelEncrypter($key);
}

/**
* Get the additional JWT claims for the publisher side of the given
* Mercure configuration.
*
* The publish token has no request/user to draw a "sub" from, so it
* defaults to the app's own "client_id" identity.
*
* @param array $config
* @return array
*/
protected function mercurePublishClaims(array $config)
{
$claims = $this->mercureClaims($config);

$claims['sub'] = ($claims['sub'] ?? null) ?: (($claims['client_id'] ?? null) ?: $config['url']);

return $claims;
}

/**
* Get the JWT secret for the given side ("subscribe" or "publish") of
* the given Mercure configuration.
*
* @param array $config
* @param string $side
* @return string
*/
protected function mercureSecret(array $config, string $side)
{
$secret = ($config[$side.'_secret'] ?? null) ?: ($config['secret'] ?? null);

if (empty($secret)) {
throw new InvalidArgumentException(sprintf(
'The Mercure broadcasting connection requires a "secret" (or "%s_secret") configuration value.', $side
));
}

$algorithm = $config[$side.'_algorithm'] ?? $config['algorithm'] ?? 'HS256';

// Enforced downstream with a bare "Invalid key length." at the first
// token mint; failing here instead points at the configuration.
$minimumLength = ['HS256' => 32, 'HS384' => 48, 'HS512' => 64][$algorithm] ?? 0;

if (strlen($secret) < $minimumLength) {
throw new InvalidArgumentException(sprintf(
'The Mercure "secret" (or "%s_secret") configuration value must be at least %d bytes long to sign %s tokens.',
$side, $minimumLength, $algorithm
));
}

return $secret;
}

/**
* Get the additional JWT claims for the given Mercure configuration.
*
* RFC 9068 access tokens require "iss", "aud", and "client_id", so each
* defaults to a sensible identifier when not set explicitly (an empty
* string, e.g. from an unset .env value, counts as not set).
Comment on lines +685 to +687

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

link again:

Suggested change
* RFC 9068 access tokens require "iss", "aud", and "client_id", so each
* defaults to a sensible identifier when not set explicitly (an empty
* string, e.g. from an unset .env value, counts as not set).
* [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) access tokens
* require "iss", "aud", and "client_id", so each defaults to a sensible
* identifier when not set explicitly (an empty string, e.g. from an
* unset .env value, counts as not set).

*
* @param array $config
* @return array
*/
protected function mercureClaims(array $config)
{
$claims = $config['claims'] ?? [];
$appUrl = $this->app['config']['app.url'] ?? null;

// A FrankenPHP hub has no "url": it falls back to the same public
// endpoint the browser subscribes to.
$url = ($config['url'] ?? null) ?: (($config['public_url'] ?? null) ?: '/.well-known/mercure');

if (empty($config['url']) && empty($config['public_url']) && $appUrl) {
// The default endpoint is root-relative, but the hub validates
// an absolute audience. Do not include the application's path.
$origin = parse_url($appUrl);

if (isset($origin['scheme'], $origin['host'])) {
$url = $origin['scheme'].'://'.$origin['host']
.(isset($origin['port']) ? ':'.$origin['port'] : '').$url;
}
}

$claims['aud'] = ($claims['aud'] ?? null) ?: (($config['public_url'] ?? null) ?: $url);
$claims['iss'] = ($claims['iss'] ?? null) ?: ($appUrl ?: $url);
$claims['client_id'] = ($claims['client_id'] ?? null) ?: $claims['iss'];

return $claims;
}

/**
* Create an instance of the driver.
*
Expand Down
Loading