keycloak-uncached
Changes
forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js 19(+19 -0)
forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-service-accounts.html 28(+28 -0)
forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-client.html 5(+5 -0)
model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/ClientAdapter.java 12(+12 -0)
model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java 5(+5 -0)
model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java 6(+6 -0)
model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java 5(+5 -0)
model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java 13(+13 -0)
Details
diff --git a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.4.0.xml b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.4.0.xml
index cad6241..103c7ce 100755
--- a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.4.0.xml
+++ b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.4.0.xml
@@ -8,6 +8,12 @@
<delete tableName="CLIENT_SESSION"/>
<delete tableName="USER_SESSION_NOTE"/>
<delete tableName="USER_SESSION"/>
+
+ <addColumn tableName="CLIENT">
+ <column name="SERVICE_ACCOUNTS_ENABLED" type="BOOLEAN" defaultValueBoolean="false">
+ <constraints nullable="false"/>
+ </column>
+ </addColumn>
<addColumn tableName="CLIENT_SESSION">
<column name="CURRENT_ACTION" type="VARCHAR(36)">
<constraints nullable="true"/>
diff --git a/core/src/main/java/org/keycloak/constants/ServiceAccountConstants.java b/core/src/main/java/org/keycloak/constants/ServiceAccountConstants.java
new file mode 100644
index 0000000..928f62d
--- /dev/null
+++ b/core/src/main/java/org/keycloak/constants/ServiceAccountConstants.java
@@ -0,0 +1,20 @@
+package org.keycloak.constants;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public interface ServiceAccountConstants {
+
+ String CLIENT_AUTH = "client_auth";
+
+ String SERVICE_ACCOUNT_USER_PREFIX = "service-account-";
+ String SERVICE_ACCOUNT_CLIENT_ATTRIBUTE = "serviceAccountClient";
+
+ String CLIENT_ID_PROTOCOL_MAPPER = "Client ID";
+ String CLIENT_HOST_PROTOCOL_MAPPER = "Client Host";
+ String CLIENT_ADDRESS_PROTOCOL_MAPPER = "Client IP Address";
+ String CLIENT_ID = "clientId";
+ String CLIENT_HOST = "clientHost";
+ String CLIENT_ADDRESS = "clientAddress";
+
+}
diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java
index 5aba901..493748a 100644
--- a/core/src/main/java/org/keycloak/OAuth2Constants.java
+++ b/core/src/main/java/org/keycloak/OAuth2Constants.java
@@ -29,6 +29,8 @@ public interface OAuth2Constants {
String PASSWORD = "password";
+ String CLIENT_CREDENTIALS = "client_credentials";
+
}
diff --git a/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java
index 0ffb980..e5ab503 100755
--- a/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java
+++ b/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java
@@ -22,6 +22,7 @@ public class ClientRepresentation {
protected Integer notBefore;
protected Boolean bearerOnly;
protected Boolean consentRequired;
+ protected Boolean serviceAccountsEnabled;
protected Boolean directGrantsOnly;
protected Boolean publicClient;
protected Boolean frontchannelLogout;
@@ -144,6 +145,14 @@ public class ClientRepresentation {
this.consentRequired = consentRequired;
}
+ public Boolean isServiceAccountsEnabled() {
+ return serviceAccountsEnabled;
+ }
+
+ public void setServiceAccountsEnabled(Boolean serviceAccountsEnabled) {
+ this.serviceAccountsEnabled = serviceAccountsEnabled;
+ }
+
public Boolean isDirectGrantsOnly() {
return directGrantsOnly;
}
diff --git a/events/api/src/main/java/org/keycloak/events/Details.java b/events/api/src/main/java/org/keycloak/events/Details.java
index 38ff340..23cc2f7 100755
--- a/events/api/src/main/java/org/keycloak/events/Details.java
+++ b/events/api/src/main/java/org/keycloak/events/Details.java
@@ -35,4 +35,10 @@ public interface Details {
String IMPERSONATOR_REALM = "impersonator_realm";
String IMPERSONATOR = "impersonator";
+ String CLIENT_AUTH_METHOD = "client_auth_method";
+ String CLIENT_AUTH_METHOD_VALUE_CLIENT_CREDENTIALS = "client_credentials";
+ String CLIENT_AUTH_METHOD_VALUE_CERTIFICATE = "client_certificate";
+ String CLIENT_AUTH_METHOD_VALUE_KERBEROS_KEYTAB = "kerberos_keytab";
+ String CLIENT_AUTH_METHOD_VALUE_SIGNED_JWT = "signed_jwt";
+
}
diff --git a/events/api/src/main/java/org/keycloak/events/EventType.java b/events/api/src/main/java/org/keycloak/events/EventType.java
index be72f06..d8c0d19 100755
--- a/events/api/src/main/java/org/keycloak/events/EventType.java
+++ b/events/api/src/main/java/org/keycloak/events/EventType.java
@@ -15,6 +15,9 @@ public enum EventType {
CODE_TO_TOKEN(true),
CODE_TO_TOKEN_ERROR(true),
+ CLIENT_LOGIN(true),
+ CLIENT_LOGIN_ERROR(true),
+
REFRESH_TOKEN(false),
REFRESH_TOKEN_ERROR(false),
VALIDATE_ACCESS_TOKEN(false),
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/app.js b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/app.js
index 3f8618b..a500ad5 100755
--- a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/app.js
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/app.js
@@ -762,6 +762,18 @@ module.config([ '$routeProvider', function($routeProvider) {
},
controller : 'ClientInstallationCtrl'
})
+ .when('/realms/:realm/clients/:client/service-accounts', {
+ templateUrl : resourceUrl + '/partials/client-service-accounts.html',
+ resolve : {
+ realm : function(RealmLoader) {
+ return RealmLoader();
+ },
+ client : function(ClientLoader) {
+ return ClientLoader();
+ }
+ },
+ controller : 'ClientServiceAccountsCtrl'
+ })
.when('/create/client/:realm', {
templateUrl : resourceUrl + '/partials/client-detail.html',
resolve : {
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js
index 5b3ddde..1316766 100755
--- a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js
@@ -1298,6 +1298,25 @@ module.controller('ClientProtocolMapperCreateCtrl', function($scope, realm, serv
});
+module.controller('ClientServiceAccountsCtrl', function($scope, $http, realm, client, Notifications, Client) {
+ $scope.realm = realm;
+ $scope.client = angular.copy(client);
+
+ $scope.serviceAccountsEnabledChanged = function() {
+ if (client.serviceAccountsEnabled != $scope.client.serviceAccountsEnabled) {
+ Client.update({
+ realm : realm.realm,
+ client : client.id
+ }, $scope.client, function() {
+ $scope.changed = false;
+ client = angular.copy($scope.client);
+ Notifications.success("Service Account settings updated.");
+ });
+ }
+ }
+
+});
+
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-service-accounts.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-service-accounts.html
new file mode 100644
index 0000000..1e5f0e5
--- /dev/null
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-service-accounts.html
@@ -0,0 +1,28 @@
+<div class="col-sm-9 col-md-10 col-sm-push-3 col-md-push-2">
+
+ <ol class="breadcrumb">
+ <li><a href="#/realms/{{realm.realm}}/clients">Clients</a></li>
+ <li>{{client.clientId}}</li>
+ </ol>
+
+ <h1>{{client.clientId|capitalize}}</h1>
+
+ <kc-tabs-client></kc-tabs-client>
+
+ <h2><span>{{client.clientId}}</span> Service Accounts </h2>
+ <p class="subtitle"></p>
+ <form class="form-horizontal" name="serviceAccountsEnabledForm" novalidate kc-read-only="!access.manageClients">
+ <fieldset class="border-top">
+ <div class="form-group">
+ <label class="col-md-2 control-label" for="serviceAccountsEnabled">Service Accounts Enabled</label>
+ <kc-tooltip>Allows you to authenticate this client to Keycloak and retrieve access token dedicated to this client.</kc-tooltip>
+ <div class="col-md-6">
+ <input ng-model="client.serviceAccountsEnabled" ng-click="serviceAccountsEnabledChanged()" name="serviceAccountsEnabled" id="serviceAccountsEnabled" onoffswitch />
+ </div>
+ </div>
+ </fieldset>
+ </form>
+
+</div>
+
+<kc-menu></kc-menu>
\ No newline at end of file
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-client.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-client.html
index fc1d669..e350e5d 100755
--- a/forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-client.html
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-client.html
@@ -25,4 +25,9 @@
<kc-tooltip>Helper utility for generating various client adapter configuration formats which you can download or cut and paste to configure your clients.</kc-tooltip>
</li>
+ <li ng-class="{active: path[4] == 'service-accounts'}" data-ng-show="!client.publicClient && !client.bearerOnly">
+ <a href="#/realms/{{realm.realm}}/clients/{{client.id}}/service-accounts">Service Accounts</a>
+ <kc-tooltip>Allows you to authenticate this client to Keycloak and retrieve access tokens dedicated to this client.</kc-tooltip>
+ </li>
+
</ul>
\ No newline at end of file
diff --git a/model/api/src/main/java/org/keycloak/models/ClientModel.java b/model/api/src/main/java/org/keycloak/models/ClientModel.java
index 689f4cc..0f0d969 100755
--- a/model/api/src/main/java/org/keycloak/models/ClientModel.java
+++ b/model/api/src/main/java/org/keycloak/models/ClientModel.java
@@ -103,6 +103,9 @@ public interface ClientModel extends RoleContainerModel {
boolean isConsentRequired();
void setConsentRequired(boolean consentRequired);
+ boolean isServiceAccountsEnabled();
+ void setServiceAccountsEnabled(boolean serviceAccountsEnabled);
+
Set<RoleModel> getScopeMappings();
void addScopeMapping(RoleModel role);
void deleteScopeMapping(RoleModel role);
diff --git a/model/api/src/main/java/org/keycloak/models/entities/ClientEntity.java b/model/api/src/main/java/org/keycloak/models/entities/ClientEntity.java
index 109806e..8e0c21b 100755
--- a/model/api/src/main/java/org/keycloak/models/entities/ClientEntity.java
+++ b/model/api/src/main/java/org/keycloak/models/entities/ClientEntity.java
@@ -26,6 +26,7 @@ public class ClientEntity extends AbstractIdentifiableEntity {
private String baseUrl;
private boolean bearerOnly;
private boolean consentRequired;
+ private boolean serviceAccountsEnabled;
private boolean directGrantsOnly;
private int nodeReRegistrationTimeout;
@@ -210,6 +211,14 @@ public class ClientEntity extends AbstractIdentifiableEntity {
this.consentRequired = consentRequired;
}
+ public boolean isServiceAccountsEnabled() {
+ return serviceAccountsEnabled;
+ }
+
+ public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) {
+ this.serviceAccountsEnabled = serviceAccountsEnabled;
+ }
+
public boolean isDirectGrantsOnly() {
return directGrantsOnly;
}
diff --git a/model/api/src/main/java/org/keycloak/models/UserFederationManager.java b/model/api/src/main/java/org/keycloak/models/UserFederationManager.java
index 8e34a8a..23aaf1b 100755
--- a/model/api/src/main/java/org/keycloak/models/UserFederationManager.java
+++ b/model/api/src/main/java/org/keycloak/models/UserFederationManager.java
@@ -305,6 +305,11 @@ public class UserFederationManager implements UserProvider {
}
@Override
+ public List<UserModel> searchForUserByUserAttributes(Map<String, String> attributes, RealmModel realm) {
+ return session.userStorage().searchForUserByUserAttributes(attributes, realm);
+ }
+
+ @Override
public Set<FederatedIdentityModel> getFederatedIdentities(UserModel user, RealmModel realm) {
validateUser(realm, user);
if (user == null) throw new IllegalStateException("Federated user no longer valid");
diff --git a/model/api/src/main/java/org/keycloak/models/UserProvider.java b/model/api/src/main/java/org/keycloak/models/UserProvider.java
index eed008f..f48062f 100755
--- a/model/api/src/main/java/org/keycloak/models/UserProvider.java
+++ b/model/api/src/main/java/org/keycloak/models/UserProvider.java
@@ -32,6 +32,10 @@ public interface UserProvider extends Provider {
List<UserModel> searchForUser(String search, RealmModel realm, int firstResult, int maxResults);
List<UserModel> searchForUserByAttributes(Map<String, String> attributes, RealmModel realm);
List<UserModel> searchForUserByAttributes(Map<String, String> attributes, RealmModel realm, int firstResult, int maxResults);
+
+ // Searching by UserModel.attribute (not property)
+ List<UserModel> searchForUserByUserAttributes(Map<String, String> attributes, RealmModel realm);
+
Set<FederatedIdentityModel> getFederatedIdentities(UserModel user, RealmModel realm);
FederatedIdentityModel getFederatedIdentity(UserModel user, String socialProvider, RealmModel realm);
diff --git a/model/api/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java b/model/api/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java
index c98a114..f5261a0 100755
--- a/model/api/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java
+++ b/model/api/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java
@@ -351,6 +351,6 @@ public final class KeycloakModelUtils {
}
public static String toLowerCaseSafe(String str) {
- return str==null ? str : str.toLowerCase();
+ return str==null ? null : str.toLowerCase();
}
}
diff --git a/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
index ebe4ed3..c2b14cd 100755
--- a/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
+++ b/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
@@ -289,6 +289,7 @@ public class ModelToRepresentation {
rep.setFullScopeAllowed(clientModel.isFullScopeAllowed());
rep.setBearerOnly(clientModel.isBearerOnly());
rep.setConsentRequired(clientModel.isConsentRequired());
+ rep.setServiceAccountsEnabled(clientModel.isServiceAccountsEnabled());
rep.setDirectGrantsOnly(clientModel.isDirectGrantsOnly());
rep.setSurrogateAuthRequired(clientModel.isSurrogateAuthRequired());
rep.setBaseUrl(clientModel.getBaseUrl());
diff --git a/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
index 0e87282..a38b305 100755
--- a/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
+++ b/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
@@ -625,6 +625,7 @@ public class RepresentationToModel {
if (resourceRep.getBaseUrl() != null) client.setBaseUrl(resourceRep.getBaseUrl());
if (resourceRep.isBearerOnly() != null) client.setBearerOnly(resourceRep.isBearerOnly());
if (resourceRep.isConsentRequired() != null) client.setConsentRequired(resourceRep.isConsentRequired());
+ if (resourceRep.isServiceAccountsEnabled() != null) client.setServiceAccountsEnabled(resourceRep.isServiceAccountsEnabled());
if (resourceRep.isDirectGrantsOnly() != null) client.setDirectGrantsOnly(resourceRep.isDirectGrantsOnly());
if (resourceRep.isPublicClient() != null) client.setPublicClient(resourceRep.isPublicClient());
if (resourceRep.isFrontchannelLogout() != null) client.setFrontchannelLogout(resourceRep.isFrontchannelLogout());
@@ -714,6 +715,7 @@ public class RepresentationToModel {
if (rep.isEnabled() != null) resource.setEnabled(rep.isEnabled());
if (rep.isBearerOnly() != null) resource.setBearerOnly(rep.isBearerOnly());
if (rep.isConsentRequired() != null) resource.setConsentRequired(rep.isConsentRequired());
+ if (rep.isServiceAccountsEnabled() != null) resource.setServiceAccountsEnabled(rep.isServiceAccountsEnabled());
if (rep.isDirectGrantsOnly() != null) resource.setDirectGrantsOnly(rep.isDirectGrantsOnly());
if (rep.isPublicClient() != null) resource.setPublicClient(rep.isPublicClient());
if (rep.isFullScopeAllowed() != null) resource.setFullScopeAllowed(rep.isFullScopeAllowed());
diff --git a/model/file/src/main/java/org/keycloak/models/file/adapter/ClientAdapter.java b/model/file/src/main/java/org/keycloak/models/file/adapter/ClientAdapter.java
index 85ab05f..87f0ee3 100755
--- a/model/file/src/main/java/org/keycloak/models/file/adapter/ClientAdapter.java
+++ b/model/file/src/main/java/org/keycloak/models/file/adapter/ClientAdapter.java
@@ -442,6 +442,16 @@ public class ClientAdapter implements ClientModel {
}
@Override
+ public boolean isServiceAccountsEnabled() {
+ return entity.isServiceAccountsEnabled();
+ }
+
+ @Override
+ public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) {
+ entity.setServiceAccountsEnabled(serviceAccountsEnabled);
+ }
+
+ @Override
public boolean isDirectGrantsOnly() {
return entity.isDirectGrantsOnly();
}
diff --git a/model/file/src/main/java/org/keycloak/models/file/FileUserProvider.java b/model/file/src/main/java/org/keycloak/models/file/FileUserProvider.java
index 9416170..ff152f8 100755
--- a/model/file/src/main/java/org/keycloak/models/file/FileUserProvider.java
+++ b/model/file/src/main/java/org/keycloak/models/file/FileUserProvider.java
@@ -38,6 +38,7 @@ import org.keycloak.models.utils.CredentialValidation;
import org.keycloak.models.utils.KeycloakModelUtils;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
@@ -226,6 +227,25 @@ public class FileUserProvider implements UserProvider {
}
@Override
+ public List<UserModel> searchForUserByUserAttributes(Map<String, String> attributes, RealmModel realm) {
+ Collection<UserModel> users = inMemoryModel.getUsers(realm.getId());
+
+ for (Map.Entry<String, String> entry : attributes.entrySet()) {
+
+ List<UserModel> matchedUsers = new ArrayList<>();
+ for (UserModel user : users) {
+ List<String> vals = user.getAttribute(entry.getKey());
+ if (vals.contains(entry.getValue())) {
+ matchedUsers.add(user);
+ }
+ }
+ users = matchedUsers;
+ }
+
+ return (List<UserModel>) users;
+ }
+
+ @Override
public Set<FederatedIdentityModel> getFederatedIdentities(UserModel userModel, RealmModel realm) {
UserEntity userEntity = ((UserAdapter)getUserById(userModel.getId(), realm)).getUserEntity();
List<FederatedIdentityEntity> linkEntities = userEntity.getFederatedIdentities();
diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/ClientAdapter.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/ClientAdapter.java
index bc31941..5c6f382 100755
--- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/ClientAdapter.java
+++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/ClientAdapter.java
@@ -413,6 +413,18 @@ public class ClientAdapter implements ClientModel {
}
@Override
+ public boolean isServiceAccountsEnabled() {
+ if (updated != null) return updated.isServiceAccountsEnabled();
+ return cached.isServiceAccountsEnabled();
+ }
+
+ @Override
+ public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) {
+ getDelegateForUpdate();
+ updated.setServiceAccountsEnabled(serviceAccountsEnabled);
+ }
+
+ @Override
public RoleModel getRole(String name) {
if (updated != null) return updated.getRole(name);
String id = cached.getRoles().get(name);
diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java
index 2f12c9f..4e99e44 100755
--- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java
+++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java
@@ -242,6 +242,11 @@ public class DefaultCacheUserProvider implements CacheUserProvider {
}
@Override
+ public List<UserModel> searchForUserByUserAttributes(Map<String, String> attributes, RealmModel realm) {
+ return getDelegate().searchForUserByUserAttributes(attributes, realm);
+ }
+
+ @Override
public Set<FederatedIdentityModel> getFederatedIdentities(UserModel user, RealmModel realm) {
return getDelegate().getFederatedIdentities(user, realm);
}
diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java
index 1133bdb..911021e 100755
--- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java
+++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java
@@ -46,6 +46,7 @@ public class CachedClient implements Serializable {
private List<String> defaultRoles = new LinkedList<String>();
private boolean bearerOnly;
private boolean consentRequired;
+ private boolean serviceAccountsEnabled;
private Map<String, String> roles = new HashMap<String, String>();
private int nodeReRegistrationTimeout;
private Map<String, Integer> registeredNodes;
@@ -78,6 +79,7 @@ public class CachedClient implements Serializable {
defaultRoles.addAll(model.getDefaultRoles());
bearerOnly = model.isBearerOnly();
consentRequired = model.isConsentRequired();
+ serviceAccountsEnabled = model.isServiceAccountsEnabled();
for (RoleModel role : model.getRoles()) {
roles.put(role.getName(), role.getId());
cache.addCachedRole(new CachedClientRole(id, role, realm));
@@ -178,6 +180,10 @@ public class CachedClient implements Serializable {
return consentRequired;
}
+ public boolean isServiceAccountsEnabled() {
+ return serviceAccountsEnabled;
+ }
+
public Map<String, String> getRoles() {
return roles;
}
diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java
index 6137a91..3abe72f 100755
--- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java
+++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java
@@ -109,6 +109,11 @@ public class NoCacheUserProvider implements CacheUserProvider {
}
@Override
+ public List<UserModel> searchForUserByUserAttributes(Map<String, String> attributes, RealmModel realm) {
+ return getDelegate().searchForUserByUserAttributes(attributes, realm);
+ }
+
+ @Override
public Set<FederatedIdentityModel> getFederatedIdentities(UserModel user, RealmModel realm) {
return getDelegate().getFederatedIdentities(user, realm);
}
diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/ClientAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/ClientAdapter.java
index 704d987..c2fab6f 100755
--- a/model/jpa/src/main/java/org/keycloak/models/jpa/ClientAdapter.java
+++ b/model/jpa/src/main/java/org/keycloak/models/jpa/ClientAdapter.java
@@ -461,6 +461,16 @@ public class ClientAdapter implements ClientModel {
}
@Override
+ public boolean isServiceAccountsEnabled() {
+ return entity.isServiceAccountsEnabled();
+ }
+
+ @Override
+ public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) {
+ entity.setServiceAccountsEnabled(serviceAccountsEnabled);
+ }
+
+ @Override
public boolean isDirectGrantsOnly() {
return entity.isDirectGrantsOnly();
}
diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientEntity.java
index a42fa67..8b57335 100755
--- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientEntity.java
+++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientEntity.java
@@ -95,6 +95,9 @@ public class ClientEntity {
@Column(name="CONSENT_REQUIRED")
private boolean consentRequired;
+ @Column(name="SERVICE_ACCOUNTS_ENABLED")
+ private boolean serviceAccountsEnabled;
+
@Column(name="NODE_REREG_TIMEOUT")
private int nodeReRegistrationTimeout;
@@ -295,6 +298,14 @@ public class ClientEntity {
this.consentRequired = consentRequired;
}
+ public boolean isServiceAccountsEnabled() {
+ return serviceAccountsEnabled;
+ }
+
+ public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) {
+ this.serviceAccountsEnabled = serviceAccountsEnabled;
+ }
+
public boolean isDirectGrantsOnly() {
return directGrantsOnly;
}
diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java
index 03ad18f..ae04a5f 100755
--- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java
+++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java
@@ -18,8 +18,10 @@ import org.keycloak.models.utils.CredentialValidation;
import org.keycloak.models.utils.KeycloakModelUtils;
import javax.persistence.EntityManager;
+import javax.persistence.Query;
import javax.persistence.TypedQuery;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -379,6 +381,38 @@ public class JpaUserProvider implements UserProvider {
return users;
}
+ @Override
+ public List<UserModel> searchForUserByUserAttributes(Map<String, String> attributes, RealmModel realm) {
+ StringBuilder builder = new StringBuilder("select attr.user,count(attr.user) from UserAttributeEntity attr where attr.user.realmId = :realmId");
+ boolean first = true;
+ for (Map.Entry<String, String> entry : attributes.entrySet()) {
+ String attrName = entry.getKey();
+ if (first) {
+ builder.append(" and ");
+ first = false;
+ } else {
+ builder.append(" or ");
+ }
+ builder.append(" ( attr.name like :").append(attrName);
+ builder.append(" and attr.value like :").append(attrName).append("val )");
+ }
+ builder.append(" group by attr.user having count(attr.user) = " + attributes.size());
+ Query query = em.createQuery(builder.toString());
+ query.setParameter("realmId", realm.getId());
+ for (Map.Entry<String, String> entry : attributes.entrySet()) {
+ query.setParameter(entry.getKey(), entry.getKey());
+ query.setParameter(entry.getKey() + "val", entry.getValue());
+ }
+ List results = query.getResultList();
+
+ List<UserModel> users = new ArrayList<UserModel>();
+ for (Object o : results) {
+ UserEntity user = (UserEntity) ((Object[])o)[0];
+ users.add(new UserAdapter(realm, em, user));
+ }
+ return users;
+ }
+
private FederatedIdentityEntity findFederatedIdentity(UserModel user, String identityProvider) {
TypedQuery<FederatedIdentityEntity> query = em.createNamedQuery("findFederatedIdentityByUserAndProvider", FederatedIdentityEntity.class);
UserEntity userEntity = em.getReference(UserEntity.class, user.getId());
diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/ClientAdapter.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/ClientAdapter.java
index 0f50420..40ea0d2 100755
--- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/ClientAdapter.java
+++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/ClientAdapter.java
@@ -462,6 +462,17 @@ public class ClientAdapter extends AbstractMongoAdapter<MongoClientEntity> imple
}
@Override
+ public boolean isServiceAccountsEnabled() {
+ return getMongoEntity().isServiceAccountsEnabled();
+ }
+
+ @Override
+ public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) {
+ getMongoEntity().setServiceAccountsEnabled(serviceAccountsEnabled);
+ updateMongoEntity();
+ }
+
+ @Override
public boolean isDirectGrantsOnly() {
return getMongoEntity().isDirectGrantsOnly();
}
diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java
index 55ac78b..cc720c5 100755
--- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java
+++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java
@@ -215,6 +215,19 @@ public class MongoUserProvider implements UserProvider {
}
@Override
+ public List<UserModel> searchForUserByUserAttributes(Map<String, String> attributes, RealmModel realm) {
+ QueryBuilder queryBuilder = new QueryBuilder()
+ .and("realmId").is(realm.getId());
+
+ for (Map.Entry<String, String> entry : attributes.entrySet()) {
+ queryBuilder.and("attributes." + entry.getKey()).is(entry.getValue());
+ }
+
+ List<MongoUserEntity> users = getMongoStore().loadEntities(MongoUserEntity.class, queryBuilder.get(), invocationContext);
+ return convertUserEntities(realm, users);
+ }
+
+ @Override
public Set<FederatedIdentityModel> getFederatedIdentities(UserModel userModel, RealmModel realm) {
UserModel user = getUserById(userModel.getId(), realm);
MongoUserEntity userEntity = ((UserAdapter) user).getUser();
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
index 3bff39a..4281c7d 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
@@ -22,6 +22,7 @@ import org.keycloak.models.UserSessionProvider;
import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
+import org.keycloak.protocol.oidc.ServiceAccountManager;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.representations.AccessToken;
@@ -53,7 +54,7 @@ public class TokenEndpoint {
private ClientModel client;
private enum Action {
- AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD
+ AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS
}
@Context
@@ -97,7 +98,11 @@ public class TokenEndpoint {
checkSsl();
checkRealm();
checkGrantType();
- checkClient();
+
+ // client grant type will do it's own verification of client
+ if (!grantType.equals(OAuth2Constants.CLIENT_CREDENTIALS)) {
+ checkClient();
+ }
switch (action) {
case AUTHORIZATION_CODE:
@@ -106,6 +111,8 @@ public class TokenEndpoint {
return buildRefreshToken();
case PASSWORD:
return buildResourceOwnerPasswordCredentialsGrant();
+ case CLIENT_CREDENTIALS:
+ return buildClientCredentialsGrant();
}
throw new RuntimeException("Unknown action " + action);
@@ -144,7 +151,7 @@ public class TokenEndpoint {
String authorizationHeader = headers.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION);
client = AuthorizeClientUtil.authorizeClient(authorizationHeader, formParams, event, realm);
- if ((client instanceof ClientModel) && ((ClientModel) client).isBearerOnly()) {
+ if (client.isBearerOnly()) {
throw new ErrorResponseException("invalid_client", "Bearer-only not allowed", Response.Status.BAD_REQUEST);
}
}
@@ -167,6 +174,9 @@ public class TokenEndpoint {
} else if (grantType.equals(OAuth2Constants.PASSWORD)) {
event.event(EventType.LOGIN);
action = Action.PASSWORD;
+ } else if (grantType.equals(OAuth2Constants.CLIENT_CREDENTIALS)) {
+ event.event(EventType.CLIENT_LOGIN);
+ action = Action.CLIENT_CREDENTIALS;
} else {
throw new ErrorResponseException(Errors.INVALID_REQUEST, "Invalid " + OIDCLoginProtocol.GRANT_TYPE_PARAM, Response.Status.BAD_REQUEST);
}
@@ -355,4 +365,9 @@ public class TokenEndpoint {
return Cors.add(request, Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).auth().allowedOrigins(client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build();
}
+ public Response buildClientCredentialsGrant() {
+ ServiceAccountManager serviceAccountManager = new ServiceAccountManager(tokenManager, authManager, event, request, formParams, session);
+ return serviceAccountManager.buildClientCredentialsGrant();
+ }
+
}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/ServiceAccountManager.java b/services/src/main/java/org/keycloak/protocol/oidc/ServiceAccountManager.java
new file mode 100644
index 0000000..3c8b8ad
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oidc/ServiceAccountManager.java
@@ -0,0 +1,165 @@
+package org.keycloak.protocol.oidc;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+
+import org.jboss.logging.Logger;
+import org.jboss.resteasy.spi.HttpRequest;
+import org.keycloak.ClientConnection;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.constants.ServiceAccountConstants;
+import org.keycloak.events.Details;
+import org.keycloak.events.Errors;
+import org.keycloak.events.EventBuilder;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.ClientSessionModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.ModelDuplicateException;
+import org.keycloak.models.RealmModel;
+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.AccessTokenResponse;
+import org.keycloak.services.ErrorResponseException;
+import org.keycloak.services.Urls;
+import org.keycloak.services.managers.AuthenticationManager;
+import org.keycloak.services.managers.ClientManager;
+import org.keycloak.services.managers.RealmManager;
+import org.keycloak.services.resources.Cors;
+
+/**
+ * Endpoint for authenticate clients and retrieve service accounts
+ *
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class ServiceAccountManager {
+
+ protected static final Logger logger = Logger.getLogger(ServiceAccountManager.class);
+
+ private TokenManager tokenManager;
+ private AuthenticationManager authManager;
+ private EventBuilder event;
+ private HttpRequest request;
+ private MultivaluedMap<String, String> formParams;
+
+ private KeycloakSession session;
+
+ private RealmModel realm;
+ private HttpHeaders headers;
+ private UriInfo uriInfo;
+ private ClientConnection clientConnection;
+
+ private ClientModel client;
+ private UserModel clientUser;
+
+ public ServiceAccountManager(TokenManager tokenManager, AuthenticationManager authManager, EventBuilder event, HttpRequest request, MultivaluedMap<String, String> formParams, KeycloakSession session) {
+ this.tokenManager = tokenManager;
+ this.authManager = authManager;
+ this.event = event;
+ this.request = request;
+ this.formParams = formParams;
+ this.session = session;
+
+ this.realm = session.getContext().getRealm();
+ this.headers = session.getContext().getRequestHeaders();
+ this.uriInfo = session.getContext().getUri();
+ this.clientConnection = session.getContext().getConnection();
+ }
+
+ public Response buildClientCredentialsGrant() {
+ authenticateClient();
+ checkClient();
+ return finishClientAuthorization();
+ }
+
+ protected void authenticateClient() {
+ // TODO: This should be externalized into pluggable SPI for client authentication (hopefully Authentication SPI can be reused).
+ // Right now, just Client Credentials Grants (as per OAuth2 specs) is supported
+ String authorizationHeader = headers.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION);
+ client = AuthorizeClientUtil.authorizeClient(authorizationHeader, formParams, event, realm);
+ event.detail(Details.CLIENT_AUTH_METHOD, Details.CLIENT_AUTH_METHOD_VALUE_CLIENT_CREDENTIALS);
+ }
+
+ protected void checkClient() {
+ if (client.isBearerOnly()) {
+ event.error(Errors.INVALID_CLIENT);
+ throw new ErrorResponseException("unauthorized_client", "Bearer-only client not allowed to retrieve service account", Response.Status.UNAUTHORIZED);
+ }
+ if (client.isPublicClient()) {
+ event.error(Errors.INVALID_CLIENT);
+ throw new ErrorResponseException("unauthorized_client", "Public client not allowed to retrieve service account", Response.Status.UNAUTHORIZED);
+ }
+ if (!client.isServiceAccountsEnabled()) {
+ event.error(Errors.INVALID_CLIENT);
+ throw new ErrorResponseException("unauthorized_client", "Client not enabled to retrieve service account", Response.Status.UNAUTHORIZED);
+ }
+ }
+
+ protected Response finishClientAuthorization() {
+ event.detail(Details.RESPONSE_TYPE, ServiceAccountConstants.CLIENT_AUTH);
+
+ Map<String, String> search = new HashMap<>();
+ search.put(ServiceAccountConstants.SERVICE_ACCOUNT_CLIENT_ATTRIBUTE, client.getId());
+ List<UserModel> users = session.users().searchForUserByUserAttributes(search, realm);
+
+ if (users.size() == 0) {
+ // May need to handle bootstrap here as well
+ logger.warnf("Service account user for client '%s' not found. Creating now", client.getClientId());
+ new ClientManager(new RealmManager(session)).enableServiceAccount(client);
+ users = session.users().searchForUserByUserAttributes(search, realm);
+ clientUser = users.get(0);
+ } else if (users.size() == 1) {
+ clientUser = users.get(0);
+ } else {
+ throw new ModelDuplicateException("Multiple service account users found for client '" + client.getClientId() + "' . Check your DB");
+ }
+
+ String clientUsername = clientUser.getUsername();
+ event.detail(Details.USERNAME, clientUsername);
+ event.user(clientUser);
+
+ if (!clientUser.isEnabled()) {
+ event.error(Errors.USER_DISABLED);
+ throw new ErrorResponseException("invalid_request", "User '" + clientUsername + "' disabled", Response.Status.UNAUTHORIZED);
+ }
+
+ String scope = formParams.getFirst(OAuth2Constants.SCOPE);
+
+ UserSessionProvider sessions = session.sessions();
+
+ // TODO: Once more requirements are added, clientSession will be likely created earlier by authentication mechanism
+ ClientSessionModel clientSession = sessions.createClientSession(realm, client);
+ clientSession.setAuthMethod(OIDCLoginProtocol.LOGIN_PROTOCOL);
+ clientSession.setNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName()));
+
+ // TODO: Should rather obtain authMethod from client session?
+ UserSessionModel userSession = sessions.createUserSession(realm, clientUser, clientUsername, clientConnection.getRemoteAddr(), ServiceAccountConstants.CLIENT_AUTH, false, null, null);
+ event.session(userSession);
+
+ TokenManager.attachClientSession(userSession, clientSession);
+
+ // Notes about client details
+ userSession.setNote(ServiceAccountConstants.CLIENT_ID, client.getClientId());
+ userSession.setNote(ServiceAccountConstants.CLIENT_HOST, clientConnection.getRemoteHost());
+ userSession.setNote(ServiceAccountConstants.CLIENT_ADDRESS, clientConnection.getRemoteAddr());
+
+ AccessTokenResponse res = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSession)
+ .generateAccessToken(session, scope, client, clientUser, userSession, clientSession)
+ .generateRefreshToken()
+ .generateIDToken()
+ .build();
+
+ event.success();
+
+ return Cors.add(request, Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).auth().allowedOrigins(client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build();
+ }
+
+}
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 40f7a32..a7f9079 100755
--- a/services/src/main/java/org/keycloak/services/managers/ClientManager.java
+++ b/services/src/main/java/org/keycloak/services/managers/ClientManager.java
@@ -3,10 +3,15 @@ package org.keycloak.services.managers;
import org.codehaus.jackson.annotate.JsonProperty;
import org.codehaus.jackson.annotate.JsonPropertyOrder;
import org.jboss.logging.Logger;
+import org.keycloak.constants.ServiceAccountConstants;
import org.keycloak.models.ClientModel;
+import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionProvider;
import org.keycloak.models.utils.KeycloakModelUtils;
+import org.keycloak.protocol.oidc.OIDCLoginProtocol;
+import org.keycloak.protocol.oidc.mappers.UserSessionNoteMapper;
import org.keycloak.representations.adapters.config.BaseRealmConfig;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.util.Time;
@@ -84,6 +89,57 @@ public class ClientManager {
return validatedNodes;
}
+ public void enableServiceAccount(ClientModel client) {
+ client.setServiceAccountsEnabled(true);
+
+ // Add dedicated user for this service account
+ RealmModel realm = client.getRealm();
+ Map<String, String> search = new HashMap<>();
+ search.put(ServiceAccountConstants.SERVICE_ACCOUNT_CLIENT_ATTRIBUTE, client.getId());
+ List<UserModel> serviceAccountUsers = realmManager.getSession().users().searchForUserByUserAttributes(search, realm);
+ if (serviceAccountUsers.size() == 0) {
+ String username = ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + client.getClientId();
+ logger.infof("Creating service account user '%s'", username);
+
+ UserModel user = realmManager.getSession().users().addUser(realm, username);
+ user.setEnabled(true);
+ user.setEmail(username + "@placeholder.org");
+ user.setSingleAttribute(ServiceAccountConstants.SERVICE_ACCOUNT_CLIENT_ATTRIBUTE, client.getId());
+ }
+
+ // Add protocol mappers to retrieve clientId in access token
+ 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",
+ false, "",
+ true, true);
+ client.addProtocolMapper(protocolMapper);
+ }
+
+ // 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",
+ false, "",
+ true, true);
+ client.addProtocolMapper(protocolMapper);
+ }
+
+ 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",
+ false, "",
+ true, true);
+ client.addProtocolMapper(protocolMapper);
+ }
+ }
+
@JsonPropertyOrder({"realm", "realm-public-key", "bearer-only", "auth-server-url", "ssl-required",
"resource", "public-client", "credentials",
"use-resource-role-mappings"})
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 3aa1190..67cfb65 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
@@ -101,6 +101,10 @@ public class ClientResource {
auth.requireManage();
try {
+ if (rep.isServiceAccountsEnabled() && !client.isServiceAccountsEnabled()) {
+ new ClientManager(new RealmManager(session)).enableServiceAccount(client);;
+ }
+
RepresentationToModel.updateClient(rep, client);
adminEvent.operation(OperationType.UPDATE).resourcePath(uriInfo).representation(rep).success();
return Response.noContent().build();
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/AssertEvents.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/AssertEvents.java
index 3d70977..1441f40 100755
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/AssertEvents.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/AssertEvents.java
@@ -8,6 +8,7 @@ import org.junit.Assert;
import org.junit.rules.TestRule;
import org.junit.runners.model.Statement;
import org.keycloak.Config;
+import org.keycloak.constants.ServiceAccountConstants;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.events.Details;
import org.keycloak.events.Event;
@@ -130,6 +131,15 @@ public class AssertEvents implements TestRule, EventListenerProviderFactory {
.session(isUUID());
}
+ public ExpectedEvent expectClientLogin() {
+ return expect(EventType.CLIENT_LOGIN)
+ .detail(Details.CODE_ID, isCodeId())
+ .detail(Details.CLIENT_AUTH_METHOD, Details.CLIENT_AUTH_METHOD_VALUE_CLIENT_CREDENTIALS)
+ .detail(Details.RESPONSE_TYPE, ServiceAccountConstants.CLIENT_AUTH)
+ .removeDetail(Details.CODE_ID)
+ .session(isUUID());
+ }
+
public ExpectedEvent expectSocialLogin() {
return expect(EventType.LOGIN)
.detail(Details.CODE_ID, isCodeId())
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java
index afeb1d3..d0c9d00 100755
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java
@@ -147,6 +147,7 @@ public class UserModelTest extends AbstractModelTest {
public void testUserMultipleAttributes() throws Exception {
RealmModel realm = realmManager.createRealm("original");
UserModel user = session.users().addUser(realm, "user");
+ UserModel userNoAttrs = session.users().addUser(realm, "user-noattrs");
user.setSingleAttribute("key1", "value1");
List<String> attrVals = new ArrayList<>(Arrays.asList( "val21", "val22" ));
@@ -177,13 +178,6 @@ public class UserModelTest extends AbstractModelTest {
Assert.assertEquals(allAttrVals.get("key1"), user.getAttribute("key1"));
Assert.assertEquals(allAttrVals.get("key2"), user.getAttribute("key2"));
- // Test searching
- Map<String, String> attributes = new HashMap<String, String>();
- attributes.put("key2", "val22");
- List<UserModel> users = session.users().searchForUserByAttributes(attributes, realm);
- Assert.assertEquals(1, users.size());
- Assert.assertEquals(users.get(0), user);
-
// Test remove and rewrite attribute
user.removeAttribute("key1");
user.setSingleAttribute("key2", "val23");
@@ -198,6 +192,40 @@ public class UserModelTest extends AbstractModelTest {
Assert.assertEquals("val23", attrVals.get(0));
}
+ @Test
+ public void testSearchByUserAttributes() throws Exception {
+ RealmModel realm = realmManager.createRealm("original");
+ UserModel user1 = session.users().addUser(realm, "user1");
+ UserModel user2 = session.users().addUser(realm, "user2");
+ UserModel user3 = session.users().addUser(realm, "user3");
+
+ user1.setSingleAttribute("key1", "value1");
+ user1.setSingleAttribute("key2", "value21");
+
+ user2.setSingleAttribute("key1", "value1");
+ user2.setSingleAttribute("key2", "value22");
+
+ user3.setSingleAttribute("key2", "value21");
+
+ commit();
+
+ Map<String, String> attributes = new HashMap<String, String>();
+ attributes.put("key1", "value1");
+ List<UserModel> users = session.users().searchForUserByUserAttributes(attributes, realm);
+ Assert.assertEquals(2, users.size());
+ Assert.assertTrue(users.contains(user1));
+ Assert.assertTrue(users.contains(user2));
+
+ attributes.put("key2", "value21");
+ users = session.users().searchForUserByUserAttributes(attributes, realm);
+ Assert.assertEquals(1, users.size());
+ Assert.assertTrue(users.contains(user1));
+
+ attributes.put("key3", "value3");
+ users = session.users().searchForUserByUserAttributes(attributes, realm);
+ Assert.assertEquals(0, users.size());
+ }
+
public static void assertEquals(UserModel expected, UserModel actual) {
Assert.assertEquals(expected.getUsername(), actual.getUsername());
Assert.assertEquals(expected.getCreatedTimestamp(), actual.getCreatedTimestamp());
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java
new file mode 100644
index 0000000..23cf541
--- /dev/null
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java
@@ -0,0 +1,222 @@
+package org.keycloak.testsuite.oauth;
+
+import org.apache.http.HttpResponse;
+import org.junit.Assert;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.keycloak.constants.ServiceAccountConstants;
+import org.keycloak.events.Details;
+import org.keycloak.events.Errors;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.representations.AccessToken;
+import org.keycloak.representations.RefreshToken;
+import org.keycloak.services.managers.ClientManager;
+import org.keycloak.services.managers.RealmManager;
+import org.keycloak.testsuite.AssertEvents;
+import org.keycloak.testsuite.OAuthClient;
+import org.keycloak.testsuite.rule.KeycloakRule;
+import org.keycloak.testsuite.rule.WebResource;
+import org.keycloak.testsuite.rule.WebRule;
+import org.openqa.selenium.WebDriver;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class ServiceAccountTest {
+
+ @ClassRule
+ public static KeycloakRule keycloakRule = new KeycloakRule(new KeycloakRule.KeycloakSetup() {
+ @Override
+ public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) {
+ ClientModel app = appRealm.addClient("service-account-cl");
+ app.setSecret("secret1");
+ new ClientManager(manager).enableServiceAccount(app);
+
+ ClientModel disabledApp = appRealm.addClient("service-account-disabled");
+ disabledApp.setSecret("secret1");
+
+ UserModel serviceAccountUser = session.users().getUserByUsername(ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "service-account-cl", appRealm);
+ userId = serviceAccountUser.getId();
+ }
+ });
+
+ @Rule
+ public AssertEvents events = new AssertEvents(keycloakRule);
+
+ @Rule
+ public WebRule webRule = new WebRule(this);
+
+ @WebResource
+ protected WebDriver driver;
+
+ @WebResource
+ protected OAuthClient oauth;
+
+ private static String userId;
+
+ @Test
+ public void clientCredentialsAuthSuccess() throws Exception {
+ oauth.clientId("service-account-cl");
+
+ OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1");
+
+ assertEquals(200, response.getStatusCode());
+
+ AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
+ RefreshToken refreshToken = oauth.verifyRefreshToken(response.getRefreshToken());
+
+ events.expectClientLogin()
+ .client("service-account-cl")
+ .user(userId)
+ .session(accessToken.getSessionState())
+ .detail(Details.TOKEN_ID, accessToken.getId())
+ .detail(Details.REFRESH_TOKEN_ID, refreshToken.getId())
+ .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "service-account-cl")
+ .assertEvent();
+
+ assertEquals(accessToken.getSessionState(), refreshToken.getSessionState());
+ System.out.println("Access token other claims: " + accessToken.getOtherClaims());
+ Assert.assertEquals("service-account-cl", accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID));
+ Assert.assertTrue(accessToken.getOtherClaims().containsKey(ServiceAccountConstants.CLIENT_ADDRESS));
+ Assert.assertTrue(accessToken.getOtherClaims().containsKey(ServiceAccountConstants.CLIENT_HOST));
+
+ OAuthClient.AccessTokenResponse refreshedResponse = oauth.doRefreshTokenRequest(response.getRefreshToken(), "secret1");
+
+ AccessToken refreshedAccessToken = oauth.verifyToken(refreshedResponse.getAccessToken());
+ RefreshToken refreshedRefreshToken = oauth.verifyRefreshToken(refreshedResponse.getRefreshToken());
+
+ assertEquals(accessToken.getSessionState(), refreshedAccessToken.getSessionState());
+ assertEquals(accessToken.getSessionState(), refreshedRefreshToken.getSessionState());
+
+ events.expectRefresh(refreshToken.getId(), refreshToken.getSessionState()).user(userId).client("service-account-cl").assertEvent();
+ }
+
+ @Test
+ public void clientCredentialsLogout() throws Exception {
+ oauth.clientId("service-account-cl");
+
+ OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1");
+
+ assertEquals(200, response.getStatusCode());
+
+ AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
+ RefreshToken refreshToken = oauth.verifyRefreshToken(response.getRefreshToken());
+
+ events.expectClientLogin()
+ .client("service-account-cl")
+ .user(userId)
+ .session(accessToken.getSessionState())
+ .detail(Details.TOKEN_ID, accessToken.getId())
+ .detail(Details.REFRESH_TOKEN_ID, refreshToken.getId())
+ .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "service-account-cl")
+ .assertEvent();
+
+ HttpResponse logoutResponse = oauth.doLogout(response.getRefreshToken(), "secret1");
+ assertEquals(204, logoutResponse.getStatusLine().getStatusCode());
+ events.expectLogout(accessToken.getSessionState())
+ .client("service-account-cl")
+ .user(userId)
+ .removeDetail(Details.REDIRECT_URI)
+ .assertEvent();
+
+ response = oauth.doRefreshTokenRequest(response.getRefreshToken(), "secret1");
+ assertEquals(400, response.getStatusCode());
+ assertEquals("invalid_grant", response.getError());
+
+ events.expectRefresh(refreshToken.getId(), refreshToken.getSessionState())
+ .client("service-account-cl")
+ .user(userId)
+ .removeDetail(Details.TOKEN_ID)
+ .removeDetail(Details.UPDATED_REFRESH_TOKEN_ID)
+ .error(Errors.INVALID_TOKEN).assertEvent();
+ }
+
+ @Test
+ public void clientCredentialsInvalidClientCredentials() throws Exception {
+ oauth.clientId("service-account-cl");
+
+ OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret2");
+
+ assertEquals(400, response.getStatusCode());
+
+ assertEquals("unauthorized_client", response.getError());
+
+ events.expectClientLogin()
+ .client("service-account-cl")
+ .session((String) null)
+ .clearDetails()
+ .error(Errors.INVALID_CLIENT_CREDENTIALS)
+ .user((String) null)
+ .assertEvent();
+ }
+
+ @Test
+ public void clientCredentialsDisabledServiceAccount() throws Exception {
+ oauth.clientId("service-account-disabled");
+
+ OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1");
+
+ assertEquals(401, response.getStatusCode());
+
+ assertEquals("unauthorized_client", response.getError());
+
+ events.expectClientLogin()
+ .client("service-account-disabled")
+ .user((String) null)
+ .session((String) null)
+ .removeDetail(Details.USERNAME)
+ .removeDetail(Details.RESPONSE_TYPE)
+ .error(Errors.INVALID_CLIENT)
+ .assertEvent();
+ }
+
+ @Test
+ public void changeClientIdTest() throws Exception {
+ keycloakRule.update(new KeycloakRule.KeycloakSetup() {
+
+ @Override
+ public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) {
+ ClientModel app = appRealm.getClientByClientId("service-account-cl");
+ app.setClientId("updated-client");
+ }
+
+ });
+
+ oauth.clientId("updated-client");
+
+ OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1");
+
+ assertEquals(200, response.getStatusCode());
+
+ AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
+ RefreshToken refreshToken = oauth.verifyRefreshToken(response.getRefreshToken());
+ Assert.assertEquals("updated-client", accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID));
+
+ // Username still same. Client ID changed
+ events.expectClientLogin()
+ .client("updated-client")
+ .user(userId)
+ .session(accessToken.getSessionState())
+ .detail(Details.TOKEN_ID, accessToken.getId())
+ .detail(Details.REFRESH_TOKEN_ID, refreshToken.getId())
+ .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "service-account-cl")
+ .assertEvent();
+
+ // Revert change
+ keycloakRule.update(new KeycloakRule.KeycloakSetup() {
+
+ @Override
+ public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) {
+ ClientModel app = appRealm.getClientByClientId("updated-client");
+ app.setClientId("service-account-cl");
+ }
+
+ });
+ }
+
+}
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java
index 2ad8e1b..1bac627 100755
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java
@@ -194,6 +194,31 @@ public class OAuthClient {
}
}
+ public AccessTokenResponse doClientCredentialsGrantAccessTokenRequest(String clientSecret) throws Exception {
+ CloseableHttpClient client = new DefaultHttpClient();
+ try {
+ HttpPost post = new HttpPost(getServiceAccountUrl());
+
+ String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);
+ post.setHeader("Authorization", authorization);
+
+ List<NameValuePair> parameters = new LinkedList<NameValuePair>();
+ parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS));
+
+ UrlEncodedFormEntity formEntity;
+ try {
+ formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException(e);
+ }
+ post.setEntity(formEntity);
+
+ return new AccessTokenResponse(client.execute(post));
+ } finally {
+ closeClient(client);
+ }
+ }
+
public HttpResponse doLogout(String refreshToken, String clientSecret) throws IOException {
CloseableHttpClient client = new DefaultHttpClient();
try {
@@ -375,6 +400,10 @@ public class OAuthClient {
return b.build(realm).toString();
}
+ public String getServiceAccountUrl() {
+ return getResourceOwnerPasswordCredentialGrantUrl();
+ }
+
public String getRefreshTokenUrl() {
UriBuilder b = OIDCLoginProtocolService.tokenUrl(UriBuilder.fromUri(baseUrl));
return b.build(realm).toString();