ADR-056: Mercure Header-Auth for Non-Browser SSE Clients
- Status: Accepted; amended - see the amendment of 28/09/2026 before Consequences
- Date: 2026-08-13
- Depends on: ADR-023 (Authentication Strategy), ADR-038 (Hide Forbidden as Not Found), ADR-053 (Mobile Strategy — Dedicated Native App)
Context and Problem Statement
Trip computations run asynchronously and stream their results to the client over
Mercure SSE. On the web, the browser subscribes to the hub with an HttpOnly
mercureAuthorization cookie: the per-trip subscriber JWT is minted by
App\Mercure\MercureTokenIssuer (HS256, claim mercure.subscribe: ["/trips/{id}"],
1 h TTL) and set as a cookie by App\Mercure\MercureSubscriberListener on the
trip create/access responses. Keeping the token in an HttpOnly cookie is
deliberate — it never reaches JavaScript, closing an XSS exfiltration path.
The native app (ADR-053) has the same live-trip requirement but cannot read
that cookie: React Native has no browser cookie jar, and its EventSource
polyfill (react-native-sse) subscribes with request headers, not cookies. The
spike (#1011, docs/mobile-mercure-auth.md) confirmed the hub itself is not
cookie-only — the embedded Caddy Mercure module (subscriber_jwt + anonymous)
authenticates a subscriber via the cookie or an Authorization: Bearer header
or an ?authorization= query parameter, verified against the live dev hub
with a plain curl and no cookie. So no hub change is needed. The gap is
delivery: the mobile client has no way to obtain the subscriber token, because
it is only ever handed out as the cookie.
Decision
Add a readable-body endpoint that delivers the same per-trip subscriber JWT to
any authenticated client, and have the native client present it as an
Authorization: Bearer header to the hub. The web's cookie posture is untouched.
- New resource
GET /trips/{id}/mercure-token.App\ApiResource\MercureToken(providerApp\State\MercureTokenProvider) returns{ token }in the response body. The token is minted by the sameMercureTokenIssuer, signed with the same HMAC secret, and scoped to/trips/{id}for 1 h — identical to the cookie token, just delivered through a channel a non-browser client can read. - Same object-level authorization as
/detail. The operation is guarded byis_granted('TRIP_VIEW', ...), so a non-owner is masked as 404, not 403 (ADR-038,HideForbiddenAsNotFoundListener). The token is only ever surfaced to a caller the voter already granted. - Header over query parameter on the client. React Native sends the token as
Authorization: Bearer <jwt>(supported byreact-native-sse's custom headers). The?authorization=query parameter is the documented fallback if a platform strips the header — it is redacted from Caddy access logs — but the header is preferred so the token stays out of URLs. - No client detection, no cookie for mobile. Consistent with the client- agnostic API (ADR-023): the backend does not sniff who is calling. The web keeps reading its HttpOnly cookie; the mobile client fetches the body token and holds it only in memory for the lifetime of the subscription.
Amendment, 28/09/2026 - Mercure 1.0 (#1298)
The hub moved to protocol 1.0. The decision stands (one token, two delivery channels, header on mobile), but several details above describe the 0.x hub:
- The token is an RFC 9068 access token, not a hand-rolled HS256 JWT.
MercureTokenIssuerno longer builds the claims itself: it asks the bundle's token factory (protocol_version: 1.0inapi/config/packages/mercure.php), which mintstyp: at+jwtwithiss/audand anauthorization_detailsgrant instead of themercure.subscribeclaim. Scope (/trips/{id}) and TTL (1 h) are unchanged. - The cookie is
__Secure-mercure_access_token, notmercureAuthorization. The__Secure-prefix is only accepted on a secure origin, sowithSecure(true)on the cookie is required, not defensive. It stays HttpOnly,SameSite=Strict, path/.well-known/mercure. - The hub trusts an
issuerblock, notsubscriber_jwt. The Caddyfile declaresissuer {$MERCURE_ISSUER}withpublisherandsubscriberkeys; the identifier must equal theissthe backend mints. ?authorization=is gone as a fallback. The 1.0 hub rejects an access token in the query string (RFC 9700), so theAuthorization: Bearerheader is the only channel besides the cookie. Caddy no longer has anything to redact: the access-log filter on that parameter was removed.- Subscriptions use
?match=, not?topic=:?match=/trips/{id}on both the PWA and the mobile client.
See Mobile Mercure auth for the current channel table.
Consequences
- The mobile app gets real-time trip updates with the same security properties as the web: a short-lived, per-trip, owner-scoped subscriber token, never exposed to a non-owner (masked 404).
- Two delivery channels for one token (cookie for the browser, body for everyone else). They share the issuer and secret, so there is no second token lifecycle to maintain — only a second way to read the same JWT.
- The earlier Capacitor-era
X-Mercure-Tokenresponse header (a WebView-specific hack, ADR-023) stays removed; this resource supersedes that need without reintroducing client detection. - The subscriber token remains short-lived (1 h). A subscription that outlives it must re-fetch — acceptable for the current session-length trips; token refresh on the live stream is a later concern if sessions get longer.
References
- ADR-023 — Authentication strategy (client-agnostic API)
- ADR-038 — Hide forbidden as not found
- ADR-053 — Mobile strategy (native app)
- Mobile Mercure auth — spike outcome (#1011)
App\ApiResource\MercureToken,App\State\MercureTokenProvider— the readable-body endpoint (#1019)