Updated OAuth2 token list with token type and user clientId parameters.

Change-Id: Id7171fa09913cb2ab8bcefd7bce3003388ce93d4
diff --git a/full/Changes b/full/Changes
index 8d3d4a2..fd9f56e 100644
--- a/full/Changes
+++ b/full/Changes
@@ -5,6 +5,9 @@
 2021-02-26
  - Added query access roles and fixed vc access roles (margaretha)
  - Added delete query webservice and tests (margaretha) 
+2021-03-25
+ - Updated OAuth2 token list with token type and user clientId 
+   parameters (margaretha) 
  
 # version 0.63
 26/10/2020
diff --git a/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/AccessTokenDao.java b/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/AccessTokenDao.java
index b82819d..f3cc60a 100644
--- a/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/AccessTokenDao.java
+++ b/full/src/main/java/de/ids_mannheim/korap/oauth2/dao/AccessTokenDao.java
@@ -183,4 +183,32 @@
         return q.getResultList();
     }
 
+    public List<AccessToken> retrieveAccessTokenByUser (String username, String clientId)
+            throws KustvaktException {
+        ParameterChecker.checkStringValue(username, "username");
+
+        CriteriaBuilder builder = entityManager.getCriteriaBuilder();
+        CriteriaQuery<AccessToken> query =
+                builder.createQuery(AccessToken.class);
+
+        Root<AccessToken> root = query.from(AccessToken.class);
+        root.fetch(AccessToken_.client);
+        Predicate condition = builder.and(
+                builder.equal(root.get(AccessToken_.userId), username),
+                builder.equal(root.get(AccessToken_.isRevoked), false),
+                builder.greaterThan(
+                        root.<ZonedDateTime> get(AccessToken_.expiryDate),
+                        ZonedDateTime
+                                .now(ZoneId.of(Attributes.DEFAULT_TIME_ZONE))));
+        if (clientId != null && !clientId.isEmpty()) {
+            OAuth2Client client = clientDao.retrieveClientById(clientId);
+            condition = builder.and(condition,
+                    builder.equal(root.get(AccessToken_.client), client));
+        }
+            
+        query.select(root);
+        query.where(condition);
+        TypedQuery<AccessToken> 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 0bcc54d..585d8b6 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
@@ -143,7 +143,8 @@
         return q.getResultList();
     }
 
-    public List<RefreshToken> retrieveRefreshTokenByUser (String username)
+    public List<RefreshToken> retrieveRefreshTokenByUser (String username,
+            String clientId)
             throws KustvaktException {
         ParameterChecker.checkStringValue(username, "username");
 
@@ -160,6 +161,12 @@
                         root.<ZonedDateTime> get(RefreshToken_.expiryDate),
                         ZonedDateTime
                                 .now(ZoneId.of(Attributes.DEFAULT_TIME_ZONE))));
+        if (clientId != null && !clientId.isEmpty()) {
+            OAuth2Client client = clientDao.retrieveClientById(clientId);
+            condition = builder.and(condition,
+                    builder.equal(root.get(RefreshToken_.client), client));
+        }
+        
         query.select(root);
         query.where(condition);
         TypedQuery<RefreshToken> q = entityManager.createQuery(query);
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 abab7ce..d066063 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
@@ -104,7 +104,7 @@
      * credentials.
      * 
      * TODO: should create a new refresh token when the old refresh
-     * token is used
+     * token is used (DONE)
      * 
      * @param refreshTokenStr
      * @param scopes
@@ -322,9 +322,9 @@
      * Base64.
      * 
      * <br /><br />
-     * Additionally, a refresh token is issued. It can be used to
-     * request a new access token without requiring user
-     * reauthentication.
+     * Additionally, a refresh token is issued for confidential clients. 
+     * It can be used to request a new access token without requiring user
+     * re-authentication.
      * 
      * @param scopes
      *            a set of access token scopes in String
@@ -512,17 +512,17 @@
         }
     }
     
-    public List<OAuth2TokenDto> listUserRefreshToken (String username, String clientId,
-            String clientSecret) throws KustvaktException {
+    public List<OAuth2TokenDto> listUserRefreshToken (String username, String superClientId,
+            String superClientSecret, String clientId) throws KustvaktException {
         
-        OAuth2Client client = clientService.authenticateClient(clientId, clientSecret);
+        OAuth2Client client = clientService.authenticateClient(superClientId, superClientSecret);
         if (!client.isSuper()) {
             throw new KustvaktException(StatusCodes.CLIENT_AUTHORIZATION_FAILED,
                     "Only super client is allowed.",
                     OAuth2Error.UNAUTHORIZED_CLIENT);
         }
 
-        List<RefreshToken> tokens = refreshDao.retrieveRefreshTokenByUser(username);
+        List<RefreshToken> tokens = refreshDao.retrieveRefreshTokenByUser(username, clientId);
         List<OAuth2TokenDto> dtoList = new ArrayList<>(tokens.size());
         for (RefreshToken t : tokens){
             OAuth2Client tokenClient = t.getClient();
@@ -552,6 +552,48 @@
         }
         return dtoList;
     }
+    
+    public List<OAuth2TokenDto> listUserAccessToken (String username, String superClientId,
+            String superClientSecret, String clientId) throws KustvaktException {
+        
+        OAuth2Client superClient = clientService.authenticateClient(superClientId, superClientSecret);
+        if (!superClient.isSuper()) {
+            throw new KustvaktException(StatusCodes.CLIENT_AUTHORIZATION_FAILED,
+                    "Only super client is allowed.",
+                    OAuth2Error.UNAUTHORIZED_CLIENT);
+        }
+
+        List<AccessToken> tokens =
+                tokenDao.retrieveAccessTokenByUser(username, clientId);
+        List<OAuth2TokenDto> dtoList = new ArrayList<>(tokens.size());
+        for (AccessToken t : tokens){
+            OAuth2Client tokenClient = t.getClient();
+            if (tokenClient.getId().equals(superClient.getId())){
+                continue;
+            }
+            OAuth2TokenDto dto = new OAuth2TokenDto();
+            dto.setClientId(tokenClient.getId());
+            dto.setClientName(tokenClient.getName());
+            dto.setClientUrl(tokenClient.getUrl());
+            dto.setClientDescription(tokenClient.getDescription());
+            
+            DateTimeFormatter f = DateTimeFormatter.ISO_DATE_TIME;
+            dto.setCreatedDate(t.getCreatedDate().format(f));
+            dto.setExpiryDate(t.getExpiryDate().format(f));
+            dto.setUserAuthenticationTime(
+                    t.getUserAuthenticationTime().format(f));
+            dto.setToken(t.getToken());
+            
+            Set<AccessScope> accessScopes = t.getScopes();
+            Set<String> scopes = new HashSet<>(accessScopes.size());
+            for (AccessScope s : accessScopes){
+                scopes.add(s.getId().toString());
+            }
+            dto.setScopes(scopes);
+            dtoList.add(dto);
+        }
+        return dtoList;
+    }
 
     public String clearAccessTokenCache (String adminToken, String accessToken,
             ServletContext context) throws KustvaktException {
diff --git a/full/src/main/java/de/ids_mannheim/korap/web/controller/OAuth2Controller.java b/full/src/main/java/de/ids_mannheim/korap/web/controller/OAuth2Controller.java
index 17fb0a4..e043837 100644
--- a/full/src/main/java/de/ids_mannheim/korap/web/controller/OAuth2Controller.java
+++ b/full/src/main/java/de/ids_mannheim/korap/web/controller/OAuth2Controller.java
@@ -30,6 +30,8 @@
 
 import de.ids_mannheim.korap.constant.OAuth2Scope;
 import de.ids_mannheim.korap.exceptions.KustvaktException;
+import de.ids_mannheim.korap.exceptions.StatusCodes;
+import de.ids_mannheim.korap.oauth2.constant.OAuth2Error;
 import de.ids_mannheim.korap.oauth2.dto.OAuth2TokenDto;
 import de.ids_mannheim.korap.oauth2.oltu.OAuth2AuthorizationRequest;
 import de.ids_mannheim.korap.oauth2.oltu.OAuth2RevokeAllTokenSuperRequest;
@@ -130,13 +132,20 @@
      * Grants a client an access token, namely a string used in
      * authenticated requests representing user authorization for
      * the client to access user resources. An additional refresh
-     * token strictly associated to the access token is also granted.
+     * token strictly associated to the access token is also granted
+     * for confidential clients. Both public and confidential clients
+     * may issue multiple access tokens.
      * 
      * <br /><br />
      * 
-     * Clients may request refresh access token using this endpoint.
-     * This request will grants a new access token. The refresh token
-     * is not changed and can be used until it expires.
+     * Confidential clients may request refresh access token using
+     * this endpoint. This request will grant a new access token.
+     * 
+     * Usually the given refresh token is not changed and can be used
+     * until it expires. However, currently there is a limitation of
+     * one access token per one refresh token. Thus, the given refresh
+     * token will be revoked, and a new access token and a new refresh
+     * token will be returned.
      * 
      * <br /><br />
      * 
@@ -260,7 +269,6 @@
         }
     }
 
-    
     @POST
     @Path("revoke/super")
     @ResourceFilters({ AuthenticationFilter.class, BlockingFilter.class })
@@ -290,7 +298,7 @@
             throw responseHandler.throwit(e);
         }
     }
-    
+
     /**
      * Revokes all tokens of a client for the authenticated user from
      * a super client. This service is not part of the OAUTH2
@@ -343,22 +351,35 @@
     @Produces(MediaType.APPLICATION_JSON + ";charset=utf-8")
     public List<OAuth2TokenDto> listUserRefreshToken (
             @Context SecurityContext context,
-            @FormParam("client_id") String clientId,
-            @FormParam("client_secret") String clientSecret) {
+            @FormParam("super_client_id") String superClientId,
+            @FormParam("super_client_secret") String superClientSecret,
+            @FormParam("client_id") String clientId, // optional
+            @FormParam("token_type") String tokenType) {
 
         TokenContext tokenContext = (TokenContext) context.getUserPrincipal();
         String username = tokenContext.getUsername();
 
         try {
-            return tokenService.listUserRefreshToken(username, clientId,
-                    clientSecret);
+            if (tokenType.equals("access_token")) {
+                return tokenService.listUserAccessToken(username, superClientId,
+                        superClientSecret, clientId);
+            }
+            else if (tokenType.equals("refresh_token")) {
+                return tokenService.listUserRefreshToken(username,
+                        superClientId, superClientSecret, clientId);
+            }
+            else {
+                throw new KustvaktException(StatusCodes.MISSING_PARAMETER,
+                        "Missing token_type parameter value",
+                        OAuth2Error.INVALID_REQUEST);
+            }
         }
         catch (KustvaktException e) {
             throw responseHandler.throwit(e);
         }
 
     }
-    
+
     @POST
     @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
     @Path("token/clear")
@@ -367,8 +388,8 @@
             @FormParam("access_token") String accessToken,
             @Context ServletContext context) {
         try {
-            String response = tokenService.clearAccessTokenCache(adminToken, accessToken,
-                    context);
+            String response = tokenService.clearAccessTokenCache(adminToken,
+                    accessToken, context);
             return Response.ok(response).build();
         }
         catch (KustvaktException e) {
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 be97272..3b1a187 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
@@ -41,7 +41,7 @@
     public String userAuthHeader;
     public static String ACCESS_TOKEN_TYPE = "access_token";
     public static String REFRESH_TOKEN_TYPE = "refresh_token";
-    
+
     public OAuth2ControllerTest () throws KustvaktException {
         userAuthHeader = HttpAuthorizationHandler
                 .createBasicAuthorizationHeaderValue("dory", "password");
@@ -181,8 +181,7 @@
                 node.at("/token_type").asText());
         assertEquals(31536000, node.at("/expires_in").asInt());
 
-        testRevokeToken(accessToken, publicClientId,null,
-                ACCESS_TOKEN_TYPE);
+        testRevokeToken(accessToken, publicClientId, null, ACCESS_TOKEN_TYPE);
 
         assertTrue(node.at("/refresh_token").isMissingNode());
     }
@@ -217,17 +216,14 @@
         assertNotNull(node.at("/expires_in").asText());
 
         testRequestTokenWithUsedAuthorization(code);
-        
+
         String refreshToken = node.at("/refresh_token").asText();
-        
+
         testRequestRefreshTokenInvalidScope(confidentialClientId, refreshToken);
         testRequestRefreshTokenInvalidClient(refreshToken);
         testRequestRefreshTokenInvalidRefreshToken(confidentialClientId);
-        
-        testRevokeToken(refreshToken, confidentialClientId,clientSecret,
-                REFRESH_TOKEN_TYPE);
-        testRequestTokenWithRevokedRefreshToken(confidentialClientId,
-                clientSecret, refreshToken);
+
+        testRequestRefreshToken(confidentialClientId, clientSecret, refreshToken);
     }
 
     private void testRequestTokenWithUsedAuthorization (String code)
@@ -337,7 +333,7 @@
         assertEquals(TokenType.BEARER.toString(),
                 node.at("/token_type").asText());
         assertNotNull(node.at("/expires_in").asText());
-        
+
         RefreshToken refreshToken = refreshTokenDao
                 .retrieveRefreshToken(node.at("/refresh_token").asText());
         Set<AccessScope> scopes = refreshToken.getScopes();
@@ -348,8 +344,8 @@
     @Test
     public void testRequestTokenPasswordGrantConfidentialNonSuper ()
             throws KustvaktException {
-        ClientResponse response =
-                requestTokenWithDoryPassword(confidentialClientId, clientSecret);
+        ClientResponse response = requestTokenWithDoryPassword(
+                confidentialClientId, clientSecret);
         String entity = response.getEntity(String.class);
         assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
 
@@ -363,7 +359,8 @@
     @Test
     public void testRequestTokenPasswordGrantPublic ()
             throws KustvaktException {
-        ClientResponse response = requestTokenWithDoryPassword(publicClientId, "");
+        ClientResponse response =
+                requestTokenWithDoryPassword(publicClientId, "");
         String entity = response.getEntity(String.class);
 
         assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
@@ -384,12 +381,13 @@
         form.add("username", "dory");
         form.add("password", "password");
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("token")
-                .header(HttpHeaders.AUTHORIZATION,
-                        "Basic ZkNCYlFrQXlZekk0TnpVeE1nOnNlY3JldA==")
-                .header(HttpHeaders.CONTENT_TYPE,
-                        ContentType.APPLICATION_FORM_URLENCODED)
-                .entity(form).post(ClientResponse.class);
+        ClientResponse response =
+                resource().path(API_VERSION).path("oauth2").path("token")
+                        .header(HttpHeaders.AUTHORIZATION,
+                                "Basic ZkNCYlFrQXlZekk0TnpVeE1nOnNlY3JldA==")
+                        .header(HttpHeaders.CONTENT_TYPE,
+                                ContentType.APPLICATION_FORM_URLENCODED)
+                        .entity(form).post(ClientResponse.class);
         String entity = response.getEntity(String.class);
         JsonNode node = JsonUtils.readTree(entity);
         assertNotNull(node.at("/access_token").asText());
@@ -414,12 +412,13 @@
         form.add("username", "dory");
         form.add("password", "password");
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("token")
-                .header(HttpHeaders.AUTHORIZATION,
-                        "Basic ZkNCYlFrQXlZekk0TnpVeE1nOnNlY3JldA==")
-                .header(HttpHeaders.CONTENT_TYPE,
-                        ContentType.APPLICATION_FORM_URLENCODED)
-                .entity(form).post(ClientResponse.class);
+        ClientResponse response =
+                resource().path(API_VERSION).path("oauth2").path("token")
+                        .header(HttpHeaders.AUTHORIZATION,
+                                "Basic ZkNCYlFrQXlZekk0TnpVeE1nOnNlY3JldA==")
+                        .header(HttpHeaders.CONTENT_TYPE,
+                                ContentType.APPLICATION_FORM_URLENCODED)
+                        .entity(form).post(ClientResponse.class);
         String entity = response.getEntity(String.class);
         JsonNode node = JsonUtils.readTree(entity);
         assertNotNull(node.at("/access_token").asText());
@@ -447,7 +446,8 @@
     @Test
     public void testRequestTokenPasswordGrantMissingClientId ()
             throws KustvaktException {
-        ClientResponse response = requestTokenWithDoryPassword(null, clientSecret);
+        ClientResponse response =
+                requestTokenWithDoryPassword(null, clientSecret);
         String entity = response.getEntity(String.class);
         assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
 
@@ -544,11 +544,12 @@
         MultivaluedMap<String, String> form = new MultivaluedMapImpl();
         form.add("grant_type", "blahblah");
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("token")
-                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
-                .header(HttpHeaders.CONTENT_TYPE,
-                        ContentType.APPLICATION_FORM_URLENCODED)
-                .entity(form).post(ClientResponse.class);
+        ClientResponse response =
+                resource().path(API_VERSION).path("oauth2").path("token")
+                        .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                        .header(HttpHeaders.CONTENT_TYPE,
+                                ContentType.APPLICATION_FORM_URLENCODED)
+                        .entity(form).post(ClientResponse.class);
 
         String entity = response.getEntity(String.class);
         assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
@@ -569,46 +570,54 @@
         form.add("refresh_token", refreshToken);
         form.add("scope", "search serialize_query");
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("token")
-                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
-                .header(HttpHeaders.CONTENT_TYPE,
-                        ContentType.APPLICATION_FORM_URLENCODED)
-                .entity(form).post(ClientResponse.class);
+        ClientResponse response =
+                resource().path(API_VERSION).path("oauth2").path("token")
+                        .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                        .header(HttpHeaders.CONTENT_TYPE,
+                                ContentType.APPLICATION_FORM_URLENCODED)
+                        .entity(form).post(ClientResponse.class);
 
         String entity = response.getEntity(String.class);
         JsonNode node = JsonUtils.readTree(entity);
         assertEquals(OAuth2Error.INVALID_SCOPE, node.at("/error").asText());
     }
 
-    private void testRequestRefreshTokenPublicClient (String clientId,
+    private void testRequestRefreshToken (String clientId, String clientSecret,
             String refreshToken) throws KustvaktException {
         MultivaluedMap<String, String> form = new MultivaluedMapImpl();
         form.add("grant_type", GrantType.REFRESH_TOKEN.toString());
         form.add("client_id", clientId);
+        form.add("client_secret", clientSecret);
         form.add("refresh_token", refreshToken);
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("token")
-                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
-                .header(HttpHeaders.CONTENT_TYPE,
-                        ContentType.APPLICATION_FORM_URLENCODED)
-                .entity(form).post(ClientResponse.class);
+        ClientResponse response =
+                resource().path(API_VERSION).path("oauth2").path("token")
+                        .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                        .header(HttpHeaders.CONTENT_TYPE,
+                                ContentType.APPLICATION_FORM_URLENCODED)
+                        .entity(form).post(ClientResponse.class);
 
         String entity = response.getEntity(String.class);
         assertEquals(Status.OK.getStatusCode(), response.getStatus());
 
         JsonNode node = JsonUtils.readTree(entity);
         assertNotNull(node.at("/access_token").asText());
-        assertNotNull(node.at("/refresh_token").asText());
+        
+        String newRefreshToken = node.at("/refresh_token").asText();
+        assertNotNull(newRefreshToken);
         assertEquals(TokenType.BEARER.toString(),
                 node.at("/token_type").asText());
         assertNotNull(node.at("/expires_in").asText());
 
-        assertTrue(!node.at("/refresh_token").asText().equals(refreshToken));
+        assertTrue(!newRefreshToken.equals(refreshToken));
         
-        RefreshToken rt = refreshTokenDao.retrieveRefreshToken(refreshToken);
-        assertEquals(true, rt.isRevoked());
-        Set<AccessScope> scopes = rt.getScopes();
-        assertEquals(3, scopes.size());
+        testRequestTokenWithRevokedRefreshToken(clientId, clientSecret,
+                refreshToken);
+        
+        testRevokeToken(newRefreshToken, clientId, clientSecret,
+                REFRESH_TOKEN_TYPE);
+        testRequestTokenWithRevokedRefreshToken(clientId, clientSecret,
+                newRefreshToken);
     }
 
     private void testRequestRefreshTokenInvalidClient (String refreshToken)
@@ -618,11 +627,12 @@
         form.add("client_id", "iBr3LsTCxOj7D2o0A5m");
         form.add("refresh_token", refreshToken);
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("token")
-                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
-                .header(HttpHeaders.CONTENT_TYPE,
-                        ContentType.APPLICATION_FORM_URLENCODED)
-                .entity(form).post(ClientResponse.class);
+        ClientResponse response =
+                resource().path(API_VERSION).path("oauth2").path("token")
+                        .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                        .header(HttpHeaders.CONTENT_TYPE,
+                                ContentType.APPLICATION_FORM_URLENCODED)
+                        .entity(form).post(ClientResponse.class);
 
         String entity = response.getEntity(String.class);
         JsonNode node = JsonUtils.readTree(entity);
@@ -637,11 +647,12 @@
         form.add("client_secret", clientSecret);
         form.add("refresh_token", "Lia8s8w8tJeZSBlaQDrYV8ion3l");
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("token")
-                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
-                .header(HttpHeaders.CONTENT_TYPE,
-                        ContentType.APPLICATION_FORM_URLENCODED)
-                .entity(form).post(ClientResponse.class);
+        ClientResponse response =
+                resource().path(API_VERSION).path("oauth2").path("token")
+                        .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                        .header(HttpHeaders.CONTENT_TYPE,
+                                ContentType.APPLICATION_FORM_URLENCODED)
+                        .entity(form).post(ClientResponse.class);
 
         String entity = response.getEntity(String.class);
         JsonNode node = JsonUtils.readTree(entity);
@@ -654,21 +665,51 @@
         form.add("token_type", tokenType);
         form.add("token", token);
         form.add("client_id", clientId);
-        if (clientSecret!=null){
+        if (clientSecret != null) {
             form.add("client_secret", clientSecret);
         }
-        
-        ClientResponse response = resource().path(API_VERSION).path("oauth2").path("revoke")
+
+        ClientResponse response =
+                resource().path(API_VERSION).path("oauth2").path("revoke")
+                        .header(HttpHeaders.CONTENT_TYPE,
+                                ContentType.APPLICATION_FORM_URLENCODED)
+                        .entity(form).post(ClientResponse.class);
+
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        assertEquals("SUCCESS", response.getEntity(String.class));
+    }
+
+    private JsonNode requestTokenList (String userAuthHeader, String tokenType,
+            String clientId) throws KustvaktException {
+        MultivaluedMap<String, String> form = new MultivaluedMapImpl();
+        form.add("super_client_id", superClientId);
+        form.add("super_client_secret", clientSecret);
+        form.add("token_type", tokenType);
+
+        if (clientId != null && !clientId.isEmpty()){
+            form.add("client_id", clientId);
+        }
+            
+        ClientResponse response = resource().path(API_VERSION).path("oauth2")
+                .path("token").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());
-        assertEquals("SUCCESS", response.getEntity(String.class));
+
+        String entity = response.getEntity(String.class);
+        return JsonUtils.readTree(entity);
+    }
+    
+    private JsonNode requestTokenList (String userAuthHeader, String tokenType)
+            throws KustvaktException {
+        return requestTokenList(userAuthHeader, tokenType, null);
     }
     
     @Test
-    public void testListRefreshToken () throws KustvaktException {
+    public void testListRefreshTokenConfidentialClient () throws KustvaktException {
         String username = "gurgle";
         String password = "pwd";
         userAuthHeader = HttpAuthorizationHandler
@@ -682,8 +723,8 @@
         String refreshToken1 = node.at("/refresh_token").asText();
 
         // client 1
-        String code = requestAuthorizationCode(confidentialClientId, clientSecret,
-                null, userAuthHeader);
+        String code = requestAuthorizationCode(confidentialClientId,
+                clientSecret, null, userAuthHeader);
         response = requestTokenWithAuthorizationCodeAndForm(
                 confidentialClientId, clientSecret, code);
         assertEquals(Status.OK.getStatusCode(), response.getStatus());
@@ -696,11 +737,11 @@
         assertEquals(Status.OK.getStatusCode(), response.getStatus());
 
         // list
-        node = requestRefreshTokenList(userAuthHeader);
+        node = requestTokenList(userAuthHeader, REFRESH_TOKEN_TYPE);
         assertEquals(2, node.size());
         assertEquals(confidentialClientId, node.at("/0/clientId").asText());
         assertEquals(confidentialClientId2, node.at("/1/clientId").asText());
-        
+
         // client 1
         code = requestAuthorizationCode(confidentialClientId, clientSecret,
                 null, userAuthHeader);
@@ -711,61 +752,95 @@
         // another user
         String darlaAuthHeader = HttpAuthorizationHandler
                 .createBasicAuthorizationHeaderValue("darla", "pwd");
+
+        // test listing clients
+        node = requestTokenList(darlaAuthHeader, REFRESH_TOKEN_TYPE);
+        assertEquals(0, node.size());
+
         // client 1
         code = requestAuthorizationCode(confidentialClientId, clientSecret,
                 null, darlaAuthHeader);
         assertEquals(Status.OK.getStatusCode(), response.getStatus());
         response = requestTokenWithAuthorizationCodeAndForm(
                 confidentialClientId, clientSecret, code);
-        
+
         node = JsonUtils.readTree(response.getEntity(String.class));
         String refreshToken5 = node.at("/refresh_token").asText();
-        
-        // list
-        node = requestRefreshTokenList(userAuthHeader);
+
+        // list all refresh tokens
+        node = requestTokenList(userAuthHeader, REFRESH_TOKEN_TYPE);
         assertEquals(3, node.size());
+
+        // list refresh tokens from client 1
+        node = requestTokenList(userAuthHeader, REFRESH_TOKEN_TYPE, confidentialClientId);
+        assertEquals(2, node.size());
         
         testRevokeToken(refreshToken1, superClientId, clientSecret,
                 REFRESH_TOKEN_TYPE);
-        testRevokeToken(node.at("/0/token").asText(), confidentialClientId, 
+        testRevokeToken(node.at("/0/token").asText(), confidentialClientId,
                 clientSecret, REFRESH_TOKEN_TYPE);
         testRevokeToken(node.at("/1/token").asText(), confidentialClientId2,
                 clientSecret, REFRESH_TOKEN_TYPE);
-        
-        node = requestRefreshTokenList(userAuthHeader);
+
+        node = requestTokenList(userAuthHeader, REFRESH_TOKEN_TYPE);
         assertEquals(1, node.size());
-        
-        testRevokeTokenViaSuperClient(node.at("/0/token").asText(), userAuthHeader);
-        node = requestRefreshTokenList(userAuthHeader);
+
+        testRevokeTokenViaSuperClient(node.at("/0/token").asText(),
+                userAuthHeader);
+        node = requestTokenList(userAuthHeader, REFRESH_TOKEN_TYPE);
         assertEquals(0, node.size());
-        
+
         // try revoking a token belonging to another user
         // should not return any errors
         testRevokeTokenViaSuperClient(refreshToken5, userAuthHeader);
-        node = requestRefreshTokenList(darlaAuthHeader);
+        node = requestTokenList(darlaAuthHeader, REFRESH_TOKEN_TYPE);
         assertEquals(1, node.size());
-        
+
         testRevokeTokenViaSuperClient(refreshToken5, darlaAuthHeader);
-        node = requestRefreshTokenList(darlaAuthHeader);
+        node = requestTokenList(darlaAuthHeader, REFRESH_TOKEN_TYPE);
         assertEquals(0, node.size());
     }
-    
-    private JsonNode requestRefreshTokenList (String userAuthHeader)
-            throws KustvaktException {
-        MultivaluedMap<String, String> form = new MultivaluedMapImpl();
-        form.add("client_id", superClientId);
-        form.add("client_secret", clientSecret);
 
-        ClientResponse response = resource().path(API_VERSION).path("oauth2")
-                .path("token").path("list")
-                .header(Attributes.AUTHORIZATION, userAuthHeader)
-                .header(HttpHeaders.CONTENT_TYPE,
-                        ContentType.APPLICATION_FORM_URLENCODED)
-                .entity(form).post(ClientResponse.class);
 
+    @Test
+    public void testListTokenPublicClient () throws KustvaktException {
+        String username = "nemo";
+        String password = "pwd";
+        userAuthHeader = HttpAuthorizationHandler
+                .createBasicAuthorizationHeaderValue(username, password);
+
+        // access token 1
+        String code = requestAuthorizationCode(publicClientId, clientSecret,
+                null, userAuthHeader);
+        ClientResponse response = requestTokenWithAuthorizationCodeAndForm(publicClientId, "",
+                code);
         assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        JsonNode node = JsonUtils.readTree(response.getEntity(String.class));
+        String accessToken1 = node.at("/access_token").asText();
 
-        String entity = response.getEntity(String.class);
-        return JsonUtils.readTree(entity);
+        // access token 2
+        code = requestAuthorizationCode(publicClientId, clientSecret, null,
+                userAuthHeader);
+        response = requestTokenWithAuthorizationCodeAndForm(publicClientId, "",
+                code);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+        node = JsonUtils.readTree(response.getEntity(String.class));
+        String accessToken2 = node.at("/access_token").asText();
+        
+        // list access tokens
+        node = requestTokenList(userAuthHeader, ACCESS_TOKEN_TYPE);
+        assertEquals(2, node.size());
+        
+        // list refresh tokens
+        node = requestTokenList(userAuthHeader, REFRESH_TOKEN_TYPE);
+        assertEquals(0, node.size());
+        
+        testRevokeTokenViaSuperClient(accessToken1, userAuthHeader);
+        node = requestTokenList(userAuthHeader, ACCESS_TOKEN_TYPE);
+        assertEquals(1, node.size());
+        
+        testRevokeTokenViaSuperClient(accessToken2, userAuthHeader);
+        node = requestTokenList(userAuthHeader, ACCESS_TOKEN_TYPE);
+        assertEquals(0, node.size());
     }
 }
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 3d47f65..b334b65 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
@@ -42,7 +42,7 @@
     
     protected String publicClientId = "8bIDtZnH6NvRkW2Fq";
     protected String confidentialClientId = "9aHsGW6QflV13ixNpez";
-    protected String confidentialClientId2 = "9aHsGW6QflV13ixNpez";
+    protected String confidentialClientId2 = "52atrL0ajex_3_5imd9Mgw";
     protected String superClientId = "fCBbQkAyYzI4NzUxMg";
     protected String clientSecret = "secret";