Sécurité · Mercure · Symfony

Mercure : mises à jour privées et autorisation par JWT

Qui peut publier, qui peut recevoir quoi : JWT signé côté Symfony, claim mercure, cookie mercureAuthorization sur un domaine commun, topics privés et sélecteurs par utilisateur.

Le modèle d’autorisation de Mercure

L’article précédent diffusait le statut d’un sinistre à quiconque connaissait le topic. Pour l’espace client d’Acme Assurances, c’est inacceptable : un assuré ne doit recevoir que ses propres dossiers. Mercure règle la question avec deux notions.

  • Mise à jour publique ou privée : une mise à jour publique est remise à tout abonné du topic ; une mise à jour privée seulement aux abonnés autorisés sur ce topic.
  • JWT : le hub ne gère ni utilisateurs ni sessions. Il vérifie la signature d’un jeton dont le claim mercure liste ce que le porteur peut publier et à quoi il peut s’abonner.
{
  "mercure": {
    "publish": ["*"]
  }
}

{
  "mercure": {
    "subscribe": [
      "https://espace.acme-assurances.example/assures/7/sinistres/{reference}",
      "https://espace.acme-assurances.example/assures/7/messages"
    ]
  },
  "exp": 1793091600
}

L’application Symfony, qui connaît l’utilisateur connecté, émet les jetons ; le hub se contente de les vérifier. Les deux partagent une clé (HMAC) ou une paire de clés (RSA, ECDSA).

Le JWT de publication

Seul le serveur publie. MercureBundle signe pour chaque appel un JWT autorisant les topics configurés :

# config/packages/mercure.yaml
mercure:
    hubs:
        default:
            url: '%env(MERCURE_URL)%'
            public_url: '%env(MERCURE_PUBLIC_URL)%'
            jwt:
                secret: '%env(MERCURE_JWT_SECRET)%'
                publish: ['*']
                subscribe: []

Ce secret ne quitte jamais le serveur. Si un navigateur doit déclencher une diffusion, il appelle une route Symfony qui contrôle les droits puis publie ; il ne reçoit jamais de jeton de publication.

Publier une mise à jour privée

Il suffit de marquer l’Update comme privé et de construire un topic qui contient l’identifiant du propriétaire :

$assure = $sinistre->assure;

$this->hub->publish(new Update(
    topics: sprintf(
        'https://espace.acme-assurances.example/assures/%d/sinistres/%s',
        $assure->id,
        $sinistre->reference,
    ),
    data: json_encode(['reference' => $sinistre->reference, 'statut' => $sinistre->statut], JSON_THROW_ON_ERROR),
    private: true,   // seuls les abonnés autorisés sur ce topic la reçoivent
    type: 'sinistre.statut',
));

Le hub ne remettra cette mise à jour qu’à un abonné dont le JWT contient un sélecteur correspondant au topic. Un abonné non autorisé reste connecté mais ne la voit jamais passer.

Le JWT d’abonnement en cookie

EventSource ne sait pas envoyer d’en-tête Authorization. Le protocole prévoit donc un cookie, mercureAuthorization, que le service Authorization du bundle pose sur la réponse de la page :

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Authorization;
use Symfony\Component\Routing\Attribute\Route;

final class EspaceClientController extends AbstractController
{
    #[Route('/mes-sinistres', methods: ['GET'])]
    public function index(Request $request, Authorization $authorization): Response
    {
        $assure = $this->getUser();
        $base   = 'https://espace.acme-assurances.example/assures/' . $assure->getId();

        // Cookie mercureAuthorization : JWT d'abonnement limité aux topics de cet assuré.
        $authorization->setCookie($request, [
            $base . '/sinistres/{reference}',
            $base . '/messages',
        ]);

        return $this->render('espace/sinistres.html.twig', ['base' => $base]);
    }
}
{# Variante sans code PHP : l'option subscribe pose le même cookie. #}
{% set topic = base ~ '/sinistres/{reference}' %}
<div data-controller="live"
     data-live-url-value="{{ mercure(topic, { subscribe: topic })|escape('html_attr') }}">

Le cookie est HttpOnly : le JavaScript de la page ne peut pas le lire, ce qui limite l’effet d’une faille XSS. Le hub accepte aussi le JWT en paramètre authorization de l’URL, à éviter : l’URL finit dans les journaux des proxys et l’historique du navigateur.

Domaines, CORS et cookie

Un cookie n’est envoyé qu’au domaine pour lequel il a été posé. Deux architectures fonctionnent :

  • Même domaine : le hub est servi sous espace.acme-assurances.example/.well-known/mercure, par FrankenPHP ou derrière le même proxy. C’est le cas le plus simple.
  • Sous-domaine commun : application sur espace., hub sur mercure. ; le cookie est posé pour acme-assurances.example, le hub autorise l’origine de l’application en CORS et le navigateur s’abonne avec withCredentials.
// Hub sur un autre sous-domaine : le cookie n'est envoyé qu'avec withCredentials.
const source = new EventSource(url, { withCredentials: true });
# Caddyfile du hub mercure.acme-assurances.example
mercure {
    publisher_jwt  {env.MERCURE_PUBLISHER_JWT_KEY}
    subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY}
    cors_origins   https://espace.acme-assurances.example
    # pas de directive « anonymous » : un abonné sans JWT est refusé
}

Un hub sur un domaine sans rapport avec l’application ne peut pas recevoir ce cookie : il faudrait alors un polyfill d’EventSource qui envoie l’en-tête Authorization.

Choisir les sélecteurs de topics

Un sélecteur est soit un topic exact, soit un gabarit d’URI, soit *. Le choix décide de ce qu’un jeton volé permettrait de lire :

ProfilSélecteurs d’abonnement
Assuré…/assures/7/sinistres/{reference}, …/assures/7/messages
Gestionnaire d’une agence…/agences/lyon/sinistres/{reference}
Supervision interne*, sur un hub qui n’est pas exposé publiquement

Une même mise à jour peut porter plusieurs topics : le changement de statut d’un sinistre est publié à la fois sur le topic de l’assuré et sur celui de son agence, et chacun le reçoit selon ses droits.

Bonnes pratiques

  • Une expiration courte pour le JWT d’abonnement, renouvelé à chaque page servie : un utilisateur déconnecté ou dont les droits changent cesse vite de recevoir.
  • Deux clés distinctes pour la publication et l’abonnement ; idéalement une paire asymétrique pour que le hub ne détienne que des clés publiques.
  • Rotation du secret planifiée : changer la clé côté hub et côté Symfony dans le même déploiement, les abonnés se reconnectent avec un nouveau cookie.
  • Aucune donnée sensible dans le topic : il apparaît dans l’URL d’abonnement. Un identifiant technique, pas un e-mail ni un numéro de contrat.
  • Privé par défaut : toute mise à jour qui concerne une personne est privée, même si le topic semble difficile à deviner.

Prochaine étape : alimenter ces mises à jour depuis un CRM, avec les événements Salesforce relayés par Mercure.

🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.