diff --git a/services/src/main/java/org/keycloak/authorization/admin/PolicyEvaluationService.java b/services/src/main/java/org/keycloak/authorization/admin/PolicyEvaluationService.java index 200a5b4b3db..32635fc31a2 100644 --- a/services/src/main/java/org/keycloak/authorization/admin/PolicyEvaluationService.java +++ b/services/src/main/java/org/keycloak/authorization/admin/PolicyEvaluationService.java @@ -45,6 +45,7 @@ import org.keycloak.authorization.admin.representation.PolicyEvaluationResponseB import org.keycloak.authorization.attribute.Attributes; import org.keycloak.authorization.common.DefaultEvaluationContext; import org.keycloak.authorization.common.KeycloakIdentity; +import org.keycloak.authorization.common.TokenIdentityEnricher; import org.keycloak.authorization.fgap.AdminPermissionsSchema; import org.keycloak.authorization.model.Resource; import org.keycloak.authorization.model.ResourceServer; @@ -380,15 +381,7 @@ public class PolicyEvaluationService { UserModel user = keycloakSession.users().getUserById(realm, representation.getUserId()); if (user != null) { - AccessToken finalAccessToken = accessToken; - user.getRoleMappingsStream().forEach(roleModel -> { - if (roleModel.isClientRole()) { - ClientModel client = (ClientModel) roleModel.getContainer(); - finalAccessToken.addAccess(client.getClientId()).addRole(roleModel.getName()); - } else { - realmAccess.addRole(roleModel.getName()); - } - }); + TokenIdentityEnricher.addAllUserRoles(accessToken, user); } } diff --git a/services/src/main/java/org/keycloak/authorization/common/TokenIdentityEnricher.java b/services/src/main/java/org/keycloak/authorization/common/TokenIdentityEnricher.java new file mode 100644 index 00000000000..f9e8346d095 --- /dev/null +++ b/services/src/main/java/org/keycloak/authorization/common/TokenIdentityEnricher.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.authorization.common; + +import org.keycloak.models.ClientModel; +import org.keycloak.models.UserModel; +import org.keycloak.representations.AccessToken; + +/** + * Utilities for projecting a user's full role assignments onto an + * {@link AccessToken} so that authorization evaluation has visibility into + * roles that are otherwise filtered out by OIDC scope configuration. + * + *

A {@link KeycloakIdentity} constructed directly from a bearer access + * token derives its role attributes from the token's + * {@code realm_access} and {@code resource_access} claims. When a policy + * references a role defined in a client other than the resource server, and + * that client is not part of the requesting client's scope, the role will + * be absent from the token and the policy will evaluate to DENY even when + * the user has been granted the role. + * + *

This helper centralizes the enrichment logic the Keycloak admin + * console already uses internally when evaluating policies on behalf of a + * user. Extensions performing programmatic permission evaluation can use it + * to obtain a {@code KeycloakIdentity} consistent with the admin console's + * behavior: + * + *

{@code
+ * AccessToken token = Tokens.getAccessToken(session);
+ * UserModel user = session.users().getUserById(realm, token.getSubject());
+ * TokenIdentityEnricher.addAllUserRoles(token, user);
+ * Identity identity = new KeycloakIdentity(token, session, realm);
+ * }
+ */ +public final class TokenIdentityEnricher { + + private TokenIdentityEnricher() { + } + + /** + * Adds every role mapping of {@code user} to {@code token}. Realm roles + * are added to {@code realm_access}; client roles are added to + * {@code resource_access} under their owning client. Existing roles on + * the token are preserved. + * + * @param token the access token to enrich; must not be {@code null} + * @param user the user whose role mappings will be projected onto the + * token; must not be {@code null} + * @throws IllegalArgumentException if {@code token} or {@code user} is + * {@code null} + */ + public static void addAllUserRoles(AccessToken token, UserModel user) { + if (token == null) { + throw new IllegalArgumentException("token must not be null"); + } + if (user == null) { + throw new IllegalArgumentException("user must not be null"); + } + + user.getRoleMappingsStream().forEach(roleModel -> { + if (roleModel.isClientRole()) { + ClientModel client = (ClientModel) roleModel.getContainer(); + token.addAccess(client.getClientId()).addRole(roleModel.getName()); + } else { + AccessToken.Access realmAccess = token.getRealmAccess(); + if (realmAccess == null) { + realmAccess = new AccessToken.Access(); + token.setRealmAccess(realmAccess); + } + realmAccess.addRole(roleModel.getName()); + } + }); + } +} diff --git a/services/src/test/java/org/keycloak/authorization/common/TokenIdentityEnricherTest.java b/services/src/test/java/org/keycloak/authorization/common/TokenIdentityEnricherTest.java new file mode 100644 index 00000000000..877ff192200 --- /dev/null +++ b/services/src/test/java/org/keycloak/authorization/common/TokenIdentityEnricherTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.authorization.common; + +import org.keycloak.representations.AccessToken; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Boundary-condition tests for {@link TokenIdentityEnricher}. + * + *

Functional coverage of the enrichment behavior (realm vs. client roles, + * cross-client role projection) is provided by the integration test + * {@code org.keycloak.testsuite.authz.KeycloakIdentityCrossClientRoleTest}, + * which exercises the helper end-to-end against a running Keycloak session. + */ +class TokenIdentityEnricherTest { + + @Test + void addAllUserRoles_rejectsNullToken() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> TokenIdentityEnricher.addAllUserRoles(null, null)); + assertEquals("token must not be null", ex.getMessage()); + } + + @Test + void addAllUserRoles_rejectsNullUser() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> TokenIdentityEnricher.addAllUserRoles(new AccessToken(), null)); + assertEquals("user must not be null", ex.getMessage()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/KeycloakIdentityCrossClientRoleTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/KeycloakIdentityCrossClientRoleTest.java new file mode 100644 index 00000000000..b76595cee4f --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/KeycloakIdentityCrossClientRoleTest.java @@ -0,0 +1,250 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.authz; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.authorization.AuthorizationProvider; +import org.keycloak.authorization.AuthorizationProviderFactory; +import org.keycloak.authorization.common.DefaultEvaluationContext; +import org.keycloak.authorization.common.KeycloakIdentity; +import org.keycloak.authorization.common.TokenIdentityEnricher; +import org.keycloak.authorization.model.Policy; +import org.keycloak.authorization.model.Resource; +import org.keycloak.authorization.model.ResourceServer; +import org.keycloak.authorization.model.Scope; +import org.keycloak.authorization.permission.ResourcePermission; +import org.keycloak.authorization.permission.evaluator.PermissionEvaluator; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.authorization.DecisionEffect; +import org.keycloak.representations.idm.authorization.DecisionStrategy; +import org.keycloak.representations.idm.authorization.Logic; +import org.keycloak.representations.idm.authorization.Permission; +import org.keycloak.representations.idm.authorization.PolicyEvaluationRequest; +import org.keycloak.representations.idm.authorization.PolicyEvaluationResponse; +import org.keycloak.representations.idm.authorization.PolicyRepresentation; +import org.keycloak.representations.idm.authorization.ScopePermissionRepresentation; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.UserSessionManager; +import org.keycloak.sessions.AuthenticationSessionModel; + +import org.junit.Test; +import org.junit.jupiter.api.Assertions; + +import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; + +/** + * Demonstrates the cross-client role gap addressed by the + * {@code TokenIdentityEnricher} contribution. + * + *

Scenario: a resource on {@code client-a} is protected by a role policy + * referencing a client role defined in {@code client-b}. The user holds the + * {@code client-b} role. + * + *

+ */ +public class KeycloakIdentityCrossClientRoleTest extends AbstractAuthzTest { + + private static final String CLIENT_A = "resource-server-client-a"; + private static final String CLIENT_B = "role-container-client-b"; + private static final String CLIENT_B_ROLE = "special"; + private static final String USER_NAME = "cross-client-user"; + private static final String RESOURCE = "myresource"; + private static final String SCOPE = "myscope"; + private static final String PERMISSION = "mypermission"; + + @Override + public void addTestRealms(List testRealms) { + RealmRepresentation testRealmRep = new RealmRepresentation(); + testRealmRep.setId(TEST); + testRealmRep.setRealm(TEST); + testRealmRep.setEnabled(true); + testRealms.add(testRealmRep); + } + + public static void setup(KeycloakSession session) { + RealmModel realm = session.realms().getRealmByName(TEST); + session.getContext().setRealm(realm); + + // Idempotent: each @Test invokes setup, but the realm persists across + // the run, so subsequent calls become no-ops. + if (realm.getClientByClientId(CLIENT_A) != null) { + return; + } + + // client-b: role container (not a resource server, not in client-a's scope) + ClientModel clientB = session.clients().addClient(realm, CLIENT_B); + RoleModel role = clientB.addRole(CLIENT_B_ROLE); + + // client-a: resource server with a role-policy referencing client-b's role. + // fullScopeAllowed=false is the production-realistic setting: only roles + // explicitly mapped into client-a's scope appear in tokens it issues. + // Without this, the access token would carry client-b roles via the + // full-scope shortcut and the cross-client gap would not reproduce. + ClientModel clientA = session.clients().addClient(realm, CLIENT_A); + clientA.setFullScopeAllowed(false); + + AuthorizationProviderFactory factory = (AuthorizationProviderFactory) + session.getKeycloakSessionFactory().getProviderFactory(AuthorizationProvider.class); + AuthorizationProvider authz = factory.create(session, realm); + ResourceServer resourceServer = authz.getStoreFactory().getResourceServerStore().create(clientA); + Policy policy = createRolePolicy(authz, resourceServer, role); + + Scope scope = authz.getStoreFactory().getScopeStore().create(resourceServer, SCOPE); + Resource resource = authz.getStoreFactory().getResourceStore() + .create(resourceServer, RESOURCE, resourceServer.getClientId()); + addScopePermission(authz, resourceServer, PERMISSION, resource, scope, policy); + + UserModel user = session.users().addUser(realm, USER_NAME); + user.grantRole(role); + } + + public static void evaluateWithEnrichedTokenIdentity(KeycloakSession session) { + RealmModel realm = session.realms().getRealmByName(TEST); + session.getContext().setRealm(realm); + + ClientModel clientA = realm.getClientByClientId(CLIENT_A); + UserModel user = session.users().getUserByUsername(realm, USER_NAME); + + AccessToken token = synthesizeClientToken(session, realm, clientA, user); + TokenIdentityEnricher.addAllUserRoles(token, user); + + KeycloakIdentity identity = new KeycloakIdentity(token, session, realm); + + Collection permissions = evaluateResourcePermission(session, clientA, identity); + + Assertions.assertFalse( + permissions.isEmpty(), + "Expected enriched identity to grant the cross-client role policy. " + + "If empty, the enrichment loop or evaluator wiring regressed."); + } + + private static AccessToken synthesizeClientToken(KeycloakSession session, RealmModel realm, + ClientModel client, UserModel user) { + AuthenticationSessionModel authSession = session.authenticationSessions() + .createRootAuthenticationSession(realm) + .createAuthenticationSession(client); + authSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + authSession.setAuthenticatedUser(user); + + UserSessionModel userSession = new UserSessionManager(session).createUserSession( + authSession.getParentSession().getId(), realm, user, + user.getUsername(), "127.0.0.1", "passwd", false, null, null, + UserSessionModel.SessionPersistenceState.PERSISTENT); + + AuthenticationManager.setClientScopesInSession(session, authSession); + ClientSessionContext ctx = TokenManager.attachAuthenticationSession(session, userSession, authSession); + + return new TokenManager().createClientAccessToken(session, realm, client, user, userSession, ctx, + ctx.isOfflineTokenRequested()); + } + + private static Collection evaluateResourcePermission(KeycloakSession session, + ClientModel clientA, + KeycloakIdentity identity) { + AuthorizationProvider authz = session.getProvider(AuthorizationProvider.class); + ResourceServer resourceServer = authz.getStoreFactory().getResourceServerStore().findByClient(clientA); + Resource resource = authz.getStoreFactory().getResourceStore().findByName(resourceServer, RESOURCE); + Scope scope = authz.getStoreFactory().getScopeStore().findByName(resourceServer, SCOPE); + + PermissionEvaluator evaluator = authz.evaluators().from( + Arrays.asList(new ResourcePermission(resource, Arrays.asList(scope), resourceServer)), + new DefaultEvaluationContext(identity, session)); + return evaluator.evaluate(resourceServer, null); + } + + private static Policy createRolePolicy(AuthorizationProvider authz, ResourceServer resourceServer, RoleModel role) { + PolicyRepresentation representation = new PolicyRepresentation(); + representation.setName(role.getName() + "-policy"); + representation.setType("role"); + representation.setDecisionStrategy(DecisionStrategy.UNANIMOUS); + representation.setLogic(Logic.POSITIVE); + String roleValues = "[{\"id\":\"" + role.getId() + "\",\"required\": true}]"; + Map config = new HashMap<>(); + config.put("roles", roleValues); + config.put("fetchRoles", Boolean.TRUE.toString()); + representation.setConfig(config); + + return authz.getStoreFactory().getPolicyStore().create(resourceServer, representation); + } + + private static Policy addScopePermission(AuthorizationProvider authz, ResourceServer resourceServer, String name, + Resource resource, Scope scope, Policy policy) { + ScopePermissionRepresentation representation = new ScopePermissionRepresentation(); + representation.setName(name); + representation.setType("scope"); + representation.addResource(resource.getName()); + representation.addScope(scope.getName()); + representation.addPolicy(policy.getName()); + representation.setDecisionStrategy(DecisionStrategy.UNANIMOUS); + representation.setLogic(Logic.POSITIVE); + + return authz.getStoreFactory().getPolicyStore().create(resourceServer, representation); + } + + @Test + public void adminConsoleEvaluate_includesCrossClientRole_yieldsPermit() { + testingClient.server().run(KeycloakIdentityCrossClientRoleTest::setup); + + RealmResource realm = adminClient.realm(TEST); + String resourceServerId = realm.clients().findByClientId(CLIENT_A).get(0).getId(); + UserRepresentation user = realm.users().search(USER_NAME).get(0); + + PolicyEvaluationRequest request = new PolicyEvaluationRequest(); + request.setUserId(user.getId()); + request.setClientId(resourceServerId); + request.addResource(RESOURCE, SCOPE); + + PolicyEvaluationResponse result = realm.clients().get(resourceServerId) + .authorization().policies().evaluate(request); + Assertions.assertEquals(DecisionEffect.PERMIT, result.getStatus(), + "Admin console must grant access via internal role enrichment."); + } + + @Test + public void enrichedTokenIdentity_includesCrossClientRole_yieldsPermit() { + testingClient.server().run(KeycloakIdentityCrossClientRoleTest::setup); + testingClient.server().run(KeycloakIdentityCrossClientRoleTest::evaluateWithEnrichedTokenIdentity); + } +}