Pular para o conteúdo
← Voltar para projetos

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.

Nova versão disponível.