Skip to content

Expose role enrichment helper for cross-client authorization evaluation #49048

Description

@hakdogan

Description

Programmatic permission evaluation in Keycloak extensions follows a standard pattern: an extension receives a bearer token, constructs a KeycloakIdentity from it, and invokes AuthorizationProvider.evaluators() to evaluate permissions against role-based policies. Under certain configurations — specifically when a role-based policy references a client role defined in a different client than the resource server, and that client is not part of the requesting client's scope — this pattern yields authorization decisions inconsistent with the admin console's Authorization > Evaluate feature.

The admin console's PolicyEvaluationService.createIdentity() already compensates for this internally by projecting all of the user's role mappings onto the access token before constructing the identity. This logic is currently private. This enhancement promotes it to a public static helper, TokenIdentityEnricher.addAllUserRoles(AccessToken, UserModel), co-located with KeycloakIdentity in the services module, and refactors PolicyEvaluationService to delegate to the same helper.

Value Proposition

  • Extensions performing programmatic authorization evaluation gain a supported, public API for producing decisions consistent with Keycloak's own admin console, without replicating internal enrichment logic in their own code.
  • Eliminates duplication between PolicyEvaluationService and any downstream extension that needs the same scope-bypassing enrichment.
  • Cross-client role policies — a legitimate and common configuration — become reliable from extension code paths, not only from the admin console.

Goals

  • Introduce TokenIdentityEnricher.addAllUserRoles(AccessToken, UserModel) as a public static helper in org.keycloak.authorization.common.
  • Refactor PolicyEvaluationService.createIdentity() to delegate to the same helper, so the admin console and extensions share a single implementation of the enrichment loop.
  • Preserve backwards compatibility: no changes to existing KeycloakIdentity constructors or their externally observable behavior; PolicyEvaluationService's admin-API surface is unchanged.
  • Cover the new helper with unit tests (null-argument guards) and an integration test demonstrating admin-console parity end-to-end.

Non-Goals

  • Not introducing a higher-level KeycloakIdentity.fromUser(session, realm, userId) factory in this
    issue. See "Refinement from discussion KeycloakIdentity should support creating a user-model-bound identity for server-side authorization evaluation #46661" in Notes for the rationale; it remains an open option for a follow-up if an ergonomic one-line API is desired in addition to the helper.
  • Not modifying the synthetic-token-construction path used by PolicyEvaluationService for admin-console evaluation (authentication session, user session, and client session creation, plus TokenManager.createClientAccessToken()). That flow remains internal and unchanged.
  • Not addressing other OIDC scope-related limitations beyond role projection.

Discussion

#46661

Notes

Problem in detail

When a resource is protected by a role-based policy referencing a client role defined in a different client than the resource server, a KeycloakIdentity constructed from the bearer token can produce incorrect authorization decisions:

  1. A resource on client-a is protected by a policy requiring a client role from client-b.
  2. The user holds the client-b role.
  3. The token issued for client-a, however obtained, may not include client-b roles in resource_access, because OIDC scope configuration filters which clients appear there.
  4. new KeycloakIdentity(session, token) derives role attributes exclusively from the token's resource_access.
  5. Since client-b roles are absent, the kc.client.client-b.roles attribute is missing from the identity, and RolePolicyProvider evaluates to DENY.

Meanwhile, the admin console's Authorization > Evaluate feature correctly returns PERMIT for the same user, resource, and policy.

Root cause

PolicyEvaluationService.createIdentity() compensates for this internally: after constructing a token via
TokenManager.createClientAccessToken(), it injects all of the user's role mappings into the token. This ensures the identity reflects every role the user actually holds, regardless of token scope constraints.

This logic is currently private to PolicyEvaluationService and not exposed as a public API. Extension developers performing authorization evaluation must either replicate the same enrichment loop in their own code (duplicating Keycloak-internal logic) or accept incorrect DENY decisions for cross-client role policies.

Proposed API

package org.keycloak.authorization.common;

public final class TokenIdentityEnricher {
    public static void addAllUserRoles(AccessToken token, UserModel user) {
        // Realm roles → realm_access
        // Client roles → resource_access[clientId]
    }
}

Usage from an extension:

AccessToken token = Tokens.getAccessToken(session);
UserModel user = session.users().getUserById(realm, token.getSubject());
TokenIdentityEnricher.addAllUserRoles(token, user);
Identity identity = new KeycloakIdentity(token, session, realm);

PolicyEvaluationService.createIdentity() would delegate to the same helper, removing the duplicated implementation.

Refinement from discussion #46661

I previously opened discussion #46661 to surface this gap with the community. With no maintainer feedback over the ~2.5 months since, I'm promoting it to an issue with a refined proposal.

That original discussion suggested a higher-level KeycloakIdentity.fromUser(session, realm, userId) factory
mirroring the full behavior of PolicyEvaluationService.createIdentity(). While preparing the implementation, two distinct concerns emerged within that method:

  1. Synthetic token construction — creating an authentication session, user session, client session, and invoking TokenManager.createClientAccessToken(). This is required by the admin console scenario, where evaluation runs on behalf of an arbitrary user without a bearer token.
  2. Role enrichment — projecting all role mappings from UserModel.getRoleMappingsStream() onto the access token, bypassing OIDC scope filtering.

The cross-client role problem is caused exclusively by (2). Extensions performing authorization evaluation already hold a bearer access token; they need only the role enrichment, not the synthetic session machinery.

A fromUser() factory that performed both operations would:

  • Force every caller to incur user/auth session creation overhead even when a valid bearer token is already in scope.
  • Hide non-trivial side effects (writes to the user/auth session stores) behind a one-line API.
  • Couple a small public surface to the admin-console-specific token construction logic.

This issue therefore proposes the minimum public API needed to address the root cause: the TokenIdentityEnricher.addAllUserRoles static helper. A higher-level fromUser() factory remains an open option for a follow-up.

Verification plan

  • Unit (services/.../TokenIdentityEnricherTest): null-argument guards on addAllUserRoles.
  • Integration (testsuite/integration-arquillian/.../KeycloakIdentityCrossClientRoleTest): two scenarios against a realm with a role policy referencing a client role in a different client:
    • Admin console Authorization > Evaluate → PERMIT. Guards against regressions in the admin-console code path after the helper refactor.
    • Enriched token via TokenIdentityEnricher → PERMIT. Proves the helper produces the same outcome as the admin console when called explicitly on a token-bound identity.
  • Existing tests covering PolicyEvaluationService (e.g. PolicyEvaluationTest, PolicyEvaluationCompositeRoleTest) continue to pass, confirming the refactor preserves admin-console
    behavior.

Backwards compatibility

No existing KeycloakIdentity constructor or signature is modified; PolicyEvaluationService.createIdentity()'s externally observable behavior is preserved.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions