Repository: gravitee-io/graviteeio-access-management
Pull request #1052 (2021-02-18 15:53:56)

feat(logout): add registered logout uris for a security domain
closes gravitee-io/issues#5078


Patch:
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 74a4dba9a5..e6e8d3c2bf 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
@@ -53,10 +53,7 @@ import io.gravitee.am.gateway.handler.root.resources.handler.error.ErrorHandler;
 import io.gravitee.am.gateway.handler.root.resources.handler.login.*;
 import io.gravitee.am.gateway.handler.root.resources.handler.user.PasswordPolicyRequestParseHandler;
 import io.gravitee.am.gateway.handler.root.resources.handler.user.UserTokenRequestParseHandler;
-import io.gravitee.am.gateway.handler.root.resources.handler.user.password.ForgotPasswordAccessHandler;
-import io.gravitee.am.gateway.handler.root.resources.handler.user.password.ForgotPasswordSubmissionRequestParseHandler;
-import io.gravitee.am.gateway.handler.root.resources.handler.user.password.ResetPasswordRequestParseHandler;
-import io.gravitee.am.gateway.handler.root.resources.handler.user.password.ResetPasswordSubmissionRequestParseHandler;
+import io.gravitee.am.gateway.handler.root.resources.handler.user.password.*;
 import io.gravitee.am.gateway.handler.root.resources.handler.user.register.RegisterAccessHandler;
 import io.gravitee.am.gateway.handler.root.resources.handler.user.register.RegisterConfirmationRequestParseHandler;
 import io.gravitee.am.gateway.handler.root.resources.handler.user.register.RegisterConfirmationSubmissionRequestParseHandler;
@@ -272,10 +269,13 @@ public class RootProvider extends AbstractService<ProtocolProvider> implements P
         rootRouter.route(HttpMethod.GET, "/resetPassword")
                 .handler(new ResetPasswordRequestParseHandler(userService))
                 .handler(clientRequestParseHandlerOptional)
+                .handler(userTokenRequestParseHandler)
+                .handler(new ResetPasswordOneTimeTokenHandler())
                 .handler(new ResetPasswordEndpoint(thymeleafTemplateEngine));
         rootRouter.route(HttpMethod.POST, "/resetPassword")
                 .handler(new ResetPasswordSubmissionRequestParseHandler())
                 .handler(userTokenRequestParseHandler)
+                .handler(new ResetPasswordOneTimeTokenHandler())
                 .handler(passwordPolicyRequestParseHandler)
                 .handler(new ResetPasswordSubmissionEndpoint(userService));
 
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 de6adf1432..c1cd65afa2 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
@@ -23,7 +23,6 @@ import io.gravitee.am.gateway.handler.common.client.ClientSyncService;
 import io.gravitee.am.gateway.handler.common.jwt.JWTService;
 import io.gravitee.am.gateway.handler.common.utils.ConstantKeys;
 import io.gravitee.am.gateway.handler.common.vertx.utils.RequestUtils;
-import io.gravitee.am.gateway.handler.context.provider.ClientProperties;
 import io.gravitee.am.model.Domain;
 import io.gravitee.am.model.User;
 import io.gravitee.am.model.oidc.Client;
@@ -42,6 +41,8 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.util.StringUtils;
 
+import java.util.List;
+
 /**
  * @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
  * @author GraviteeSource Team
@@ -172,26 +173,15 @@ public class LogoutEndpoint implements Handler<RoutingContext> {
                 request.getParam(LOGOUT_URL_PARAMETER) : (!StringUtils.isEmpty(request.getParam(Parameters.POST_LOGOUT_REDIRECT_URI)) ?
                 request.getParam(Parameters.POST_LOGOUT_REDIRECT_URI) : DEFAULT_TARGET_URL);
 
-        // what should we do in this case ?
-        // for back-compatibility purpose, only check the target_url parameter
-        if (client == null) {
-            final String targetUrl = !StringUtils.isEmpty(request.getParam(LOGOUT_URL_PARAMETER)) ? request.getParam(LOGOUT_URL_PARAMETER) : DEFAULT_TARGET_URL;
-            doRedirect0(routingContext, targetUrl);
-            return;
-        }
-
         // The OP also MUST NOT perform post-logout redirection if the post_logout_redirect_uri value supplied
         // does not exactly match one of the previously registered post_logout_redirect_uris values.
-        if (client.getPostLogoutRedirectUris() != null
-                && !client.getPostLogoutRedirectUris().isEmpty()
-                && !DEFAULT_TARGET_URL.equals(logoutRedirectUrl)) {
-            if (client.getPostLogoutRedirectUris()
-                    .stream()
-                    .noneMatch(registeredClientUri -> logoutRedirectUrl.equals(registeredClientUri))) {
-                routingContext.fail(new InvalidRequestException("The post_logout_redirect_uri MUST match the registered callback URL for this application"));
-                return;
-            }
-        }
+        // if client is null, check security domain options
+        List<String> registeredUris = client != null ? client.getPostLogoutRedirectUris() :
+                (domain.getOidc() != null ? domain.getOidc().getPostLogoutRedirectUris() : null);
+        if (!isMatchingRedirectUri(logoutRedirectUrl, registeredUris)) {
+            routingContext.fail(new InvalidRequestException("The post_logout_redirect_uri MUST match the registered callback URLs"));
+            return;
+         }
 
         // redirect the End-User
         doRedirect0(routingContext, logoutRedirectUrl);
@@ -274,4 +264,23 @@ public class LogoutEndpoint implements Handler<RoutingContext> {
             routingContext.fail(500);
         }
     }
+
+    private boolean isMatchingRedirectUri(String requestedRedirectUri, List<String> registeredRedirectUris) {
+        // no registered uris to check, continue
+        if (registeredRedirectUris == null) {
+            return true;
+        }
+        // no registered uris to check, continue
+        if (registeredRedirectUris.isEmpty()) {
+            return true;
+        }
+        // default value, continue
+        if (DEFAULT_TARGET_URL.equals(requestedRedirectUri)) {
+            return true;
+        }
+        // compare values
+        return registeredRedirectUris
+                .stream()
+                .anyMatch(registeredUri -> requestedRedirectUri.equals(registeredUri));
+    }
 }
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/handler/user/UserTokenRequestParseHandler.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/handler/user/UserTokenRequestParseHandler.java
index 4278edf43d..db91014fe3 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/handler/user/UserTokenRequestParseHandler.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/handler/user/UserTokenRequestParseHandler.java
@@ -84,6 +84,7 @@ public class UserTokenRequestParseHandler extends UserRequestHandler {
             UserToken userToken = handler.result();
             context.put(ConstantKeys.USER_CONTEXT_KEY, userToken.getUser());
             context.put(ConstantKeys.CLIENT_CONTEXT_KEY, userToken.getClient());
+            context.put(ConstantKeys.TOKEN_CONTEXT_KEY, userToken.getToken());
             context.next();
         });
     }
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/handler/user/password/ResetPasswordOneTimeTokenHandler.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/handler/user/password/ResetPasswordOneTimeTokenHandler.java
new file mode 100644
index 0000000000..70dd3f5bd0
--- /dev/null
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/resources/handler/user/password/ResetPasswordOneTimeTokenHandler.java
@@ -0,0 +1,71 @@
+/**
+ * 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.gateway.handler.root.resources.handler.user.password;
+
+import io.gravitee.am.common.jwt.JWT;
+import io.gravitee.am.gateway.handler.common.utils.ConstantKeys;
+import io.gravitee.am.gateway.handler.common.vertx.utils.RequestUtils;
+import io.gravitee.am.gateway.handler.root.resources.handler.user.UserRequestHandler;
+import io.gravitee.am.model.User;
+import io.vertx.reactivex.core.MultiMap;
+import io.vertx.reactivex.ext.web.RoutingContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+
+/**
+ * @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
+ * @author GraviteeSource Team
+ */
+public class ResetPasswordOneTimeTokenHandler extends UserRequestHandler {
+
+    private final static Logger logger = LoggerFactory.getLogger(ResetPasswordOneTimeTokenHandler.class);
+
+    @Override
+    public void handle(RoutingContext context) {
+        final String success = context.request().getParam(ConstantKeys.SUCCESS_PARAM_KEY);
+        final String warning = context.request().getParam(ConstantKeys.WARNING_PARAM_KEY);
+        final String error = context.request().getParam(ConstantKeys.ERROR_PARAM_KEY);
+        final User user = context.get(ConstantKeys.USER_CONTEXT_KEY);
+        final JWT jwt = context.get(ConstantKeys.TOKEN_CONTEXT_KEY);
+        final MultiMap queryParams = RequestUtils.getCleanedQueryParams(context.request());
+
+        // user has been redirected due to success, warn or error, continue
+        if (success != null || warning != null || error != null) {
+            context.next();
+            return;
+        }
+
+        // first time the user resets its password, continue
+        if (user.getLastPasswordReset() == null) {
+            context.next();
+            return;
+        }
+
+        // the current token has already been used for the action
+        // redirect user to the reset password page with an error
+        if (Instant.ofEpochSecond(jwt.getIat()).isBefore(user.getLastPasswordReset().toInstant())) {
+            logger.debug("Token has already been used for reset password action, skip it.");
+            queryParams.set(ConstantKeys.ERROR_PARAM_KEY, "invalid_token");
+            redirectToPage(context, queryParams);
+            return;
+        }
+
+        // continue
+        context.next();
+    }
+}
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/impl/UserServiceImpl.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/impl/UserServiceImpl.java
index eab547af7c..1e800563c5 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/impl/UserServiceImpl.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/impl/UserServiceImpl.java
@@ -37,6 +37,7 @@ import io.gravitee.am.model.factor.EnrolledFactor;
 import io.gravitee.am.model.oidc.Client;
 import io.gravitee.am.repository.management.api.search.LoginAttemptCriteria;
 import io.gravitee.am.service.AuditService;
+import io.gravitee.am.service.CredentialService;
 import io.gravitee.am.service.LoginAttemptService;
 import io.gravitee.am.service.exception.*;
 import io.gravitee.am.service.reporter.builder.AuditBuilder;
@@ -83,10 +84,19 @@ public class UserServiceImpl implements UserService {
     @Autowired
     private LoginAttemptService loginAttemptService;
 
+    @Autowired
+    private CredentialService credentialService;
+
     @Override
     public Maybe<UserToken> verifyToken(String token) {
         return Maybe.fromCallable(() -> jwtParser.parse(token))
-                .flatMap(jwt -> userService.findById(jwt.getSub()).zipWith(clientSource(jwt.getAud()), (user, optionalClient) -> new UserToken(user, optionalClient.orElse(null))));
+                .flatMap(jwt -> {
+                    return userService.findById(jwt.getSub())
+                            .zipWith(clientSource(jwt.getAud()),
+                                    (user, optionalClient) -> {
+                                return new UserToken(user, optionalClient.orElse(null), jwt);
+                            });
+                });
     }
 
     @Override
@@ -192,6 +202,9 @@ public class UserServiceImpl implements UserService {
 
     @Override
     public Single<ResetPasswordResponse> resetPassword(Client client, User user, io.gravitee.am.identityprovider.api.User principal) {
+        // get account settings
+        final AccountSettings accountSettings = AccountSettings.getInstance(domain, client);
+
         // if user registration is not completed and force registration option is disabled throw invalid account exception
         if (user.isInactive() && !forceUserRegistration(domain, client)) {
             return Single.error(new AccountInactiveException("User needs to complete the activation process"));
@@ -235,7 +248,6 @@ public class UserServiceImpl implements UserService {
                     // additional information
                     extractAdditionalInformation(user, idpUser.getAdditionalInformation());
                     // set login information
-                    AccountSettings accountSettings = AccountSettings.getInstance(domain, client);
                     if (accountSettings != null && accountSettings.isAutoLoginAfterResetPassword()) {
                         user.setLoggedAt(new Date());
                         user.setLoginsCount(user.getLoginsCount() + 1);
@@ -251,11 +263,16 @@ public class UserServiceImpl implements UserService {
                             .build();
                     return loginAttemptService.reset(criteria).andThen(Single.just(user1));
                 })
-                .flatMap(userService::enhance)
-                .map(user1 -> {
-                    AccountSettings accountSettings = AccountSettings.getInstance(domain, client);
-                    return new ResetPasswordResponse(user1, accountSettings != null ? accountSettings.getRedirectUriAfterResetPassword() : null, accountSettings != null ? accountSettings.isAutoLoginAfterResetPassword() : false);
+                // delete passwordless devices
+                .flatMap(user1 -> {
+                    if (accountSettings != null && accountSettings.isDeletePasswordlessDevicesAfterResetPassword()) {
+                        return credentialService.deleteByUserId(user1.getReferenceType(), user1.getReferenceId(), user1.getId())
+                                .andThen(Single.just(user1));
+                    }
+                    return Single.just(user1);
                 })
+                .flatMap(userService::enhance)
+                .map(user1 -> new ResetPasswordResponse(user1, accountSettings != null ? accountSettings.getRedirectUriAfterResetPassword() : null, accountSettings != null ? accountSettings.isAutoLoginAfterResetPassword() : false))
                 .doOnSuccess(response -> auditService.report(AuditBuilder.builder(UserAuditBuilder.class).domain(domain.getId()).client(user.getClient()).principal(principal).type(EventType.USER_PASSWORD_RESET)))
                 .doOnError(throwable -> auditService.report(AuditBuilder.builder(UserAuditBuilder.class).domain(domain.getId()).client(user.getClient()).principal(principal).type(EventType.USER_PASSWORD_RESET).throwable(throwable)));
     }
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/model/UserToken.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/model/UserToken.java
index f41b807e70..9bb76321fd 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/model/UserToken.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/root/service/user/model/UserToken.java
@@ -15,6 +15,7 @@
  */
 package io.gravitee.am.gateway.handler.root.service.user.model;
 
+import io.gravitee.am.common.jwt.JWT;
 import io.gravitee.am.model.oidc.Client;
 import io.gravitee.am.model.User;
 
@@ -26,10 +27,12 @@ public class UserToken {
 
     private final User user;
     private final Client client;
+    private final JWT token;
 
-    public UserToken(User user, Client client) {
+    public UserToken(User user, Client client, JWT token) {
         this.user = user;
         this.client = client;
+        this.token = token;
     }
 
     public User getUser() {
@@ -39,4 +42,8 @@ public class UserToken {
     public Client getClient() {
         return client;
     }
+
+    public JWT getToken() {
+        return token;
+    }
 }
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/vertx/auth/webauthn/store/RepositoryCredentialStore.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/vertx/auth/webauthn/store/RepositoryCredentialStore.java
index 56aa5a7dfb..3b72ace57c 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/vertx/auth/webauthn/store/RepositoryCredentialStore.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/main/java/io/gravitee/am/gateway/handler/vertx/auth/webauthn/store/RepositoryCredentialStore.java
@@ -32,9 +32,10 @@ import io.vertx.core.Promise;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
 
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.*;
 import java.util.stream.Collectors;
 
 /**
@@ -70,15 +71,41 @@ public class RepositoryCredentialStore {
                         return Single.zip(
                                 generateCredID(query.getUserName(), Claims.sub),
                                 generateCredID(query.getUserName(), StandardClaims.PREFERRED_USERNAME), (part1, part2) -> {
-                                    Authenticator authenticator = new Authenticator();
-                                    authenticator.setUserName(query.getUserName());
-                                    String credID = part2 + part1;
-                                    if (credID.length() > 86) {
-                                        // 86 characters is the length of a CredID for some devices
-                                        credID = credID.substring(0, 86);
+                                    MessageDigest md = MessageDigest.getInstance("SHA-512");
+                                    SecureRandom secureRandom = SecureRandom.getInstance("SHA1PRNG");
+                                    secureRandom.setSeed(part1.getBytes());
+                                    int nbDevices = secureRandom.nextInt(3) + 1;
+                                    int deviceType = secureRandom.nextInt(2) + 1;
+                                    List<Authenticator> authenticators = new ArrayList<>(nbDevices);
+                                    for (int i = 0; i < nbDevices; i++) {
+                                        byte[] salt = new byte[16];
+                                        secureRandom.nextBytes(salt);
+                                        md.update(salt);
+                                        String initialValue = shiftValue(part2, i);
+                                        Authenticator authenticator = new Authenticator();
+                                        authenticator.setUserName(query.getUserName());
+                                        if (deviceType == 1) {
+                                            if (i < 2) {
+                                                if (initialValue.length() > 27) {
+                                                    initialValue = initialValue.substring(0, 27);
+                                                }
+                                                authenticator.setCredID(initialValue);
+                                            } else {
+                                                authenticator.setCredID(createCredID(md, initialValue, part1));
+                                            }
+                                        } else {
+                                            if (i < 2) {
+                                                authenticator.setCredID(createCredID(md, initialValue, part1));
+                                            } else {
+                                                if (initialValue.length() > 27) {
+                                                    initialValue = initialValue.substring(0, 27);
+                                                }
+                                                authenticator.setCredID(initialValue);
+                                            }
+                                        }
+                                        authenticators.add(authenticator);
                                     }
-                                    authenticator.setCredID(credID);
-                                    return Collections.singletonList(authenticator);
+                                    return authenticators;
                                 });
                     } else {
                         return Single.just(credentials
@@ -151,4 +178,27 @@ public class RepositoryCredentialStore {
             emitter.onSuccess(credID);
         });
     }
+
+    private static String createCredID(MessageDigest md, String input, String suffix) {
+        String result = Base64.getUrlEncoder().encodeToString(md.digest(input.getBytes(StandardCharsets.UTF_8))).replace("=", "") + suffix;
+        if (result.length() > 87) {
+            result = result.substring(0, 87);
+        }
+        return result;
+    }
+
+    private static String shiftValue(String input, int delta) {
+        String credID = "";
+        char c;
+        for (int j = 0; j < input.length(); j++) {
+            c = input.charAt(j);
+            char deltaC = (char) (c + delta);
+            if (Character.isLetterOrDigit(deltaC)) {
+                credID += deltaC;
+            } else {
+                credID += c;
+            }
+        }
+        return credID;
+    }
 }
diff --git a/gravitee-am-model/src/main/java/io/gravitee/am/model/account/AccountSettings.java b/gravitee-am-model/src/main/java/io/gravitee/am/model/account/AccountSettings.java
index 857a9256ef..8ba3b4ee0d 100644
--- a/gravitee-am-model/src/main/java/io/gravitee/am/model/account/AccountSettings.java
+++ b/gravitee-am-model/src/main/java/io/gravitee/am/model/account/AccountSettings.java
@@ -90,6 +90,11 @@ public class AccountSettings {
      */
     private String redirectUriAfterResetPassword;
 
+    /**
+     * Delete passwordless devices after reset password process
+     */
+    private boolean deletePasswordlessDevicesAfterResetPassword;
+
     public AccountSettings() {
     }
 
@@ -106,6 +111,7 @@ public class AccountSettings {
         this.dynamicUserRegistration = other.dynamicUserRegistration;
         this.autoLoginAfterResetPassword = other.autoLoginAfterResetPassword;
         this.redirectUriAfterResetPassword = other.redirectUriAfterResetPassword;
+        this.deletePasswordlessDevicesAfterResetPassword = other.deletePasswordlessDevicesAfterResetPassword;
     }
 
     public boolean isInherited() {
@@ -212,6 +218,14 @@ public class AccountSettings {
         this.redirectUriAfterResetPassword = redirectUriAfterResetPassword;
     }
 
+    public boolean isDeletePasswordlessDevicesAfterResetPassword() {
+        return deletePasswordlessDevicesAfterResetPassword;
+    }
+
+    public void setDeletePasswordlessDevicesAfterResetPassword(boolean deletePasswordlessDevicesAfterResetPassword) {
+        this.deletePasswordlessDevicesAfterResetPassword = deletePasswordlessDevicesAfterResetPassword;
+    }
+
     public static AccountSettings getInstance(Domain domain, Client client) {
         if (client == null) {
             return domain.getAccountSettings();
diff --git a/gravitee-am-model/src/main/java/io/gravitee/am/model/oidc/OIDCSettings.java b/gravitee-am-model/src/main/java/io/gravitee/am/model/oidc/OIDCSettings.java
index 3d3c58d06f..c81f9eba96 100644
--- a/gravitee-am-model/src/main/java/io/gravitee/am/model/oidc/OIDCSettings.java
+++ b/gravitee-am-model/src/main/java/io/gravitee/am/model/oidc/OIDCSettings.java
@@ -15,6 +15,8 @@
  */
 package io.gravitee.am.model.oidc;
 
+import java.util.List;
+
 /**
  * @author Alexandre FARIA (contact at alexandrefaria.net)
  * @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
@@ -29,6 +31,11 @@ public class OIDCSettings {
      */
     private boolean redirectUriStrictMatching;
 
+    /**
+     * Array of URLs supplied by the RP to which it MAY request that the End-User's User Agent be redirected using the post_logout_redirect_uri parameter after a logout has been performed.
+     */
+    private List<String> postLogoutRedirectUris;
+
     public ClientRegistrationSettings getClientRegistrationSettings() {
         return clientRegistrationSettings!=null?clientRegistrationSettings: ClientRegistrationSettings.defaultSettings();
     }
@@ -45,6 +52,14 @@ public class OIDCSettings {
         this.redirectUriStrictMatching = redirectUriStrictMatching;
     }
 
+    public List<String> getPostLogoutRedirectUris() {
+        return postLogoutRedirectUris;
+    }
+
+    public void setPostLogoutRedirectUris(List<String> postLogoutRedirectUris) {
+        this.postLogoutRedirectUris = postLogoutRedirectUris;
+    }
+
     public static OIDCSettings defaultSettings() {
         OIDCSettings defaultSettings = new OIDCSettings();
         defaultSettings.setClientRegistrationSettings(ClientRegistrationSettings.defaultSettings());
diff --git a/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/management/api/CredentialRepository.java b/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/management/api/CredentialRepository.java
index 58d7a09dc4..4815132d5f 100644
--- a/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/management/api/CredentialRepository.java
+++ b/gravitee-am-repository/gravitee-am-repository-api/src/main/java/io/gravitee/am/repository/management/api/CredentialRepository.java
@@ -18,6 +18,7 @@ package io.gravitee.am.repository.management.api;
 import io.gravitee.am.model.Credential;
 import io.gravitee.am.model.ReferenceType;
 import io.gravitee.am.repository.common.CrudRepository;
+import io.reactivex.Completable;
 import io.reactivex.Single;
 
 import java.util.List;
@@ -33,4 +34,6 @@ public interface CredentialRepository extends CrudRepository<Credential, String>
     Single<List<Credential>> findByUsername(ReferenceType referenceType, String referenceId, String username);
 
     Single<List<Credential>> findByCredentialId(ReferenceType referenceType, String referenceId, String credentialId);
+
+    Completable deleteByUserId(ReferenceType referenceType, String referenceId, String userId);
 }
diff --git a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepository.java b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepository.java
index f2d3f53a7f..fb5661c33f 100644
--- a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepository.java
+++ b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepository.java
@@ -116,6 +116,19 @@ public class MongoCredentialRepository extends AbstractManagementMongoRepository
     public Completable delete(String id) {
         return Completable.fromPublisher(credentialsCollection.deleteOne(eq(FIELD_ID, id)));
     }
+
+    @Override
+    public Completable deleteByUserId(ReferenceType referenceType, String referenceId, String userId) {
+        return Completable.fromPublisher(
+                credentialsCollection.deleteMany(
+                        and(
+                                eq(FIELD_REFERENCE_TYPE, referenceType.name()),
+                                eq(FIELD_REFERENCE_ID, referenceId),
+                                eq(FIELD_USER_ID, userId)
+                        )
+                ));
+    }
+
     private Credential convert(CredentialMongo credentialMongo) {
         if (credentialMongo == null) {
             return null;
diff --git a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/MongoDomainRepository.java b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/MongoDomainRepository.java
index c25248af7a..1a0c09cdc1 100644
--- a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/MongoDomainRepository.java
+++ b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/MongoDomainRepository.java
@@ -163,6 +163,7 @@ public class MongoDomainRepository extends AbstractManagementMongoRepository imp
         OIDCSettings oidcSettings = new OIDCSettings();
         oidcSettings.setRedirectUriStrictMatching(oidcMongo.isRedirectUriStrictMatching());
         oidcSettings.setClientRegistrationSettings(convert(oidcMongo.getClientRegistrationSettings()));
+        oidcSettings.setPostLogoutRedirectUris(oidcMongo.getPostLogoutRedirectUris());
 
         return oidcSettings;
     }
@@ -204,6 +205,7 @@ public class MongoDomainRepository extends AbstractManagementMongoRepository imp
         OIDCSettingsMongo oidcSettings = new OIDCSettingsMongo();
         oidcSettings.setRedirectUriStrictMatching(oidc.isRedirectUriStrictMatching());
         oidcSettings.setClientRegistrationSettings(convert(oidc.getClientRegistrationSettings()));
+        oidcSettings.setPostLogoutRedirectUris(oidc.getPostLogoutRedirectUris());
 
         return oidcSettings;
     }
diff --git a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/internal/model/AccountSettingsMongo.java b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/internal/model/AccountSettingsMongo.java
index 5ec355016b..f51272775a 100644
--- a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/internal/model/AccountSettingsMongo.java
+++ b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/internal/model/AccountSettingsMongo.java
@@ -36,6 +36,7 @@ public class AccountSettingsMongo {
     private String defaultIdentityProviderForRegistration;
     private boolean autoLoginAfterResetPassword;
     private String redirectUriAfterResetPassword;
+    private boolean deletePasswordlessDevicesAfterResetPassword;
 
     public boolean isInherited() {
         return inherited;
@@ -141,6 +142,14 @@ public class AccountSettingsMongo {
         this.redirectUriAfterResetPassword = redirectUriAfterResetPassword;
     }
 
+    public boolean isDeletePasswordlessDevicesAfterResetPassword() {
+        return deletePasswordlessDevicesAfterResetPassword;
+    }
+
+    public void setDeletePasswordlessDevicesAfterResetPassword(boolean deletePasswordlessDevicesAfterResetPassword) {
+        this.deletePasswordlessDevicesAfterResetPassword = deletePasswordlessDevicesAfterResetPassword;
+    }
+
     public AccountSettings convert() {
         AccountSettings accountSettings = new AccountSettings();
         accountSettings.setInherited(isInherited());
@@ -156,6 +165,7 @@ public class AccountSettingsMongo {
         accountSettings.setAutoLoginAfterResetPassword(isAutoLoginAfterResetPassword());
         accountSettings.setRedirectUriAfterResetPassword(getRedirectUriAfterResetPassword());
         accountSettings.setSendRecoverAccountEmail(isSendRecoverAccountEmail());
+        accountSettings.setDeletePasswordlessDevicesAfterResetPassword(isDeletePasswordlessDevicesAfterResetPassword());
         return accountSettings;
     }
 
@@ -177,6 +187,7 @@ public class AccountSettingsMongo {
         accountSettingsMongo.setAutoLoginAfterResetPassword(accountSettings.isAutoLoginAfterResetPassword());
         accountSettingsMongo.setRedirectUriAfterResetPassword(accountSettings.getRedirectUriAfterResetPassword());
         accountSettingsMongo.setSendRecoverAccountEmail(accountSettings.isSendRecoverAccountEmail());
+        accountSettingsMongo.setDeletePasswordlessDevicesAfterResetPassword(accountSettings.isDeletePasswordlessDevicesAfterResetPassword());
         return accountSettingsMongo;
     }
 }
diff --git a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/internal/model/oidc/OIDCSettingsMongo.java b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/internal/model/oidc/OIDCSettingsMongo.java
index 682219e4c2..19876bf930 100644
--- a/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/internal/model/oidc/OIDCSettingsMongo.java
+++ b/gravitee-am-repository/gravitee-am-repository-mongodb/src/main/java/io/gravitee/am/repository/mongodb/management/internal/model/oidc/OIDCSettingsMongo.java
@@ -15,6 +15,8 @@
  */
 package io.gravitee.am.repository.mongodb.management.internal.model.oidc;
 
+import java.util.List;
+
 /**
  * @author Alexandre FARIA (contact at alexandrefaria.net)
  * @author GraviteeSource Team
@@ -23,6 +25,7 @@ public class OIDCSettingsMongo {
 
     private ClientRegistrationSettingsMongo clientRegistrationSettings;
     private boolean redirectUriStrictMatching;
+    private List<String> postLogoutRedirectUris;
 
     public ClientRegistrationSettingsMongo getClientRegistrationSettings() {
         return clientRegistrationSettings;
@@ -39,4 +42,12 @@ public class OIDCSettingsMongo {
     public void setRedirectUriStrictMatching(boolean redirectUriStrictMatching) {
         this.redirectUriStrictMatching = redirectUriStrictMatching;
     }
+
+    public List<String> getPostLogoutRedirectUris() {
+        return postLogoutRedirectUris;
+    }
+
+    public void setPostLogoutRedirectUris(List<String> postLogoutRedirectUris) {
+        this.postLogoutRedirectUris = postLogoutRedirectUris;
+    }
 }
diff --git a/gravitee-am-service/src/main/java/io/gravitee/am/service/CredentialService.java b/gravitee-am-service/src/main/java/io/gravitee/am/service/CredentialService.java
index 2eeeab7158..85dec4ba13 100644
--- a/gravitee-am-service/src/main/java/io/gravitee/am/service/CredentialService.java
+++ b/gravitee-am-service/src/main/java/io/gravitee/am/service/CredentialService.java
@@ -44,4 +44,7 @@ public interface CredentialService {
     Completable update(ReferenceType referenceType, String referenceId, String credentialId, Credential credential);
 
     Completable delete(String id);
+
+    Completable deleteByUserId(ReferenceType referenceType, String referenceId, String userId);
+
 }
diff --git a/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/CredentialServiceImpl.java b/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/CredentialServiceImpl.java
index d1003e1363..4efc5282a3 100644
--- a/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/CredentialServiceImpl.java
+++ b/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/CredentialServiceImpl.java
@@ -152,4 +152,18 @@ public class CredentialServiceImpl implements CredentialService {
                             String.format("An error occurs while trying to delete credential: %s", id), ex));
                 });
     }
+
+    @Override
+    public Completable deleteByUserId(ReferenceType referenceType, String referenceId, String userId) {
+        LOGGER.debug("Delete credentials by {} {} and user id: {}", referenceType, referenceId, userId);
+        return credentialRepository.deleteByUserId(referenceType, referenceId, userId)
+                .onErrorResumeNext(ex -> {
+                    if (ex instanceof AbstractManagementException) {
+                        return Completable.error(ex);
+                    }
+                    LOGGER.error("An error has occurred while trying to delete credentials using {} {} and user id: {}", referenceType, referenceId, userId, ex);
+                    return Completable.error(new TechnicalManagementException(
+                            String.format("An error has occurred while trying to delete credentials using: %s %s and user id: %s", referenceType, referenceId, userId), ex));
+                });
+    }
 }
diff --git a/gravitee-am-service/src/main/java/io/gravitee/am/service/model/openid/PatchOIDCSettings.java b/gravitee-am-service/src/main/java/io/gravitee/am/service/model/openid/PatchOIDCSettings.java
index f5998e72d4..c10a41bead 100644
--- a/gravitee-am-service/src/main/java/io/gravitee/am/service/model/openid/PatchOIDCSettings.java
+++ b/gravitee-am-service/src/main/java/io/gravitee/am/service/model/openid/PatchOIDCSettings.java
@@ -22,6 +22,7 @@ import io.gravitee.am.model.permissions.Permission;
 import io.gravitee.am.service.utils.SetterUtils;
 
 import java.util.HashSet;
+import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 
@@ -38,6 +39,8 @@ public class PatchOIDCSettings {
 
     private Optional<Boolean> redirectUriStrictMatching;
 
+    private Optional<List<String>> postLogoutRedirectUris;
+
     public Optional<PatchClientRegistrationSettings> getClientRegistrationSettings() {
         return clientRegistrationSettings;
     }
@@ -54,6 +57,14 @@ public class PatchOIDCSettings {
         this.redirectUriStrictMatching = redirectUriStrictMatching;
     }
 
+    public Optional<List<String>> getPostLogoutRedirectUris() {
+        return postLogoutRedirectUris;
+    }
+
+    public void setPostLogoutRedirectUris(Optional<List<String>> postLogoutRedirectUris) {
+        this.postLogoutRedirectUris = postLogoutRedirectUris;
+    }
+
     public OIDCSettings patch(OIDCSettings toPatch) {
 
         //If source may be null, in such case init with default values
@@ -61,6 +72,7 @@ public class PatchOIDCSettings {
             toPatch = OIDCSettings.defaultSettings();
         }
         SetterUtils.safeSet(toPatch::setRedirectUriStrictMatching, this.getRedirectUriStrictMatching(), boolean.class);
+        SetterUtils.safeSet(toPatch::setPostLogoutRedirectUris, this.getPostLogoutRedirectUris());
 
         if(getClientRegistrationSettings()!=null) {
             //If present apply settings, else return default settings.
diff --git a/gravitee-am-ui/src/app/domain/components/account/account-settings.component.html b/gravitee-am-ui/src/app/domain/components/account/account-settings.component.html
index c249bbde90..d369bffc53 100644
--- a/gravitee-am-ui/src/app/domain/components/account/account-settings.component.html
+++ b/gravitee-am-ui/src/app/domain/components/account/account-settings.component.html
@@ -28,10 +28,9 @@
   </div>
 
   <div *ngIf="!inheritMode || !accountSettings.inherited">
-
     <div class="gv-form-section">
       <div class="gv-form-section-title">
-        <h5>Security</h5>
+        <h5>Login</h5>
         <mat-divider></mat-divider>
       </div>
       <div>
@@ -83,28 +82,35 @@
             </mat-select>
           </mat-form-field>
         </div>
+        <div fxLayout="column" style="margin-top: 10px;">
+          <mat-slide-toggle
+            (change)="enableSendRecoverAccountEmail($event)"
+            [checked]="isSendRecoverAccountEmailEnabled()" [disabled]="readonly">
+            Send account recovery email
+          </mat-slide-toggle>
+          <mat-hint style="font-size: 75%;">Send an email if user account has been blocked.</mat-hint>
+        </div>
       </div>
     </div>
 
     <div class="gv-form-section">
       <div class="gv-form-section-title">
-        <h5>Management</h5>
+        <h5>User registration</h5>
         <mat-divider></mat-divider>
       </div>
       <div>
-        <mat-form-field>
-        <mat-select placeholder="Identity provider" name="defaultIdentityProviderForRegistration" [(ngModel)]="accountSettings.defaultIdentityProviderForRegistration" (ngModelChange)="updateModel()">
-          <mat-option>None</mat-option>
-          <mat-option *ngFor="let userProvider of userProviders" [value]="userProvider.id">{{userProvider.name}}</mat-option>
-        </mat-select>
-        <mat-hint>Default identity provider for user registration. The user will be stored in the selected identity provider instead of the default one.</mat-hint>
-      </mat-form-field>
-    </div>
-
-    <div class="account-settings-zone">
-      <div fxLayout="column">
+        <div fxLayout="column">
+          <mat-form-field appearance="outline">
+            <mat-select placeholder="Identity provider" name="defaultIdentityProviderForRegistration" [(ngModel)]="accountSettings.defaultIdentityProviderForRegistration" (ngModelChange)="updateModel()">
+              <mat-option>None</mat-option>
+              <mat-option *ngFor="let userProvider of userProviders" [value]="userProvider.id">{{userProvider.name}}</mat-option>
+            </mat-select>
+            <mat-hint>Default identity provider for user registration. The user will be stored in the selected identity provider instead of the default one.</mat-hint>
+          </mat-form-field>
+        </div>
+        <div fxLayout="column">
           <mat-slide-toggle
-              (change)="enableDynamicUserRegistration($event)"
+            (change)="enableDynamicUserRegistration($event)"
             [checked]="isDynamicUserRegistrationEnabled()">
             Dynamic user registration
           </mat-slide-toggle>
@@ -112,9 +118,9 @@
         </div>
         <div fxLayout="column" style="margin-top: 10px;">
           <mat-slide-toggle
-          (change)="enableCompleteRegistration($event)"
+            (change)="enableCompleteRegistration($event)"
             [checked]="isCompleteRegistrationEnabled()" [disabled]="readonly">
-            Complete user registration (Reset Password)
+            Complete user registration (reset password)
           </mat-slide-toggle>
           <mat-hint style="font-size: 75%;">If a user renewed his password without completing his registration, finalize the account. The default behavior is to display an error to the user.</mat-hint>
         </div>
@@ -125,31 +131,44 @@
             Auto login after user registration
           </mat-slide-toggle>
           <mat-hint style="font-size: 75%;">Automatically login user after registration process.</mat-hint>
+        </div>
+        <div fxLayout="column">
           <mat-form-field appearance="outline" floatLabel="always">
             <input matInput placeholder="Registration redirect URI" name="autoLoginRegistration" [(ngModel)]="accountSettings.redirectUriAfterRegistration" (ngModelChange)="updateModel()" [disabled]="readonly">
             <mat-hint>After complete its registration the user can be redirected to the specified URL. The default behavior is to display the registration confirmation page to the user.</mat-hint>
           </mat-form-field>
         </div>
-        <div fxLayout="column" style="margin-top: 20px;">
+      </div>
+    </div>
+
+    <div class="gv-form-section">
+      <div class="gv-form-section-title">
+        <h5>Reset password</h5>
+        <mat-divider></mat-divider>
+      </div>
+      <div>
+        <div fxLayout="column">
+          <mat-slide-toggle
+            (change)="enableDeletePasswordlessDevicesAfterResetPassword($event)"
+            [checked]="isDeletePasswordlessDevicesAfterResetPasswordEnabled()" [disabled]="readonly">
+            Delete passwordless devices
+          </mat-slide-toggle>
+          <mat-hint style="font-size: 75%;">Automatically delete passswordless devices after reset password process.</mat-hint>
+        </div>
+        <div fxLayout="column" style="margin-top: 10px;">
           <mat-slide-toggle
             (change)="enableAutoLoginAfterResetPassword($event)"
             [checked]="isAutoLoginAfterResetPasswordEnabled()" [disabled]="readonly">
             Auto login after reset password
           </mat-slide-toggle>
           <mat-hint style="font-size: 75%;">Automatically login user after reset password process.</mat-hint>
+        </div>
+        <div fxLayout="column">
           <mat-form-field appearance="outline" floatLabel="always">
             <input matInput placeholder="Reset password redirect URI" name="autoLoginResetPassword" [(ngModel)]="accountSettings.redirectUriAfterResetPassword" (ngModelChange)="updateModel()" [disabled]="readonly">
             <mat-hint>After reset its password the user can be redirected to the specified URL. The default behavior is to display the reset password page to the user.</mat-hint>
           </mat-form-field>
         </div>
-        <div fxLayout="column" style="margin-top: 10px;">
-          <mat-slide-toggle
-            (change)="enableSendRecoverAccountEmail($event)"
-            [checked]="isSendRecoverAccountEmailEnabled()" [disabled]="readonly">
-            Send account recovery email
-          </mat-slide-toggle>
-          <mat-hint style="font-size: 75%;">Send an email if user account has been blocked.</mat-hint>
-        </div>
       </div>
     </div>
   </div>
diff --git a/gravitee-am-ui/src/app/domain/components/account/account-settings.component.ts b/gravitee-am-ui/src/app/domain/components/account/account-settings.component.ts
index 0c630d6d0e..2515c37020 100644
--- a/gravitee-am-ui/src/app/domain/components/account/account-settings.component.ts
+++ b/gravitee-am-ui/src/app/domain/components/account/account-settings.component.ts
@@ -144,6 +144,15 @@ export class AccountSettingsComponent implements OnInit, OnChanges {
     return this.accountSettings && this.accountSettings.sendRecoverAccountEmail;
   }
 
+  enableDeletePasswordlessDevicesAfterResetPassword(event) {
+    this.accountSettings.deletePasswordlessDevicesAfterResetPassword = event.checked;
+    this.formChanged = true;
+  }
+
+  isDeletePasswordlessDevicesAfterResetPasswordEnabled() {
+    return this.accountSettings && this.accountSettings.deletePasswordlessDevicesAfterResetPassword;
+  }
+
   updateModel() {
     this.formChanged = true;
   }
diff --git a/gravitee-am-ui/src/app/domain/settings/general/general.component.html b/gravitee-am-ui/src/app/domain/settings/general/general.component.html
index 3e8e0b9078..11f4bac569 100644
--- a/gravitee-am-ui/src/app/domain/settings/general/general.component.html
+++ b/gravitee-am-ui/src/app/domain/settings/general/general.component.html
@@ -41,6 +41,47 @@
           </mat-form-field>
         </div>
 
+        <div class="gv-form-section">
+          <div class="gv-form-section-title">
+            <h5>Post logout redirect URIs</h5>
+            <small>List of allowed URLs where the user will be redirected after being sign out.
+              It prevents some vulnerabilities like being redirected to unsafe websites.</small>
+            <mat-divider></mat-divider>
+          </div>
+          <div fxLayout="column">
+            <div fxLayout="row" fxLayoutGap="10px" *hasPermission="['domain_settings_update']">
+              <div fxFlex="90">
+                <mat-form-field appearance="outline" floatLabel="always">
+                  <input matInput type="text" placeholder="Enter a redirect URI" name="logoutRedirectUri" (keydown.enter)="addLogoutRedirectUris($event)" [value]="logoutRedirectUri || ''" (input)='logoutRedirectUri = $event.target.value'>
+                </mat-form-field>
+              </div>
+              <div>
+                <button mat-stroked-button style="margin-top: 7px;" (click)="addLogoutRedirectUris($event)" [disabled]="!logoutRedirectUri">+ ADD</button>
+              </div>
+            </div>
+            <ngx-datatable class="material"
+                           [columnMode]="'flex'"
+                           [headerHeight]="40"
+                           [footerHeight]="40"
+                           [rowHeight]="50"
+                           [messages]="{emptyMessage:'There is no redirect URIs'}"
+                           [rows]="logoutRedirectUris">
+              <ngx-datatable-column name="Redirect URIs" [flexGrow]="4">
+                <ng-template let-row="row" ngx-datatable-cell-template>
+                  {{row.value}}
+                </ng-template>
+              </ngx-datatable-column>
+              <ngx-datatable-column name="" [flexGrow]="1" *hasPermission="['domain_settings_update']">
+                <ng-template let-row="row" ngx-datatable-cell-template>
+                  <div fxLayout="row" class="gv-table-cell-actions">
+                    <button mat-icon-button (click)="deleteLogoutRedirectUris(row.value, $event)"><mat-icon>close</mat-icon></button>
+                  </div>
+                </ng-template>
+              </ngx-datatable-column>
+            </ngx-datatable>
+          </div>
+        </div>
+
         <div class="gv-form-section">
           <div class="gv-form-section-title">
             <h5>Deployment</h5>
diff --git a/gravitee-am-ui/src/app/domain/settings/general/general.component.scss b/gravitee-am-ui/src/app/domain/settings/general/general.component.scss
index e69de29bb2..65f32b87dc 100644
--- a/gravitee-am-ui/src/app/domain/settings/general/general.component.scss
+++ b/gravitee-am-ui/src/app/domain/settings/general/general.component.scss
@@ -0,0 +1,5 @@
+:host ::ng-deep {
+  .ngx-datatable.material .datatable-footer {
+    display: none;
+  }
+}
diff --git a/gravitee-am-ui/src/app/domain/settings/general/general.component.ts b/gravitee-am-ui/src/app/domain/settings/general/general.component.ts
index f67bc6fe54..2dac317e53 100644
--- a/gravitee-am-ui/src/app/domain/settings/general/general.component.ts
+++ b/gravitee-am-ui/src/app/domain/settings/general/general.component.ts
@@ -38,9 +38,12 @@ export class DomainSettingsGeneralComponent implements OnInit {
   @ViewChild('chipInput') chipInput: MatInput;
   formChanged = false;
   domain: any = {};
+  domainOIDCSettings: any = {};
   tags: Tag[];
   selectedTags: Tag[];
   readonly = false;
+  logoutRedirectUri: string;
+  logoutRedirectUris: any[] = [];
 
   constructor(private domainService: DomainService,
               private dialogService: DialogService,
@@ -54,11 +57,11 @@ export class DomainSettingsGeneralComponent implements OnInit {
 
   ngOnInit() {
     this.domain = this.route.snapshot.parent.data['domain'];
-
-    if(this.domain.tags === undefined) {
+    this.domainOIDCSettings = this.domain.oidc || {};
+    this.logoutRedirectUris = _.map(this.domainOIDCSettings.postLogoutRedirectUris, function (item) { return { value: item }; });
+    if (this.domain.tags === undefined) {
       this.domain.tags = [];
     }
-
     this.readonly = !this.authService.hasPermissions(['domain_settings_update']);
     this.initTags();
   }
@@ -91,7 +94,35 @@ export class DomainSettingsGeneralComponent implements OnInit {
     this.formChanged = true;
   }
 
+  addLogoutRedirectUris(event) {
+    event.preventDefault();
+    if (this.logoutRedirectUri) {
+      if (!this.logoutRedirectUris.some(el => el.value === this.logoutRedirectUri)) {
+        this.logoutRedirectUris.push({value: this.logoutRedirectUri});
+        this.logoutRedirectUris = [...this.logoutRedirectUris];
+        this.logoutRedirectUri = null;
+        this.formChanged = true;
+      } else {
+        this.snackbarService.open(`Error : redirect URI "${this.logoutRedirectUri}" already exists`);
+      }
+    }
+  }
+
+  deleteLogoutRedirectUris(logoutRedirectUri, event) {
+    event.preventDefault();
+    this.dialogService
+      .confirm('Remove logout redirect URI', 'Are you sure you want to remove this redirect URI ?')
+      .subscribe(res => {
+        if (res) {
+          _.remove(this.logoutRedirectUris, { value: logoutRedirectUri });
+          this.logoutRedirectUris = [...this.logoutRedirectUris];
+          this.formChanged = true;
+        }
+      });
+  }
+
   update() {
+    this.domain.oidc = { 'postLogoutRedirectUris' : _.map(this.logoutRedirectUris, 'value') };
     this.domainService.patchGeneralSettings(this.domain.id, this.domain).subscribe(response => {
       this.domain = response;
       this.domainService.notify(this.domain);
diff --git a/gravitee-am-ui/src/app/services/domain.service.ts b/gravitee-am-ui/src/app/services/domain.service.ts
index 58f8f7e758..9863d83c1d 100644
--- a/gravitee-am-ui/src/app/services/domain.service.ts
+++ b/gravitee-am-ui/src/app/services/domain.service.ts
@@ -72,7 +72,8 @@ export class DomainService {
       'description': domain.description,
       'path': domain.path,
       'enabled': domain.enabled,
-      'tags': domain.tags
+      'tags': domain.tags,
+      'oidc': domain.oidc
     });
   }
 


Test patch:
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/resources/endpoint/logout/LogoutEndpointHandlerTest.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/resources/endpoint/logout/LogoutEndpointHandlerTest.java
index 0b6f07aafc..dbefd24e7a 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/resources/endpoint/logout/LogoutEndpointHandlerTest.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/resources/endpoint/logout/LogoutEndpointHandlerTest.java
@@ -23,6 +23,7 @@ import io.gravitee.am.gateway.handler.root.resources.handler.error.ErrorHandler;
 import io.gravitee.am.model.Domain;
 import io.gravitee.am.model.User;
 import io.gravitee.am.model.oidc.Client;
+import io.gravitee.am.model.oidc.OIDCSettings;
 import io.gravitee.am.service.AuditService;
 import io.gravitee.am.service.TokenService;
 import io.gravitee.common.http.HttpStatusCode;
@@ -184,7 +185,7 @@ public class LogoutEndpointHandlerTest extends RxWebTestBase {
                 resp -> {
                     String location = resp.headers().get("location");
                     assertNotNull(location);
-                    assertTrue(location.endsWith("/error?client_id=client-id&error=invalid_request&error_description=The+post_logout_redirect_uri+MUST+match+the+registered+callback+URL+for+this+application"));
+                    assertTrue(location.endsWith("/error?client_id=client-id&error=invalid_request&error_description=The+post_logout_redirect_uri+MUST+match+the+registered+callback+URLs"));
                 },
                 HttpStatusCode.FOUND_302, "Found", null);
     }
@@ -214,7 +215,24 @@ public class LogoutEndpointHandlerTest extends RxWebTestBase {
                 resp -> {
                     String location = resp.headers().get("location");
                     assertNotNull(location);
-                    assertTrue(location.endsWith("/error?client_id=client-id&error=invalid_request&error_description=The+post_logout_redirect_uri+MUST+match+the+registered+callback+URL+for+this+application"));
+                    assertTrue(location.endsWith("/error?client_id=client-id&error=invalid_request&error_description=The+post_logout_redirect_uri+MUST+match+the+registered+callback+URLs"));
+                },
+                HttpStatusCode.FOUND_302, "Found", null);
+    }
+
+    @Test
+    public void shouldInvokeLogoutEndpoint_targetUrl_noClient_restriction_mismatch() throws Exception {
+        OIDCSettings oidcSettings = mock(OIDCSettings.class);
+        when(oidcSettings.getPostLogoutRedirectUris()).thenReturn(Arrays.asList("https://dev"));
+        when(domain.getOidc()).thenReturn(oidcSettings);
+
+        testRequest(
+                HttpMethod.GET, "/logout?target_url=https%3A%2F%2Ftest",
+                null,
+                resp -> {
+                    String location = resp.headers().get("location");
+                    assertNotNull(location);
+                    assertTrue(location.endsWith("/error?error=invalid_request&error_description=The+post_logout_redirect_uri+MUST+match+the+registered+callback+URLs"));
                 },
                 HttpStatusCode.FOUND_302, "Found", null);
     }
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/resources/handler/user/password/ResetPasswordOneTimeTokenHandlerTest.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/resources/handler/user/password/ResetPasswordOneTimeTokenHandlerTest.java
new file mode 100644
index 0000000000..530bc52d94
--- /dev/null
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/resources/handler/user/password/ResetPasswordOneTimeTokenHandlerTest.java
@@ -0,0 +1,117 @@
+/**
+ * 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.gateway.handler.root.resources.handler.user.password;
+
+import io.gravitee.am.common.jwt.JWT;
+import io.gravitee.am.gateway.handler.common.utils.ConstantKeys;
+import io.gravitee.am.gateway.handler.common.vertx.RxWebTestBase;
+import io.gravitee.am.gateway.handler.common.vertx.web.handler.ErrorHandler;
+import io.gravitee.am.model.User;
+import io.vertx.core.http.HttpMethod;
+import io.vertx.reactivex.core.buffer.Buffer;
+import io.vertx.reactivex.ext.web.handler.BodyHandler;
+import org.junit.Test;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+
+/**
+ * @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
+ * @author GraviteeSource Team
+ */
+public class ResetPasswordOneTimeTokenHandlerTest extends RxWebTestBase {
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+
+        router.route("/resetPassword")
+                .handler(BodyHandler.create())
+                .handler(new ResetPasswordOneTimeTokenHandler())
+                .handler(rc -> rc.response().end())
+                .failureHandler(new ErrorHandler());
+    }
+
+    @Test
+    public void shouldHandle_first_time_reset_password() throws Exception {
+        router.route().order(-1).handler(routingContext -> {
+            User endUser = new User();
+            endUser.setLastPasswordReset(null);
+            routingContext.put(ConstantKeys.USER_CONTEXT_KEY, endUser);
+            routingContext.next();
+        });
+
+        testRequest(HttpMethod.POST, "/resetPassword", req -> {
+            Buffer buffer = Buffer.buffer();
+            buffer.appendString("password=password");
+            req.headers().set("content-length", String.valueOf(buffer.length()));
+            req.headers().set("content-type", "application/x-www-form-urlencoded");
+            req.write(buffer);
+        },200, "OK", null);
+    }
+
+    @Test
+    public void shouldHandle_valid_token() throws Exception {
+        router.route().order(-1).handler(routingContext -> {
+            User endUser = new User();
+            endUser.setLastPasswordReset(new Date());
+
+            JWT token = new JWT();
+            token.setIat(Instant.now().plus(1, ChronoUnit.HOURS).getEpochSecond());
+
+            routingContext.put(ConstantKeys.TOKEN_CONTEXT_KEY, token);
+            routingContext.put(ConstantKeys.USER_CONTEXT_KEY, endUser);
+            routingContext.next();
+        });
+
+        testRequest(HttpMethod.POST, "/resetPassword", req -> {
+            Buffer buffer = Buffer.buffer();
+            buffer.appendString("password=password");
+            req.headers().set("content-length", String.valueOf(buffer.length()));
+            req.headers().set("content-type", "application/x-www-form-urlencoded");
+            req.write(buffer);
+        },200, "OK", null);
+    }
+
+    @Test
+    public void shouldNotHandle_invalid_token() throws Exception {
+        router.route().order(-1).handler(routingContext -> {
+            User endUser = new User();
+            endUser.setLastPasswordReset(new Date());
+
+            JWT token = new JWT();
+            token.setIat(Instant.now().minus(1, ChronoUnit.HOURS).getEpochSecond());
+
+            routingContext.put(ConstantKeys.TOKEN_CONTEXT_KEY, token);
+            routingContext.put(ConstantKeys.USER_CONTEXT_KEY, endUser);
+            routingContext.next();
+        });
+
+        testRequest(HttpMethod.POST, "/resetPassword", req -> {
+            Buffer buffer = Buffer.buffer();
+            buffer.appendString("password=password");
+            req.headers().set("content-length", String.valueOf(buffer.length()));
+            req.headers().set("content-type", "application/x-www-form-urlencoded");
+            req.write(buffer);
+        }, resp -> {
+            String location = resp.headers().get("location");
+            assertNotNull(location);
+            assertTrue(location.contains("error=invalid_token"));
+        },302, "Found", null);
+    }
+
+}
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/service/user/UserServiceTest.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/service/user/UserServiceTest.java
index 4ef387d079..5253ace3fd 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/service/user/UserServiceTest.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/root/service/user/UserServiceTest.java
@@ -24,6 +24,7 @@ import io.gravitee.am.model.Domain;
 import io.gravitee.am.model.User;
 import io.gravitee.am.model.account.AccountSettings;
 import io.gravitee.am.service.AuditService;
+import io.gravitee.am.service.CredentialService;
 import io.gravitee.am.service.LoginAttemptService;
 import io.gravitee.am.service.exception.UserInvalidException;
 import io.gravitee.am.service.exception.UserNotFoundException;
@@ -65,6 +66,9 @@ public class UserServiceTest {
     @Mock
     private LoginAttemptService loginAttemptService;
 
+    @Mock
+    private CredentialService credentialService;
+
     @Mock
     private AuditService auditService;
 
@@ -108,6 +112,8 @@ public class UserServiceTest {
         TestObserver testObserver = userService.resetPassword(client, user).test();
         testObserver.assertComplete();
         testObserver.assertNoErrors();
+
+        verify(credentialService, never()).deleteByUserId(any(), any(), any());
     }
 
     @Test
@@ -134,6 +140,8 @@ public class UserServiceTest {
         TestObserver testObserver = userService.resetPassword(client, user).test();
         testObserver.assertComplete();
         testObserver.assertNoErrors();
+
+        verify(credentialService, never()).deleteByUserId(any(), any(), any());
     }
 
     @Test
@@ -160,6 +168,8 @@ public class UserServiceTest {
         TestObserver testObserver = userService.resetPassword(client, user).test();
         testObserver.assertComplete();
         testObserver.assertNoErrors();
+
+        verify(credentialService, never()).deleteByUserId(any(), any(), any());
     }
 
     @Test
@@ -187,6 +197,7 @@ public class UserServiceTest {
         testObserver.assertComplete();
         testObserver.assertNoErrors();
         verify(userProvider, times(1)).create(any());
+        verify(credentialService, never()).deleteByUserId(any(), any(), any());
     }
 
     @Test
@@ -355,4 +366,36 @@ public class UserServiceTest {
         testObserver.assertError(UserNotFoundException.class);
     }
 
+    @Test
+    public void shouldResetPassword_delete_passwordless_devices() {
+        AccountSettings accountSettings = mock(AccountSettings.class);
+        when(accountSettings.isDeletePasswordlessDevicesAfterResetPassword()).thenReturn(true);
+
+        Client client = mock(Client.class);
+        when(client.getAccountSettings()).thenReturn(accountSettings);
+
+        User user = mock(User.class);
+        when(user.getUsername()).thenReturn("username");
+        when(user.isInactive()).thenReturn(false);
+        when(user.getSource()).thenReturn("default-idp");
+
+        io.gravitee.am.identityprovider.api.User idpUser = mock(io.gravitee.am.identityprovider.api.DefaultUser.class);
+        when(idpUser.getId()).thenReturn("idp-id");
+
+        UserProvider userProvider = mock(UserProvider.class);
+        when(userProvider.findByUsername(user.getUsername())).thenReturn(Maybe.just(idpUser));
+        when(userProvider.update(anyString(), any())).thenReturn(Single.just(idpUser));
+
+        when(identityProviderManager.getUserProvider(user.getSource())).thenReturn(Maybe.just(userProvider));
+        when(commonUserService.update(any())).thenReturn(Single.just(user));
+        when(commonUserService.enhance(any())).thenReturn(Single.just(user));
+        when(loginAttemptService.reset(any())).thenReturn(Completable.complete());
+        when(credentialService.deleteByUserId(any(), any(), any())).thenReturn(Completable.complete());
+
+        TestObserver testObserver = userService.resetPassword(client, user).test();
+        testObserver.assertComplete();
+        testObserver.assertNoErrors();
+
+        verify(credentialService, times(1)).deleteByUserId(any(), any(), any());
+    }
 }
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/vertx/auth/webauthn/store/RepositoryCredentialStoreTest.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/vertx/auth/webauthn/store/RepositoryCredentialStoreTest.java
index 007d2a5a83..d8d20f5c76 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/vertx/auth/webauthn/store/RepositoryCredentialStoreTest.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-core/src/test/java/io/gravitee/am/gateway/handler/vertx/auth/webauthn/store/RepositoryCredentialStoreTest.java
@@ -61,32 +61,41 @@ public class RepositoryCredentialStoreTest {
 
         when(domain.getId()).thenReturn("domain-id");
         when(credentialService.findByUsername(ReferenceType.DOMAIN, domain.getId(), query.getUserName())).thenReturn(Single.just(Collections.emptyList()));
-        when(jwtBuilder.sign(any())).thenReturn("part1.part2.part3");
+        when(jwtBuilder.sign(any())).thenReturn("part1.part2.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c");
+
         List<Authenticator> authenticators = repositoryCredentialStore.fetch(query).result();
+        List<Authenticator> authenticators2 = repositoryCredentialStore.fetch(query).result();
 
         Assert.assertNotNull(authenticators);
-        Assert.assertTrue(authenticators.size() == 1);
-        Assert.assertTrue(authenticators.get(0).getCredID().equals("part3part3"));
-        verify(jwtBuilder, times(2)).sign(any());
-
+        Assert.assertNotNull(authenticators2);
+        Assert.assertTrue(!authenticators.isEmpty());
+        Assert.assertTrue(!authenticators2.isEmpty());
+        Assert.assertTrue(authenticators.get(0).getCredID().equals(authenticators2.get(0).getCredID()));
+        verify(jwtBuilder, times(4)).sign(any());
     }
 
     @Test
-    public void shouldFetchAuthenticator_byUsername_emptyList_truncateCredId() {
+    public void shouldFetchAuthenticator_byUsername_emptyList_different_users() {
         Authenticator query = new Authenticator();
         query.setUserName("username");
 
+        Authenticator query2 = new Authenticator();
+        query2.setUserName("username2");
+
         when(domain.getId()).thenReturn("domain-id");
         when(credentialService.findByUsername(ReferenceType.DOMAIN, domain.getId(), query.getUserName())).thenReturn(Single.just(Collections.emptyList()));
-        when(jwtBuilder.sign(any())).thenReturn("part1.part2.000000000000000000000000000000000000000000000000000000000000000000","part1.part2.000000000000000000000000000000000000000000000000000000000000000000");
+        when(credentialService.findByUsername(ReferenceType.DOMAIN, domain.getId(), query2.getUserName())).thenReturn(Single.just(Collections.emptyList()));
+        when(jwtBuilder.sign(any())).thenReturn("part1.part2.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c").thenReturn("part1.part2.-sVkXqTOhFeJwQXyH3WhuNJfAfnRkVM6llEu6k46iqY");
+
         List<Authenticator> authenticators = repositoryCredentialStore.fetch(query).result();
+        List<Authenticator> authenticators2 = repositoryCredentialStore.fetch(query2).result();
 
         Assert.assertNotNull(authenticators);
-        Assert.assertTrue(authenticators.size() == 1);
-        Assert.assertTrue(authenticators.get(0).getCredID().equals("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000"));
-        Assert.assertTrue(authenticators.get(0).getCredID().length() == 86);
-        verify(jwtBuilder, times(2)).sign(any());
-
+        Assert.assertNotNull(authenticators2);
+        Assert.assertTrue(!authenticators.isEmpty());
+        Assert.assertTrue(!authenticators2.isEmpty());
+        Assert.assertTrue(!authenticators.get(0).getCredID().equals(authenticators2.get(0).getCredID()));
+        verify(jwtBuilder, times(4)).sign(any());
     }
 
     @Test
diff --git a/gravitee-am-repository/gravitee-am-repository-mongodb/src/test/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepositoryTest.java b/gravitee-am-repository/gravitee-am-repository-mongodb/src/test/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepositoryTest.java
index 93eea97b06..2ad2385df5 100644
--- a/gravitee-am-repository/gravitee-am-repository-mongodb/src/test/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepositoryTest.java
+++ b/gravitee-am-repository/gravitee-am-repository-mongodb/src/test/java/io/gravitee/am/repository/mongodb/management/MongoCredentialRepositoryTest.java
@@ -178,4 +178,61 @@ public class MongoCredentialRepositoryTest extends AbstractManagementRepositoryT
         credentialRepository.findById(credentialCreated.getId()).test().assertEmpty();
     }
 
+    @Test
+    public void testDeleteByUser() throws TechnicalException {
+        // create credential
+        Credential credential = new Credential();
+        credential.setCredentialId("credentialId");
+        credential.setReferenceType(ReferenceType.DOMAIN);
+        credential.setReferenceId("domain-id");
+        credential.setUserId("user-id");
+        Credential credentialCreated = credentialRepository.create(credential).blockingGet();
+
+        // fetch credential
+        TestObserver<List<Credential>> testObserver = credentialRepository.findByUserId(ReferenceType.DOMAIN, "domain-id", "user-id").test();
+        testObserver.awaitTerminalEvent();
+        testObserver.assertComplete();
+        testObserver.assertNoErrors();
+        testObserver.assertValue(l -> l.size() == 1 && l.get(0).getCredentialId().equals(credentialCreated.getCredentialId()));
+
+        // delete credential
+        TestObserver testObserver1 = credentialRepository.deleteByUserId(ReferenceType.DOMAIN, "domain-id", "user-id").test();
+        testObserver1.awaitTerminalEvent();
+
+        // fetch credential
+        TestObserver<List<Credential>> testObserver2 = credentialRepository.findByUserId(ReferenceType.DOMAIN, "domain-id", "user-id").test();
+        testObserver2.awaitTerminalEvent();
+        testObserver2.assertComplete();
+        testObserver2.assertNoErrors();
+        testObserver2.assertValue(l -> l.isEmpty());
+    }
+
+    @Test
+    public void testDeleteByUser_invalid_user() throws TechnicalException {
+        // create credential
+        Credential credential = new Credential();
+        credential.setCredentialId("credentialId");
+        credential.setReferenceType(ReferenceType.DOMAIN);
+        credential.setReferenceId("domain-id");
+        credential.setUserId("user-id");
+        Credential credentialCreated = credentialRepository.create(credential).blockingGet();
+
+        // fetch credential
+        TestObserver<List<Credential>> testObserver = credentialRepository.findByUserId(ReferenceType.DOMAIN, "domain-id", "user-id").test();
+        testObserver.awaitTerminalEvent();
+        testObserver.assertComplete();
+        testObserver.assertNoErrors();
+        testObserver.assertValue(l -> l.size() == 1 && l.get(0).getCredentialId().equals(credentialCreated.getCredentialId()));
+
+        // delete credential
+        TestObserver testObserver1 = credentialRepository.deleteByUserId(ReferenceType.DOMAIN, "domain-id", "wrong-user-id").test();
+        testObserver1.awaitTerminalEvent();
+
+        // fetch credential
+        TestObserver<List<Credential>> testObserver2 = credentialRepository.findByUserId(ReferenceType.DOMAIN, "domain-id", "user-id").test();
+        testObserver2.awaitTerminalEvent();
+        testObserver2.assertComplete();
+        testObserver2.assertNoErrors();
+        testObserver.assertValue(l -> l.size() == 1 && l.get(0).getCredentialId().equals(credentialCreated.getCredentialId()));
+    }
 }