Lightweight access tokens for Admin REST API (#32347)

* Lightweight access tokens for Admin REST API

Closes #31513


Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano 2024-09-04 18:04:23 +02:00 committed by GitHub
parent d0a346066d
commit a14548a7a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 145 additions and 38 deletions

View file

@ -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.

View file

@ -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));
}
}

View file

@ -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<String, Collection<String>> attributes = new HashMap<>();

View file

@ -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<String, AccessToken.Access> resourceAccess = RoleResolveUtil.getAllResolvedClientRoles(session, clientSessionCtx);
Map<String, Map<String, List<String>>> 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<String, Object> otherClaims = jwt.getOtherClaims();
if (otherClaims != null) {

View file

@ -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.");
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -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<String, AccessToken.Access> 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) {

View file

@ -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)));
}
}

View file

@ -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 {

View file

@ -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());
}