Added the list-user-client controller.

Change-Id: I65b6d392da59d1f3412b28ae81ae0bec321d2077
diff --git a/core/src/main/java/de/ids_mannheim/korap/constant/OAuth2Scope.java b/core/src/main/java/de/ids_mannheim/korap/constant/OAuth2Scope.java
index 5cd1293..9993541 100644
--- a/core/src/main/java/de/ids_mannheim/korap/constant/OAuth2Scope.java
+++ b/core/src/main/java/de/ids_mannheim/korap/constant/OAuth2Scope.java
@@ -8,6 +8,8 @@
     OPENID, 
     AUTHORIZE,
     
+    LIST_USER_CLIENT,
+    
     CLIENT_INFO,
     REGISTER_CLIENT,
     DEREGISTER_CLIENT,
diff --git a/full/Changes b/full/Changes
index b111732..1df0b92 100644
--- a/full/Changes
+++ b/full/Changes
@@ -7,6 +7,8 @@
 28/11/2018
    - Updated NamedVCLoader to delete existing VC in DB (margaretha)
    - Handled storing cached VC with VC reference (margaretha)
+29/11/2018
+   - Added the list-user-client controller (margaretha)  
    
 # version 0.61.3
 17/10/2018
diff --git a/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/AccessScopeDao.java b/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/AccessScopeDao.java
index de0cfac..a714420 100644
--- a/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/AccessScopeDao.java
+++ b/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/AccessScopeDao.java
@@ -15,7 +15,6 @@
 
 import de.ids_mannheim.korap.constant.OAuth2Scope;
 import de.ids_mannheim.korap.oauth2.entity.AccessScope;
-import de.ids_mannheim.korap.oauth2.entity.AccessScope;
 
 @Repository
 @Transactional
diff --git a/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/OAuth2ClientDao.java b/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/OAuth2ClientDao.java
index 31fa2b3..444659e 100644
--- a/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/OAuth2ClientDao.java
+++ b/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/OAuth2ClientDao.java
@@ -1,22 +1,32 @@
 package de.ids_mannheim.korap.oauth2.dao;
 
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.List;
+
 import javax.persistence.EntityManager;
 import javax.persistence.NoResultException;
 import javax.persistence.PersistenceContext;
 import javax.persistence.Query;
+import javax.persistence.TypedQuery;
 import javax.persistence.criteria.CriteriaBuilder;
 import javax.persistence.criteria.CriteriaQuery;
+import javax.persistence.criteria.ListJoin;
+import javax.persistence.criteria.Predicate;
 import javax.persistence.criteria.Root;
 
 import org.springframework.stereotype.Repository;
 import org.springframework.transaction.annotation.Transactional;
 
+import de.ids_mannheim.korap.config.Attributes;
 import de.ids_mannheim.korap.exceptions.KustvaktException;
 import de.ids_mannheim.korap.exceptions.StatusCodes;
 import de.ids_mannheim.korap.oauth2.constant.OAuth2ClientType;
 import de.ids_mannheim.korap.oauth2.entity.OAuth2Client;
 import de.ids_mannheim.korap.oauth2.entity.OAuth2ClientUrl;
 import de.ids_mannheim.korap.oauth2.entity.OAuth2Client_;
+import de.ids_mannheim.korap.oauth2.entity.RefreshToken;
+import de.ids_mannheim.korap.oauth2.entity.RefreshToken_;
 import de.ids_mannheim.korap.utils.ParameterChecker;
 
 @Transactional
@@ -97,4 +107,29 @@
         client = entityManager.merge(client);
     }
 
+    public List<OAuth2Client> retrieveUserClients (String username)
+            throws KustvaktException {
+        ParameterChecker.checkStringValue(username, "username");
+
+        CriteriaBuilder builder = entityManager.getCriteriaBuilder();
+        CriteriaQuery<OAuth2Client> query =
+                builder.createQuery(OAuth2Client.class);
+
+        Root<OAuth2Client> client = query.from(OAuth2Client.class);
+        ListJoin<OAuth2Client, RefreshToken> refreshToken =
+                client.join(OAuth2Client_.refreshTokens);
+        Predicate condition = builder.and(
+                builder.equal(refreshToken.get(RefreshToken_.userId), username),
+                builder.equal(refreshToken.get(RefreshToken_.isRevoked), false),
+                builder.greaterThan(
+                        refreshToken
+                                .<ZonedDateTime> get(RefreshToken_.expiryDate),
+                        ZonedDateTime
+                                .now(ZoneId.of(Attributes.DEFAULT_TIME_ZONE))));
+        query.select(client);
+        query.where(condition);
+        TypedQuery<OAuth2Client> q = entityManager.createQuery(query);
+        return q.getResultList();
+    }
+
 }
diff --git a/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/RefreshTokenDao.java b/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/RefreshTokenDao.java
index e98c627..8ee798b 100644
--- a/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/RefreshTokenDao.java
+++ b/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/RefreshTokenDao.java
@@ -8,8 +8,10 @@
 import javax.persistence.EntityManager;
 import javax.persistence.PersistenceContext;
 import javax.persistence.Query;
+import javax.persistence.TypedQuery;
 import javax.persistence.criteria.CriteriaBuilder;
 import javax.persistence.criteria.CriteriaQuery;
+import javax.persistence.criteria.Join;
 import javax.persistence.criteria.Root;
 
 import org.springframework.beans.factory.annotation.Autowired;
@@ -20,6 +22,8 @@
 import de.ids_mannheim.korap.config.FullConfiguration;
 import de.ids_mannheim.korap.exceptions.KustvaktException;
 import de.ids_mannheim.korap.oauth2.entity.AccessScope;
+import de.ids_mannheim.korap.oauth2.entity.OAuth2Client;
+import de.ids_mannheim.korap.oauth2.entity.OAuth2Client_;
 import de.ids_mannheim.korap.oauth2.entity.RefreshToken;
 import de.ids_mannheim.korap.oauth2.entity.RefreshToken_;
 import de.ids_mannheim.korap.utils.ParameterChecker;
@@ -32,6 +36,8 @@
     private EntityManager entityManager;
     @Autowired
     private FullConfiguration config;
+    @Autowired
+    private OAuth2ClientDao clientDao;
 
     public RefreshToken storeRefreshToken (String refreshToken, String userId,
             ZonedDateTime userAuthenticationTime, String clientId,
@@ -43,12 +49,13 @@
 
         ZonedDateTime now =
                 ZonedDateTime.now(ZoneId.of(Attributes.DEFAULT_TIME_ZONE));
+        OAuth2Client client = clientDao.retrieveClientById(clientId);
 
         RefreshToken token = new RefreshToken();
         token.setToken(refreshToken);
         token.setUserId(userId);
         token.setUserAuthenticationTime(userAuthenticationTime);
-        token.setClientId(clientId);
+        token.setClient(client);
         token.setCreatedDate(now);
         token.setExpiryDate(now.plusSeconds(config.getRefreshTokenExpiry()));
         token.setScopes(scopes);
@@ -63,13 +70,14 @@
         CriteriaQuery<RefreshToken> query =
                 builder.createQuery(RefreshToken.class);
         Root<RefreshToken> root = query.from(RefreshToken.class);
+        root.fetch(RefreshToken_.client);
+        
         query.select(root);
         query.where(builder.equal(root.get(RefreshToken_.token), token));
         Query q = entityManager.createQuery(query);
         return (RefreshToken) q.getSingleResult();
     }
 
-    @SuppressWarnings("unchecked")
     public List<RefreshToken> retrieveRefreshTokenByClientId (String clientId)
             throws KustvaktException {
         ParameterChecker.checkStringValue(clientId, "client_id");
@@ -78,9 +86,11 @@
         CriteriaQuery<RefreshToken> query =
                 builder.createQuery(RefreshToken.class);
         Root<RefreshToken> root = query.from(RefreshToken.class);
+        Join<RefreshToken, OAuth2Client> client =
+                root.join(RefreshToken_.client);
         query.select(root);
-        query.where(builder.equal(root.get(RefreshToken_.clientId), clientId));
-        Query q = entityManager.createQuery(query);
+        query.where(builder.equal(client.get(OAuth2Client_.id), clientId));
+        TypedQuery<RefreshToken> q = entityManager.createQuery(query);
         return q.getResultList();
     }
 
@@ -91,4 +101,28 @@
         token = entityManager.merge(token);
         return token;
     }
+
+    // public List<RefreshToken> retrieveRefreshTokenByUser (String
+    // username)
+    // throws KustvaktException {
+    // ParameterChecker.checkStringValue(username, "username");
+    //
+    // CriteriaBuilder builder = entityManager.getCriteriaBuilder();
+    // CriteriaQuery<RefreshToken> query =
+    // builder.createQuery(RefreshToken.class);
+    //
+    // Root<RefreshToken> root = query.from(RefreshToken.class);
+    // Predicate condition = builder.and(
+    // builder.equal(root.get(RefreshToken_.userId), username),
+    // builder.equal(root.get(RefreshToken_.isRevoked), false),
+    // builder.greaterThan(
+    // root.<ZonedDateTime> get(RefreshToken_.expiryDate),
+    // ZonedDateTime
+    // .now(ZoneId.of(Attributes.DEFAULT_TIME_ZONE)))
+    // );
+    // query.select(root);
+    // query.where(condition);
+    // TypedQuery<RefreshToken> q = entityManager.createQuery(query);
+    // return q.getResultList();
+    // }
 }
diff --git a/full/src/main/java/de/ids_mannheim/korap/oauth2/dto/OAuth2UserClientDto.java b/full/src/main/java/de/ids_mannheim/korap/oauth2/dto/OAuth2UserClientDto.java
new file mode 100644
index 0000000..02ab148
--- /dev/null
+++ b/full/src/main/java/de/ids_mannheim/korap/oauth2/dto/OAuth2UserClientDto.java
@@ -0,0 +1,20 @@
+package de.ids_mannheim.korap.oauth2.dto;
+
+public class OAuth2UserClientDto {
+    
+    private String clientId;
+    private String clientName;
+    
+    public String getClientName () {
+        return clientName;
+    }
+    public void setClientName (String clientName) {
+        this.clientName = clientName;
+    }
+    public String getClientId () {
+        return clientId;
+    }
+    public void setClientId (String clientId) {
+        this.clientId = clientId;
+    }
+}
diff --git a/full/src/main/java/de/ids_mannheim/korap/oauth2/entity/OAuth2Client.java b/full/src/main/java/de/ids_mannheim/korap/oauth2/entity/OAuth2Client.java
index 09c6e35..8023e32 100644
--- a/full/src/main/java/de/ids_mannheim/korap/oauth2/entity/OAuth2Client.java
+++ b/full/src/main/java/de/ids_mannheim/korap/oauth2/entity/OAuth2Client.java
@@ -1,5 +1,7 @@
 package de.ids_mannheim.korap.oauth2.entity;
 
+import java.util.List;
+
 import javax.persistence.CascadeType;
 import javax.persistence.Column;
 import javax.persistence.Entity;
@@ -8,6 +10,7 @@
 import javax.persistence.FetchType;
 import javax.persistence.Id;
 import javax.persistence.JoinColumn;
+import javax.persistence.OneToMany;
 import javax.persistence.OneToOne;
 import javax.persistence.Table;
 
@@ -19,7 +22,7 @@
  */
 @Entity
 @Table(name = "oauth2_client")
-public class OAuth2Client {
+public class OAuth2Client implements Comparable<OAuth2Client>{
 
     @Id
     private String id;
@@ -40,6 +43,9 @@
     @JoinColumn(name = "url_id")
     private OAuth2ClientUrl clientUrl;
 
+    @OneToMany(fetch = FetchType.LAZY, mappedBy = "client")
+    private List<RefreshToken> refreshTokens;
+    
     @Override
     public String toString () {
         return "id=" + id + ", name=" + name + ", secret=" + secret + ", type="
@@ -119,4 +125,9 @@
     public void setClientUrl (OAuth2ClientUrl clientUrl) {
         this.clientUrl = clientUrl;
     }
+
+    @Override
+    public int compareTo (OAuth2Client o) {
+        return this.getName().compareTo(o.getName());
+    }
 }
diff --git a/full/src/main/java/de/ids_mannheim/korap/oauth2/entity/RefreshToken.java b/full/src/main/java/de/ids_mannheim/korap/oauth2/entity/RefreshToken.java
index a43a493..a60aef5 100644
--- a/full/src/main/java/de/ids_mannheim/korap/oauth2/entity/RefreshToken.java
+++ b/full/src/main/java/de/ids_mannheim/korap/oauth2/entity/RefreshToken.java
@@ -12,6 +12,7 @@
 import javax.persistence.JoinColumn;
 import javax.persistence.JoinTable;
 import javax.persistence.ManyToMany;
+import javax.persistence.ManyToOne;
 import javax.persistence.OneToMany;
 import javax.persistence.Table;
 import javax.persistence.UniqueConstraint;
@@ -30,8 +31,8 @@
     private ZonedDateTime expiryDate;
     @Column(name = "user_id")
     private String userId;
-    @Column(name = "client_id")
-    private String clientId;
+//    @Column(name = "client_id")
+//    private String clientId;
     @Column(name = "user_auth_time", updatable = false)
     private ZonedDateTime userAuthenticationTime;
     @Column(name = "is_revoked")
@@ -39,6 +40,10 @@
 
     @OneToMany(fetch = FetchType.EAGER, mappedBy = "refreshToken")
     private Set<AccessToken> accessTokens;
+    
+    @ManyToOne(fetch=FetchType.LAZY)
+    @JoinColumn(name="client")
+    private OAuth2Client client;
 
     @ManyToMany(fetch = FetchType.EAGER)
     @JoinTable(name = "oauth2_refresh_token_scope",
@@ -82,14 +87,6 @@
         this.userId = userId;
     }
 
-    public String getClientId () {
-        return clientId;
-    }
-
-    public void setClientId (String clientId) {
-        this.clientId = clientId;
-    }
-
     public ZonedDateTime getUserAuthenticationTime () {
         return userAuthenticationTime;
     }
@@ -123,4 +120,12 @@
         this.scopes = scopes;
     }
 
+    public OAuth2Client getClient () {
+        return client;
+    }
+
+    public void setClient (OAuth2Client client) {
+        this.client = client;
+    }
+
 }
diff --git a/full/src/main/java/de/ids_mannheim/korap/oauth2/oltu/service/OltuTokenService.java b/full/src/main/java/de/ids_mannheim/korap/oauth2/oltu/service/OltuTokenService.java
index 6bf3b39..7b4ac81 100644
--- a/full/src/main/java/de/ids_mannheim/korap/oauth2/oltu/service/OltuTokenService.java
+++ b/full/src/main/java/de/ids_mannheim/korap/oauth2/oltu/service/OltuTokenService.java
@@ -120,7 +120,7 @@
                     "Refresh token is not found", OAuth2Error.INVALID_GRANT);
         }
 
-        if (!clientId.equals(refreshToken.getClientId())) {
+        if (!clientId.equals(refreshToken.getClient().getId())) {
             throw new KustvaktException(StatusCodes.CLIENT_AUTHORIZATION_FAILED,
                     "Client " + clientId + "is not authorized",
                     OAuth2Error.INVALID_CLIENT);
diff --git a/full/src/main/java/de/ids_mannheim/korap/oauth2/service/OAuth2ClientService.java b/full/src/main/java/de/ids_mannheim/korap/oauth2/service/OAuth2ClientService.java
index 47dffe3..23b8267 100644
--- a/full/src/main/java/de/ids_mannheim/korap/oauth2/service/OAuth2ClientService.java
+++ b/full/src/main/java/de/ids_mannheim/korap/oauth2/service/OAuth2ClientService.java
@@ -5,6 +5,8 @@
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 
 import org.apache.commons.validator.routines.UrlValidator;
@@ -25,6 +27,7 @@
 import de.ids_mannheim.korap.oauth2.dao.RefreshTokenDao;
 import de.ids_mannheim.korap.oauth2.dto.OAuth2ClientDto;
 import de.ids_mannheim.korap.oauth2.dto.OAuth2ClientInfoDto;
+import de.ids_mannheim.korap.oauth2.dto.OAuth2UserClientDto;
 import de.ids_mannheim.korap.oauth2.entity.AccessToken;
 import de.ids_mannheim.korap.oauth2.entity.Authorization;
 import de.ids_mannheim.korap.oauth2.entity.OAuth2Client;
@@ -337,4 +340,36 @@
                     "Unauthorized operation for user: " + username, username);
         }
     }
+
+    public OAuth2Client retrieveClient (String clientId)
+            throws KustvaktException {
+        return clientDao.retrieveClientById(clientId);
+    }
+
+    public List<OAuth2Client> retrieveUserClients (String username)
+            throws KustvaktException {
+        return clientDao.retrieveUserClients(username);
+    }
+
+    public List<OAuth2UserClientDto> listUserClients (String username,
+            String clientId, String clientSecret) throws KustvaktException {
+        OAuth2Client client = authenticateClient(clientId, clientSecret);
+        if (!client.isSuper()) {
+            throw new KustvaktException(StatusCodes.CLIENT_AUTHORIZATION_FAILED,
+                    "Only super client is allowed to list user clients.",
+                    OAuth2Error.UNAUTHORIZED_CLIENT);
+        }
+        List<OAuth2Client> userClients = retrieveUserClients(username);
+        Collections.sort(userClients);
+        
+        List<OAuth2UserClientDto> dtoList = new ArrayList<>(userClients.size());
+        for (OAuth2Client uc : userClients) {
+            if (uc.isSuper()) continue;
+            OAuth2UserClientDto dto = new OAuth2UserClientDto();
+            dto.setClientId(uc.getId());
+            dto.setClientName(uc.getName());
+            dtoList.add(dto);
+        }
+        return dtoList;
+    }
 }
diff --git a/full/src/main/java/de/ids_mannheim/korap/web/controller/OAuthClientController.java b/full/src/main/java/de/ids_mannheim/korap/web/controller/OAuthClientController.java
index ca7e168..02b190f 100644
--- a/full/src/main/java/de/ids_mannheim/korap/web/controller/OAuthClientController.java
+++ b/full/src/main/java/de/ids_mannheim/korap/web/controller/OAuthClientController.java
@@ -1,5 +1,7 @@
 package de.ids_mannheim.korap.web.controller;
 
+import java.util.List;
+
 import javax.ws.rs.Consumes;
 import javax.ws.rs.DELETE;
 import javax.ws.rs.FormParam;
@@ -22,6 +24,7 @@
 import de.ids_mannheim.korap.exceptions.KustvaktException;
 import de.ids_mannheim.korap.oauth2.dto.OAuth2ClientDto;
 import de.ids_mannheim.korap.oauth2.dto.OAuth2ClientInfoDto;
+import de.ids_mannheim.korap.oauth2.dto.OAuth2UserClientDto;
 import de.ids_mannheim.korap.oauth2.service.OAuth2ClientService;
 import de.ids_mannheim.korap.oauth2.service.OAuth2ScopeService;
 import de.ids_mannheim.korap.security.context.TokenContext;
@@ -55,7 +58,8 @@
  */
 @Controller
 @Path("{version}/oauth2/client")
-@ResourceFilters({APIVersionFilter.class, AuthenticationFilter.class, BlockingFilter.class })
+@ResourceFilters({ APIVersionFilter.class, AuthenticationFilter.class,
+        BlockingFilter.class })
 public class OAuthClientController {
 
     @Autowired
@@ -180,7 +184,8 @@
      * authorization codes are invalidated.
      * 
      * @param securityContext
-     * @param clientId OAuth2 client id
+     * @param clientId
+     *            OAuth2 client id
      * @param super
      *            true indicating super client, false otherwise
      * @return Response status OK, if successful
@@ -222,4 +227,43 @@
             throw responseHandler.throwit(e);
         }
     }
+
+    /**
+     * Lists user clients having refresh tokens. This service is not
+     * part of the OAuth2 specification. It is intended to facilitate
+     * users revoking any suspicious and misused access or refresh
+     * tokens.
+     * 
+     * Only super clients are allowed to use this service. It requires
+     * user and client authentications.
+     * 
+     * @param context
+     * @return a list of clients having refresh tokens of the
+     *         given user
+     */
+    @POST
+    @Path("list")
+    @ResourceFilters({ AuthenticationFilter.class, BlockingFilter.class })
+    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
+    @Produces(MediaType.APPLICATION_JSON + ";charset=utf-8")
+    public List<OAuth2UserClientDto> listUserApp (
+            @Context SecurityContext context,
+            @FormParam("client_id") String clientId,
+            @FormParam("client_secret") String clientSecret) {
+
+        TokenContext tokenContext = (TokenContext) context.getUserPrincipal();
+        String username = tokenContext.getUsername();
+
+        try {
+            scopeService.verifyScope(tokenContext,
+                    OAuth2Scope.LIST_USER_CLIENT);
+
+            return clientService.listUserClients(username, clientId,
+                    clientSecret);
+        }
+        catch (KustvaktException e) {
+            throw responseHandler.throwit(e);
+        }
+    }
+
 }
diff --git a/full/src/main/resources/db/sqlite/V1.4__oauth2_tables.sql b/full/src/main/resources/db/sqlite/V1.4__oauth2_tables.sql
index e0c06d6..fe0123c 100644
--- a/full/src/main/resources/db/sqlite/V1.4__oauth2_tables.sql
+++ b/full/src/main/resources/db/sqlite/V1.4__oauth2_tables.sql
@@ -62,11 +62,11 @@
 	token VARCHAR(255) NOT NULL,
 	user_id VARCHAR(100) DEFAULT NULL,
 	user_auth_time TIMESTAMP NOT NULL,
-	client_id VARCHAR(100) DEFAULT NULL,
 	created_date TIMESTAMP NOT NULL,
 	expiry_date TIMESTAMP NULL,
 	is_revoked BOOLEAN DEFAULT 0,
-	FOREIGN KEY (client_id)
+	client VARCHAR(100) DEFAULT NULL,
+	FOREIGN KEY (client)
 	   REFERENCES oauth2_client(id)
 );
 
diff --git a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AccessTokenTest.java b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AccessTokenTest.java
index 674a5e6..7697db7 100644
--- a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AccessTokenTest.java
+++ b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2AccessTokenTest.java
@@ -42,7 +42,7 @@
     @Test
     public void testScopeWithSuperClient () throws KustvaktException {
         ClientResponse response =
-                requestTokenWithPassword(superClientId, clientSecret);
+                requestTokenWithDoryPassword(superClientId, clientSecret);
 
         JsonNode node = JsonUtils.readTree(response.getEntity(String.class));
         assertEquals("all", node.at("/scope").asText());
@@ -261,7 +261,7 @@
     public void testRequestAuthorizationWithBearerToken ()
             throws KustvaktException {
         ClientResponse response =
-                requestTokenWithPassword(superClientId, clientSecret);
+                requestTokenWithDoryPassword(superClientId, clientSecret);
         String entity = response.getEntity(String.class);
         
         JsonNode node = JsonUtils.readTree(entity);
diff --git a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ClientControllerTest.java b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ClientControllerTest.java
index 1c59d76..a87e83b 100644
--- a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ClientControllerTest.java
+++ b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ClientControllerTest.java
@@ -67,7 +67,8 @@
         json.setRedirectURI("https://example.client.com/redirect");
         json.setDescription("This is a confidential test client.");
 
-        return resource().path(API_VERSION).path("oauth2").path("client").path("register")
+        return resource().path(API_VERSION).path("oauth2").path("client")
+                .path("register")
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
@@ -78,8 +79,8 @@
     private JsonNode retrieveClientInfo (String clientId, String username)
             throws UniformInterfaceException, ClientHandlerException,
             KustvaktException {
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("client")
-                .path("info").path(clientId)
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("client").path("info").path(clientId)
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .get(ClientResponse.class);
@@ -128,8 +129,8 @@
         json.setRedirectURI("https://test.public.client.com/redirect");
         json.setDescription("This is a public test client.");
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("client")
-                .path("register")
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("client").path("register")
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
@@ -155,8 +156,8 @@
         json.setType(OAuth2ClientType.PUBLIC);
         json.setDescription("This is a desktop test client.");
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("client")
-                .path("register")
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("client").path("register")
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
@@ -182,8 +183,8 @@
 
         String code =
                 requestAuthorizationCode(clientId, "", null, userAuthHeader);
-        ClientResponse response = requestTokenWithAuthorizationCodeAndForm(clientId,
-                clientSecret, code);
+        ClientResponse response = requestTokenWithAuthorizationCodeAndForm(
+                clientId, clientSecret, code);
         JsonNode node = JsonUtils.readTree(response.getEntity(String.class));
         String accessToken = node.at("/access_token").asText();
 
@@ -213,8 +214,9 @@
             String clientId) throws UniformInterfaceException,
             ClientHandlerException, KustvaktException {
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("client")
-                .path("deregister").path(clientId).delete(ClientResponse.class);
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("client").path("deregister").path(clientId)
+                .delete(ClientResponse.class);
 
         assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
 
@@ -228,8 +230,8 @@
             throws UniformInterfaceException, ClientHandlerException,
             KustvaktException {
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("client")
-                .path("deregister")
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("client").path("deregister")
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .delete(ClientResponse.class);
@@ -241,8 +243,8 @@
             throws UniformInterfaceException, ClientHandlerException,
             KustvaktException {
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("client")
-                .path("deregister").path(clientId)
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("client").path("deregister").path(clientId)
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .delete(ClientResponse.class);
@@ -257,8 +259,8 @@
         MultivaluedMap<String, String> form = new MultivaluedMapImpl();
         form.add("client_secret", clientSecret);
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("client")
-                .path("deregister").path(clientId)
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("client").path("deregister").path(clientId)
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .header(HttpHeaders.CONTENT_TYPE,
@@ -271,8 +273,8 @@
     private void testDeregisterConfidentialClientMissingSecret (String clientId)
             throws KustvaktException {
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("client")
-                .path("deregister").path(clientId)
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("client").path("deregister").path(clientId)
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .header(HttpHeaders.CONTENT_TYPE,
@@ -295,8 +297,8 @@
         MultivaluedMap<String, String> form = new MultivaluedMapImpl();
         form.add("client_secret", clientSecret);
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("client")
-                .path("deregister").path(clientId)
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("client").path("deregister").path(clientId)
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .header(HttpHeaders.CONTENT_TYPE,
@@ -320,8 +322,8 @@
         MultivaluedMap<String, String> form = new MultivaluedMapImpl();
         form.add("client_id", clientId);
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("client")
-                .path("reset")
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("client").path("reset")
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .header(HttpHeaders.CONTENT_TYPE,
@@ -343,8 +345,8 @@
         form.add("client_id", clientId);
         form.add("client_secret", clientSecret);
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("client")
-                .path("reset")
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("client").path("reset")
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue(username, "pass"))
                 .header(HttpHeaders.CONTENT_TYPE,
@@ -382,7 +384,7 @@
 
         testAccessTokenAfterUpgradingClient(clientId, accessToken);
         testAccessTokenAfterDegradingSuperClient(clientId, accessToken);
-        
+
         testDeregisterConfidentialClient(clientId, clientSecret);
     }
 
@@ -398,9 +400,11 @@
         assertTrue(node.at("/isSuper").asBoolean());
 
         // list vc
-        ClientResponse response = resource().path(API_VERSION).path("vc").path("list")
-                .header(Attributes.AUTHORIZATION, "Bearer " + accessToken)
-                .get(ClientResponse.class);
+        ClientResponse response =
+                resource().path(API_VERSION).path("vc").path("list")
+                        .header(Attributes.AUTHORIZATION,
+                                "Bearer " + accessToken)
+                        .get(ClientResponse.class);
 
         assertEquals(ClientResponse.Status.UNAUTHORIZED.getStatusCode(),
                 response.getStatus());
@@ -410,7 +414,7 @@
                 node.at("/errors/0/0").asInt());
         assertEquals("Scope vc_info is not authorized",
                 node.at("/errors/0/1").asText());
-        
+
         // search
         response = searchWithAccessToken(accessToken);
         assertEquals(Status.OK.getStatusCode(), response.getStatus());
@@ -421,7 +425,7 @@
         MultivaluedMap<String, String> form = new MultivaluedMapImpl();
         form.add("client_id", clientId);
         form.add("super", "false");
-        
+
         updateClientPrivilege(form);
         JsonNode node = retrieveClientInfo(clientId, username);
         assertTrue(node.at("/isSuper").isMissingNode());
@@ -429,7 +433,7 @@
         ClientResponse response = searchWithAccessToken(accessToken);
         assertEquals(ClientResponse.Status.UNAUTHORIZED.getStatusCode(),
                 response.getStatus());
-        
+
         String entity = response.getEntity(String.class);
         node = JsonUtils.readTree(entity);
         assertEquals(StatusCodes.INVALID_ACCESS_TOKEN,
@@ -437,4 +441,51 @@
         assertEquals("Access token has been revoked",
                 node.at("/errors/0/1").asText());
     }
+
+    @Test
+    public void testListUserClients () throws KustvaktException {
+        String username = "pearl";
+        String password = "pwd";
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(username, password);
+
+        // super client
+        ClientResponse response = requestTokenWithPassword(superClientId,
+                clientSecret, username, password);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+
+        // client 1
+        String code = requestAuthorizationCode(publicClientId, clientSecret,
+                null, userAuthHeader);
+        response = requestTokenWithAuthorizationCodeAndForm(publicClientId, "",
+                code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+
+        // client 2
+        code = requestAuthorizationCode(confidentialClientId, clientSecret,
+                null, userAuthHeader);
+        response = requestTokenWithAuthorizationCodeAndForm(
+                confidentialClientId, clientSecret, code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+
+        MultivaluedMap<String, String> form = new MultivaluedMapImpl();
+        form.add("client_id", superClientId);
+        form.add("client_secret", clientSecret);
+
+        response = resource().path(API_VERSION).path("oauth2").path("client")
+                .path("list").header(Attributes.AUTHORIZATION, userAuthHeader)
+                .header(HttpHeaders.CONTENT_TYPE,
+                        ContentType.APPLICATION_FORM_URLENCODED)
+                .entity(form).post(ClientResponse.class);
+
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        
+        String entity = response.getEntity(String.class);
+        JsonNode node = JsonUtils.readTree(entity);
+        
+        assertEquals(2, node.size());
+        assertEquals(confidentialClientId, node.at("/0/clientId").asText());
+        assertEquals(publicClientId, node.at("/1/clientId").asText());
+    }
+
 }
diff --git a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ControllerTest.java b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ControllerTest.java
index 945e9d0..e454762 100644
--- a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ControllerTest.java
+++ b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2ControllerTest.java
@@ -322,7 +322,7 @@
     public void testRequestTokenPasswordGrantConfidentialSuper ()
             throws KustvaktException {
         ClientResponse response =
-                requestTokenWithPassword(superClientId, clientSecret);
+                requestTokenWithDoryPassword(superClientId, clientSecret);
 
         assertEquals(Status.OK.getStatusCode(), response.getStatus());
 
@@ -338,7 +338,7 @@
     public void testRequestTokenPasswordGrantConfidentialNonSuper ()
             throws KustvaktException {
         ClientResponse response =
-                requestTokenWithPassword(confidentialClientId, clientSecret);
+                requestTokenWithDoryPassword(confidentialClientId, clientSecret);
         String entity = response.getEntity(String.class);
         assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
 
@@ -352,7 +352,7 @@
     @Test
     public void testRequestTokenPasswordGrantPublic ()
             throws KustvaktException {
-        ClientResponse response = requestTokenWithPassword(publicClientId, "");
+        ClientResponse response = requestTokenWithDoryPassword(publicClientId, "");
         String entity = response.getEntity(String.class);
 
         assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
@@ -422,7 +422,7 @@
     public void testRequestTokenPasswordGrantMissingClientSecret ()
             throws KustvaktException {
         ClientResponse response =
-                requestTokenWithPassword(confidentialClientId, "");
+                requestTokenWithDoryPassword(confidentialClientId, "");
         assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
 
         String entity = response.getEntity(String.class);
@@ -436,7 +436,7 @@
     @Test
     public void testRequestTokenPasswordGrantMissingClientId ()
             throws KustvaktException {
-        ClientResponse response = requestTokenWithPassword(null, clientSecret);
+        ClientResponse response = requestTokenWithDoryPassword(null, clientSecret);
         String entity = response.getEntity(String.class);
         assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
 
diff --git a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2TestBase.java b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2TestBase.java
index bf068bf..42318bc 100644
--- a/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2TestBase.java
+++ b/full/src/test/java/de/ids_mannheim/korap/web/controller/OAuth2TestBase.java
@@ -24,8 +24,9 @@
 import de.ids_mannheim.korap.exceptions.KustvaktException;
 import de.ids_mannheim.korap.utils.JsonUtils;
 
-/** Provides common methods and variables for OAuth2 tests,
- *  and does not run any test. 
+/**
+ * Provides common methods and variables for OAuth2 tests,
+ * and does not run any test.
  * 
  * @author margaretha
  *
@@ -105,8 +106,8 @@
         form.add("client_id", clientId);
         form.add("code", code);
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("token")
-                .header(Attributes.AUTHORIZATION, authHeader)
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("token").header(Attributes.AUTHORIZATION, authHeader)
                 .header(HttpHeaders.CONTENT_TYPE,
                         ContentType.APPLICATION_FORM_URLENCODED)
                 .entity(form).post(ClientResponse.class);
@@ -115,14 +116,21 @@
         return JsonUtils.readTree(entity);
     }
 
-    public ClientResponse requestTokenWithPassword (String clientId,
+    public ClientResponse requestTokenWithDoryPassword (String clientId,
             String clientSecret) throws KustvaktException {
+        return requestTokenWithPassword(clientId, clientSecret, "dory",
+                "password");
+    }
+
+    public ClientResponse requestTokenWithPassword (String clientId,
+            String clientSecret, String username, String password)
+            throws KustvaktException {
         MultivaluedMap<String, String> form = new MultivaluedMapImpl();
         form.add("grant_type", "password");
         form.add("client_id", clientId);
         form.add("client_secret", clientSecret);
-        form.add("username", "dory");
-        form.add("password", "password");
+        form.add("username", username);
+        form.add("password", password);
 
         return requestToken(form);
     }
@@ -130,8 +138,8 @@
     public void updateClientPrivilege (MultivaluedMap<String, String> form)
             throws UniformInterfaceException, ClientHandlerException,
             KustvaktException {
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("client")
-                .path("privilege")
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("client").path("privilege")
                 .header(Attributes.AUTHORIZATION, HttpAuthorizationHandler
                         .createBasicAuthorizationHeaderValue("admin", "pass"))
                 .header(HttpHeaders.CONTENT_TYPE,
@@ -140,10 +148,10 @@
 
         assertEquals(Status.OK.getStatusCode(), response.getStatus());
     }
-    
+
     public ClientResponse searchWithAccessToken (String accessToken) {
-        return resource().path(API_VERSION).path("search").queryParam("q", "Wasser")
-                .queryParam("ql", "poliqarp")
+        return resource().path(API_VERSION).path("search")
+                .queryParam("q", "Wasser").queryParam("ql", "poliqarp")
                 .header(Attributes.AUTHORIZATION, "Bearer " + accessToken)
                 .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
                 .get(ClientResponse.class);