Demo Mercure Keycloak
Mercure 1.0 + Keycloak demo: RFC 9396 authorization_details tokens, all in Docker
#Mercure + Keycloak demo
Keycloak issues RFC 9068 JWT access tokens carrying an
RFC 9396 authorization_details claim (Rich Authorization
Requests). A Mercure 1.0 hub validates those tokens against Keycloak's JWKS
and enforces the claim on every subscription and every publication.
Everything runs in Docker. No key material is shared between the hub and the issuer: the hub only ever sees Keycloak's public keys.
#Run it
docker compose up --build
Then open http://localhost:3000 and log in as alice / alice. Open a private window and log in
as bob / bob.
Publish to https://demo.example.com/users/alice/notifications and only Alice's window receives it.
Publish to https://demo.example.com/announcements and both do. The hub decides, from the token
alone.
| Service | URL |
|---|---|
| Demo app | http://localhost:3000 |
| Hub | http://localhost:8080/.well-known/mercure |
| Keycloak | http://localhost:8081 (admin console: admin / admin) |
All three ports must be free before you start.
#The token
Keycloak mints this for Alice:
// header
{ "alg": "RS256", "typ": "at+jwt", "kid": "…" }
// payload (abridged)
{
"iss": "http://localhost:8081/realms/mercure",
"aud": ["http://localhost:8080/.well-known/mercure", "account"],
"sub": "3522b93b-967b-4c79-b05a-07da3fd1760c",
"exp": 1789559000,
"authorization_details": [
{
"type": "https://mercure.rocks/authorization-detail",
"actions": ["subscribe"],
"topics": [
{ "match": "https://demo.example.com/users/alice/notifications" },
{ "match": "https://demo.example.com/announcements" },
],
"payload": { "user": "https://demo.example.com/users/alice" },
},
],
}
Inspect yours on the demo page: it prints the claim the hub is about to enforce.
#How Keycloak is configured
Everything lives in keycloak/realm-mercure.json, imported on
startup. Four pieces matter.
#1. The at+jwt header type
Mercure rejects tokens that are not JWT access tokens. Keycloak emits typ: JWT unless the client
opts in:
"attributes": { "access.token.header.type.rfc9068": "true" }
In the admin console: Clients → client → Advanced → Fine grain OpenID Connect configuration → Use "at+jwt" as access token header type.
#2. The audience
The aud claim must contain the hub's resource identifier. An oidc-audience-mapper adds it:
{ "protocolMapper": "oidc-audience-mapper", "config": { "included.custom.audience": "http://localhost:8080/.well-known/mercure" } }
Keycloak also adds account; the hub only requires that its own identifier be present.
#3. Per-user grants
Keycloak has no native Rich Authorization Requests support, so the grants are stored per user and
mapped into the claim. The trick is jsonType.label: JSON, which makes Keycloak parse the stored
attribute as JSON instead of emitting it as a string:
{ "protocolMapper": "oidc-usermodel-attribute-mapper", "config": { "user.attribute": "authorization_details", "claim.name": "authorization_details", "jsonType.label": "JSON", "access.token.claim": "true" } }
The realm declares authorization_details as an admin-only user profile attribute, so it can be
edited in Users → user → Attributes but never by the user.
#4. The publisher
The demo app publishes with its own service account (client_credentials), whose grants are the
same for every request and so come from a hardcoded claim mapper:
{ "protocolMapper": "oidc-hardcoded-claim-mapper", "config": { "claim.name": "authorization_details", "claim.value": "[{\"type\":\"https://mercure.rocks/authorization-detail\",\"actions\":[\"publish\"],\"topics\":[{\"match\":\"https://demo.example.com/users/:user/notifications\",\"match_type\":\"urlpattern\"},{\"match\":\"https://demo.example.com/announcements\"}]}]", "jsonType.label": "JSON", "access.token.claim": "true" } }
Publishing anywhere else returns 403 Bearer error="insufficient_scope", even though the token is
perfectly valid.
#How the hub is configured
Caddyfile, in full:
mercure {
issuer http://localhost:8081/realms/mercure {
authorization_server
publisher {
jwks_uri http://keycloak:8081/realms/mercure/protocol/openid-connect/certs RS256
}
subscriber {
jwks_uri http://keycloak:8081/realms/mercure/protocol/openid-connect/certs RS256
}
}
resource_identifier http://localhost:8080/.well-known/mercure
cors_origins http://localhost:3000
cookie_name mercure_access_token
}
The issuer identifier is the browser-facing Keycloak URL because that is what lands in iss; the
jwks_uri is the Compose-network address because that is what the hub dials. authorization_server
advertises Keycloak in the hub's RFC 9728 metadata:
curl http://localhost:8080/.well-known/oauth-protected-resource/.well-known/mercure
{ "resource": "http://localhost:8080/.well-known/mercure", "bearer_methods_supported": ["header"], "authorization_servers": ["http://localhost:8081/realms/mercure"], "authorization_details_types_supported": ["https://mercure.rocks/authorization-detail"], "mercure_cookie": "mercure_access_token" }
#The app
A single-file Go server (app/main.go):
- runs the authorization code flow with PKCE against Keycloak;
- stores the access token in the cookie the hub reads, which the browser also sends to the hub's origin because cookies are scoped by host and ignore the port;
- builds the SSE URL from the token's own claim, so the browser asks for exactly what was granted and nothing more;
- publishes server-side with the service account token.
#What this demo is not
Development settings that must change in production:
- Plain HTTP. Mercure requires HTTPS for any non-anonymous request. Behind TLS, drop
cookie_nameand use the default__Secure-mercure_access_token, and set the cookie'sSecureattribute. - Secrets in
compose.yaml. Client secrets and the Keycloak admin password are literals here. start-dev. Keycloak runs on an in-memory database; the realm is re-imported on every start.
Use Chrome or Firefox: Keycloak marks its session cookies Secure even over HTTP, which both
browsers accept on localhost. Safari may not.
#Layout
compose.yaml three services Caddyfile the Mercure hub keycloak/realm-mercure.json realm, clients, mappers, users app/main.go the demo application app/index.html its single page