From a14548a7a27685b35e3dd974a06dcf078aec3970 Mon Sep 17 00:00:00 2001 From: Giuseppe Graziano Date: Wed, 4 Sep 2024 18:04:23 +0200 Subject: [PATCH] Lightweight access tokens for Admin REST API (#32347) * Lightweight access tokens for Admin REST API Closes #31513 Signed-off-by: Giuseppe Graziano --- .../release_notes/topics/26_0_0.adoc | 4 ++ .../migration/migrators/MigrateTo26_0_0.java | 9 +++ .../common/KeycloakIdentity.java | 11 +++- .../ClientRegistrationAuth.java | 63 ++++++++++++------- .../services/managers/RealmManager.java | 10 +-- .../admin/permissions/MgmtPermissions.java | 37 ++++++++--- .../migration/AbstractMigrationTest.java | 10 +++ .../oidc/LightWeightAccessTokenTest.java | 35 +++++++++++ .../testsuite/util/AssertAdminEvents.java | 4 +- 9 files changed, 145 insertions(+), 38 deletions(-) diff --git a/docs/documentation/release_notes/topics/26_0_0.adoc b/docs/documentation/release_notes/topics/26_0_0.adoc index 0a8df6ee73..fc7fdd8c26 100644 --- a/docs/documentation/release_notes/topics/26_0_0.adoc +++ b/docs/documentation/release_notes/topics/26_0_0.adoc @@ -168,3 +168,7 @@ For information on how to upgrade, see the link:{upgradingguide_link}[{upgrading There are now generalized events for updating (`UPDATE_CREDENTIAL`) and removing (`REMOVE_CREDENTIAL`) a credential. The credential type is described in the `credential_type` attribute of the events. The new event types are supported by the Email Event Listener. The following event types are now deprecated and will be removed in a future version: `UPDATE_PASSWORD`, `UPDATE_PASSWORD_ERROR`, `UPDATE_TOTP`, `UPDATE_TOTP_ERROR`, `REMOVE_TOTP`, `REMOVE_TOTP_ERROR` + += Lightweight access tokens for Admin REST API + +Lightweight access tokens can now be used on the admin REST API. The `security-admin-console` and `admin-cli` clients are now using lightweight access tokens by default, so “Always Use Lightweight Access Token” and “Full Scope Allowed” are now enabled on these two clients. However, the behavior in the admin console should effectively remain the same. Be cautious if you have made changes to these two clients and if you are using them for other purposes. diff --git a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_0_0.java b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_0_0.java index 265e9622b8..1480da2646 100644 --- a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_0_0.java +++ b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_0_0.java @@ -21,7 +21,10 @@ package org.keycloak.migration.migrators; import java.lang.invoke.MethodHandles; import org.jboss.logging.Logger; +import org.keycloak.Config; import org.keycloak.migration.ModelVersion; +import org.keycloak.models.ClientModel; +import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserSessionProvider; @@ -55,6 +58,12 @@ public class MigrateTo26_0_0 implements Migration { } private void migrateRealm(KeycloakSession session, RealmModel realm) { + ClientModel adminConsoleClient = realm.getClientByClientId(Constants.ADMIN_CONSOLE_CLIENT_ID); + adminConsoleClient.setFullScopeAllowed(true); + adminConsoleClient.setAttribute(Constants.USE_LIGHTWEIGHT_ACCESS_TOKEN_ENABLED, String.valueOf(true)); + ClientModel adminCliClient = realm.getClientByClientId(Constants.ADMIN_CLI_CLIENT_ID); + adminCliClient.setFullScopeAllowed(true); + adminCliClient.setAttribute(Constants.USE_LIGHTWEIGHT_ACCESS_TOKEN_ENABLED, String.valueOf(true)); } } diff --git a/services/src/main/java/org/keycloak/authorization/common/KeycloakIdentity.java b/services/src/main/java/org/keycloak/authorization/common/KeycloakIdentity.java index 3bdcf937bc..9168aa01af 100644 --- a/services/src/main/java/org/keycloak/authorization/common/KeycloakIdentity.java +++ b/services/src/main/java/org/keycloak/authorization/common/KeycloakIdentity.java @@ -131,7 +131,7 @@ public class KeycloakIdentity implements Identity { if (userSession == null) { userSession = sessions.getOfflineUserSession(realm, token.getSessionState()); } - + if (userSession == null) { throw new RuntimeException("No active session associated with the token"); } @@ -176,15 +176,22 @@ public class KeycloakIdentity implements Identity { } public KeycloakIdentity(AccessToken accessToken, KeycloakSession keycloakSession) { + this(accessToken, keycloakSession, keycloakSession.getContext().getRealm()); + } + + public KeycloakIdentity(AccessToken accessToken, KeycloakSession keycloakSession, RealmModel realm) { if (accessToken == null) { throw new ErrorResponseException("invalid_bearer_token", "Could not obtain bearer access_token from request.", Status.FORBIDDEN); } if (keycloakSession == null) { throw new ErrorResponseException("no_keycloak_session", "No keycloak session", Status.FORBIDDEN); } + if (realm == null) { + throw new ErrorResponseException("no_keycloak_session", "No realm set", Status.FORBIDDEN); + } this.accessToken = accessToken; this.keycloakSession = keycloakSession; - this.realm = keycloakSession.getContext().getRealm(); + this.realm = realm; Map> attributes = new HashMap<>(); diff --git a/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationAuth.java b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationAuth.java index 3bcae128e4..cac1035bb9 100644 --- a/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationAuth.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/ClientRegistrationAuth.java @@ -25,14 +25,19 @@ import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.models.AdminRoles; +import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientInitialAccessModel; import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionContext; import org.keycloak.models.Constants; 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.models.UserSessionProvider; import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; +import org.keycloak.representations.AccessToken; import org.keycloak.representations.JsonWebToken; import org.keycloak.services.ErrorResponseException; import org.keycloak.services.clientpolicy.ClientPolicyException; @@ -43,11 +48,16 @@ import org.keycloak.services.clientpolicy.context.DynamicClientViewContext; import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicyException; import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicyManager; import org.keycloak.services.clientregistration.policy.RegistrationAuth; +import org.keycloak.services.util.DefaultClientSessionContext; import org.keycloak.util.TokenUtil; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; +import org.keycloak.utils.RoleResolveUtil; + +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -283,36 +293,43 @@ public class ClientRegistrationAuth { private boolean hasRole(String... roles) { try { - if (jwt.getIssuedFor().equals(Constants.ADMIN_CLI_CLIENT_ID) - || jwt.getIssuedFor().equals(Constants.ADMIN_CONSOLE_CLIENT_ID)) { - return hasRoleInModel(roles); - } else { - return hasRoleInToken(roles); + //support for lightweight access token + if (jwt.getSubject() == null) { + String sid = (String) jwt.getOtherClaims().get("sid"); + if (sid != null) { + final String issuedFor = jwt.getIssuedFor(); + UserSessionProvider sessions = session.sessions(); + UserSessionModel userSession = sessions.getUserSession(realm, sid); + if (userSession == null) { + userSession = sessions.getOfflineUserSession(realm, sid); + } + + if (userSession != null) { + //get client session + ClientModel client = realm.getClientByClientId(issuedFor); + AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); + + //set realm roles + ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter(clientSession, (String) jwt.getOtherClaims().get("scope"), session); + Map resourceAccess = RoleResolveUtil.getAllResolvedClientRoles(session, clientSessionCtx); + + Map>> resourceAccessMap = new HashMap<>(); + resourceAccess.forEach((key, access) -> + resourceAccessMap.put(key, Map.of("roles", new ArrayList<>(access.getRoles()))) + ); + jwt.setSubject(userSession.getUser().getId()); + jwt.getOtherClaims().put("resource_access", resourceAccessMap); + } + } } + return hasRoleInToken(roles); + } catch (Throwable t) { return false; } } - private boolean hasRoleInModel(String[] roles) { - ClientModel roleNamespace; - UserModel user = session.users().getUserById(realm, jwt.getSubject()); - if (user == null) { - return false; - } - if (realm.getName().equals(Config.getAdminRealm())) { - roleNamespace = realm.getMasterAdminClient(); - } else { - roleNamespace = realm.getClientByClientId(Constants.REALM_MANAGEMENT_CLIENT_ID); - } - for (String role : roles) { - RoleModel roleModel = roleNamespace.getRole(role); - if (user.hasRole(roleModel)) return true; - } - return false; - } - private boolean hasRoleInToken(String[] role) { Map otherClaims = jwt.getOtherClaims(); if (otherClaims != null) { diff --git a/services/src/main/java/org/keycloak/services/managers/RealmManager.java b/services/src/main/java/org/keycloak/services/managers/RealmManager.java index 6980bda920..1793bb5ab5 100755 --- a/services/src/main/java/org/keycloak/services/managers/RealmManager.java +++ b/services/src/main/java/org/keycloak/services/managers/RealmManager.java @@ -188,11 +188,12 @@ public class RealmManager { adminConsole.setEnabled(true); adminConsole.setAlwaysDisplayInConsole(false); + adminConsole.setFullScopeAllowed(true); adminConsole.setPublicClient(true); - adminConsole.setFullScopeAllowed(false); adminConsole.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); adminConsole.setAttribute(OIDCConfigAttributes.PKCE_CODE_CHALLENGE_METHOD, "S256"); + adminConsole.setAttribute(Constants.USE_LIGHTWEIGHT_ACCESS_TOKEN_ENABLED, "true"); } protected void setupAdminConsoleLocaleMapper(RealmModel realm) { @@ -214,10 +215,11 @@ public class RealmManager { adminCli.setName("${client_" + Constants.ADMIN_CLI_CLIENT_ID + "}"); adminCli.setEnabled(true); adminCli.setAlwaysDisplayInConsole(false); - adminCli.setFullScopeAllowed(false); + adminCli.setFullScopeAllowed(true); adminCli.setStandardFlowEnabled(false); adminCli.setDirectAccessGrantsEnabled(true); adminCli.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + adminCli.setAttribute(Constants.USE_LIGHTWEIGHT_ACCESS_TOKEN_ENABLED, "true"); } } @@ -644,7 +646,7 @@ public class RealmManager { } private String determineDefaultRoleName(RealmRepresentation rep) { - String defaultRoleName = Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + rep.getRealm().toLowerCase(); + String defaultRoleName = Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + rep.getRealm().toLowerCase(); if (! hasRealmRole(rep, defaultRoleName)) { return defaultRoleName; } else { @@ -778,7 +780,7 @@ public class RealmManager { ClientModel clientModel = Optional.ofNullable(client.getId()) .map(realmModel::getClientById) .orElseGet(() -> realmModel.getClientByClientId(client.getClientId())); - + if (clientModel == null) { throw new RuntimeException("Cannot find provided client by dir import."); } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissions.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissions.java index 619d3605cb..959c7b73f7 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissions.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/MgmtPermissions.java @@ -30,12 +30,17 @@ import org.keycloak.authorization.permission.ResourcePermission; import org.keycloak.authorization.policy.evaluation.EvaluationContext; import org.keycloak.common.Profile; import org.keycloak.models.AdminRoles; +import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionContext; import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.UserSessionProvider; +import org.keycloak.representations.AccessToken; import org.keycloak.representations.idm.authorization.Permission; import org.keycloak.services.managers.RealmManager; import org.keycloak.services.resources.admin.AdminAuth; @@ -43,8 +48,11 @@ import org.keycloak.services.resources.admin.AdminAuth; import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.Map; import jakarta.ws.rs.ForbiddenException; +import org.keycloak.services.util.DefaultClientSessionContext; +import org.keycloak.utils.RoleResolveUtil; /** * @author Bill Burke @@ -98,17 +106,30 @@ class MgmtPermissions implements AdminPermissionEvaluator, AdminPermissionManage private void initIdentity(KeycloakSession session, AdminAuth auth) { final String issuedFor = auth.getToken().getIssuedFor(); + AccessToken accessToken = auth.getToken(); + //support for lightweight access token + if (auth.getToken().getSubject() == null) { + //get user session + UserSessionProvider sessions = session.sessions(); + UserSessionModel userSession = sessions.getUserSession(adminsRealm, auth.getToken().getSessionId()); + if (userSession == null) { + userSession = sessions.getOfflineUserSession(adminsRealm, auth.getToken().getSessionId()); + } - if (Constants.ADMIN_CLI_CLIENT_ID.equals(issuedFor) || Constants.ADMIN_CONSOLE_CLIENT_ID.equals(issuedFor)) { - this.identity = new UserModelIdentity(auth.getRealm(), auth.getUser()); - } else { - ClientModel client = session.clients().getClientByClientId(auth.getRealm(), issuedFor); - if (client != null && Boolean.parseBoolean(client.getAttribute(Constants.SECURITY_ADMIN_CONSOLE_ATTR))) { - this.identity = new UserModelIdentity(auth.getRealm(), auth.getUser()); - } else { - this.identity = new KeycloakIdentity(auth.getToken(), session); + if (userSession != null) { + //get client session + ClientModel client = adminsRealm.getClientByClientId(issuedFor); + AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); + + //set realm roles + ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter(clientSession, auth.getToken().getScope(), session); + AccessToken.Access realmAccess = RoleResolveUtil.getResolvedRealmRoles(session, clientSessionCtx, false); + Map clientAccess = RoleResolveUtil.getAllResolvedClientRoles(session, clientSessionCtx); + accessToken.setRealmAccess(realmAccess); + accessToken.setResourceAccess(clientAccess); } } + this.identity = new KeycloakIdentity(accessToken, session, adminsRealm); } MgmtPermissions(KeycloakSession session, RealmModel adminsRealm, UserModel admin) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java index 73733dcd0a..7ff814bc64 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java @@ -434,6 +434,10 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest { if (testIdentityProviderConfigMigration) { testIdentityProviderConfigMigration(migrationRealm2); } + testLightweightClientAndFullScopeAllowed(masterRealm, Constants.ADMIN_CONSOLE_CLIENT_ID); + testLightweightClientAndFullScopeAllowed(masterRealm, Constants.ADMIN_CLI_CLIENT_ID); + testLightweightClientAndFullScopeAllowed(migrationRealm, Constants.ADMIN_CONSOLE_CLIENT_ID); + testLightweightClientAndFullScopeAllowed(migrationRealm, Constants.ADMIN_CLI_CLIENT_ID); } private void testClientContainsExpectedClientScopes() { @@ -1351,4 +1355,10 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest { assertThat(rep.isHideOnLogin(), is(true)); assertThat(rep.getConfig().containsKey(IdentityProviderModel.LEGACY_HIDE_ON_LOGIN_ATTR), is(false)); } + + private void testLightweightClientAndFullScopeAllowed(RealmResource realm, String clientId) { + ClientRepresentation clientRepresentation = realm.clients().findByClientId(clientId).get(0); + assertTrue(clientRepresentation.isFullScopeAllowed()); + assertTrue(Boolean.parseBoolean(clientRepresentation.getAttributes().get(Constants.USE_LIGHTWEIGHT_ACCESS_TOKEN_ENABLED))); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/LightWeightAccessTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/LightWeightAccessTokenTest.java index 6b6338aba9..5d1f56ed9b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/LightWeightAccessTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/LightWeightAccessTokenTest.java @@ -19,10 +19,15 @@ package org.keycloak.testsuite.oidc; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.HttpHeaders; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; import org.jboss.logging.Logger; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.ClientScopeResource; import org.keycloak.admin.client.resource.ProtocolMappersResource; import org.keycloak.admin.client.resource.RealmResource; @@ -486,6 +491,36 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest { } } + @Test + public void testAdminConsoleClientWithLightweightAccessToken() { + + oauth.realm("master"); + oauth.clientId(Constants.ADMIN_CONSOLE_CLIENT_ID); + oauth.redirectUri(OAuthClient.SERVER_ROOT + "/auth/admin/master/console"); + PkceGenerator pkce = new PkceGenerator(); + oauth.codeChallenge(pkce.getCodeChallenge()); + oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256); + oauth.codeVerifier(pkce.getCodeVerifier()); + + OAuthClient.AuthorizationEndpointResponse authsEndpointResponse = oauth.doLogin("admin", "admin"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(authsEndpointResponse.getCode(), TEST_CLIENT_SECRET); + String accessToken = tokenResponse.getAccessToken(); + logger.debug("access token:" + accessToken); + assertBasicClaims(oauth.verifyToken(accessToken), true, true); + + try (CloseableHttpClient client = HttpClientBuilder.create().build()) { + HttpGet get = new HttpGet(OAuthClient.SERVER_ROOT + "/auth/admin/realms/master"); + get.setHeader("Authorization", "Bearer " + accessToken); + try (CloseableHttpResponse response = client.execute(get)) { + Assert.assertEquals(200, response.getStatusLine().getStatusCode()); + RealmRepresentation realmRepresentation = JsonSerialization.readValue(response.getEntity().getContent(), RealmRepresentation.class); + Assert.assertEquals("master", realmRepresentation.getRealm()); + } + } catch (Exception e) { + Assert.fail(e.getMessage()); + } + } + private void removeSession(final String sessionId) { testingClient.testing().removeExpired(REALM_NAME); try { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AssertAdminEvents.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AssertAdminEvents.java index e44cab4ddf..e1752f3cbf 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AssertAdminEvents.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AssertAdminEvents.java @@ -204,7 +204,9 @@ public class AssertAdminEvents implements TestRule { AuthDetailsRepresentation actualAuth = actual.getAuthDetails(); Assert.assertEquals(expectedAuth.getRealmId(), actualAuth.getRealmId()); - Assert.assertEquals(expectedAuth.getUserId(), actualAuth.getUserId()); + if(expectedAuth.getUserId() != null) { + Assert.assertEquals(expectedAuth.getUserId(), actualAuth.getUserId()); + } if (expectedAuth.getClientId() != null) { Assert.assertEquals(expectedAuth.getClientId(), actualAuth.getClientId()); }