keycloak-uncached

Merge pull request #4510 from glavoie/KEYCLOAK-3303 KEYCLOAK-3303:

9/29/2017 12:07:45 PM

Changes

Details

diff --git a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java
index c3dd733..4a7396c 100755
--- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java
+++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java
@@ -38,6 +38,7 @@ public class RealmRepresentation {
     protected String displayNameHtml;
     protected Integer notBefore;
     protected Boolean revokeRefreshToken;
+    protected Integer refreshTokenMaxReuse;
     protected Integer accessTokenLifespan;
     protected Integer accessTokenLifespanForImplicitFlow;
     protected Integer ssoSessionIdleTimeout;
@@ -240,6 +241,14 @@ public class RealmRepresentation {
         this.revokeRefreshToken = revokeRefreshToken;
     }
 
+    public Integer getRefreshTokenMaxReuse() {
+        return refreshTokenMaxReuse;
+    }
+
+    public void setRefreshTokenMaxReuse(Integer refreshTokenMaxReuse) {
+        this.refreshTokenMaxReuse = refreshTokenMaxReuse;
+    }
+
     public Integer getAccessTokenLifespan() {
         return accessTokenLifespan;
     }
diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java
index 160fee5..52a81de 100755
--- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java
+++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java
@@ -75,6 +75,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
     //--- end brute force settings
 
     protected boolean revokeRefreshToken;
+    protected int refreshTokenMaxReuse;
     protected int ssoSessionIdleTimeout;
     protected int ssoSessionMaxLifespan;
     protected int offlineSessionIdleTimeout;
@@ -170,6 +171,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
         //--- end brute force settings
 
         revokeRefreshToken = model.isRevokeRefreshToken();
+        refreshTokenMaxReuse = model.getRefreshTokenMaxReuse();
         ssoSessionIdleTimeout = model.getSsoSessionIdleTimeout();
         ssoSessionMaxLifespan = model.getSsoSessionMaxLifespan();
         offlineSessionIdleTimeout = model.getOfflineSessionIdleTimeout();
@@ -374,6 +376,10 @@ public class CachedRealm extends AbstractExtendableRevisioned {
         return revokeRefreshToken;
     }
 
+    public int getRefreshTokenMaxReuse() {
+        return refreshTokenMaxReuse;
+    }
+
     public int getSsoSessionIdleTimeout() {
         return ssoSessionIdleTimeout;
     }
diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java
index 7144c3b..f3ae325 100755
--- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java
+++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java
@@ -358,6 +358,18 @@ public class RealmAdapter implements CachedRealmModel {
     }
 
     @Override
+    public int getRefreshTokenMaxReuse() {
+        if (isUpdated()) return updated.getRefreshTokenMaxReuse();
+        return cached.getRefreshTokenMaxReuse();
+    }
+
+    @Override
+    public void setRefreshTokenMaxReuse(int refreshTokenMaxReuse) {
+        getDelegateForUpdate();
+        updated.setRefreshTokenMaxReuse(refreshTokenMaxReuse);
+    }
+
+    @Override
     public int getSsoSessionIdleTimeout() {
         if (isUpdated()) return updated.getSsoSessionIdleTimeout();
         return cached.getSsoSessionIdleTimeout();
diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java
index ab8ceed..a786084 100644
--- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java
+++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java
@@ -157,6 +157,56 @@ public class AuthenticatedClientSessionAdapter implements AuthenticatedClientSes
     }
 
     @Override
+    public int getCurrentRefreshTokenUseCount() {
+        return entity.getCurrentRefreshTokenUseCount();
+    }
+
+    @Override
+    public void setCurrentRefreshTokenUseCount(int currentRefreshTokenUseCount) {
+        UserSessionClientSessionUpdateTask task = new UserSessionClientSessionUpdateTask(client.getId()) {
+
+            @Override
+            protected void runClientSessionUpdate(AuthenticatedClientSessionEntity entity) {
+                entity.setCurrentRefreshTokenUseCount(currentRefreshTokenUseCount);
+            }
+
+            @Override
+            public CrossDCMessageStatus getCrossDCMessageStatus(SessionEntityWrapper<UserSessionEntity> sessionWrapper) {
+                // We usually update lastSessionRefresh at the same time. That would handle it.
+                return CrossDCMessageStatus.NOT_NEEDED;
+            }
+
+        };
+
+        update(task);
+    }
+
+    @Override
+    public String getCurrentRefreshToken() {
+        return entity.getCurrentRefreshToken();
+    }
+
+    @Override
+    public void setCurrentRefreshToken(String currentRefreshToken) {
+        UserSessionClientSessionUpdateTask task = new UserSessionClientSessionUpdateTask(client.getId()) {
+
+            @Override
+            protected void runClientSessionUpdate(AuthenticatedClientSessionEntity entity) {
+                entity.setCurrentRefreshToken(currentRefreshToken);
+            }
+
+            @Override
+            public CrossDCMessageStatus getCrossDCMessageStatus(SessionEntityWrapper<UserSessionEntity> sessionWrapper) {
+                // We usually update lastSessionRefresh at the same time. That would handle it.
+                return CrossDCMessageStatus.NOT_NEEDED;
+            }
+
+        };
+
+        update(task);
+    }
+
+    @Override
     public String getAction() {
         return entity.getAction();
     }
diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java
index 901a313..b8a6223 100644
--- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java
+++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java
@@ -46,6 +46,9 @@ public class AuthenticatedClientSessionEntity implements Serializable {
     private Set<String> protocolMappers;
     private Map<String, String> notes = new ConcurrentHashMap<>();
 
+    private String currentRefreshToken;
+    private int currentRefreshTokenUseCount;
+
     public String getAuthMethod() {
         return authMethod;
     }
@@ -102,6 +105,21 @@ public class AuthenticatedClientSessionEntity implements Serializable {
         this.notes = notes;
     }
 
+    public String getCurrentRefreshToken() {
+        return currentRefreshToken;
+    }
+
+    public void setCurrentRefreshToken(String currentRefreshToken) {
+        this.currentRefreshToken = currentRefreshToken;
+    }
+
+    public int getCurrentRefreshTokenUseCount() {
+        return currentRefreshTokenUseCount;
+    }
+
+    public void setCurrentRefreshTokenUseCount(int currentRefreshTokenUseCount) {
+        this.currentRefreshTokenUseCount = currentRefreshTokenUseCount;
+    }
 
     public static class ExternalizerImpl implements Externalizer<AuthenticatedClientSessionEntity> {
 
@@ -117,6 +135,9 @@ public class AuthenticatedClientSessionEntity implements Serializable {
 
             KeycloakMarshallUtil.writeCollection(session.getProtocolMappers(), KeycloakMarshallUtil.STRING_EXT, output);
             KeycloakMarshallUtil.writeCollection(session.getRoles(), KeycloakMarshallUtil.STRING_EXT, output);
+
+            MarshallUtil.marshallString(session.getCurrentRefreshToken(), output);
+            MarshallUtil.marshallInt(output, session.getCurrentRefreshTokenUseCount());
         }
 
 
@@ -139,6 +160,9 @@ public class AuthenticatedClientSessionEntity implements Serializable {
             Set<String> roles = KeycloakMarshallUtil.readCollection(input, KeycloakMarshallUtil.STRING_EXT, new KeycloakMarshallUtil.HashSetBuilder<>());
             sessionEntity.setRoles(roles);
 
+            sessionEntity.setCurrentRefreshToken(MarshallUtil.unmarshallString(input));
+            sessionEntity.setCurrentRefreshTokenUseCount(MarshallUtil.unmarshallInt(input));
+
             return sessionEntity;
         }
 
diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmEntity.java
index 33578e3..fe6ee14 100755
--- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmEntity.java
+++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmEntity.java
@@ -102,6 +102,8 @@ public class RealmEntity {
 
     @Column(name="REVOKE_REFRESH_TOKEN")
     private boolean revokeRefreshToken;
+    @Column(name="REFRESH_TOKEN_MAX_REUSE")
+    private int refreshTokenMaxReuse;
     @Column(name="SSO_IDLE_TIMEOUT")
     private int ssoSessionIdleTimeout;
     @Column(name="SSO_MAX_LIFESPAN")
@@ -340,6 +342,14 @@ public class RealmEntity {
         this.revokeRefreshToken = revokeRefreshToken;
     }
 
+    public int getRefreshTokenMaxReuse() {
+        return refreshTokenMaxReuse;
+    }
+
+    public void setRefreshTokenMaxReuse(int revokeRefreshTokenCount) {
+        this.refreshTokenMaxReuse = revokeRefreshTokenCount;
+    }
+
     public int getSsoSessionIdleTimeout() {
         return ssoSessionIdleTimeout;
     }
diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java
index 3b07a73..862db6c 100755
--- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java
+++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java
@@ -391,6 +391,16 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
     }
 
     @Override
+    public int getRefreshTokenMaxReuse() {
+        return realm.getRefreshTokenMaxReuse();
+    }
+
+    @Override
+    public void setRefreshTokenMaxReuse(int revokeRefreshTokenReuseCount) {
+        realm.setRefreshTokenMaxReuse(revokeRefreshTokenReuseCount);
+    }
+
+    @Override
     public int getAccessTokenLifespan() {
         return realm.getAccessTokenLifespan();
     }
diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-authz-3.4.0.CR1.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-authz-3.4.0.CR1.xml
index 24b2970..6266b42 100755
--- a/model/jpa/src/main/resources/META-INF/jpa-changelog-authz-3.4.0.CR1.xml
+++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-authz-3.4.0.CR1.xml
@@ -112,4 +112,10 @@
                                  baseTableName="RESOURCE_SERVER_SCOPE" baseColumnNames="RESOURCE_SERVER_ID"
                                  referencedTableName="RESOURCE_SERVER" referencedColumnNames="ID"/>
     </changeSet>
+
+    <changeSet author="glavoie@gmail.com" id="authn-3.4.0.CR1-refresh-token-max-reuse">
+        <addColumn tableName="REALM">
+            <column name="REFRESH_TOKEN_MAX_REUSE" type="INT" defaultValueNumeric="0"/>
+        </addColumn>
+    </changeSet>
 </databaseChangeLog>
diff --git a/server-spi/src/main/java/org/keycloak/models/AuthenticatedClientSessionModel.java b/server-spi/src/main/java/org/keycloak/models/AuthenticatedClientSessionModel.java
index 099a39c..cee10e1 100644
--- a/server-spi/src/main/java/org/keycloak/models/AuthenticatedClientSessionModel.java
+++ b/server-spi/src/main/java/org/keycloak/models/AuthenticatedClientSessionModel.java
@@ -30,6 +30,12 @@ public interface AuthenticatedClientSessionModel extends CommonClientSessionMode
     void setUserSession(UserSessionModel userSession);
     UserSessionModel getUserSession();
 
+    String getCurrentRefreshToken();
+    void setCurrentRefreshToken(String currentRefreshToken);
+
+    int getCurrentRefreshTokenUseCount();
+    void setCurrentRefreshTokenUseCount(int currentRefreshTokenUseCount);
+
     String getNote(String name);
     void setNote(String name, String value);
     void removeNote(String name);
diff --git a/server-spi/src/main/java/org/keycloak/models/RealmModel.java b/server-spi/src/main/java/org/keycloak/models/RealmModel.java
index e88c59f..4d7090e 100755
--- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java
+++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java
@@ -159,6 +159,9 @@ public interface RealmModel extends RoleContainerModel {
     boolean isRevokeRefreshToken();
     void setRevokeRefreshToken(boolean revokeRefreshToken);
 
+    int getRefreshTokenMaxReuse();
+    void setRefreshTokenMaxReuse(int revokeRefreshTokenCount);
+
     int getSsoSessionIdleTimeout();
     void setSsoSessionIdleTimeout(int seconds);
 
diff --git a/server-spi-private/src/main/java/org/keycloak/models/session/PersistentAuthenticatedClientSessionAdapter.java b/server-spi-private/src/main/java/org/keycloak/models/session/PersistentAuthenticatedClientSessionAdapter.java
index 1550d93..670e9cb 100644
--- a/server-spi-private/src/main/java/org/keycloak/models/session/PersistentAuthenticatedClientSessionAdapter.java
+++ b/server-spi-private/src/main/java/org/keycloak/models/session/PersistentAuthenticatedClientSessionAdapter.java
@@ -141,6 +141,26 @@ public class PersistentAuthenticatedClientSessionAdapter implements Authenticate
     }
 
     @Override
+    public String getCurrentRefreshToken() {
+        return null; // Information not persisted.
+    }
+
+    @Override
+    public void setCurrentRefreshToken(String currentRefreshToken) {
+        // Information not persisted.
+    }
+
+    @Override
+    public int getCurrentRefreshTokenUseCount() {
+        return 0; // Information not persisted.
+    }
+
+    @Override
+    public void setCurrentRefreshTokenUseCount(int currentRefreshTokenUseCount) {
+        // Information not persisted.
+    }
+
+    @Override
     public String getAction() {
         return getData().getAction();
     }
diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
index 82089a1..24f8ff9 100755
--- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
+++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
@@ -255,6 +255,7 @@ public class ModelToRepresentation {
         rep.setResetPasswordAllowed(realm.isResetPasswordAllowed());
         rep.setEditUsernameAllowed(realm.isEditUsernameAllowed());
         rep.setRevokeRefreshToken(realm.isRevokeRefreshToken());
+        rep.setRefreshTokenMaxReuse(realm.getRefreshTokenMaxReuse());
         rep.setAccessTokenLifespan(realm.getAccessTokenLifespan());
         rep.setAccessTokenLifespanForImplicitFlow(realm.getAccessTokenLifespanForImplicitFlow());
         rep.setSsoSessionIdleTimeout(realm.getSsoSessionIdleTimeout());
diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
index ad838ff..f5c881f 100755
--- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
+++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
@@ -165,6 +165,9 @@ public class RepresentationToModel {
         if (rep.getRevokeRefreshToken() != null) newRealm.setRevokeRefreshToken(rep.getRevokeRefreshToken());
         else newRealm.setRevokeRefreshToken(false);
 
+        if (rep.getRefreshTokenMaxReuse() != null) newRealm.setRefreshTokenMaxReuse(rep.getRefreshTokenMaxReuse());
+        else newRealm.setRefreshTokenMaxReuse(0);
+
         if (rep.getAccessTokenLifespan() != null) newRealm.setAccessTokenLifespan(rep.getAccessTokenLifespan());
         else newRealm.setAccessTokenLifespan(300);
 
@@ -841,6 +844,7 @@ public class RepresentationToModel {
             realm.setActionTokenGeneratedByUserLifespan(rep.getActionTokenGeneratedByUserLifespan());
         if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore());
         if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken());
+        if (rep.getRefreshTokenMaxReuse() != null) realm.setRefreshTokenMaxReuse(rep.getRefreshTokenMaxReuse());
         if (rep.getAccessTokenLifespan() != null) realm.setAccessTokenLifespan(rep.getAccessTokenLifespan());
         if (rep.getAccessTokenLifespanForImplicitFlow() != null)
             realm.setAccessTokenLifespanForImplicitFlow(rep.getAccessTokenLifespanForImplicitFlow());
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
index 00b02c0..d4bb40f 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
@@ -250,17 +250,9 @@ public class TokenManager {
             throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token. Token client and authorized client don't match");
         }
 
-        int currentTime = Time.currentTime();
-
-        if (realm.isRevokeRefreshToken()) {
-            int clusterStartupTime = session.getProvider(ClusterProvider.class).getClusterStartupTime();
-
-            if (refreshToken.getIssuedAt() < validation.clientSession.getTimestamp() && (clusterStartupTime != validation.clientSession.getTimestamp())) {
-                throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale token");
-            }
-
-        }
+        validateTokenReuse(session, realm, refreshToken, validation);
 
+        int currentTime = Time.currentTime();
         validation.clientSession.setTimestamp(currentTime);
         validation.userSession.setLastSessionRefresh(currentTime);
 
@@ -278,6 +270,36 @@ public class TokenManager {
         return new RefreshResult(res, TokenUtil.TOKEN_TYPE_OFFLINE.equals(refreshToken.getType()));
     }
 
+    private void validateTokenReuse(KeycloakSession session, RealmModel realm, RefreshToken refreshToken,
+            TokenValidation validation) throws OAuthErrorException {
+        if (realm.isRevokeRefreshToken()) {
+            int clusterStartupTime = session.getProvider(ClusterProvider.class).getClusterStartupTime();
+
+            if (validation.clientSession.getCurrentRefreshToken() != null &&
+                    !refreshToken.getId().equals(validation.clientSession.getCurrentRefreshToken()) &&
+                    refreshToken.getIssuedAt() < validation.clientSession.getTimestamp() &&
+                    clusterStartupTime != validation.clientSession.getTimestamp()) {
+                throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale token");
+            }
+        }
+
+        if (realm.isRevokeRefreshToken()) {
+            if (!refreshToken.getId().equals(validation.clientSession.getCurrentRefreshToken())) {
+                validation.clientSession.setCurrentRefreshToken(refreshToken.getId());
+                validation.clientSession.setCurrentRefreshTokenUseCount(0);
+            }
+
+            int currentCount = validation.clientSession.getCurrentRefreshTokenUseCount();
+            if (currentCount > realm.getRefreshTokenMaxReuse()) {
+                throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Maximum allowed refresh token reuse exceeded",
+                        "Maximum allowed refresh token reuse exceeded");
+            }
+            validation.clientSession.setCurrentRefreshTokenUseCount(currentCount + 1);
+        } else {
+            validation.clientSession.setCurrentRefreshToken(null);
+        }
+    }
+
     public RefreshToken verifyRefreshToken(KeycloakSession session, RealmModel realm, String encodedRefreshToken) throws OAuthErrorException {
         return verifyRefreshToken(session, realm, encodedRefreshToken, true);
     }
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java
index 7e3594f..0a1415d 100755
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java
@@ -27,11 +27,9 @@ import org.keycloak.events.Details;
 import org.keycloak.events.Errors;
 import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
 import org.keycloak.representations.AccessToken;
-import org.keycloak.representations.IDToken;
 import org.keycloak.representations.RefreshToken;
 import org.keycloak.representations.idm.EventRepresentation;
 import org.keycloak.representations.idm.RealmRepresentation;
-import org.keycloak.representations.idm.UserRepresentation;
 import org.keycloak.testsuite.AbstractKeycloakTest;
 import org.keycloak.testsuite.AssertEvents;
 import org.keycloak.testsuite.util.ClientManager;
@@ -273,6 +271,178 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
         }
     }
 
+    @Test
+    public void refreshTokenReuseTokenWithRefreshTokensRevokedAfterSingleReuse() throws Exception {
+        try {
+            RealmManager.realm(adminClient.realm("test"))
+                    .revokeRefreshToken(true)
+                    .refreshTokenMaxReuse(1);
+
+            oauth.doLogin("test-user@localhost", "password");
+
+            EventRepresentation loginEvent = events.expectLogin().assertEvent();
+
+            String sessionId = loginEvent.getSessionId();
+            String codeId = loginEvent.getDetails().get(Details.CODE_ID);
+
+            String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+
+            OAuthClient.AccessTokenResponse initialResponse = oauth.doAccessTokenRequest(code, "password");
+            RefreshToken initialRefreshToken = oauth.verifyRefreshToken(initialResponse.getRefreshToken());
+
+            events.expectCodeToToken(codeId, sessionId).assertEvent();
+
+            setTimeOffset(2);
+
+            // Initial refresh.
+            OAuthClient.AccessTokenResponse responseFirstUse = oauth.doRefreshTokenRequest(initialResponse.getRefreshToken(), "password");
+            RefreshToken newTokenFirstUse = oauth.verifyRefreshToken(responseFirstUse.getRefreshToken());
+
+            assertEquals(200, responseFirstUse.getStatusCode());
+
+            events.expectRefresh(initialRefreshToken.getId(), sessionId).assertEvent();
+
+            setTimeOffset(4);
+
+            // Second refresh (allowed).
+            OAuthClient.AccessTokenResponse responseFirstReuse = oauth.doRefreshTokenRequest(initialResponse.getRefreshToken(), "password");
+            RefreshToken newTokenFirstReuse = oauth.verifyRefreshToken(responseFirstReuse.getRefreshToken());
+
+            assertEquals(200, responseFirstReuse.getStatusCode());
+
+            events.expectRefresh(initialRefreshToken.getId(), sessionId).assertEvent();
+
+            // Token reused twice, became invalid.
+            OAuthClient.AccessTokenResponse responseSecondReuse = oauth.doRefreshTokenRequest(initialResponse.getRefreshToken(), "password");
+
+            assertEquals(400, responseSecondReuse.getStatusCode());
+
+            events.expectRefresh(initialRefreshToken.getId(), sessionId).removeDetail(Details.TOKEN_ID)
+                    .removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
+
+            // Refresh token from first use became invalid.
+            OAuthClient.AccessTokenResponse responseUseOfInvalidatedRefreshToken =
+                    oauth.doRefreshTokenRequest(responseFirstUse.getRefreshToken(), "password");
+
+            assertEquals(400, responseUseOfInvalidatedRefreshToken.getStatusCode());
+
+            events.expectRefresh(newTokenFirstUse.getId(), sessionId).removeDetail(Details.TOKEN_ID)
+                    .removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
+
+            // Refresh token from reuse is still valid.
+            OAuthClient.AccessTokenResponse responseUseOfValidRefreshToken =
+                    oauth.doRefreshTokenRequest(responseFirstReuse.getRefreshToken(), "password");
+
+            assertEquals(200, responseUseOfValidRefreshToken.getStatusCode());
+
+            events.expectRefresh(newTokenFirstReuse.getId(), sessionId).assertEvent();
+        } finally {
+            setTimeOffset(0);
+            RealmManager.realm(adminClient.realm("test"))
+                    .refreshTokenMaxReuse(0)
+                    .revokeRefreshToken(false);
+        }
+    }
+
+    @Test
+    public void refreshTokenReuseOfExistingTokenAfterEnablingReuseRevokation() throws Exception {
+        try {
+            oauth.doLogin("test-user@localhost", "password");
+
+            EventRepresentation loginEvent = events.expectLogin().assertEvent();
+
+            String sessionId = loginEvent.getSessionId();
+            String codeId = loginEvent.getDetails().get(Details.CODE_ID);
+
+            String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+
+            OAuthClient.AccessTokenResponse initialResponse = oauth.doAccessTokenRequest(code, "password");
+            RefreshToken initialRefreshToken = oauth.verifyRefreshToken(initialResponse.getRefreshToken());
+
+            events.expectCodeToToken(codeId, sessionId).assertEvent();
+
+            setTimeOffset(2);
+
+            // Infinite reuse allowed
+            processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
+            processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
+            processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
+
+            RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(true).refreshTokenMaxReuse(1);
+
+            // Config changed, we start tracking reuse.
+            processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
+            processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
+
+            OAuthClient.AccessTokenResponse responseReuseExceeded = oauth.doRefreshTokenRequest(initialResponse.getRefreshToken(), "password");
+
+            assertEquals(400, responseReuseExceeded.getStatusCode());
+
+            events.expectRefresh(initialRefreshToken.getId(), sessionId).removeDetail(Details.TOKEN_ID).removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
+        } finally {
+            setTimeOffset(0);
+            RealmManager.realm(adminClient.realm("test"))
+                    .refreshTokenMaxReuse(0)
+                    .revokeRefreshToken(false);
+        }
+    }
+
+    @Test
+    public void refreshTokenReuseOfExistingTokenAfterDisablingReuseRevokation() throws Exception {
+        try {
+            RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(true).refreshTokenMaxReuse(1);
+
+            oauth.doLogin("test-user@localhost", "password");
+
+            EventRepresentation loginEvent = events.expectLogin().assertEvent();
+
+            String sessionId = loginEvent.getSessionId();
+            String codeId = loginEvent.getDetails().get(Details.CODE_ID);
+
+            String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+
+            OAuthClient.AccessTokenResponse initialResponse = oauth.doAccessTokenRequest(code, "password");
+            RefreshToken initialRefreshToken = oauth.verifyRefreshToken(initialResponse.getRefreshToken());
+
+            events.expectCodeToToken(codeId, sessionId).assertEvent();
+
+            setTimeOffset(2);
+
+            // Single reuse authorized.
+            processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
+            processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
+
+            OAuthClient.AccessTokenResponse responseReuseExceeded = oauth.doRefreshTokenRequest(initialResponse.getRefreshToken(), "password");
+
+            assertEquals(400, responseReuseExceeded.getStatusCode());
+
+            events.expectRefresh(initialRefreshToken.getId(), sessionId).removeDetail(Details.TOKEN_ID)
+                    .removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
+
+            RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false);
+
+            // Config changed, token can be reused again
+            processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
+            processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
+            processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
+        } finally {
+            setTimeOffset(0);
+            RealmManager.realm(adminClient.realm("test"))
+                    .refreshTokenMaxReuse(0)
+                    .revokeRefreshToken(false);
+        }
+    }
+
+    private void processExpectedValidRefresh(String sessionId, RefreshToken requestToken, String refreshToken) {
+        OAuthClient.AccessTokenResponse response2 = oauth.doRefreshTokenRequest(refreshToken, "password");
+        RefreshToken refreshToken2 = oauth.verifyRefreshToken(response2.getRefreshToken());
+
+        assertEquals(200, response2.getStatusCode());
+
+        events.expectRefresh(requestToken.getId(), sessionId).assertEvent();
+    }
+
+
     String privateKey;
     String publicKey;
 
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmManager.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmManager.java
index c5c17af..7a4d79a 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmManager.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmManager.java
@@ -53,6 +53,13 @@ public class RealmManager {
         return this;
     }
 
+    public RealmManager refreshTokenMaxReuse(int refreshTokenMaxReuse) {
+        RealmRepresentation rep = realm.toRepresentation();
+        rep.setRefreshTokenMaxReuse(refreshTokenMaxReuse);
+        realm.update(rep);
+        return this;
+    }
+
     public void generateKeys() {
         RealmRepresentation rep = realm.toRepresentation();
 
diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
index 6cbba48..224fdaa 100644
--- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
+++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
@@ -93,7 +93,9 @@ user-cache-clear.tooltip=Clears all entries from the user cache (this will clear
 keys-cache-clear=Keys Cache
 keys-cache-clear.tooltip=Clears all entries from the cache of external public keys. These are keys of external clients or identity providers. (this wil clear entries for all realms)
 revoke-refresh-token=Revoke Refresh Token
-revoke-refresh-token.tooltip=If enabled refresh tokens can only be used once. Otherwise refresh tokens are not revoked when used and can be used multiple times.
+revoke-refresh-token.tooltip=If enabled a refresh token can only be used up to 'Refresh Token Max Reuse' and is revoked when a different token is used. Otherwise refresh tokens are not revoked when used and can be used multiple times.
+refresh-token-max-reuse=Refresh Token Max Reuse
+refresh-token-max-reuse.tooltip=Maximum number of times a refresh token can be reused. When a different token is used, revokation is immediate.
 sso-session-idle=SSO Session Idle
 seconds=Seconds
 minutes=Minutes
diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js
index ad8a407..5eee2b4 100644
--- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js
+++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js
@@ -1088,6 +1088,10 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, 
         }
     }, true);
 
+    $scope.changeRevokeRefreshToken = function() {
+
+    };
+
     $scope.save = function() {
         $scope.realm.accessTokenLifespan = $scope.realm.accessTokenLifespan.toSeconds();
         $scope.realm.accessTokenLifespanForImplicitFlow = $scope.realm.accessTokenLifespanForImplicitFlow.toSeconds();
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html b/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html
index f508716..81374d6 100755
--- a/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html
+++ b/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html
@@ -7,13 +7,25 @@
             <label class="col-md-2 control-label" for="revokeRefreshToken">{{:: 'revoke-refresh-token' | translate}}</label>
 
             <div class="col-md-6">
-                <input ng-model="realm.revokeRefreshToken" name="revokeRefreshToken" id="revokeRefreshToken" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
+                <input ng-change="" ng-model="realm.revokeRefreshToken" name="revokeRefreshToken" id="revokeRefreshToken" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
             </div>
 
             <kc-tooltip>{{:: 'revoke-refresh-token.tooltip' | translate}}
             </kc-tooltip>
         </div>
 
+        <div class="form-group" data-ng-show="realm.revokeRefreshToken == true">
+            <label class="col-md-2 control-label" for="refreshTokenMaxReuse">{{:: 'refresh-token-max-reuse' | translate}}</label>
+
+            <div class="col-md-6">
+                <input class="form-control" type="number" required min="0" max="31536000" data-ng-model="realm.refreshTokenMaxReuse" id="refreshTokenMaxReuse"
+                       name="refreshTokenMaxReuse"/>
+            </div>
+
+            <kc-tooltip>{{:: 'refresh-token-max-reuse.tooltip' | translate}}
+            </kc-tooltip>
+        </div>
+
         <div class="form-group">
             <label class="col-md-2 control-label" for="ssoSessionIdleTimeout">{{:: 'sso-session-idle' | translate}}</label>