Add service account mappers via client scope instead of dedicated scope (#34664)

Closes #10417

Signed-off-by: rmartinc <rmartinc@redhat.com>


Co-authored-by: andymunro <48995441+andymunro@users.noreply.github.com>
Signed-off-by: Ricardo Martin <rmartinc@redhat.com>
This commit is contained in:
Ricardo Martin 2024-11-07 08:45:11 +01:00 committed by GitHub
parent fec661cf10
commit 226daa41c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 253 additions and 48 deletions

View file

@ -35,4 +35,6 @@ public interface ServiceAccountConstants {
String CLIENT_HOST = "clientHost"; String CLIENT_HOST = "clientHost";
String CLIENT_ADDRESS = "clientAddress"; String CLIENT_ADDRESS = "clientAddress";
String SERVICE_ACCOUNT_SCOPE = "service_account";
} }

View file

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

View file

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

View file

@ -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.MigrateTo24_0_3;
import org.keycloak.migration.migrators.MigrateTo25_0_0; import org.keycloak.migration.migrators.MigrateTo25_0_0;
import org.keycloak.migration.migrators.MigrateTo26_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_0_0;
import org.keycloak.migration.migrators.MigrateTo2_1_0; import org.keycloak.migration.migrators.MigrateTo2_1_0;
import org.keycloak.migration.migrators.MigrateTo2_2_0; import org.keycloak.migration.migrators.MigrateTo2_2_0;
@ -121,6 +122,7 @@ public class DefaultMigrationManager implements MigrationManager {
new MigrateTo24_0_3(), new MigrateTo24_0_3(),
new MigrateTo25_0_0(), new MigrateTo25_0_0(),
new MigrateTo26_0_0(), new MigrateTo26_0_0(),
new MigrateTo26_1_0(),
}; };
private final KeycloakSession session; private final KeycloakSession session;

View file

@ -85,4 +85,12 @@ public interface MigrationProvider extends Provider {
* @return created or already existing client scope 'basic' * @return created or already existing client scope 'basic'
*/ */
ClientScopeModel addOIDCBasicClientScope(RealmModel realm); 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);
} }

View file

@ -21,6 +21,7 @@ import org.keycloak.Config;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.common.constants.KerberosConstants; import org.keycloak.common.constants.KerberosConstants;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.UriUtils; import org.keycloak.common.util.UriUtils;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
@ -298,6 +299,7 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory {
addMicroprofileJWTClientScope(newRealm); addMicroprofileJWTClientScope(newRealm);
addAcrClientScope(newRealm); addAcrClientScope(newRealm);
addBasicClientScope(newRealm); addBasicClientScope(newRealm);
addServiceAccountClientScope(newRealm);
if (Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) { if (Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
ClientScopeModel organizationScope = newRealm.addClientScope(OAuth2Constants.ORGANIZATION); ClientScopeModel organizationScope = newRealm.addClientScope(OAuth2Constants.ORGANIZATION);
@ -426,6 +428,38 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory {
return basicScope; 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 @Override
protected void addDefaults(ClientModel client) { protected void addDefaults(ClientModel client) {
} }

View file

@ -28,9 +28,11 @@ import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext; import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.AccessTokenResponse;
@ -79,8 +81,9 @@ public class ClientCredentialsGrantType extends OAuth2GrantTypeBase {
} }
UserModel clientUser = session.users().getServiceAccount(client); 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 // 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()); 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); new ClientManager(new RealmManager(session)).enableServiceAccount(client);

View file

@ -25,17 +25,16 @@ import org.keycloak.common.Profile;
import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserManager; import org.keycloak.models.UserManager;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionProvider; import org.keycloak.models.UserSessionProvider;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.models.utils.RepresentationToModel;
import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocolFactory; 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.SamlClient;
import org.keycloak.protocol.saml.SamlConfigAttributes; import org.keycloak.protocol.saml.SamlConfigAttributes;
import org.keycloak.protocol.saml.SamlProtocol; 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) // 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) { if (!Profile.isFeatureEnabled(Profile.Feature.CLIENT_TYPES) || client.getType() == null) {
addServiceAccountProtocolMappers(client); addServiceAccountProtocolMappersViaScope(client);
} }
} }
private void addServiceAccountProtocolMappers(ClientModel client) { public void disableServiceAccount(ClientModel client) {
if (client.getProtocolMapperByName(OIDCLoginProtocol.LOGIN_PROTOCOL, ServiceAccountConstants.CLIENT_ID_PROTOCOL_MAPPER) == null) { client.setServiceAccountsEnabled(false);
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, // remove the service account
ServiceAccountConstants.CLIENT_ID, UserModel serviceAccount = realmManager.getSession().users().getServiceAccount(client);
ServiceAccountConstants.CLIENT_ID, "String", if (serviceAccount != null) {
true, true, true); new UserManager(realmManager.getSession()).removeUser(client.getRealm(), serviceAccount);
client.addProtocolMapper(protocolMapper);
} }
// Add protocol mappers to retrieve hostname and IP address of client in access token // 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 (client.getProtocolMapperByName(OIDCLoginProtocol.LOGIN_PROTOCOL, ServiceAccountConstants.CLIENT_HOST_PROTOCOL_MAPPER) == null) { if (!Profile.isFeatureEnabled(Profile.Feature.CLIENT_TYPES) || client.getType() == null) {
logger.debugf("Creating service account protocol mapper '%s' for client '%s'", ServiceAccountConstants.CLIENT_HOST_PROTOCOL_MAPPER, client.getClientId()); removeServiceAccountProtocolMappersViaScope(client);
ProtocolMapperModel protocolMapper = UserSessionNoteMapper.createClaimMapper(ServiceAccountConstants.CLIENT_HOST_PROTOCOL_MAPPER,
ServiceAccountConstants.CLIENT_HOST,
ServiceAccountConstants.CLIENT_HOST, "String",
true, true, true);
client.addProtocolMapper(protocolMapper);
} }
}
if (client.getProtocolMapperByName(OIDCLoginProtocol.LOGIN_PROTOCOL, ServiceAccountConstants.CLIENT_ADDRESS_PROTOCOL_MAPPER) == null) { private void addServiceAccountProtocolMappersViaScope(ClientModel client) {
logger.debugf("Creating service account protocol mapper '%s' for client '%s'", ServiceAccountConstants.CLIENT_ADDRESS_PROTOCOL_MAPPER, client.getClientId()); ClientScopeModel serviceAccountScope = KeycloakModelUtils.getClientScopeByName(client.getRealm(), ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE);
ProtocolMapperModel protocolMapper = UserSessionNoteMapper.createClaimMapper(ServiceAccountConstants.CLIENT_ADDRESS_PROTOCOL_MAPPER,
ServiceAccountConstants.CLIENT_ADDRESS, if (serviceAccountScope != null) {
ServiceAccountConstants.CLIENT_ADDRESS, "String", client.addClientScope(serviceAccountScope, true);
true, true, true); } else {
client.addProtocolMapper(protocolMapper); 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());
} }
} }

View file

@ -112,6 +112,11 @@ public class DefaultMigrationProvider implements MigrationProvider {
return getOIDCLoginProtocolFactory().addBasicClientScope(realm); return getOIDCLoginProtocolFactory().addBasicClientScope(realm);
} }
@Override
public ClientScopeModel addOIDCServiceAccountClientScope(RealmModel realm) {
return getOIDCLoginProtocolFactory().addServiceAccountClientScope(realm);
}
@Override @Override
public void close() { public void close() {
} }

View file

@ -26,12 +26,9 @@ import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.NoCache; import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.OAuthErrorException; import org.keycloak.OAuthErrorException;
import org.keycloak.authorization.admin.AuthorizationService; import org.keycloak.authorization.admin.AuthorizationService;
import org.keycloak.client.clienttype.ClientType;
import org.keycloak.client.clienttype.ClientTypeException; import org.keycloak.client.clienttype.ClientTypeException;
import org.keycloak.client.clienttype.ClientTypeManager;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.OperationType;
@ -45,7 +42,6 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserManager;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
@ -99,8 +95,6 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Stream; import java.util.stream.Stream;
import static java.lang.Boolean.TRUE;
/** /**
* Base resource class for managing one particular client of a realm. * 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 { private void updateClientFromRep(ClientRepresentation rep, ClientModel client, KeycloakSession session) throws ModelDuplicateException {
UserModel serviceAccount = this.session.users().getServiceAccount(client); UserModel serviceAccount = this.session.users().getServiceAccount(client);
if (TRUE.equals(rep.isServiceAccountsEnabled())) { if (Boolean.TRUE.equals(rep.isServiceAccountsEnabled())) {
if (serviceAccount == null) { if (serviceAccount == null) {
new ClientManager(new RealmManager(session)).enableServiceAccount(client); new ClientManager(new RealmManager(session)).enableServiceAccount(client);
} }
} } else if (Boolean.FALSE.equals(rep.isServiceAccountsEnabled()) || !client.isServiceAccountsEnabled()) {
else {
if (serviceAccount != null) { 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) { private void updateAuthorizationSettings(ClientRepresentation rep) {
if (Profile.isFeatureEnabled(Profile.Feature.AUTHORIZATION)) { if (Profile.isFeatureEnabled(Profile.Feature.AUTHORIZATION)) {
if (TRUE.equals(rep.getAuthorizationServicesEnabled())) { if (Boolean.TRUE.equals(rep.getAuthorizationServicesEnabled())) {
authorization().enable(false); authorization().enable(false);
} else { } else {
authorization().disable(); authorization().disable();

View file

@ -134,6 +134,7 @@ public abstract class AbstractClientTest extends AbstractAuthTest {
Response resp = testRealmResource().clients().create(clientRep); Response resp = testRealmResource().clients().create(clientRep);
resp.close(); resp.close();
String id = ApiUtil.getCreatedId(resp); String id = ApiUtil.getCreatedId(resp);
clientRep.setSecret(null);
assertAdminEvents.assertEvent(getRealmId(), OperationType.CREATE, AdminEventPaths.clientResourcePath(id), clientRep, ResourceType.CLIENT); assertAdminEvents.assertEvent(getRealmId(), OperationType.CREATE, AdminEventPaths.clientResourcePath(id), clientRep, ResourceType.CLIENT);

View file

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

View file

@ -109,7 +109,7 @@ public class KcAdmCreateTest extends AbstractAdmCliTest {
Assert.assertEquals("consentRequired", false, client.isConsentRequired()); Assert.assertEquals("consentRequired", false, client.isConsentRequired());
Assert.assertEquals("baseUrl", "http://localhost:8980/myapp", client.getBaseUrl()); Assert.assertEquals("baseUrl", "http://localhost:8980/myapp", client.getBaseUrl());
Assert.assertEquals("bearerOnly", true, client.isStandardFlowEnabled()); 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 // 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() + 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("baseUrl", "http://localhost:8980/myapp2", client2.getBaseUrl());
Assert.assertEquals("rootUrl", "http://localhost:8980/myapp2", client2.getRootUrl()); Assert.assertEquals("rootUrl", "http://localhost:8980/myapp2", client2.getRootUrl());
Assert.assertEquals("bearerOnly", true, client2.isStandardFlowEnabled()); 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 // simple create, output an id

View file

@ -27,6 +27,7 @@ import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource; import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.common.constants.KerberosConstants; import org.keycloak.common.constants.KerberosConstants;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.models.LDAPConstants; import org.keycloak.models.LDAPConstants;
import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.models.credential.PasswordCredentialModel;
@ -731,6 +732,7 @@ public class ExportImportUtil {
OIDCLoginProtocolFactory.MICROPROFILE_JWT_SCOPE, OIDCLoginProtocolFactory.MICROPROFILE_JWT_SCOPE,
OIDCLoginProtocolFactory.ACR_SCOPE, OIDCLoginProtocolFactory.ACR_SCOPE,
OIDCLoginProtocolFactory.BASIC_SCOPE, OIDCLoginProtocolFactory.BASIC_SCOPE,
ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE,
SamlProtocolFactory.SCOPE_ROLE_LIST SamlProtocolFactory.SCOPE_ROLE_LIST
)); ));

View file

@ -31,7 +31,6 @@ import org.keycloak.authentication.authenticators.broker.IdpReviewProfileAuthent
import org.keycloak.authentication.authenticators.broker.IdpUsernamePasswordFormFactory; import org.keycloak.authentication.authenticators.broker.IdpUsernamePasswordFormFactory;
import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory; import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory;
import org.keycloak.authentication.authenticators.conditional.ConditionalUserConfiguredAuthenticatorFactory; import org.keycloak.authentication.authenticators.conditional.ConditionalUserConfiguredAuthenticatorFactory;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.common.constants.KerberosConstants; import org.keycloak.common.constants.KerberosConstants;
import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.PrioritizedComponentModel; import org.keycloak.component.PrioritizedComponentModel;
@ -440,6 +439,10 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
testLightweightClientAndFullScopeAllowed(migrationRealm, Constants.ADMIN_CLI_CLIENT_ID); testLightweightClientAndFullScopeAllowed(migrationRealm, Constants.ADMIN_CLI_CLIENT_ID);
} }
protected void testMigrationTo26_1_0(boolean testIdentityProviderConfigMigration) {
testRealmDefaultClientScopes(migrationRealm);
}
private void testClientContainsExpectedClientScopes() { private void testClientContainsExpectedClientScopes() {
// Test OIDC client contains expected client scopes // Test OIDC client contains expected client scopes
ClientResource migrationTestOIDCClient = ApiUtil.findClientByClientId(migrationRealm, "migration-test-client"); ClientResource migrationTestOIDCClient = ApiUtil.findClientByClientId(migrationRealm, "migration-test-client");

View file

@ -72,6 +72,7 @@ public class MigrationTest extends AbstractMigrationTest {
testMigrationTo24_x(true, true); testMigrationTo24_x(true, true);
testMigrationTo25_0_0(); testMigrationTo25_0_0();
testMigrationTo26_0_0(true); testMigrationTo26_0_0(true);
testMigrationTo26_1_0(true);
} }
@Test @Test
@ -85,5 +86,6 @@ public class MigrationTest extends AbstractMigrationTest {
testMigrationTo25_0_0(); testMigrationTo25_0_0();
testMigrationTo26_0_0(true); testMigrationTo26_0_0(true);
testMigrationTo26_1_0(true);
} }
} }

View file

@ -545,7 +545,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
oauth.scope(optionalScope); oauth.scope(optionalScope);
OAuthClient.AccessTokenResponse response1 = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", "password"); OAuthClient.AccessTokenResponse response1 = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", "password");
RefreshToken refreshToken1 = oauth.parseRefreshToken(response1.getRefreshToken()); 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); setTimeOffset(2);
@ -556,7 +556,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
AbstractOIDCScopeTest.assertScopes("openid email phone profile", response2.getScope()); AbstractOIDCScopeTest.assertScopes("openid email phone profile", response2.getScope());
RefreshToken refreshToken2 = oauth.parseRefreshToken(response2.getRefreshToken()); RefreshToken refreshToken2 = oauth.parseRefreshToken(response2.getRefreshToken());
assertNotNull(refreshToken2); 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 { } finally {
setTimeOffset(0); setTimeOffset(0);
@ -574,7 +574,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
OAuthClient.AccessTokenResponse response1 = oauth.doAccessTokenRequest(code, "password"); OAuthClient.AccessTokenResponse response1 = oauth.doAccessTokenRequest(code, "password");
RefreshToken refreshToken1 = oauth.parseRefreshToken(response1.getRefreshToken()); 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); setTimeOffset(2);
@ -608,7 +608,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
AbstractOIDCScopeTest.assertScopes("openid email profile", accessToken.getScope()); 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.getRealmAccess());
Assert.assertNotNull(accessToken.getResourceAccess()); Assert.assertNotNull(accessToken.getResourceAccess());
@ -621,7 +621,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
AbstractOIDCScopeTest.assertScopes("openid email profile", accessToken.getScope()); 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.getRealmAccess());
Assert.assertNotNull(accessToken.getResourceAccess()); Assert.assertNotNull(accessToken.getResourceAccess());

View file

@ -30,6 +30,7 @@ import org.junit.Test;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.crypto.Algorithm; import org.keycloak.crypto.Algorithm;
import org.keycloak.jose.jwe.JWEConstants; import org.keycloak.jose.jwe.JWEConstants;
import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JSONWebKeySet;
@ -386,7 +387,8 @@ public abstract class AbstractWellKnownProviderTest extends AbstractKeycloakTest
protected void assertScopesSupportedMatchesWithRealm(OIDCConfigurationRepresentation oidcConfig) { protected void assertScopesSupportedMatchesWithRealm(OIDCConfigurationRepresentation oidcConfig) {
Assert.assertNames(oidcConfig.getScopesSupported(), OAuth2Constants.SCOPE_OPENID, OAuth2Constants.OFFLINE_ACCESS, 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, 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) { protected OIDCConfigurationRepresentation getOIDCDiscoveryRepresentation(Client client, String uriTemplate) {