Keycloak Rar
RFC 9396 Rich Authorization Requests for Keycloak, with a Mercure profile
#Rich Authorization Requests for Keycloak
Keycloak can validate an RFC 9396 authorization_details
request, but it never puts the result in the access token. This provider closes that gap, so a
client can ask for a subset of what it is allowed and get a token carrying exactly that.
Without it, Keycloak can only stamp a fixed claim onto every token through a hardcoded or attribute-based mapper. With it, the authorization server does what RAR was designed for: the client states what it needs, the server validates it against policy, and the token carries the approved set.
It ships a profile for the Mercure protocol as a worked example. Serving a different resource server means writing a profile, not reading Keycloak internals for an afternoon.
Status: demo. Built against Keycloak 26.7.3 on
keycloak-server-spi-private, which carries no API stability guarantee. The RAR SPI was reworked between 26.4 and 26.7 and is still moving. Read What Keycloak does and does not do first.
#What it does
docker compose up --build -d ./demo.py
Narrowed to one of the two granted topics
-> [{"type":"https://mercure.rocks/authorization-detail","actions":["publish"],
"topics":[{"match":"https://demo.example.com/announcements","match_type":"exact"}]}]
Over-requesting bob's private topic
-> HTTP 400: {"error":"invalid_authorization_details",
"error_description":"... the request exceeds the grants of this subject"}
narrowed token -> users/alice/notifications 403 Bearer error="insufficient_scope"
The last line is the point: the client narrowed its own token, and the resource server enforces the narrowing. A leaked token is worth only what was asked for.
#Configuring grants
Grants live in an attribute holding a JSON array of authorization details, named by the profile
(mercure.authorization_details for Mercure). On a user for human subjects, on a client for
service accounts. The user attribute wins when both are set.
[ { "type": "https://mercure.rocks/authorization-detail", "actions": ["subscribe"], "topics": [ { "match": "https://demo.example.com/users/alice/notifications", "match_type": "exact" }, { "match": "https://demo.example.com/announcements", "match_type": "exact" } ], "payload": { "user": "https://demo.example.com/users/alice" } } ]
Declare the attribute in the realm's user profile with "permissions": {"view": ["admin"], "edit": ["admin"]}. A subject that can edit its own grants has no grants.
Then add the profile's mapper to the client, and set access.token.header.type.rfc9068 so Keycloak
stamps typ: at+jwt as RFC 9068 requires.
keycloak/realm-mercure-rar.json is a working example.
#How a request is narrowed
Every RFC 9396 §2.2 common data field is handled generically: locations, actions, datatypes,
privileges are covered when the requested values are a subset of the granted ones, and
identifier when it matches.
A field the request leaves out is not read as "any value", which would let an unconstrained request widen a constrained grant. It inherits the grant's value, so the issued detail is never broader than what was configured. Requesting nothing therefore yields the subject's full grants, which keeps existing clients working.
A profile adds rules for the members its own type defines. Mercure adds topic matcher containment,
deliberately literal: a requested matcher is covered by an identical one, or by the * wildcard.
Deciding whether one URL Pattern subsumes another is not something an authorization server should be
guessing at, so it does not try.
Mercure also takes payload from the grant rather than the request. It is surfaced to subscribers as
identity, so a client that could choose its own would be choosing who it is.
#Writing a profile
Implement AuthorizationDetailsProfile:
the type URI, the Java class its members deserialise to, and the attribute holding grants. Override
validate, covers and approved only where the common data fields are not enough.
public final class InvoiceProfile implements AuthorizationDetailsProfile<InvoiceDetail> {
public String type() { return "https://example.com/invoice"; }
public Class<InvoiceDetail> javaType() { return InvoiceDetail.class; }
public String grantAttribute() { return "invoice.authorization_details"; }
}
Then a factory and a mapper, both a handful of lines, registered in META-INF/services/ for
org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorFactory and
org.keycloak.protocol.ProtocolMapper. The Mercure profile is 140 lines including its data model;
see mercure/.
One type per factory. Keycloak keys its processor map on the provider id, so the set of types cannot come from configuration: each needs its own registered factory subclass. Several profiles coexist happily in one deployment, and each mapper appends to the claim rather than overwriting it, so one token can carry details for several resource servers.
#What Keycloak does and does not do
Keycloak ships a RAR SPI in org.keycloak.protocol.oidc.rar, but it was built for OpenID4VCI and
reaches only part of the way.
The claim never reaches the token. TokenManager puts the processor's output into the token
endpoint's JSON response body via AccessTokenResponse.setAuthorizationDetails. A resource server
only ever sees the signed JWT. So this is two pieces, not one:
| Piece | Job |
|---|---|
ProfileAuthorizationDetailsProcessor |
Validates the request against policy (the SPI Keycloak calls) |
AbstractProfileClaimMapper |
Writes the approved set into the access token as authorization_details |
The provider id has to be the detail type. AuthorizationDetailsProcessorManager keys its
processor map on ProviderFactory::getId rather than on getSupportedType(), so a factory whose id
does not equal the type is never found and every request is refused as an unsupported type.
Keycloak's own OID4VCI provider works around it the same way (PROVIDER_ID = OPENID_CREDENTIAL).
Converting a detail to a subtype also needs a parser registered in a JVM-global static map, which
the factory base does in init. Both are tracked in
keycloak#52827.
client_credentials never calls the SPI. It stores the raw parameter as a client note instead,
with the comment "to support custom protocol mappers using RAR until RAR is fully implemented"
(see keycloak#32488). The mapper therefore
validates that path itself, which has one visible consequence:
| Grant | Over-request |
|---|---|
authorization_code |
400 invalid_authorization_details |
client_credentials |
Entry dropped, a warning logged |
A protocol mapper cannot fail a token request, so the second row cannot be improved from outside Keycloak. The resulting token simply lacks the grant, and the resource server refuses the operation.
Keycloak reads authorization_details at the token endpoint, not the authorization endpoint, so
a client narrows at code exchange rather than at consent time.
#Building
The JAR needs Java 21 and Keycloak 26.7.3 on the classpath; both are handled by the
Dockerfile, which builds it and runs kc.sh build to register the providers.
docker build -t keycloak-rar .
To install into an existing Keycloak instead, drop target/keycloak-rar.jar into
/opt/keycloak/providers/ and re-run kc.sh build.
Retargeting another Keycloak version means changing keycloak.version in pom.xml and
the base image, then checking that AuthorizationDetailsProcessor still has the same shape. It is a
private SPI and it does move: 26.4 had a non-generic interface taking the whole array and reported
failures as a plain invalid_request, while 26.7 dispatches per member by type and returns the
RFC 9396 invalid_authorization_details error code with the reason attached. Do not expect a
version bump to be a no-op.
#Layout
src/main/java/dev/dunglas/keycloak/rar/ AuthorizationDetailsProfile.java what a resource server implements CommonDataFields.java RFC 9396 §2.2 containment and inheritance AuthorizationDetailsPolicy.java grant lookup, parsing, narrowing ProfileAuthorizationDetailsProcessor.java the SPI: validate the request AbstractProfileProcessorFactory.java registration, id-is-type, subtype parser AbstractProfileClaimMapper.java the bridge: claim into the token mercure/ the Mercure profile compose.yaml Keycloak with the provider, plus a real Mercure hub demo.py exercises every path above
#License
Apache-2.0, matching Keycloak. See LICENSE.