diff --git a/common/src/main/java/org/keycloak/common/constants/ServiceAccountConstants.java b/common/src/main/java/org/keycloak/common/constants/ServiceAccountConstants.java index 4dc0dd1cf7..a1ea929f3a 100644 --- a/common/src/main/java/org/keycloak/common/constants/ServiceAccountConstants.java +++ b/common/src/main/java/org/keycloak/common/constants/ServiceAccountConstants.java @@ -35,4 +35,6 @@ public interface ServiceAccountConstants { String CLIENT_HOST = "clientHost"; String CLIENT_ADDRESS = "clientAddress"; + String SERVICE_ACCOUNT_SCOPE = "service_account"; + } diff --git a/docs/documentation/upgrading/topics/changes/changes-26_1_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_1_0.adoc index ee28e773f9..db31b2e352 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_1_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_1_0.adoc @@ -60,3 +60,9 @@ The `robots.txt` file, previously included by default, is now removed. The defau Any offline session in {project_name} is created from another online session. When the `offline_access` scope is requested, the current online session is used to create the associated offline session for the client. Therefore any `offline_access` request finished, until now, with two sessions, one online and one offline. Starting with this version, {project_name} removes the initial online session if the `offline_scope` is directly requested as the first interaction for the session. The client retrieves the offline token after the code to token exchange that is associated to the offline session, but the previous online session is removed. If the online session has been used before the `offline_scope` request, by the same or another client, the online session remains active as today. Although the new behavior makes sense because the client application is just asking for an offline token, it can affect some scenarios that rely on having the online session still active after the initial `offline_scope` token request. + += New client scope `service_account` for `client_credentials` grant mappers + +{project_name} introduces a new client scope at the realm level called `service_account` which is in charge of adding the specific claims for `client_credentials` grant (`client_id`, `clientHost` and `clientAddress`) via protocol mappers. This scope will be automatically assigned to and unassigned from the client when the `serviceAccountsEnabled` option is set or unset in the client configuration. + +Previously, the three mappers (`Client Id`, `Client Host` and `Client IP Address`) where added directly to the dedicated scope when the client was configured to enable service accounts, and they were never removed. \ No newline at end of file diff --git a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_1_0.java b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_1_0.java new file mode 100644 index 0000000000..c962c80742 --- /dev/null +++ b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_1_0.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024 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.migration.migrators; + +import java.lang.invoke.MethodHandles; +import org.jboss.logging.Logger; +import org.keycloak.migration.MigrationProvider; +import org.keycloak.migration.ModelVersion; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.representations.idm.RealmRepresentation; + +/** + * + * @author rmartinc + */ +public class MigrateTo26_1_0 implements Migration { +public static final ModelVersion VERSION = new ModelVersion("26.1.0"); + + private static final Logger LOG = Logger.getLogger(MethodHandles.lookup().lookupClass()); + + @Override + public ModelVersion getVersion() { + return VERSION; + } + + @Override + public void migrate(KeycloakSession session) { + session.realms().getRealmsStream().forEach(realm -> migrateRealm(session, realm)); + } + + @Override + public void migrateImport(KeycloakSession session, RealmModel realm, RealmRepresentation rep, boolean skipUserDependent) { + migrateRealm(session, realm); + } + + private void migrateRealm(KeycloakSession session, RealmModel realm) { + // add the new service_account scope to the realm + MigrationProvider migrationProvider = session.getProvider(MigrationProvider.class); + migrationProvider.addOIDCServiceAccountClientScope(realm); + } +} diff --git a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultMigrationManager.java b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultMigrationManager.java index 32d423d81b..5aef959966 100644 --- a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultMigrationManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultMigrationManager.java @@ -41,6 +41,7 @@ import org.keycloak.migration.migrators.MigrateTo24_0_0; import org.keycloak.migration.migrators.MigrateTo24_0_3; import org.keycloak.migration.migrators.MigrateTo25_0_0; import org.keycloak.migration.migrators.MigrateTo26_0_0; +import org.keycloak.migration.migrators.MigrateTo26_1_0; import org.keycloak.migration.migrators.MigrateTo2_0_0; import org.keycloak.migration.migrators.MigrateTo2_1_0; import org.keycloak.migration.migrators.MigrateTo2_2_0; @@ -121,6 +122,7 @@ public class DefaultMigrationManager implements MigrationManager { new MigrateTo24_0_3(), new MigrateTo25_0_0(), new MigrateTo26_0_0(), + new MigrateTo26_1_0(), }; private final KeycloakSession session; diff --git a/server-spi-private/src/main/java/org/keycloak/migration/MigrationProvider.java b/server-spi-private/src/main/java/org/keycloak/migration/MigrationProvider.java index 2fba28125e..2a70c74933 100755 --- a/server-spi-private/src/main/java/org/keycloak/migration/MigrationProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/migration/MigrationProvider.java @@ -85,4 +85,12 @@ public interface MigrationProvider extends Provider { * @return created or already existing client scope 'basic' */ ClientScopeModel addOIDCBasicClientScope(RealmModel realm); + + /** + * Add 'service_account' client scope or return it if already exists + * + * @param realm + * @return created or already existing client scope 'service_account' + */ + ClientScopeModel addOIDCServiceAccountClientScope(RealmModel realm); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java index 79e0b7d36e..b33158f61b 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java @@ -21,6 +21,7 @@ import org.keycloak.Config; import org.keycloak.OAuth2Constants; import org.keycloak.common.Profile; import org.keycloak.common.constants.KerberosConstants; +import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.common.util.UriUtils; import org.keycloak.events.EventBuilder; import org.keycloak.models.ClientModel; @@ -298,6 +299,7 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory { addMicroprofileJWTClientScope(newRealm); addAcrClientScope(newRealm); addBasicClientScope(newRealm); + addServiceAccountClientScope(newRealm); if (Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) { ClientScopeModel organizationScope = newRealm.addClientScope(OAuth2Constants.ORGANIZATION); @@ -426,6 +428,38 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory { return basicScope; } + public ClientScopeModel addServiceAccountClientScope(RealmModel newRealm) { + ClientScopeModel serviceAccountScope = KeycloakModelUtils.getClientScopeByName(newRealm, ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE); + if (serviceAccountScope == null) { + serviceAccountScope = newRealm.addClientScope(ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE); + serviceAccountScope.setDescription("Specific scope for a client enabled for service accounts"); + serviceAccountScope.setDisplayOnConsentScreen(false); + serviceAccountScope.setIncludeInTokenScope(false); + serviceAccountScope.setProtocol(getId()); + serviceAccountScope.addProtocolMapper(UserSessionNoteMapper.createClaimMapper(ServiceAccountConstants.CLIENT_ID_PROTOCOL_MAPPER, + ServiceAccountConstants.CLIENT_ID, + ServiceAccountConstants.CLIENT_ID, "String", + true, true, true)); + serviceAccountScope.addProtocolMapper(UserSessionNoteMapper.createClaimMapper(ServiceAccountConstants.CLIENT_HOST_PROTOCOL_MAPPER, + ServiceAccountConstants.CLIENT_HOST, + ServiceAccountConstants.CLIENT_HOST, "String", + true, true, true)); + serviceAccountScope.addProtocolMapper(UserSessionNoteMapper.createClaimMapper(ServiceAccountConstants.CLIENT_ADDRESS_PROTOCOL_MAPPER, + ServiceAccountConstants.CLIENT_ADDRESS, + ServiceAccountConstants.CLIENT_ADDRESS, "String", + true, true, true)); + + newRealm.addDefaultClientScope(serviceAccountScope, true); + + logger.debugf("Client scope '%s' created in the realm '%s'.", ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE, newRealm.getName()); + } else { + logger.debugf("Client scope '%s' already exists in realm '%s'. Skip creating it.", ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE, newRealm.getName()); + } + + return serviceAccountScope; + } + + @Override protected void addDefaults(ClientModel client) { } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java index be51b274fc..4d40452978 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java @@ -28,9 +28,11 @@ import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventType; +import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ClientSessionContext; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; +import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.representations.AccessTokenResponse; @@ -79,8 +81,9 @@ public class ClientCredentialsGrantType extends OAuth2GrantTypeBase { } UserModel clientUser = session.users().getServiceAccount(client); + ClientScopeModel serviceAccountScope = KeycloakModelUtils.getClientScopeByName(client.getRealm(), ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE); - if (clientUser == null || client.getProtocolMapperByName(OIDCLoginProtocol.LOGIN_PROTOCOL, ServiceAccountConstants.CLIENT_ID_PROTOCOL_MAPPER) == null) { + if (clientUser == null || (serviceAccountScope != null && !client.getClientScopes(true).containsKey(serviceAccountScope.getId()))) { // May need to handle bootstrap here as well logger.debugf("Service account user for client '%s' not found or default protocol mapper for service account not found. Creating now", client.getClientId()); new ClientManager(new RealmManager(session)).enableServiceAccount(client); diff --git a/services/src/main/java/org/keycloak/services/managers/ClientManager.java b/services/src/main/java/org/keycloak/services/managers/ClientManager.java index cc623ce533..23c282b147 100644 --- a/services/src/main/java/org/keycloak/services/managers/ClientManager.java +++ b/services/src/main/java/org/keycloak/services/managers/ClientManager.java @@ -25,17 +25,16 @@ import org.keycloak.common.Profile; import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.common.util.Time; import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientScopeModel; import org.keycloak.models.KeycloakSession; -import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.RealmModel; import org.keycloak.models.UserManager; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionProvider; +import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocolFactory; -import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.protocol.oidc.mappers.UserSessionNoteMapper; import org.keycloak.protocol.saml.SamlClient; import org.keycloak.protocol.saml.SamlConfigAttributes; import org.keycloak.protocol.saml.SamlProtocol; @@ -168,37 +167,42 @@ public class ClientManager { // Add protocol mappers to retrieve clientId in access token. Ignore this in case type is filled (protocol mappers can be explicitly specified for particular specific type) if (!Profile.isFeatureEnabled(Profile.Feature.CLIENT_TYPES) || client.getType() == null) { - addServiceAccountProtocolMappers(client); + addServiceAccountProtocolMappersViaScope(client); } } - private void addServiceAccountProtocolMappers(ClientModel client) { - if (client.getProtocolMapperByName(OIDCLoginProtocol.LOGIN_PROTOCOL, ServiceAccountConstants.CLIENT_ID_PROTOCOL_MAPPER) == null) { - logger.debugf("Creating service account protocol mapper '%s' for client '%s'", ServiceAccountConstants.CLIENT_ID_PROTOCOL_MAPPER, client.getClientId()); - ProtocolMapperModel protocolMapper = UserSessionNoteMapper.createClaimMapper(ServiceAccountConstants.CLIENT_ID_PROTOCOL_MAPPER, - ServiceAccountConstants.CLIENT_ID, - ServiceAccountConstants.CLIENT_ID, "String", - true, true, true); - client.addProtocolMapper(protocolMapper); + public void disableServiceAccount(ClientModel client) { + client.setServiceAccountsEnabled(false); + + // remove the service account + UserModel serviceAccount = realmManager.getSession().users().getServiceAccount(client); + if (serviceAccount != null) { + new UserManager(realmManager.getSession()).removeUser(client.getRealm(), serviceAccount); } - // Add protocol mappers to retrieve hostname and IP address of client in access token - if (client.getProtocolMapperByName(OIDCLoginProtocol.LOGIN_PROTOCOL, ServiceAccountConstants.CLIENT_HOST_PROTOCOL_MAPPER) == null) { - logger.debugf("Creating service account protocol mapper '%s' for client '%s'", ServiceAccountConstants.CLIENT_HOST_PROTOCOL_MAPPER, client.getClientId()); - ProtocolMapperModel protocolMapper = UserSessionNoteMapper.createClaimMapper(ServiceAccountConstants.CLIENT_HOST_PROTOCOL_MAPPER, - ServiceAccountConstants.CLIENT_HOST, - ServiceAccountConstants.CLIENT_HOST, "String", - true, true, true); - client.addProtocolMapper(protocolMapper); + // Remove protocol mappers to retrieve clientId in access token. Ignore this in case type is filled (protocol mappers can be explicitly specified for particular specific type) + if (!Profile.isFeatureEnabled(Profile.Feature.CLIENT_TYPES) || client.getType() == null) { + removeServiceAccountProtocolMappersViaScope(client); } + } - if (client.getProtocolMapperByName(OIDCLoginProtocol.LOGIN_PROTOCOL, ServiceAccountConstants.CLIENT_ADDRESS_PROTOCOL_MAPPER) == null) { - logger.debugf("Creating service account protocol mapper '%s' for client '%s'", ServiceAccountConstants.CLIENT_ADDRESS_PROTOCOL_MAPPER, client.getClientId()); - ProtocolMapperModel protocolMapper = UserSessionNoteMapper.createClaimMapper(ServiceAccountConstants.CLIENT_ADDRESS_PROTOCOL_MAPPER, - ServiceAccountConstants.CLIENT_ADDRESS, - ServiceAccountConstants.CLIENT_ADDRESS, "String", - true, true, true); - client.addProtocolMapper(protocolMapper); + private void addServiceAccountProtocolMappersViaScope(ClientModel client) { + ClientScopeModel serviceAccountScope = KeycloakModelUtils.getClientScopeByName(client.getRealm(), ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE); + + if (serviceAccountScope != null) { + client.addClientScope(serviceAccountScope, true); + } else { + logger.tracef("Service account scope not added to client %s because it does not exist", client.getClientId()); + } + } + + private void removeServiceAccountProtocolMappersViaScope(ClientModel client) { + ClientScopeModel serviceAccountScope = KeycloakModelUtils.getClientScopeByName(client.getRealm(), ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE); + + if (serviceAccountScope != null) { + client.removeClientScope(serviceAccountScope); + } else { + logger.tracef("Service account scope not removed from client %s because it does not exist", client.getClientId()); } } diff --git a/services/src/main/java/org/keycloak/services/migration/DefaultMigrationProvider.java b/services/src/main/java/org/keycloak/services/migration/DefaultMigrationProvider.java index 508dc29257..9527480418 100755 --- a/services/src/main/java/org/keycloak/services/migration/DefaultMigrationProvider.java +++ b/services/src/main/java/org/keycloak/services/migration/DefaultMigrationProvider.java @@ -112,6 +112,11 @@ public class DefaultMigrationProvider implements MigrationProvider { return getOIDCLoginProtocolFactory().addBasicClientScope(realm); } + @Override + public ClientScopeModel addOIDCServiceAccountClientScope(RealmModel realm) { + return getOIDCLoginProtocolFactory().addServiceAccountClientScope(realm); + } + @Override public void close() { } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java index 4834b5c524..6989e67d79 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java @@ -26,12 +26,9 @@ import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.NoCache; import org.keycloak.OAuthErrorException; import org.keycloak.authorization.admin.AuthorizationService; -import org.keycloak.client.clienttype.ClientType; import org.keycloak.client.clienttype.ClientTypeException; -import org.keycloak.client.clienttype.ClientTypeManager; import org.keycloak.common.ClientConnection; import org.keycloak.common.Profile; -import org.keycloak.common.util.ObjectUtil; import org.keycloak.common.util.Time; import org.keycloak.events.Errors; import org.keycloak.events.admin.OperationType; @@ -45,7 +42,6 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.RealmModel; import org.keycloak.models.UserCredentialModel; -import org.keycloak.models.UserManager; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.KeycloakModelUtils; @@ -99,8 +95,6 @@ import java.util.Map; import java.util.Objects; import java.util.stream.Stream; -import static java.lang.Boolean.TRUE; - /** * Base resource class for managing one particular client of a realm. @@ -810,14 +804,13 @@ public class ClientResource { private void updateClientFromRep(ClientRepresentation rep, ClientModel client, KeycloakSession session) throws ModelDuplicateException { UserModel serviceAccount = this.session.users().getServiceAccount(client); - if (TRUE.equals(rep.isServiceAccountsEnabled())) { + if (Boolean.TRUE.equals(rep.isServiceAccountsEnabled())) { if (serviceAccount == null) { new ClientManager(new RealmManager(session)).enableServiceAccount(client); } - } - else { + } else if (Boolean.FALSE.equals(rep.isServiceAccountsEnabled()) || !client.isServiceAccountsEnabled()) { if (serviceAccount != null) { - new UserManager(session).removeUser(realm, serviceAccount); + new ClientManager(new RealmManager(session)).disableServiceAccount(client); } } @@ -840,7 +833,7 @@ public class ClientResource { private void updateAuthorizationSettings(ClientRepresentation rep) { if (Profile.isFeatureEnabled(Profile.Feature.AUTHORIZATION)) { - if (TRUE.equals(rep.getAuthorizationServicesEnabled())) { + if (Boolean.TRUE.equals(rep.getAuthorizationServicesEnabled())) { authorization().enable(false); } else { authorization().disable(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/AbstractClientTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/AbstractClientTest.java index 713f06d3dd..6805e0ab7e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/AbstractClientTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/AbstractClientTest.java @@ -134,6 +134,7 @@ public abstract class AbstractClientTest extends AbstractAuthTest { Response resp = testRealmResource().clients().create(clientRep); resp.close(); String id = ApiUtil.getCreatedId(resp); + clientRep.setSecret(null); assertAdminEvents.assertEvent(getRealmId(), OperationType.CREATE, AdminEventPaths.clientResourcePath(id), clientRep, ResourceType.CLIENT); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/ServiceAccountClientTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/ServiceAccountClientTest.java new file mode 100644 index 0000000000..0747dc9a16 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/client/ServiceAccountClientTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2024 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @author tags. All rights reserved. + * + * 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.admin.client; + +import java.util.stream.Collectors; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.Test; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.common.constants.ServiceAccountConstants; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.testsuite.util.OAuthClient; + +/** + * + * @author rmartinc + */ +public class ServiceAccountClientTest extends AbstractClientTest { + + + @Test + public void testServiceAccountEnableDisable() throws Exception { + // Create a client with service account enabled + ClientRepresentation clientRep = new ClientRepresentation(); + clientRep.setClientId("service-account-client"); + clientRep.setProtocol("openid-connect"); + clientRep.setSecret("password"); + clientRep.setServiceAccountsEnabled(Boolean.TRUE); + clientRep.setClientAuthenticatorType("client-secret"); + clientRep.setPublicClient(Boolean.FALSE); + String clientUuid = createClient(clientRep); + ClientResource client = testRealmResource().clients().get(clientUuid); + getCleanup().addClientUuid(clientUuid); + MatcherAssert.assertThat(client.getDefaultClientScopes().stream().map(ClientScopeRepresentation::getName).collect(Collectors.toList()), + Matchers.hasItem("service_account")); + + // perform a login and check the claims are there + oauth.clientId("service-account-client"); + OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("password"); + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); + org.junit.Assert.assertEquals("service-account-client", accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID)); + org.junit.Assert.assertNotNull(accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_HOST)); + org.junit.Assert.assertNotNull(accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ADDRESS)); + + // update the client to remove service account + clientRep.setServiceAccountsEnabled(Boolean.FALSE); + client.update(clientRep); + MatcherAssert.assertThat(client.getDefaultClientScopes().stream().map(ClientScopeRepresentation::getName).collect(Collectors.toList()), + Matchers.not(Matchers.hasItem("service_account"))); + response = oauth.doClientCredentialsGrantAccessTokenRequest("password"); + org.junit.Assert.assertEquals("unauthorized_client", response.getError()); + + // re-enable sevice accounts + clientRep.setServiceAccountsEnabled(Boolean.TRUE); + client.update(clientRep); + MatcherAssert.assertThat(client.getDefaultClientScopes().stream().map(ClientScopeRepresentation::getName).collect(Collectors.toList()), + Matchers.hasItem("service_account")); + response = oauth.doClientCredentialsGrantAccessTokenRequest("password"); + accessToken = oauth.verifyToken(response.getAccessToken()); + org.junit.Assert.assertEquals("service-account-client", accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID)); + org.junit.Assert.assertNotNull(accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_HOST)); + org.junit.Assert.assertNotNull(accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ADDRESS)); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmCreateTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmCreateTest.java index 106f4f53b5..9bafd62da2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmCreateTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmCreateTest.java @@ -109,7 +109,7 @@ public class KcAdmCreateTest extends AbstractAdmCliTest { Assert.assertEquals("consentRequired", false, client.isConsentRequired()); Assert.assertEquals("baseUrl", "http://localhost:8980/myapp", client.getBaseUrl()); Assert.assertEquals("bearerOnly", true, client.isStandardFlowEnabled()); - Assert.assertFalse("mappers not empty", client.getProtocolMappers().isEmpty()); + Assert.assertNull("mappers are not empty", client.getProtocolMappers()); // create configuration from file as a template and override clientId and other attributes ... output an object exe = execute("create clients --config '" + configFile.getName() + "' -o -f '" + tmpFile.getName() + @@ -133,7 +133,7 @@ public class KcAdmCreateTest extends AbstractAdmCliTest { Assert.assertEquals("baseUrl", "http://localhost:8980/myapp2", client2.getBaseUrl()); Assert.assertEquals("rootUrl", "http://localhost:8980/myapp2", client2.getRootUrl()); Assert.assertEquals("bearerOnly", true, client2.isStandardFlowEnabled()); - Assert.assertFalse("mappers not empty", client2.getProtocolMappers().isEmpty()); + Assert.assertNull("mappers are not empty", client2.getProtocolMappers()); } // simple create, output an id diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java index 1613456b6f..576a1458a8 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java @@ -27,6 +27,7 @@ import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.common.Profile; import org.keycloak.common.constants.KerberosConstants; +import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.models.Constants; import org.keycloak.models.LDAPConstants; import org.keycloak.models.credential.PasswordCredentialModel; @@ -731,6 +732,7 @@ public class ExportImportUtil { OIDCLoginProtocolFactory.MICROPROFILE_JWT_SCOPE, OIDCLoginProtocolFactory.ACR_SCOPE, OIDCLoginProtocolFactory.BASIC_SCOPE, + ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE, SamlProtocolFactory.SCOPE_ROLE_LIST )); 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 7ff814bc64..0722154dee 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 @@ -31,7 +31,6 @@ import org.keycloak.authentication.authenticators.broker.IdpReviewProfileAuthent import org.keycloak.authentication.authenticators.broker.IdpUsernamePasswordFormFactory; import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory; import org.keycloak.authentication.authenticators.conditional.ConditionalUserConfiguredAuthenticatorFactory; -import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.common.constants.KerberosConstants; import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.component.PrioritizedComponentModel; @@ -440,6 +439,10 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest { testLightweightClientAndFullScopeAllowed(migrationRealm, Constants.ADMIN_CLI_CLIENT_ID); } + protected void testMigrationTo26_1_0(boolean testIdentityProviderConfigMigration) { + testRealmDefaultClientScopes(migrationRealm); + } + private void testClientContainsExpectedClientScopes() { // Test OIDC client contains expected client scopes ClientResource migrationTestOIDCClient = ApiUtil.findClientByClientId(migrationRealm, "migration-test-client"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/MigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/MigrationTest.java index 502966c059..313367f1c2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/MigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/MigrationTest.java @@ -72,6 +72,7 @@ public class MigrationTest extends AbstractMigrationTest { testMigrationTo24_x(true, true); testMigrationTo25_0_0(); testMigrationTo26_0_0(true); + testMigrationTo26_1_0(true); } @Test @@ -85,5 +86,6 @@ public class MigrationTest extends AbstractMigrationTest { testMigrationTo25_0_0(); testMigrationTo26_0_0(true); + testMigrationTo26_1_0(true); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java index 9a2a76486c..0dbee05731 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java @@ -545,7 +545,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { oauth.scope(optionalScope); OAuthClient.AccessTokenResponse response1 = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", "password"); RefreshToken refreshToken1 = oauth.parseRefreshToken(response1.getRefreshToken()); - AbstractOIDCScopeTest.assertScopes("openid basic email roles web-origins acr profile address phone", refreshToken1.getScope()); + AbstractOIDCScopeTest.assertScopes("openid basic email roles web-origins acr profile address phone service_account", refreshToken1.getScope()); setTimeOffset(2); @@ -556,7 +556,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { AbstractOIDCScopeTest.assertScopes("openid email phone profile", response2.getScope()); RefreshToken refreshToken2 = oauth.parseRefreshToken(response2.getRefreshToken()); assertNotNull(refreshToken2); - AbstractOIDCScopeTest.assertScopes("openid acr roles phone address email profile basic web-origins", refreshToken2.getScope()); + AbstractOIDCScopeTest.assertScopes("openid acr roles phone address email profile basic web-origins service_account", refreshToken2.getScope()); } finally { setTimeOffset(0); @@ -574,7 +574,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { OAuthClient.AccessTokenResponse response1 = oauth.doAccessTokenRequest(code, "password"); RefreshToken refreshToken1 = oauth.parseRefreshToken(response1.getRefreshToken()); - AbstractOIDCScopeTest.assertScopes("openid basic email roles web-origins acr profile", refreshToken1.getScope()); + AbstractOIDCScopeTest.assertScopes("openid basic email roles web-origins acr profile service_account", refreshToken1.getScope()); setTimeOffset(2); @@ -608,7 +608,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); AbstractOIDCScopeTest.assertScopes("openid email profile", accessToken.getScope()); - AbstractOIDCScopeTest.assertScopes("openid basic email roles web-origins acr profile", refreshToken.getScope()); + AbstractOIDCScopeTest.assertScopes("openid basic email roles web-origins acr profile service_account", refreshToken.getScope()); Assert.assertNotNull(accessToken.getRealmAccess()); Assert.assertNotNull(accessToken.getResourceAccess()); @@ -621,7 +621,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); AbstractOIDCScopeTest.assertScopes("openid email profile", accessToken.getScope()); - AbstractOIDCScopeTest.assertScopes("openid basic email roles web-origins acr profile", refreshToken.getScope()); + AbstractOIDCScopeTest.assertScopes("openid basic email roles web-origins acr profile service_account", refreshToken.getScope()); Assert.assertNotNull(accessToken.getRealmAccess()); Assert.assertNotNull(accessToken.getResourceAccess()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AbstractWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AbstractWellKnownProviderTest.java index dd6dac73fb..774433c89b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AbstractWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AbstractWellKnownProviderTest.java @@ -30,6 +30,7 @@ import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.common.Profile; +import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.crypto.Algorithm; import org.keycloak.jose.jwe.JWEConstants; import org.keycloak.jose.jwk.JSONWebKeySet; @@ -386,7 +387,8 @@ public abstract class AbstractWellKnownProviderTest extends AbstractKeycloakTest protected void assertScopesSupportedMatchesWithRealm(OIDCConfigurationRepresentation oidcConfig) { Assert.assertNames(oidcConfig.getScopesSupported(), OAuth2Constants.SCOPE_OPENID, OAuth2Constants.OFFLINE_ACCESS, OAuth2Constants.SCOPE_PROFILE, OAuth2Constants.SCOPE_EMAIL, OAuth2Constants.SCOPE_PHONE, OAuth2Constants.SCOPE_ADDRESS, OIDCLoginProtocolFactory.ACR_SCOPE, OIDCLoginProtocolFactory.BASIC_SCOPE, - OIDCLoginProtocolFactory.ROLES_SCOPE, OIDCLoginProtocolFactory.WEB_ORIGINS_SCOPE, OIDCLoginProtocolFactory.MICROPROFILE_JWT_SCOPE, OAuth2Constants.ORGANIZATION); + OIDCLoginProtocolFactory.ROLES_SCOPE, OIDCLoginProtocolFactory.WEB_ORIGINS_SCOPE, OIDCLoginProtocolFactory.MICROPROFILE_JWT_SCOPE, OAuth2Constants.ORGANIZATION, + ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE); } protected OIDCConfigurationRepresentation getOIDCDiscoveryRepresentation(Client client, String uriTemplate) {