keycloak-uncached
Changes
examples/demo-template/pom.xml 1(+1 -0)
examples/demo-template/README.md 8(+8 -0)
examples/demo-template/service-account/src/main/java/org/keycloak/example/ProductServiceAccountServlet.java 234(+234 -0)
examples/demo-template/service-account/src/main/webapp/WEB-INF/jboss-deployment-structure.xml 9(+9 -0)
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),
examples/demo-template/pom.xml 1(+1 -0)
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>
examples/demo-template/README.md 8(+8 -0)
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();