keycloak-uncached

Merge pull request #1477 from mposolda/service-acc KEYCLOAK-401

7/22/2015 6:37:55 AM

Changes

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/examples/demo-template/pom.xml b/examples/demo-template/pom.xml
index a0172c8..4ea8c39 100755
--- a/examples/demo-template/pom.xml
+++ b/examples/demo-template/pom.xml
@@ -36,6 +36,7 @@
         <module>database-service</module>
         <module>third-party</module>
         <module>third-party-cdi</module>
+        <module>service-account</module>
     </modules>
 
     <profiles>
diff --git a/examples/demo-template/README.md b/examples/demo-template/README.md
index 1a896af..2445e31 100755
--- a/examples/demo-template/README.md
+++ b/examples/demo-template/README.md
@@ -210,6 +210,14 @@ An pure HTML5/Javascript example using Keycloak to secure it.
 If you are already logged in, you will not be asked for a username and password, but you will be redirected to
 an oauth grant page.  This page asks you if you want to grant certain permissions to the third-part app.
 
+Step 10: Service Account Example
+================================
+An example for retrieve service account dedicated to the Client Application itself (not to any user). 
+
+[http://localhost:8080/service-account-portal](http://localhost:8080/service-account-portal)
+
+Client authentication is done with OAuth2 Client Credentials Grant in out-of-bound request (Not Keycloak login screen displayed) 
+
 Admin Console
 ==========================
 
diff --git a/examples/demo-template/service-account/pom.xml b/examples/demo-template/service-account/pom.xml
new file mode 100644
index 0000000..fde6966
--- /dev/null
+++ b/examples/demo-template/service-account/pom.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <parent>
+        <artifactId>keycloak-examples-demo-parent</artifactId>
+        <groupId>org.keycloak</groupId>
+        <version>1.4.0.Final-SNAPSHOT</version>
+    </parent>
+
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>org.keycloak.example.demo</groupId>
+    <artifactId>service-account-example</artifactId>
+    <packaging>war</packaging>
+    <name>Service Account Example App</name>
+    <description/>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.jboss.spec.javax.servlet</groupId>
+            <artifactId>jboss-servlet-api_3.0_spec</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.keycloak</groupId>
+            <artifactId>keycloak-core</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.keycloak</groupId>
+            <artifactId>keycloak-adapter-core</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.httpcomponents</groupId>
+            <artifactId>httpclient</artifactId>
+            <scope>provided</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <finalName>service-account-portal</finalName>
+        <plugins>
+            <plugin>
+                <groupId>org.jboss.as.plugins</groupId>
+                <artifactId>jboss-as-maven-plugin</artifactId>
+                <configuration>
+                    <skip>false</skip>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.wildfly.plugins</groupId>
+                <artifactId>wildfly-maven-plugin</artifactId>
+                <configuration>
+                    <skip>false</skip>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>
\ No newline at end of file
diff --git a/examples/demo-template/service-account/src/main/java/org/keycloak/example/ProductServiceAccountServlet.java b/examples/demo-template/service-account/src/main/java/org/keycloak/example/ProductServiceAccountServlet.java
new file mode 100644
index 0000000..f9dc9f1
--- /dev/null
+++ b/examples/demo-template/service-account/src/main/java/org/keycloak/example/ProductServiceAccountServlet.java
@@ -0,0 +1,234 @@
+package org.keycloak.example;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.apache.http.message.BasicNameValuePair;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.RSATokenVerifier;
+import org.keycloak.VerificationException;
+import org.keycloak.adapters.KeycloakDeployment;
+import org.keycloak.adapters.KeycloakDeploymentBuilder;
+import org.keycloak.adapters.ServerRequest;
+import org.keycloak.constants.ServiceAccountConstants;
+import org.keycloak.representations.AccessToken;
+import org.keycloak.representations.AccessTokenResponse;
+import org.keycloak.util.BasicAuthHelper;
+import org.keycloak.util.JsonSerialization;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class ProductServiceAccountServlet extends HttpServlet {
+
+    public static final String ERROR = "error";
+    public static final String TOKEN = "token";
+    public static final String TOKEN_PARSED = "idTokenParsed";
+    public static final String REFRESH_TOKEN = "refreshToken";
+    public static final String PRODUCTS = "products";
+
+    @Override
+    public void init() throws ServletException {
+        InputStream config = getServletContext().getResourceAsStream("WEB-INF/keycloak.json");
+        KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(config);
+        HttpClient client = new DefaultHttpClient();
+
+        getServletContext().setAttribute(KeycloakDeployment.class.getName(), deployment);
+        getServletContext().setAttribute(HttpClient.class.getName(), client);
+    }
+
+    @Override
+    public void destroy() {
+        getHttpClient().getConnectionManager().shutdown();
+    }
+
+    @Override
+    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+        String reqUri = req.getRequestURI();
+        if (reqUri.endsWith("/login")) {
+            serviceAccountLogin(req);
+        } else if (reqUri.endsWith("/refresh")) {
+            refreshToken(req);
+        } else if (reqUri.endsWith("/logout")){
+            logout(req);
+        }
+
+        // Don't load products if some error happened during login,refresh or logout
+        if (req.getAttribute(ERROR) == null) {
+            loadProducts(req);
+        }
+
+        req.getRequestDispatcher("/WEB-INF/page.jsp").forward(req, resp);
+    }
+
+    private void serviceAccountLogin(HttpServletRequest req) {
+        KeycloakDeployment deployment = getKeycloakDeployment();
+        HttpClient client = getHttpClient();
+
+        String clientId = deployment.getResourceName();
+        String clientSecret = deployment.getResourceCredentials().get("secret");
+
+        try {
+            HttpPost post = new HttpPost(deployment.getTokenUrl());
+            List<NameValuePair> formparams = new ArrayList<NameValuePair>();
+            formparams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS));
+
+            String authHeader = BasicAuthHelper.createHeader(clientId, clientSecret);
+            post.addHeader("Authorization", authHeader);
+
+            UrlEncodedFormEntity form = new UrlEncodedFormEntity(formparams, "UTF-8");
+            post.setEntity(form);
+
+            HttpResponse response = client.execute(post);
+            int status = response.getStatusLine().getStatusCode();
+            HttpEntity entity = response.getEntity();
+            if (status != 200) {
+                String json = getContent(entity);
+                String error = "Service account login failed. Bad status: " + status + " response: " + json;
+                req.setAttribute(ERROR, error);
+            } else if (entity == null) {
+                req.setAttribute(ERROR, "No entity");
+            } else {
+                String json = getContent(entity);
+                AccessTokenResponse tokenResp = JsonSerialization.readValue(json, AccessTokenResponse.class);
+                setTokens(req, deployment, tokenResp);
+            }
+        } catch (IOException ioe) {
+            ioe.printStackTrace();
+            req.setAttribute(ERROR, "Service account login failed. IOException occured. See server.log for details. Message is: " + ioe.getMessage());
+        } catch (VerificationException vfe) {
+            req.setAttribute(ERROR, "Service account login failed. Failed to verify token Message is: " + vfe.getMessage());
+        }
+    }
+
+    private void setTokens(HttpServletRequest req, KeycloakDeployment deployment, AccessTokenResponse tokenResponse) throws IOException, VerificationException {
+        String token = tokenResponse.getToken();
+        String refreshToken = tokenResponse.getRefreshToken();
+        AccessToken tokenParsed = RSATokenVerifier.verifyToken(token, deployment.getRealmKey(), deployment.getRealmInfoUrl());
+        req.getSession().setAttribute(TOKEN, token);
+        req.getSession().setAttribute(REFRESH_TOKEN, refreshToken);
+        req.getSession().setAttribute(TOKEN_PARSED, tokenParsed);
+    }
+
+    private void loadProducts(HttpServletRequest req) {
+        HttpClient client = getHttpClient();
+        String token = (String) req.getSession().getAttribute(TOKEN);
+
+        HttpGet get = new HttpGet("http://localhost:8080/database/products");
+        if (token != null) {
+            get.addHeader("Authorization", "Bearer " + token);
+        }
+        try {
+            HttpResponse response = client.execute(get);
+            HttpEntity entity = response.getEntity();
+            int status = response.getStatusLine().getStatusCode();
+            if (status != 200) {
+                String json = getContent(entity);
+                String error = "Failed retrieve products.";
+
+                if (status == 401) {
+                    error = error + " You need to login first with the service account.";
+                } else if (status == 403) {
+                    error = error + " Maybe service account user doesn't have needed role? Assign role 'user' in Keycloak admin console to user '" +
+                            ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + getKeycloakDeployment().getResourceName() + "' and then logout and login again.";
+                }
+                error = error + " Status: " + status + ", Response: " + json;
+                req.setAttribute(ERROR, error);
+            } else if (entity == null) {
+                req.setAttribute(ERROR, "No entity");
+            } else {
+                String products = getContent(entity);
+                req.setAttribute(PRODUCTS, products);
+            }
+        } catch (IOException ioe) {
+            ioe.printStackTrace();
+            req.setAttribute(ERROR, "Failed retrieve products. IOException occured. See server.log for details. Message is: " + ioe.getMessage());
+        }
+    }
+
+    private void refreshToken(HttpServletRequest req) {
+        KeycloakDeployment deployment = getKeycloakDeployment();
+        String refreshToken = (String) req.getSession().getAttribute(REFRESH_TOKEN);
+        if (refreshToken == null) {
+            req.setAttribute(ERROR, "No refresh token available. Please login first");
+        } else {
+            try {
+                AccessTokenResponse tokenResponse = ServerRequest.invokeRefresh(deployment, refreshToken);
+                setTokens(req, deployment, tokenResponse);
+            } catch (ServerRequest.HttpFailure hfe) {
+                hfe.printStackTrace();
+                req.setAttribute(ERROR, "Failed refresh token. See server.log for details. Status was: " + hfe.getStatus() + ", Error is: " + hfe.getError());
+            } catch (Exception ioe) {
+                ioe.printStackTrace();
+                req.setAttribute(ERROR, "Failed refresh token. See server.log for details. Message is: " + ioe.getMessage());
+            }
+        }
+    }
+
+    private void logout(HttpServletRequest req) {
+        KeycloakDeployment deployment = getKeycloakDeployment();
+        String refreshToken = (String) req.getSession().getAttribute(REFRESH_TOKEN);
+        if (refreshToken == null) {
+            req.setAttribute(ERROR, "No refresh token available. Please login first");
+        } else {
+            try {
+                ServerRequest.invokeLogout(deployment, refreshToken);
+                req.getSession().removeAttribute(TOKEN);
+                req.getSession().removeAttribute(REFRESH_TOKEN);
+                req.getSession().removeAttribute(TOKEN_PARSED);
+            } catch (IOException ioe) {
+                ioe.printStackTrace();
+                req.setAttribute(ERROR, "Failed refresh token. See server.log for details. Message is: " + ioe.getMessage());
+            } catch (ServerRequest.HttpFailure hfe) {
+                hfe.printStackTrace();
+                req.setAttribute(ERROR, "Failed refresh token. See server.log for details. Status was: " + hfe.getStatus() + ", Error is: " + hfe.getError());
+            }
+        }
+    }
+
+    private String getContent(HttpEntity entity) throws IOException {
+        if (entity == null) return null;
+        InputStream is = entity.getContent();
+        try {
+            ByteArrayOutputStream os = new ByteArrayOutputStream();
+            int c;
+            while ((c = is.read()) != -1) {
+                os.write(c);
+            }
+            byte[] bytes = os.toByteArray();
+            String data = new String(bytes);
+            return data;
+        } finally {
+            try {
+                is.close();
+            } catch (IOException ignored) {
+
+            }
+        }
+
+    }
+
+    private KeycloakDeployment getKeycloakDeployment() {
+        return (KeycloakDeployment) getServletContext().getAttribute(KeycloakDeployment.class.getName());
+    }
+
+    private HttpClient getHttpClient() {
+        return (HttpClient) getServletContext().getAttribute(HttpClient.class.getName());
+    }
+}
diff --git a/examples/demo-template/service-account/src/main/webapp/index.html b/examples/demo-template/service-account/src/main/webapp/index.html
new file mode 100644
index 0000000..e2820d1
--- /dev/null
+++ b/examples/demo-template/service-account/src/main/webapp/index.html
@@ -0,0 +1,5 @@
+<html>
+    <head>
+        <meta http-equiv="Refresh" content="0; URL=app">
+    </head>
+</html>
\ No newline at end of file
diff --git a/examples/demo-template/service-account/src/main/webapp/WEB-INF/jboss-deployment-structure.xml b/examples/demo-template/service-account/src/main/webapp/WEB-INF/jboss-deployment-structure.xml
new file mode 100644
index 0000000..9c1bac9
--- /dev/null
+++ b/examples/demo-template/service-account/src/main/webapp/WEB-INF/jboss-deployment-structure.xml
@@ -0,0 +1,9 @@
+<jboss-deployment-structure>
+    <deployment>
+        <dependencies>
+            <!-- the Demo code uses classes in these modules.  These are optional to import if you are not using
+                 Apache Http Client or the HttpClientBuilder that comes with the adapter core -->
+            <module name="org.apache.httpcomponents"/>
+        </dependencies>
+    </deployment>
+</jboss-deployment-structure>
\ No newline at end of file
diff --git a/examples/demo-template/service-account/src/main/webapp/WEB-INF/keycloak.json b/examples/demo-template/service-account/src/main/webapp/WEB-INF/keycloak.json
new file mode 100644
index 0000000..7eec22a
--- /dev/null
+++ b/examples/demo-template/service-account/src/main/webapp/WEB-INF/keycloak.json
@@ -0,0 +1,10 @@
+{
+  "realm" : "demo",
+  "realm-public-key" : "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB",
+  "auth-server-url" : "http://localhost:8080/auth",
+  "ssl-required" : "external",
+  "resource" : "product-sa-client",
+  "credentials": {
+    "secret": "password"
+  }
+}
\ No newline at end of file
diff --git a/examples/demo-template/service-account/src/main/webapp/WEB-INF/page.jsp b/examples/demo-template/service-account/src/main/webapp/WEB-INF/page.jsp
new file mode 100644
index 0000000..e151f96
--- /dev/null
+++ b/examples/demo-template/service-account/src/main/webapp/WEB-INF/page.jsp
@@ -0,0 +1,52 @@
+<%@ page language="java" contentType="text/html; charset=ISO-8859-1"
+         pageEncoding="ISO-8859-1" %>
+<%@ page import="org.keycloak.example.ProductServiceAccountServlet" %>
+<%@ page import="org.keycloak.representations.AccessToken" %>
+<%@ page import="org.keycloak.constants.ServiceAccountConstants" %>
+<%@ page import="org.keycloak.util.Time" %>
+<html>
+<head>
+    <title>Service account portal</title>
+</head>
+<body bgcolor="#FFFFFF">
+<%
+    AccessToken token = (AccessToken) request.getSession().getAttribute(ProductServiceAccountServlet.TOKEN_PARSED);
+    String products = (String) request.getAttribute(ProductServiceAccountServlet.PRODUCTS);
+    String appError = (String) request.getAttribute(ProductServiceAccountServlet.ERROR);
+%>
+<h1>Service account portal</h1>
+<p><a href="/service-account-portal/app/login">Login</a> | <a href="/service-account-portal/app/refresh">Refresh token</a> | <a
+        href="/service-account-portal/app/logout">Logout</a></p>
+<hr />
+
+<% if (appError != null) { %>
+    <p><font color="red">
+        <b>Error: </b> <%= appError %>
+    </font></p>
+    <hr />
+<% } %>
+
+<% if (token != null) { %>
+    <p>
+        <b>Service account available</b><br />
+        Client ID: <%= token.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID) %><br />
+        Client hostname: <%= token.getOtherClaims().get(ServiceAccountConstants.CLIENT_HOST) %><br />
+        Client address: <%= token.getOtherClaims().get(ServiceAccountConstants.CLIENT_ADDRESS) %><br />
+        Token expiration: <%= Time.toDate(token.getExpiration()) %><br />
+        <% if (token.isExpired()) { %>
+            <font color="red">Access token is expired. You may need to refresh</font><br />
+        <% } %>
+    </p>
+    <hr />
+<% } %>
+
+<% if (products != null) { %>
+    <p>
+        <b>Products retrieved successfully from REST endpoint</b><br />
+        Product list: <%= products %>
+    </p>
+    <hr />
+<% } %>
+
+</body>
+</html>
\ No newline at end of file
diff --git a/examples/demo-template/service-account/src/main/webapp/WEB-INF/web.xml b/examples/demo-template/service-account/src/main/webapp/WEB-INF/web.xml
new file mode 100644
index 0000000..5dc7103
--- /dev/null
+++ b/examples/demo-template/service-account/src/main/webapp/WEB-INF/web.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<web-app xmlns="http://java.sun.com/xml/ns/javaee"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
+         version="3.0">
+
+    <module-name>service-account-portal</module-name>
+
+    <servlet>
+        <servlet-name>ServiceAccountExample</servlet-name>
+        <servlet-class>org.keycloak.example.ProductServiceAccountServlet</servlet-class>
+    </servlet>
+
+    <servlet-mapping>
+        <servlet-name>ServiceAccountExample</servlet-name>
+        <url-pattern>/app/*</url-pattern>
+    </servlet-mapping>
+
+</web-app>
\ No newline at end of file
diff --git a/examples/demo-template/testrealm.json b/examples/demo-template/testrealm.json
index d592010..a26a058 100755
--- a/examples/demo-template/testrealm.json
+++ b/examples/demo-template/testrealm.json
@@ -162,6 +162,12 @@
             "publicClient": true,
             "directGrantsOnly": true,
             "consentRequired": true
+        },
+        {
+            "clientId": "product-sa-client",
+            "enabled": true,
+            "secret": "password",
+            "serviceAccountsEnabled": true
         }
     ],
     "clientScopeMappings": {
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();