Repository: gravitee-io/graviteeio-access-management
Pull request #679 (2019-11-14 11:10:33)

feat(logout): add an option (query param) to invalidate tokens after logout
closes gravitee-io/issues#2879


Patch:
diff --git a/Makefile b/Makefile
index c81bea6723..83f4e152a6 100644
--- a/Makefile
+++ b/Makefile
@@ -187,6 +187,7 @@ postman: ## Run postman non regression test (require newman npm module)
 	@newman run postman/collections/graviteeio-am-client-management-collection.json -e postman/environment/dev.json --ignore-redirects --insecure --bail
 	@newman run postman/collections/graviteeio-am-policies-collection.json -e postman/environment/dev.json --ignore-redirects --insecure --bail
 	@newman run postman/collections/graviteeio-am-login-collection.json -e postman/environment/dev.json --ignore-redirects --insecure --bail
+	@newman run postman/collections/graviteeio-am-logout-collection.json -e postman/environment/dev.json --ignore-redirects --insecure --bail
 	@newman run postman/collections/graviteeio-am-user-management-collection.json -e postman/environment/dev.json --ignore-redirects --insecure --bail
 
 oidctest-run: oidctest-install oidctest-start ## Run openid-certification tools, using same docker network
diff --git a/docs/oauth2-api-descriptor.yml b/docs/oauth2-api-descriptor.yml
index 1ba98135e3..7a40d490f3 100644
--- a/docs/oauth2-api-descriptor.yml
+++ b/docs/oauth2-api-descriptor.yml
@@ -19,7 +19,7 @@ info:
   description: >-
     Gravitee.io - Access Management - OAuth 2.0 API.
     Defines The OAuth 2.0 Authorization Framework Endpoints exposed by AM server.
-  version: 2.5.0
+  version: 2.10.x
   title: Gravitee.io - Access Management - OAuth 2.0 API
   contact:
     email: contact@graviteesource.com
diff --git a/docs/oidc-api-descriptor.yml b/docs/oidc-api-descriptor.yml
index cbc7ee3c50..4d7dd3ee5e 100644
--- a/docs/oidc-api-descriptor.yml
+++ b/docs/oidc-api-descriptor.yml
@@ -19,7 +19,7 @@ info:
   description: >-
     Gravitee.io - Access Management - OpenID Connect API.
     Defines The OpenID Connect Endpoints exposed by AM server.
-  version: 2.5.0
+  version: 2.10.x
   title: Gravitee.io - Access Management - OpenID Connect API
   contact:
     email: contact@graviteesource.com
diff --git a/docs/scim-api-descriptor.yml b/docs/scim-api-descriptor.yml
index d5b9ce4754..4060c24c33 100644
--- a/docs/scim-api-descriptor.yml
+++ b/docs/scim-api-descriptor.yml
@@ -19,7 +19,7 @@ info:
   description: >-
     Gravitee.io - Access Management - SCIM 2.0 API.
     Defines The SCIM 2.0 Endpoints exposed by AM server.
-  version: 2.5.0
+  version: 2.10.x
   title: Gravitee.io - Access Management - SCIM 2.0 API
   contact:
     email: contact@graviteesource.com
diff --git a/docs/session-api-descriptor.yml b/docs/session-api-descriptor.yml
new file mode 100644
index 0000000000..098c5e5832
--- /dev/null
+++ b/docs/session-api-descriptor.yml
@@ -0,0 +1,51 @@
+#
+# Copyright (C) 2015 The Gravitee team (http://gravitee.io)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+swagger: '2.0'
+info:
+  description: >-
+    Gravitee.io - Access Management - User Session API.
+    Defines User Session Endpoints exposed by AM server.
+  version: 2.10.x
+  title: Gravitee.io - Access Management - User Session API
+  contact:
+    email: contact@graviteesource.com
+  license:
+    name: Apache 2.0
+    url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
+host: auth.gravitee.io
+basePath: /{domain}
+schemes:
+  - https
+paths:
+  /logout:
+    get:
+      tags:
+        - Logout
+      summary: Logout user
+      description: Logout current user (invalidate the HTTP session)
+      parameters:
+        - in: query
+          name: target_url
+          type: string
+          description: Specifies the redirect URL after logout
+        - in: query
+          name: invalidate_tokens
+          type: boolean
+          description: Specifies if we must invalidate current user tokens
+      responses:
+        '302':
+          description: Redirection after logout process
diff --git a/docs/users-api-descriptor.yml b/docs/users-api-descriptor.yml
index fe3f158519..83c1dcdd0e 100644
--- a/docs/users-api-descriptor.yml
+++ b/docs/users-api-descriptor.yml
@@ -22,7 +22,7 @@ info:
 
     Users endpoints are OAuth 2.0 secured and can be access either with a token with "consent_admin" scope
     or if the token 'sub' claim match the "userId" path parameter.
-  version: 2.5.0
+  version: 2.10.x
   title: Gravitee.io - Access Management - Users API
   contact:
     email: contact@graviteesource.com
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/RootProvider.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/RootProvider.java
index a674db9d85..ad2edfbc95 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/RootProvider.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/RootProvider.java
@@ -52,7 +52,7 @@ import io.gravitee.am.gateway.handler.root.resources.handler.user.register.Regis
 import io.gravitee.am.gateway.handler.root.service.user.UserService;
 import io.gravitee.am.model.Domain;
 import io.gravitee.am.service.AuditService;
-import io.gravitee.am.service.LoginAttemptService;
+import io.gravitee.am.service.TokenService;
 import io.gravitee.am.service.authentication.crypto.password.PasswordValidator;
 import io.gravitee.common.service.AbstractService;
 import io.vertx.core.Handler;
@@ -115,7 +115,7 @@ public class RootProvider extends AbstractService<ProtocolProvider> implements P
     private UserService userService;
 
     @Autowired
-    private LoginAttemptService loginAttemptService;
+    private TokenService tokenService;
 
     @Autowired
     private PolicyChainHandler policyChainHandler;
@@ -184,7 +184,7 @@ public class RootProvider extends AbstractService<ProtocolProvider> implements P
                 .failureHandler(loginCallbackFailureHandler);
 
         // logout route
-        rootRouter.route("/logout").handler(LogoutEndpoint.create(domain, auditService));
+        rootRouter.route("/logout").handler(new LogoutEndpoint(domain, tokenService, auditService));
 
         // error route
         rootRouter.route(HttpMethod.GET, "/error")
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/endpoint/logout/LogoutEndpoint.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/endpoint/logout/LogoutEndpoint.java
index 687f59657d..65d776408d 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/endpoint/logout/LogoutEndpoint.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/endpoint/logout/LogoutEndpoint.java
@@ -18,13 +18,18 @@ package io.gravitee.am.gateway.handler.root.resources.endpoint.logout;
 import io.gravitee.am.model.Domain;
 import io.gravitee.am.model.User;
 import io.gravitee.am.service.AuditService;
+import io.gravitee.am.service.TokenService;
 import io.gravitee.am.service.reporter.builder.AuditBuilder;
 import io.gravitee.am.service.reporter.builder.LogoutAuditBuilder;
 import io.gravitee.common.http.HttpHeaders;
+import io.vertx.core.AsyncResult;
+import io.vertx.core.Future;
 import io.vertx.core.Handler;
 import io.vertx.reactivex.core.http.HttpServerRequest;
 import io.vertx.reactivex.core.net.SocketAddress;
 import io.vertx.reactivex.ext.web.RoutingContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
@@ -32,21 +37,43 @@ import io.vertx.reactivex.ext.web.RoutingContext;
  */
 public class LogoutEndpoint implements Handler<RoutingContext> {
 
+    private static final Logger LOGGER = LoggerFactory.getLogger(LogoutEndpoint.class);
     private static final String LOGOUT_URL_PARAMETER = "target_url";
+    private static final String INVALIDATE_TOKENS_PARAMETER = "invalidate_tokens";
     private static final String DEFAULT_TARGET_URL = "/";
-    private AuditService auditService;
     private Domain domain;
+    private TokenService tokenService;
+    private AuditService auditService;
 
-    public LogoutEndpoint(Domain domain, AuditService auditService) {
+    public LogoutEndpoint(Domain domain, TokenService tokenService, AuditService auditService) {
         this.domain = domain;
+        this.tokenService = tokenService;
         this.auditService = auditService;
     }
 
     @Override
     public void handle(RoutingContext routingContext) {
+        // invalidate session
+        invalidateSession(routingContext, invalidateSessionHandler -> {
+            // invalidate tokens if option is enabled
+            if (invalidateTokensEnabled(routingContext)) {
+                invalidateTokens(invalidateSessionHandler.result(), invalidateTokensHandler -> {
+                    if (invalidateTokensHandler.failed()) {
+                        LOGGER.error("An error occurs while invalidating user tokens", invalidateSessionHandler.cause());
+                    }
+                    doRedirect(routingContext);
+                });
+            } else {
+                doRedirect(routingContext);
+            }
+        });
+    }
+
+    private void invalidateSession(RoutingContext routingContext, Handler<AsyncResult<User>> handler) {
+        io.gravitee.am.model.User endUser = null;
         // clear context and session
         if (routingContext.user() != null) {
-            io.gravitee.am.model.User endUser = ((io.gravitee.am.gateway.handler.common.vertx.web.auth.user.User) routingContext.user().getDelegate()).getUser();
+            endUser = ((io.gravitee.am.gateway.handler.common.vertx.web.auth.user.User) routingContext.user().getDelegate()).getUser();
             // audit event
             report(endUser, routingContext.request());
             // clear user
@@ -57,6 +84,24 @@ public class LogoutEndpoint implements Handler<RoutingContext> {
             routingContext.session().destroy();
         }
 
+        handler.handle(Future.succeededFuture(endUser));
+    }
+
+    private void invalidateTokens(User user, Handler<AsyncResult<Void>> handler) {
+        // if you have no user, continue
+        if (user == null) {
+            handler.handle(Future.succeededFuture());
+            return;
+        }
+
+        tokenService.deleteByUserId(user.getId())
+                .subscribe(
+                        () -> handler.handle(Future.succeededFuture()),
+                        error -> handler.handle(Future.failedFuture(error)));
+
+    }
+
+    private void doRedirect(RoutingContext routingContext) {
         // redirect to target url
         String logoutRedirectUrl = routingContext.request().getParam(LOGOUT_URL_PARAMETER);
         routingContext
@@ -66,14 +111,15 @@ public class LogoutEndpoint implements Handler<RoutingContext> {
                 .end();
     }
 
-    public static LogoutEndpoint create(Domain domain, AuditService auditService) {
-        return new LogoutEndpoint(domain, auditService);
-    }
-
     private void report(User endUser, HttpServerRequest request) {
         auditService.report(AuditBuilder.builder(LogoutAuditBuilder.class).domain(domain.getId()).user(endUser).ipAddress(remoteAddress(request)).userAgent(userAgent(request)));
     }
 
+    private boolean invalidateTokensEnabled(RoutingContext routingContext) {
+        String invalidateTokensParam = routingContext.request().getParam(INVALIDATE_TOKENS_PARAMETER);
+        return invalidateTokensParam != null && Boolean.valueOf(invalidateTokensParam);
+    }
+
     private String remoteAddress(HttpServerRequest httpServerRequest) {
         String xForwardedFor = httpServerRequest.getHeader(HttpHeaders.X_FORWARDED_FOR);
         String remoteAddress;
diff --git a/gravitee-am-management-api/gravitee-am-management-api-repository/src/main/java/io/gravitee/am/management/repository/proxy/AccessTokenRepositoryProxy.java b/gravitee-am-management-api/gravitee-am-management-api-repository/src/main/java/io/gravitee/am/management/repository/proxy/AccessTokenRepositoryProxy.java
index 5cb68dc79c..956b87ec61 100644
--- a/gravitee-am-management-api/gravitee-am-management-api-repository/src/main/java/io/gravitee/am/management/repository/proxy/AccessTokenRepositoryProxy.java
+++ b/gravitee-am-management-api/gravitee-am-management-api-repository/src/main/java/io/gravitee/am/management/repository/proxy/AccessTokenRepositoryProxy.java
@@ -69,4 +69,8 @@ public class AccessTokenRepositoryProxy extends AbstractProxy<AccessTokenReposit
         return target.countByClientId(clientId);
     }
 
+    @Override
+    public Completable deleteByUserId(String userId) {
+        return target.deleteByUserId(userId);
+    }
 }
diff --git a/gravitee-am-management-api/gravitee-am-management-api-repository/src/main/java/io/gravitee/am/management/repository/proxy/RefreshTokenRepositoryProxy.java b/gravitee-am-management-api/gravitee-am-management-api-repository/src/main/java/io/gravitee/am/management/repository/proxy/RefreshTokenRepositoryProxy.java
new file mode 100644
index 0000000000..e06de41283
--- /dev/null
+++ b/gravitee-am-management-api/gravitee-am-management-api-repository/src/main/java/io/gravitee/am/management/repository/proxy/RefreshTokenRepositoryProxy.java
@@ -0,0 +1,59 @@
+/**
+ * Copyright (C) 2015 The Gravitee team (http://gravitee.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.gravitee.am.management.repository.proxy;
+
+import io.gravitee.am.repository.oauth2.api.RefreshTokenRepository;
+import io.gravitee.am.repository.oauth2.model.RefreshToken;
+import io.reactivex.Completable;
+import io.reactivex.Flowable;
+import io.reactivex.Maybe;
+import io.reactivex.Single;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
+ * @author GraviteeSource Team
+ */
+@Component
+public class RefreshTokenRepositoryProxy extends AbstractProxy<RefreshTokenRepository> implements RefreshTokenRepository {
+
+    @Override
+    public Maybe<RefreshToken> findByToken(String token) {
+        return target.findByToken(token);
+    }
+
+    @Override
+    public Single<RefreshToken> create(RefreshToken refreshToken) {
+        return target.create(refreshToken);
+    }
+
+    @Override
+    public Flowable bulkWrite(List<RefreshToken> refreshTokens) {
+        return target.bulkWrite(refreshTokens);
+    }
+
+    @Override
+    public Completable delete(String token) {
+        return target.delete(token);
+    }
+
+    @Override
+    public Completable deleteByUserId(String userId) {
+        return target.deleteByUserId(userId);
+    }
+}
diff --git a/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/oauth2/api/AccessTokenRepository.java b/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/oauth2/api/AccessTokenRepository.java
index 40ed6e0a31..90c16d4747 100644
--- a/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/oauth2/api/AccessTokenRepository.java
+++ b/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/oauth2/api/AccessTokenRepository.java
@@ -22,16 +22,36 @@ import java.util.List;
 
 /**
  * @author David BRASSELY (david.brassely at graviteesource.com)
+ * @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
  * @author GraviteeSource Team
  */
 public interface AccessTokenRepository {
-
+    /**
+     * Find access token by id
+     * @param token access token's id
+     * @return Access token if any
+     */
     Maybe<AccessToken> findByToken(String token);
 
+    /**
+     * Create an access token
+     * @param accessToken access token to store
+     * @return th created access token
+     */
     Single<AccessToken> create(AccessToken accessToken);
 
+    /**
+     * Delete token by its id
+     * @param token token's id
+     * @return acknowledge of the operation
+     */
     Completable delete(String token);
 
+    /**
+     * Bulk insert of access tokens
+     * @param accessTokens access token to store
+     * @return acknowledge of the operation
+     */
     Flowable bulkWrite(List<AccessToken> accessTokens);
 
     /**
@@ -66,4 +86,11 @@ public interface AccessTokenRepository {
      * @return the number of access tokens
      */
     Single<Long> countByClientId(String clientId);
+
+    /**
+     * Delete access tokens by user id
+     * @param userId end-user
+     * @return acknowledge of the operation
+     */
+    Completable deleteByUserId(String userId);
 }
diff --git a/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/oauth2/api/RefreshTokenRepository.java b/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/oauth2/api/RefreshTokenRepository.java
index e4c4f1d4ea..dbff10c97a 100644
--- a/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/oauth2/api/RefreshTokenRepository.java
+++ b/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/oauth2/api/RefreshTokenRepository.java
@@ -36,4 +36,6 @@ public interface RefreshTokenRepository {
     Flowable bulkWrite(List<RefreshToken> refreshTokens);
 
     Completable delete(String token);
+
+    Completable deleteByUserId(String userId);
 }
diff --git a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/oauth2/MongoAccessTokenRepository.java b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/oauth2/MongoAccessTokenRepository.java
index b687ba19c0..9737dc141b 100644
--- a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/oauth2/MongoAccessTokenRepository.java
+++ b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/oauth2/MongoAccessTokenRepository.java
@@ -59,6 +59,7 @@ public class MongoAccessTokenRepository extends AbstractOAuth2MongoRepository im
         accessTokenCollection.createIndex(new Document(FIELD_TOKEN, 1)).subscribe(new LoggableIndexSubscriber());
         accessTokenCollection.createIndex(new Document(FIELD_CLIENT_ID, 1)).subscribe(new LoggableIndexSubscriber());
         accessTokenCollection.createIndex(new Document(FIELD_AUTHORIZATION_CODE, 1)).subscribe(new LoggableIndexSubscriber());
+        accessTokenCollection.createIndex(new Document(FIELD_SUBJECT, 1)).subscribe(new LoggableIndexSubscriber());
 
         // two fields index
         accessTokenCollection.createIndex(new Document(FIELD_CLIENT_ID, 1).append(FIELD_SUBJECT, 1)).subscribe(new LoggableIndexSubscriber());
@@ -125,6 +126,11 @@ public class MongoAccessTokenRepository extends AbstractOAuth2MongoRepository im
         return Single.fromPublisher(accessTokenCollection.count(eq(FIELD_CLIENT_ID, clientId)));
     }
 
+    @Override
+    public Completable deleteByUserId(String userId) {
+        return Completable.fromPublisher(accessTokenCollection.deleteMany(eq(FIELD_SUBJECT, userId)));
+    }
+
     private List<WriteModel<AccessTokenMongo>> convert(List<AccessToken> accessTokens) {
         return accessTokens.stream().map(accessToken -> new InsertOneModel<>(convert(accessToken))).collect(Collectors.toList());
     }
diff --git a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/oauth2/MongoRefreshTokenRepository.java b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/oauth2/MongoRefreshTokenRepository.java
index 91e37c0f89..14dd67e0a8 100644
--- a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/oauth2/MongoRefreshTokenRepository.java
+++ b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/oauth2/MongoRefreshTokenRepository.java
@@ -21,10 +21,8 @@ import com.mongodb.client.model.WriteModel;
 import com.mongodb.reactivestreams.client.MongoCollection;
 import io.gravitee.am.common.utils.RandomString;
 import io.gravitee.am.repository.mongodb.common.LoggableIndexSubscriber;
-import io.gravitee.am.repository.mongodb.oauth2.internal.model.AccessTokenMongo;
 import io.gravitee.am.repository.mongodb.oauth2.internal.model.RefreshTokenMongo;
 import io.gravitee.am.repository.oauth2.api.RefreshTokenRepository;
-import io.gravitee.am.repository.oauth2.model.AccessToken;
 import io.gravitee.am.repository.oauth2.model.RefreshToken;
 import io.reactivex.*;
 import org.bson.Document;
@@ -48,11 +46,13 @@ public class MongoRefreshTokenRepository extends AbstractOAuth2MongoRepository i
     private static final String FIELD_ID = "_id";
     private static final String FIELD_RESET_TIME = "expire_at";
     private static final String FIELD_TOKEN = "token";
+    private static final String FIELD_SUBJECT = "subject";
 
     @PostConstruct
     public void init() {
         refreshTokenCollection = mongoOperations.getCollection("refresh_tokens", RefreshTokenMongo.class);
         refreshTokenCollection.createIndex(new Document(FIELD_TOKEN, 1)).subscribe(new LoggableIndexSubscriber());
+        refreshTokenCollection.createIndex(new Document(FIELD_SUBJECT, 1)).subscribe(new LoggableIndexSubscriber());
         refreshTokenCollection.createIndex(new Document(FIELD_RESET_TIME, 1), new IndexOptions().expireAfter(0L, TimeUnit.SECONDS)).subscribe(new LoggableIndexSubscriber());
     }
 
@@ -93,6 +93,11 @@ public class MongoRefreshTokenRepository extends AbstractOAuth2MongoRepository i
         return Completable.fromPublisher(refreshTokenCollection.deleteOne(eq(FIELD_TOKEN, token)));
     }
 
+    @Override
+    public Completable deleteByUserId(String userId) {
+        return Completable.fromPublisher(refreshTokenCollection.deleteMany(eq(FIELD_SUBJECT, userId)));
+    }
+
     private List<WriteModel<RefreshTokenMongo>> convert(List<RefreshToken> refreshTokens) {
         return refreshTokens.stream().map(refreshToken -> new InsertOneModel<>(convert(refreshToken))).collect(Collectors.toList());
     }
diff --git a/gravitee-am-service/src/main/java/io/gravitee/am/service/TokenService.java b/gravitee-am-service/src/main/java/io/gravitee/am/service/TokenService.java
index a7e8afecd5..369f933b52 100644
--- a/gravitee-am-service/src/main/java/io/gravitee/am/service/TokenService.java
+++ b/gravitee-am-service/src/main/java/io/gravitee/am/service/TokenService.java
@@ -16,6 +16,7 @@
 package io.gravitee.am.service;
 
 import io.gravitee.am.service.model.TotalToken;
+import io.reactivex.Completable;
 import io.reactivex.Single;
 
 /**
@@ -27,4 +28,6 @@ public interface TokenService {
     Single<TotalToken> findTotalTokensByDomain(String domain);
 
     Single<TotalToken> findTotalTokens();
+
+    Completable deleteByUserId(String userId);
 }
diff --git a/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/TokenServiceImpl.java b/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/TokenServiceImpl.java
index 4488f18857..f0cf2c256f 100644
--- a/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/TokenServiceImpl.java
+++ b/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/TokenServiceImpl.java
@@ -16,10 +16,12 @@
 package io.gravitee.am.service.impl;
 
 import io.gravitee.am.repository.oauth2.api.AccessTokenRepository;
+import io.gravitee.am.repository.oauth2.api.RefreshTokenRepository;
 import io.gravitee.am.service.ClientService;
 import io.gravitee.am.service.TokenService;
 import io.gravitee.am.service.exception.TechnicalManagementException;
 import io.gravitee.am.service.model.TotalToken;
+import io.reactivex.Completable;
 import io.reactivex.Observable;
 import io.reactivex.Single;
 import org.slf4j.Logger;
@@ -42,6 +44,9 @@ public class TokenServiceImpl implements TokenService {
     @Autowired
     private AccessTokenRepository accessTokenRepository;
 
+    @Autowired
+    private RefreshTokenRepository refreshTokenRepository;
+
     @Override
     public Single<TotalToken> findTotalTokensByDomain(String domain) {
         LOGGER.debug("Find total tokens by domain: {}", domain);
@@ -78,4 +83,16 @@ public class TokenServiceImpl implements TokenService {
                     return Single.error(new TechnicalManagementException("An error occurs while trying to find total tokens", ex));
                 });
     }
+
+    @Override
+    public Completable deleteByUserId(String userId) {
+        LOGGER.debug("Delete tokens by user : {}", userId);
+        return accessTokenRepository.deleteByUserId(userId)
+                .andThen(refreshTokenRepository.deleteByUserId(userId))
+                .onErrorResumeNext(ex -> {
+                    LOGGER.error("An error occurs while trying to delete tokens by user {}", userId, ex);
+                    return Completable.error(new TechnicalManagementException(
+                            String.format("An error occurs while trying to find total tokens by user: %s", userId), ex));
+                });
+    }
 }
diff --git a/postman/collections/graviteeio-am-logout-collection.json b/postman/collections/graviteeio-am-logout-collection.json
new file mode 100644
index 0000000000..f65ab90fe5
--- /dev/null
+++ b/postman/collections/graviteeio-am-logout-collection.json
@@ -0,0 +1,1367 @@
+{
+	"info": {
+		"_postman_id": "28e4881f-fe0a-414f-8f22-ef68515414fd",
+		"name": "Gravitee.io - AM - Logout",
+		"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+	},
+	"item": [
+		{
+			"name": "Prepare",
+			"item": [
+				{
+					"name": "Generate admin token",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "571e9b59-b9e7-452c-9469-9786ded290a6",
+								"exec": [
+									"",
+									"pm.test(\"Status code is 200\", function () {",
+									"    pm.response.to.have.status(200);",
+									"});",
+									"",
+									"var token = JSON.parse(responseBody);",
+									"pm.environment.set('token', token.access_token);"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Authorization",
+								"value": "Basic YWRtaW46YWRtaW5hZG1pbg=="
+							}
+						],
+						"body": {
+							"mode": "urlencoded",
+							"urlencoded": [
+								{
+									"key": "grant_type",
+									"value": "password",
+									"type": "text"
+								},
+								{
+									"key": "username",
+									"value": "admin",
+									"type": "text"
+								},
+								{
+									"key": "password",
+									"value": "adminadmin",
+									"type": "text"
+								}
+							]
+						},
+						"url": {
+							"raw": "{{management_url}}/admin/token",
+							"host": [
+								"{{management_url}}"
+							],
+							"path": [
+								"admin",
+								"token"
+							]
+						}
+					},
+					"response": []
+				},
+				{
+					"name": "Create logout domain",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "b286f0fa-cfcc-45b9-863a-1dbc87fdf835",
+								"exec": [
+									"pm.test(\"Status code is 201\", function () {",
+									"    pm.response.to.have.status(201);",
+									"});",
+									"",
+									"var jsonData = pm.response.json();",
+									"pm.environment.set('domain', jsonData.id);"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "Authorization",
+								"value": "Bearer {{token}}"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n\t\"name\": \"logout\", \n\t\"description\": \"test logout flows\"\n}"
+						},
+						"url": {
+							"raw": "{{management_url}}/management/domains/",
+							"host": [
+								"{{management_url}}"
+							],
+							"path": [
+								"management",
+								"domains",
+								""
+							]
+						}
+					},
+					"response": []
+				},
+				{
+					"name": "Patch domain - allow localhost",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "4d80e686-0750-477e-b1fe-e379e1442cbb",
+								"exec": [
+									"pm.test(\"Status code is 200\", function () {",
+									"    pm.response.to.have.status(200);",
+									"});",
+									"",
+									"pm.test(\"DCR disabled for domain\", function () {",
+									"    var jsonData = pm.response.json();",
+									"    pm.expect(jsonData.oidc.clientRegistrationSettings.allowLocalhostRedirectUri).to.eql(true);",
+									"    pm.expect(jsonData.oidc.clientRegistrationSettings.allowHttpSchemeRedirectUri).to.eql(true);",
+									"    pm.expect(jsonData.oidc.clientRegistrationSettings.allowWildCardRedirectUri).to.eql(true);",
+									"    pm.expect(jsonData.oidc.clientRegistrationSettings.isDynamicClientRegistrationEnabled).to.eql(false);",
+									"    pm.expect(jsonData.oidc.clientRegistrationSettings.isOpenDynamicClientRegistrationEnabled).to.eql(false);",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "PATCH",
+						"header": [
+							{
+								"key": "Content-Type",
+								"name": "Content-Type",
+								"value": "application/json",
+								"type": "text"
+							},
+							{
+								"key": "Authorization",
+								"value": "Bearer {{token}}",
+								"type": "text"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"oidc\": {\n    \"clientRegistrationSettings\": {\n      \"allowLocalhostRedirectUri\": true,\n      \"allowHttpSchemeRedirectUri\": true,\n      \"allowWildCardRedirectUri\": true,\n      \"isDynamicClientRegistrationEnabled\": false,\n      \"isOpenDynamicClientRegistrationEnabled\": false\n    }\n  }\n}"
+						},
+						"url": {
+							"raw": "{{management_url}}/management/domains/{{domain}}",
+							"host": [
+								"{{management_url}}"
+							],
+							"path": [
+								"management",
+								"domains",
+								"{{domain}}"
+							]
+						}
+					},
+					"response": []
+				},
+				{
+					"name": "Create in-memory IDP",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "2e92f1ea-5d06-4aa3-9ddc-95b0359a8f20",
+								"exec": [
+									"pm.test(\"Status code is 201\", function () {",
+									"    pm.response.to.have.status(201);",
+									"});",
+									"",
+									"var body = JSON.parse(responseBody);",
+									"pm.environment.set('idp.inmemory', body.id);"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "Authorization",
+								"value": "Bearer {{token}}"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"external\": false,\n  \"type\": \"inline-am-idp\",\n  \"configuration\": \"{\\\"users\\\":[{\\\"firstname\\\":\\\"my-user\\\",\\\"lastname\\\":\\\"my-user-lastname\\\",\\\"username\\\":\\\"user\\\",\\\"password\\\":\\\"password\\\"},{\\\"firstname\\\":\\\"Jensen\\\",\\\"lastname\\\":\\\"Barbara\\\",\\\"username\\\":\\\"jensen.barbara\\\",\\\"email\\\":\\\"jensen.barbara@mail.com\\\",\\\"password\\\":\\\"password\\\"}]}\",\n  \"name\": \"inmemory\"\n}"
+						},
+						"url": {
+							"raw": "{{management_url}}/management/domains/{{domain}}/identities",
+							"host": [
+								"{{management_url}}"
+							],
+							"path": [
+								"management",
+								"domains",
+								"{{domain}}",
+								"identities"
+							]
+						}
+					},
+					"response": []
+				},
+				{
+					"name": "Create client",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "fbe2362d-ad75-4baa-8825-b74218b047c7",
+								"exec": [
+									"pm.test(\"Status code is 201\", function () {",
+									"    pm.response.to.have.status(201);",
+									"});",
+									"",
+									"var body = JSON.parse(responseBody);",
+									"pm.environment.set('client', body.id);"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "Authorization",
+								"value": "Bearer {{token}}"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"clientId\": \"my-client\",\n  \"clientSecret\": \"my-client-secret\"\n}"
+						},
+						"url": {
+							"raw": "{{management_url}}/management/domains/{{domain}}/clients",
+							"host": [
+								"{{management_url}}"
+							],
+							"path": [
+								"management",
+								"domains",
+								"{{domain}}",
+								"clients"
+							]
+						}
+					},
+					"response": []
+				},
+				{
+					"name": "Configure client",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "c37cba72-91c2-4850-80cd-6fd3908e406e",
+								"exec": [
+									"pm.test(\"Status code is 200\", function () {",
+									"    pm.response.to.have.status(200);",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "PUT",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "Authorization",
+								"value": "Bearer {{token}}"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"redirectUris\": [],\n  \"authorizedGrantTypes\": [\n    \"implicit\"\n  ],\n  \"redirectUris\": [\n  \t\"http://localhost:4000\"\n  ],\n  \"accessTokenValiditySeconds\": 7200,\n  \"refreshTokenValiditySeconds\": 14400,\n  \"idTokenValiditySeconds\": 14400,\n  \"enabled\": true,\n  \"identities\": [\n    \"{{idp.inmemory}}\"\n  ],\n  \"enhanceScopesWithUserPermissions\": false\n}"
+						},
+						"url": {
+							"raw": "{{management_url}}/management/domains/{{domain}}/clients/{{client}}",
+							"host": [
+								"{{management_url}}"
+							],
+							"path": [
+								"management",
+								"domains",
+								"{{domain}}",
+								"clients",
+								"{{client}}"
+							]
+						}
+					},
+					"response": []
+				},
+				{
+					"name": "Start domain",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "bfe78ac1-144a-4bbd-abf9-55e160e723bf",
+								"exec": [
+									"// wait for sync process",
+									"setTimeout(function(){}, 10000);"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "PATCH",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "Authorization",
+								"value": "Bearer {{token}}"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"enabled\": true\n}"
+						},
+						"url": {
+							"raw": "{{management_url}}/management/domains/{{domain}}",
+							"host": [
+								"{{management_url}}"
+							],
+							"path": [
+								"management",
+								"domains",
+								"{{domain}}"
+							]
+						}
+					},
+					"response": []
+				},
+				{
+					"name": "well-known/openid-configuration",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "5332b6f5-7419-4792-a30f-cce968d3d67e",
+								"exec": [
+									"pm.test(\"Status code is 200\", function () {",
+									"    pm.response.to.have.status(200);",
+									"});",
+									"",
+									"pm.test(\"Check discovery endpoints\", function () {",
+									"    pm.response.to.be.header('Content-Type', 'application/json');",
+									"    var body = pm.response.json();",
+									"    ",
+									"    pm.expect(body).to.have.property(\"authorization_endpoint\");",
+									"    pm.expect(body).to.have.property(\"token_endpoint\");",
+									"    pm.expect(body).to.have.property(\"revocation_endpoint\");",
+									"    pm.expect(body).to.have.property(\"userinfo_endpoint\");",
+									"    pm.expect(body).to.have.property(\"registration_endpoint\");",
+									"    pm.expect(body).to.have.property(\"end_session_endpoint\");",
+									"    pm.expect(body).to.have.property(\"introspection_endpoint\");",
+									"",
+									"    pm.environment.set('authorizationEndpoint', body.authorization_endpoint);",
+									"    pm.environment.set('tokenEndpoint', body.token_endpoint);",
+									"    pm.environment.set('userinfoEndpoint', body.userinfo_endpoint);",
+									"    pm.environment.set('revocationEndpoint', body.revocation_endpoint);",
+									"    pm.environment.set('registrationEndpoint', body.registration_endpoint);",
+									"    pm.environment.set('logoutEndpoint', body.end_session_endpoint);",
+									"    pm.environment.set('introspectionEndpoint', body.introspection_endpoint);",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{gateway_url}}/{{domain}}/oidc/.well-known/openid-configuration",
+							"host": [
+								"{{gateway_url}}"
+							],
+							"path": [
+								"{{domain}}",
+								"oidc",
+								".well-known",
+								"openid-configuration"
+							]
+						}
+					},
+					"response": []
+				}
+			],
+			"protocolProfileBehavior": {}
+		},
+		{
+			"name": "Get Access Token",
+			"item": [
+				{
+					"name": "Access token - Initiate the flow",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "fb56a849-b090-4e80-9c8c-eca9f4d72e85",
+								"exec": [
+									"pm.test(\"Status code is 302\", function () {",
+									"    pm.response.to.have.status(302);",
+									"});",
+									"",
+									"pm.test(\"Should be redirected\", function () {",
+									"    pm.response.to.be.redirection;",
+									"    pm.response.to.have.header('Location');",
+									"});",
+									"",
+									"pm.test(\"Should be a redirection to login page\", function() {",
+									"    var location = postman.getResponseHeader('Location');",
+									"    let domain = pm.environment.get(\"domain\");",
+									"    ",
+									"    tests['Redirect to login page with client_id'] = location === pm.environment.get('gateway_url') + '/' + domain + '/login?client_id=my-client';",
+									"    ",
+									"    pm.environment.set('redirection', postman.getResponseHeader(\"Location\"));",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						},
+						{
+							"listen": "prerequest",
+							"script": {
+								"id": "904f0113-b2b9-4645-9511-48bb10791506",
+								"exec": [
+									""
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{authorizationEndpoint}}?response_type=token&client_id=my-client&redirect_uri=http://localhost:4000/",
+							"host": [
+								"{{authorizationEndpoint}}"
+							],
+							"query": [
+								{
+									"key": "response_type",
+									"value": "token"
+								},
+								{
+									"key": "client_id",
+									"value": "my-client"
+								},
+								{
+									"key": "redirect_uri",
+									"value": "http://localhost:4000/"
+								}
+							]
+						}
+					},
+					"response": []
+				},
+				{
+					"name": "Redirect to login form",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "67df9bf8-89a2-4c95-9728-097aad6d2be4",
+								"type": "text/javascript",
+								"exec": [
+									"pm.test(\"Status code is 200\", function () {",
+									"    pm.response.to.have.status(200);",
+									"});",
+									"",
+									"pm.test(\"Should be ok\", function () {",
+									"    pm.response.to.be.ok;",
+									"    ",
+									"    // Extract the XSRF token to send it with the next request.",
+									"    var responseHTML = cheerio(pm.response.text());",
+									"    var xsrfToken = responseHTML.find('[name=\"X-XSRF-TOKEN\"]').val();",
+									"    pm.environment.set('xsrf', xsrfToken);",
+									"});"
+								]
+							}
+						},
+						{
+							"listen": "prerequest",
+							"script": {
+								"id": "904f0113-b2b9-4645-9511-48bb10791506",
+								"type": "text/javascript",
+								"exec": [
+									""
+								]
+							}
+						}
+					],
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{redirection}}",
+							"host": [
+								"{{redirection}}"
+							]
+						},
+						"description": "The client does not have a redirect_uri define"
+					},
+					"response": []
+				},
+				{
+					"name": "Post login form",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "4afec9f2-a5ba-4647-9a78-6d74cb9c157c",
+								"exec": [
+									"pm.test(\"Status code is 302\", function () {",
+									"    pm.response.to.have.status(302);",
+									"});",
+									"",
+									"pm.test(\"Should be redirected\", function () {",
+									"    pm.response.to.be.redirection;",
+									"    pm.response.to.have.header('Location');",
+									"",
+									"    pm.environment.set('redirection', postman.getResponseHeader(\"Location\"));",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						},
+						{
+							"listen": "prerequest",
+							"script": {
+								"id": "904f0113-b2b9-4645-9511-48bb10791506",
+								"exec": [
+									""
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/x-www-form-urlencoded"
+							}
+						],
+						"body": {
+							"mode": "urlencoded",
+							"urlencoded": [
+								{
+									"key": "X-XSRF-TOKEN",
+									"value": "{{xsrf}}",
+									"type": "text"
+								},
+								{
+									"key": "client_id",
+									"value": "my-client",
+									"type": "text"
+								},
+								{
+									"key": "username",
+									"value": "user",
+									"type": "text"
+								},
+								{
+									"key": "password",
+									"value": "password",
+									"type": "text"
+								}
+							]
+						},
+						"url": {
+							"raw": "{{gateway_url}}/{{domain}}/login",
+							"host": [
+								"{{gateway_url}}"
+							],
+							"path": [
+								"{{domain}}",
+								"login"
+							]
+						},
+						"description": "The client does not have a redirect_uri define"
+					},
+					"response": []
+				},
+				{
+					"name": "Follow redirection",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "73f280ec-f0ac-46ec-9424-0cdecd34f1c8",
+								"exec": [
+									"pm.test(\"Status code is 302\", function () {",
+									"    pm.response.to.have.status(302);",
+									"});",
+									"",
+									"pm.test(\"Should be redirected\", function () {",
+									"    pm.response.to.be.redirection;",
+									"    pm.response.to.have.header('Location');",
+									"});",
+									"",
+									"pm.test(\"Should be a redirection to redirect_uri with implicit\", function() {",
+									"    var location = postman.getResponseHeader('Location');",
+									"    tests['Redirect to redirect_uri'] = location.includes('http://localhost:4000/#');",
+									"    ",
+									"    tests['Contains an access_token param'] = location.includes('access_token=');",
+									"    tests['Contains a token_type param'] = location.includes('token_type=bearer');",
+									"    tests['Contains an expires_in param'] = location.includes('expires_in=');",
+									"    ",
+									"    // extract access token",
+									"    var hash = location.substr(23);",
+									"    var responseParameters = hash.split(\"&\");",
+									"    var parameterMap = [];",
+									"    for(var i = 0; i < responseParameters.length; i++) {",
+									"        parameterMap[responseParameters[i].split(\"=\")[0]] = responseParameters[i].split(\"=\")[1];",
+									"    }",
+									"    pm.environment.set('access_token', parameterMap.access_token);",
+									"    ",
+									"    // wait for offline verification sync process",
+									"    setTimeout(function(){}, 11000);",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						},
+						{
+							"listen": "prerequest",
+							"script": {
+								"id": "904f0113-b2b9-4645-9511-48bb10791506",
+								"exec": [
+									""
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{redirection}}",
+							"host": [
+								"{{redirection}}"
+							]
+						},
+						"description": "The client does not have a redirect_uri define"
+					},
+					"response": []
+				},
+				{
+					"name": "Introspect Token",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "660c6db8-4bea-464f-aa77-26dbf6af12d8",
+								"exec": [
+									"pm.test(\"Status code is 200\", function () {",
+									"    pm.response.to.have.status(200);",
+									"});",
+									"",
+									"pm.test(\"Is token active\", function () {",
+									"    var body = pm.response.json();",
+									"    pm.expect(body).to.have.property('active');",
+									"    pm.expect(body.active).to.eql(true);",
+									"    pm.expect(body.client_id).to.eql(\"my-client\");",
+									"    pm.expect(body.username).to.eql(\"user\");",
+									"    pm.expect(body.token_type).to.eql(\"bearer\");",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"auth": {
+							"type": "noauth"
+						},
+						"method": "POST",
+						"header": [
+							{
+								"key": "Authorization",
+								"value": "Basic bXktY2xpZW50Om15LWNsaWVudC1zZWNyZXQ="
+							}
+						],
+						"body": {
+							"mode": "urlencoded",
+							"urlencoded": [
+								{
+									"key": "token",
+									"value": "{{access_token}}",
+									"type": "text"
+								}
+							]
+						},
+						"url": {
+							"raw": "{{introspectionEndpoint}}",
+							"host": [
+								"{{introspectionEndpoint}}"
+							]
+						}
+					},
+					"response": []
+				},
+				{
+					"name": "Logout user",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "ba6b6fb1-32ac-46d5-8e2d-f97961029a05",
+								"exec": [
+									"pm.test(\"Status code is 302\", function () {",
+									"    pm.response.to.have.status(302);",
+									"});",
+									"",
+									"pm.test(\"Should be redirected\", function () {",
+									"    pm.response.to.be.redirection;",
+									"    pm.response.to.have.header('Location');",
+									"    ",
+									"    pm.environment.set('redirection', postman.getResponseHeader(\"Location\"));",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						},
+						{
+							"listen": "prerequest",
+							"script": {
+								"id": "904f0113-b2b9-4645-9511-48bb10791506",
+								"exec": [
+									""
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{logoutEndpoint}}",
+							"host": [
+								"{{logoutEndpoint}}"
+							]
+						},
+						"description": "The client does not have a redirect_uri define"
+					},
+					"response": []
+				},
+				{
+					"name": "Introspect Token",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "660c6db8-4bea-464f-aa77-26dbf6af12d8",
+								"exec": [
+									"pm.test(\"Status code is 200\", function () {",
+									"    pm.response.to.have.status(200);",
+									"});",
+									"",
+									"pm.test(\"Is token active\", function () {",
+									"    var body = pm.response.json();",
+									"    pm.expect(body).to.have.property('active');",
+									"    pm.expect(body.active).to.eql(true);",
+									"    pm.expect(body.client_id).to.eql(\"my-client\");",
+									"    pm.expect(body.username).to.eql(\"user\");",
+									"    pm.expect(body.token_type).to.eql(\"bearer\");",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"auth": {
+							"type": "noauth"
+						},
+						"method": "POST",
+						"header": [
+							{
+								"key": "Authorization",
+								"value": "Basic bXktY2xpZW50Om15LWNsaWVudC1zZWNyZXQ="
+							}
+						],
+						"body": {
+							"mode": "urlencoded",
+							"urlencoded": [
+								{
+									"key": "token",
+									"value": "{{access_token}}",
+									"type": "text"
+								}
+							]
+						},
+						"url": {
+							"raw": "{{introspectionEndpoint}}",
+							"host": [
+								"{{introspectionEndpoint}}"
+							]
+						}
+					},
+					"response": []
+				}
+			],
+			"event": [
+				{
+					"listen": "prerequest",
+					"script": {
+						"id": "7a9143c0-cc02-4402-bb68-90bb92bb3b19",
+						"type": "text/javascript",
+						"exec": [
+							""
+						]
+					}
+				},
+				{
+					"listen": "test",
+					"script": {
+						"id": "ffcf002d-109b-407f-aab7-a1a5492ed4b1",
+						"type": "text/javascript",
+						"exec": [
+							""
+						]
+					}
+				}
+			],
+			"protocolProfileBehavior": {}
+		},
+		{
+			"name": "Get Access Token - invalidate tokens",
+			"item": [
+				{
+					"name": "Access token - Initiate the flow",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "fb56a849-b090-4e80-9c8c-eca9f4d72e85",
+								"exec": [
+									"pm.test(\"Status code is 302\", function () {",
+									"    pm.response.to.have.status(302);",
+									"});",
+									"",
+									"pm.test(\"Should be redirected\", function () {",
+									"    pm.response.to.be.redirection;",
+									"    pm.response.to.have.header('Location');",
+									"});",
+									"",
+									"pm.test(\"Should be a redirection to login page\", function() {",
+									"    var location = postman.getResponseHeader('Location');",
+									"    let domain = pm.environment.get(\"domain\");",
+									"    ",
+									"    tests['Redirect to login page with client_id'] = location === pm.environment.get('gateway_url') + '/' + domain + '/login?client_id=my-client';",
+									"    ",
+									"    pm.environment.set('redirection', postman.getResponseHeader(\"Location\"));",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						},
+						{
+							"listen": "prerequest",
+							"script": {
+								"id": "904f0113-b2b9-4645-9511-48bb10791506",
+								"exec": [
+									""
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{authorizationEndpoint}}?response_type=token&client_id=my-client&redirect_uri=http://localhost:4000/",
+							"host": [
+								"{{authorizationEndpoint}}"
+							],
+							"query": [
+								{
+									"key": "response_type",
+									"value": "token"
+								},
+								{
+									"key": "client_id",
+									"value": "my-client"
+								},
+								{
+									"key": "redirect_uri",
+									"value": "http://localhost:4000/"
+								}
+							]
+						}
+					},
+					"response": []
+				},
+				{
+					"name": "Redirect to login form",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "67df9bf8-89a2-4c95-9728-097aad6d2be4",
+								"type": "text/javascript",
+								"exec": [
+									"pm.test(\"Status code is 200\", function () {",
+									"    pm.response.to.have.status(200);",
+									"});",
+									"",
+									"pm.test(\"Should be ok\", function () {",
+									"    pm.response.to.be.ok;",
+									"    ",
+									"    // Extract the XSRF token to send it with the next request.",
+									"    var responseHTML = cheerio(pm.response.text());",
+									"    var xsrfToken = responseHTML.find('[name=\"X-XSRF-TOKEN\"]').val();",
+									"    pm.environment.set('xsrf', xsrfToken);",
+									"});"
+								]
+							}
+						},
+						{
+							"listen": "prerequest",
+							"script": {
+								"id": "904f0113-b2b9-4645-9511-48bb10791506",
+								"type": "text/javascript",
+								"exec": [
+									""
+								]
+							}
+						}
+					],
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{redirection}}",
+							"host": [
+								"{{redirection}}"
+							]
+						},
+						"description": "The client does not have a redirect_uri define"
+					},
+					"response": []
+				},
+				{
+					"name": "Post login form",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "4afec9f2-a5ba-4647-9a78-6d74cb9c157c",
+								"exec": [
+									"pm.test(\"Status code is 302\", function () {",
+									"    pm.response.to.have.status(302);",
+									"});",
+									"",
+									"pm.test(\"Should be redirected\", function () {",
+									"    pm.response.to.be.redirection;",
+									"    pm.response.to.have.header('Location');",
+									"",
+									"    pm.environment.set('redirection', postman.getResponseHeader(\"Location\"));",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						},
+						{
+							"listen": "prerequest",
+							"script": {
+								"id": "904f0113-b2b9-4645-9511-48bb10791506",
+								"exec": [
+									""
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/x-www-form-urlencoded"
+							}
+						],
+						"body": {
+							"mode": "urlencoded",
+							"urlencoded": [
+								{
+									"key": "X-XSRF-TOKEN",
+									"value": "{{xsrf}}",
+									"type": "text"
+								},
+								{
+									"key": "client_id",
+									"value": "my-client",
+									"type": "text"
+								},
+								{
+									"key": "username",
+									"value": "user",
+									"type": "text"
+								},
+								{
+									"key": "password",
+									"value": "password",
+									"type": "text"
+								}
+							]
+						},
+						"url": {
+							"raw": "{{gateway_url}}/{{domain}}/login",
+							"host": [
+								"{{gateway_url}}"
+							],
+							"path": [
+								"{{domain}}",
+								"login"
+							]
+						},
+						"description": "The client does not have a redirect_uri define"
+					},
+					"response": []
+				},
+				{
+					"name": "Follow redirection",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "73f280ec-f0ac-46ec-9424-0cdecd34f1c8",
+								"exec": [
+									"pm.test(\"Status code is 302\", function () {",
+									"    pm.response.to.have.status(302);",
+									"});",
+									"",
+									"pm.test(\"Should be redirected\", function () {",
+									"    pm.response.to.be.redirection;",
+									"    pm.response.to.have.header('Location');",
+									"});",
+									"",
+									"pm.test(\"Should be a redirection to redirect_uri with implicit\", function() {",
+									"    var location = postman.getResponseHeader('Location');",
+									"    tests['Redirect to redirect_uri'] = location.includes('http://localhost:4000/#');",
+									"    ",
+									"    tests['Contains an access_token param'] = location.includes('access_token=');",
+									"    tests['Contains a token_type param'] = location.includes('token_type=bearer');",
+									"    tests['Contains an expires_in param'] = location.includes('expires_in=');",
+									"    ",
+									"    // extract access token",
+									"    var hash = location.substr(23);",
+									"    var responseParameters = hash.split(\"&\");",
+									"    var parameterMap = [];",
+									"    for(var i = 0; i < responseParameters.length; i++) {",
+									"        parameterMap[responseParameters[i].split(\"=\")[0]] = responseParameters[i].split(\"=\")[1];",
+									"    }",
+									"    pm.environment.set('access_token', parameterMap.access_token);",
+									"    ",
+									"    // wait for offline verification sync process",
+									"    setTimeout(function(){}, 11000);",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						},
+						{
+							"listen": "prerequest",
+							"script": {
+								"id": "904f0113-b2b9-4645-9511-48bb10791506",
+								"exec": [
+									""
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{redirection}}",
+							"host": [
+								"{{redirection}}"
+							]
+						},
+						"description": "The client does not have a redirect_uri define"
+					},
+					"response": []
+				},
+				{
+					"name": "Introspect Token",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "660c6db8-4bea-464f-aa77-26dbf6af12d8",
+								"exec": [
+									"pm.test(\"Status code is 200\", function () {",
+									"    pm.response.to.have.status(200);",
+									"});",
+									"",
+									"pm.test(\"Is token active\", function () {",
+									"    var body = pm.response.json();",
+									"    pm.expect(body).to.have.property('active');",
+									"    pm.expect(body.active).to.eql(true);",
+									"    pm.expect(body.client_id).to.eql(\"my-client\");",
+									"    pm.expect(body.username).to.eql(\"user\");",
+									"    pm.expect(body.token_type).to.eql(\"bearer\");",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"auth": {
+							"type": "noauth"
+						},
+						"method": "POST",
+						"header": [
+							{
+								"key": "Authorization",
+								"value": "Basic bXktY2xpZW50Om15LWNsaWVudC1zZWNyZXQ="
+							}
+						],
+						"body": {
+							"mode": "urlencoded",
+							"urlencoded": [
+								{
+									"key": "token",
+									"value": "{{access_token}}",
+									"type": "text"
+								}
+							]
+						},
+						"url": {
+							"raw": "{{introspectionEndpoint}}",
+							"host": [
+								"{{introspectionEndpoint}}"
+							]
+						}
+					},
+					"response": []
+				},
+				{
+					"name": "Logout user",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "ba6b6fb1-32ac-46d5-8e2d-f97961029a05",
+								"exec": [
+									"pm.test(\"Status code is 302\", function () {",
+									"    pm.response.to.have.status(302);",
+									"});",
+									"",
+									"pm.test(\"Should be redirected\", function () {",
+									"    pm.response.to.be.redirection;",
+									"    pm.response.to.have.header('Location');",
+									"    ",
+									"    pm.environment.set('redirection', postman.getResponseHeader(\"Location\"));",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						},
+						{
+							"listen": "prerequest",
+							"script": {
+								"id": "904f0113-b2b9-4645-9511-48bb10791506",
+								"exec": [
+									""
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{logoutEndpoint}}?invalidate_tokens=true",
+							"host": [
+								"{{logoutEndpoint}}"
+							],
+							"query": [
+								{
+									"key": "invalidate_tokens",
+									"value": "true"
+								}
+							]
+						},
+						"description": "The client does not have a redirect_uri define"
+					},
+					"response": []
+				},
+				{
+					"name": "Introspect Token",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"id": "660c6db8-4bea-464f-aa77-26dbf6af12d8",
+								"exec": [
+									"pm.test(\"Status code is 200\", function () {",
+									"    pm.response.to.have.status(200);",
+									"});",
+									"",
+									"pm.test(\"Is token not active anymore\", function () {",
+									"    var body = pm.response.json();",
+									"    pm.expect(body).to.have.property('active');",
+									"    pm.expect(body.active).to.eql(false);",
+									"});"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"auth": {
+							"type": "noauth"
+						},
+						"method": "POST",
+						"header": [
+							{
+								"key": "Authorization",
+								"value": "Basic bXktY2xpZW50Om15LWNsaWVudC1zZWNyZXQ="
+							}
+						],
+						"body": {
+							"mode": "urlencoded",
+							"urlencoded": [
+								{
+									"key": "token",
+									"value": "{{access_token}}",
+									"type": "text"
+								}
+							]
+						},
+						"url": {
+							"raw": "{{introspectionEndpoint}}",
+							"host": [
+								"{{introspectionEndpoint}}"
+							]
+						}
+					},
+					"response": []
+				}
+			],
+			"event": [
+				{
+					"listen": "prerequest",
+					"script": {
+						"id": "7a9143c0-cc02-4402-bb68-90bb92bb3b19",
+						"type": "text/javascript",
+						"exec": [
+							""
+						]
+					}
+				},
+				{
+					"listen": "test",
+					"script": {
+						"id": "ffcf002d-109b-407f-aab7-a1a5492ed4b1",
+						"type": "text/javascript",
+						"exec": [
+							""
+						]
+					}
+				}
+			],
+			"protocolProfileBehavior": {}
+		},
+		{
+			"name": "Delete domain",
+			"event": [
+				{
+					"listen": "test",
+					"script": {
+						"id": "abfa8366-3ee2-45b0-b658-0040b79d565c",
+						"exec": [
+							"pm.test(\"Status code is 204\", function () {",
+							"    pm.response.to.have.status(204);",
+							"});"
+						],
+						"type": "text/javascript"
+					}
+				}
+			],
+			"request": {
+				"method": "DELETE",
+				"header": [
+					{
+						"key": "Content-Type",
+						"value": "application/json"
+					},
+					{
+						"key": "Authorization",
+						"value": "Bearer {{token}}"
+					}
+				],
+				"body": {
+					"mode": "raw",
+					"raw": ""
+				},
+				"url": {
+					"raw": "{{management_url}}/management/domains/{{domain}}",
+					"host": [
+						"{{management_url}}"
+					],
+					"path": [
+						"management",
+						"domains",
+						"{{domain}}"
+					]
+				}
+			},
+			"response": []
+		}
+	],
+	"protocolProfileBehavior": {}
+}
\ No newline at end of file


Test patch:
diff --git a/gravitee-am-service/src/test/java/io/gravitee/am/service/TokenServiceTest.java b/gravitee-am-service/src/test/java/io/gravitee/am/service/TokenServiceTest.java
index 4f5bca56e2..aa4d88fa70 100644
--- a/gravitee-am-service/src/test/java/io/gravitee/am/service/TokenServiceTest.java
+++ b/gravitee-am-service/src/test/java/io/gravitee/am/service/TokenServiceTest.java
@@ -18,9 +18,11 @@ package io.gravitee.am.service;
 import io.gravitee.am.model.Client;
 import io.gravitee.am.repository.exceptions.TechnicalException;
 import io.gravitee.am.repository.oauth2.api.AccessTokenRepository;
+import io.gravitee.am.repository.oauth2.api.RefreshTokenRepository;
 import io.gravitee.am.service.exception.TechnicalManagementException;
 import io.gravitee.am.service.impl.TokenServiceImpl;
 import io.gravitee.am.service.model.TotalToken;
+import io.reactivex.Completable;
 import io.reactivex.Single;
 import io.reactivex.observers.TestObserver;
 import org.junit.Test;
@@ -48,6 +50,9 @@ public class TokenServiceTest {
     @Mock
     private AccessTokenRepository accessTokenRepository;
 
+    @Mock
+    private RefreshTokenRepository refreshTokenRepository;
+
     @Mock
     private ClientService clientService;
 
@@ -147,4 +152,14 @@ public class TokenServiceTest {
         testObserver.assertNotComplete();
     }
 
+    @Test
+    public void shouldDeleteTokensByUser() {
+        when(accessTokenRepository.deleteByUserId("userId")).thenReturn(Completable.complete());
+        when(refreshTokenRepository.deleteByUserId("userId")).thenReturn(Completable.complete());
+
+        TestObserver testObserver = tokenService.deleteByUserId("userId").test();
+        testObserver.assertComplete();
+        testObserver.assertNoErrors();
+    }
+
 }