Pular para o conteúdo
← Voltar para projetos

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_name and use the default __Secure-mercure_access_token, and set the cookie's Secure attribute.
  • 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

Nova versão disponível.