keycloak-uncached

Changes

Details

diff --git a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.6.0.xml b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.6.0.xml
index 7c5de94..9e48a6a 100644
--- a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.6.0.xml
+++ b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.6.0.xml
@@ -48,5 +48,11 @@
 
         <addPrimaryKey columnNames="USER_SESSION_ID, OFFLINE" constraintName="CONSTRAINT_OFFLINE_US_SES_PK" tableName="OFFLINE_USER_SESSION"/>
         <addPrimaryKey columnNames="CLIENT_SESSION_ID, OFFLINE" constraintName="CONSTRAINT_OFFLINE_CL_SES_PK" tableName="OFFLINE_CLIENT_SESSION"/>
+
+        <addColumn tableName="REALM">
+            <column name="REVOKE_REFRESH_TOKEN" type="BOOLEAN" defaultValueBoolean="false">
+                <constraints nullable="false"/>
+            </column>
+        </addColumn>
     </changeSet>
 </databaseChangeLog>
\ No newline at end of file
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 c1c53b3..1bec10b 100755
--- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java
+++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java
@@ -10,6 +10,7 @@ public class RealmRepresentation {
     protected String id;
     protected String realm;
     protected Integer notBefore;
+    protected Boolean revokeRefreshToken;
     protected Integer accessTokenLifespan;
     protected Integer ssoSessionIdleTimeout;
     protected Integer ssoSessionMaxLifespan;
@@ -166,6 +167,14 @@ public class RealmRepresentation {
         this.sslRequired = sslRequired;
     }
 
+    public Boolean getRevokeRefreshToken() {
+        return revokeRefreshToken;
+    }
+
+    public void setRevokeRefreshToken(Boolean revokeRefreshToken) {
+        this.revokeRefreshToken = revokeRefreshToken;
+    }
+
     public Integer getAccessTokenLifespan() {
         return accessTokenLifespan;
     }
diff --git a/docbook/auth-server-docs/reference/en/en-US/modules/MigrationFromOlderVersions.xml b/docbook/auth-server-docs/reference/en/en-US/modules/MigrationFromOlderVersions.xml
index 10027b9..25a393f 100755
--- a/docbook/auth-server-docs/reference/en/en-US/modules/MigrationFromOlderVersions.xml
+++ b/docbook/auth-server-docs/reference/en/en-US/modules/MigrationFromOlderVersions.xml
@@ -80,6 +80,19 @@
     <section>
         <title>Version specific migration</title>
         <section>
+            <title>Migrating to 1.6.0.Final</title>
+            <simplesect>
+                <title>Refresh tokens are not reusable anymore</title>
+                <para>
+                    Old versions of Keycloak allowed reusing refresh tokens multiple times. Keycloak no longer permits
+                    this by default. When a refresh token is used to obtain a new access token a new refresh token is also
+                    included. This new refresh token should be used next time the access token is refreshed. If this is
+                    a problem for you it's possible to enable reuse of refresh tokens in the admin console under token
+                    settings.
+                </para>
+            </simplesect>
+        </section>
+        <section>
             <title>Migrating to 1.5.0.Final</title>
             <simplesect>
                 <title>Realm and User cache providers</title>
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
index 3e653db..802645e 100644
--- a/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
+++ b/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
@@ -66,6 +66,8 @@ realm-cache-enabled=Realm Cache Enabled
 realm-cache-enabled.tooltip=Enable/disable cache for realm, client and role data.
 user-cache-enabled=User Cache Enabled
 user-cache-enabled.tooltip=Enable/disable user and user role mapping cache.
+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.
 sso-session-idle=SSO Session Idle
 seconds=Seconds
 minutes=Minutes
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html
index a44b939..bb55022 100755
--- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html
+++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html
@@ -4,6 +4,17 @@
     <form class="form-horizontal" name="realmForm" novalidate kc-read-only="!access.manageRealm">
 
         <div class="form-group">
+            <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}}" />
+            </div>
+
+            <kc-tooltip>{{:: 'revoke-refresh-token.tooltip' | translate}}
+            </kc-tooltip>
+        </div>
+
+        <div class="form-group">
             <label class="col-md-2 control-label" for="ssoSessionIdleTimeout">{{:: 'sso-session-idle' | translate}}</label>
 
             <div class="col-md-6 time-selector">
diff --git a/model/api/src/main/java/org/keycloak/models/entities/RealmEntity.java b/model/api/src/main/java/org/keycloak/models/entities/RealmEntity.java
index 3dc43ef..e5fe6d2 100755
--- a/model/api/src/main/java/org/keycloak/models/entities/RealmEntity.java
+++ b/model/api/src/main/java/org/keycloak/models/entities/RealmEntity.java
@@ -39,6 +39,7 @@ public class RealmEntity extends AbstractIdentifiableEntity {
     private int failureFactor;
     //--- end brute force settings
 
+    private boolean revokeRefreshToken;
     private int ssoSessionIdleTimeout;
     private int ssoSessionMaxLifespan;
     private int accessTokenLifespan;
@@ -229,6 +230,14 @@ public class RealmEntity extends AbstractIdentifiableEntity {
         this.failureFactor = failureFactor;
     }
 
+    public boolean isRevokeRefreshToken() {
+        return revokeRefreshToken;
+    }
+
+    public void setRevokeRefreshToken(boolean revokeRefreshToken) {
+        this.revokeRefreshToken = revokeRefreshToken;
+    }
+
     public int getSsoSessionIdleTimeout() {
         return ssoSessionIdleTimeout;
     }
diff --git a/model/api/src/main/java/org/keycloak/models/RealmModel.java b/model/api/src/main/java/org/keycloak/models/RealmModel.java
index 452e2b3..7471a4c 100755
--- a/model/api/src/main/java/org/keycloak/models/RealmModel.java
+++ b/model/api/src/main/java/org/keycloak/models/RealmModel.java
@@ -91,6 +91,9 @@ public interface RealmModel extends RoleContainerModel {
 
     void setResetPasswordAllowed(boolean resetPasswordAllowed);
 
+    boolean isRevokeRefreshToken();
+    void setRevokeRefreshToken(boolean revokeRefreshToken);
+
     int getSsoSessionIdleTimeout();
     void setSsoSessionIdleTimeout(int seconds);
 
diff --git a/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
index 03e88a8..6b4a6be 100755
--- a/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
+++ b/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
@@ -144,6 +144,7 @@ public class ModelToRepresentation {
         rep.setVerifyEmail(realm.isVerifyEmail());
         rep.setResetPasswordAllowed(realm.isResetPasswordAllowed());
         rep.setEditUsernameAllowed(realm.isEditUsernameAllowed());
+        rep.setRevokeRefreshToken(realm.isRevokeRefreshToken());
         rep.setAccessTokenLifespan(realm.getAccessTokenLifespan());
         rep.setSsoSessionIdleTimeout(realm.getSsoSessionIdleTimeout());
         rep.setSsoSessionMaxLifespan(realm.getSsoSessionMaxLifespan());
diff --git a/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
index 839e149..6c61e66 100755
--- a/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
+++ b/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
@@ -1,9 +1,5 @@
 package org.keycloak.models.utils;
 
-import org.keycloak.models.session.PersistentClientSessionModel;
-import org.keycloak.models.session.PersistentUserSessionModel;
-import org.keycloak.representations.idm.OfflineClientSessionRepresentation;
-import org.keycloak.representations.idm.OfflineUserSessionRepresentation;
 import org.keycloak.util.Base64;
 import org.jboss.logging.Logger;
 import org.keycloak.enums.SslRequired;
@@ -100,6 +96,9 @@ public class RepresentationToModel {
 
         if (rep.getNotBefore() != null) newRealm.setNotBefore(rep.getNotBefore());
 
+        if (rep.getRevokeRefreshToken() != null) newRealm.setRevokeRefreshToken(rep.getRevokeRefreshToken());
+        else newRealm.setRevokeRefreshToken(false);
+
         if (rep.getAccessTokenLifespan() != null) newRealm.setAccessTokenLifespan(rep.getAccessTokenLifespan());
         else newRealm.setAccessTokenLifespan(300);
 
@@ -532,6 +531,7 @@ public class RepresentationToModel {
         if (rep.getAccessCodeLifespanUserAction() != null) realm.setAccessCodeLifespanUserAction(rep.getAccessCodeLifespanUserAction());
         if (rep.getAccessCodeLifespanLogin() != null) realm.setAccessCodeLifespanLogin(rep.getAccessCodeLifespanLogin());
         if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore());
+        if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken());
         if (rep.getAccessTokenLifespan() != null) realm.setAccessTokenLifespan(rep.getAccessTokenLifespan());
         if (rep.getSsoSessionIdleTimeout() != null) realm.setSsoSessionIdleTimeout(rep.getSsoSessionIdleTimeout());
         if (rep.getSsoSessionMaxLifespan() != null) realm.setSsoSessionMaxLifespan(rep.getSsoSessionMaxLifespan());
diff --git a/model/file/src/main/java/org/keycloak/models/file/adapter/RealmAdapter.java b/model/file/src/main/java/org/keycloak/models/file/adapter/RealmAdapter.java
index 5a62223..26227b1 100755
--- a/model/file/src/main/java/org/keycloak/models/file/adapter/RealmAdapter.java
+++ b/model/file/src/main/java/org/keycloak/models/file/adapter/RealmAdapter.java
@@ -326,6 +326,16 @@ public class RealmAdapter implements RealmModel {
 
 
     @Override
+    public boolean isRevokeRefreshToken() {
+        return realm.isRevokeRefreshToken();
+    }
+
+    @Override
+    public void setRevokeRefreshToken(boolean revokeRefreshToken) {
+        realm.setRevokeRefreshToken(revokeRefreshToken);
+    }
+
+    @Override
     public int getSsoSessionIdleTimeout() {
         return realm.getSsoSessionIdleTimeout();
     }
diff --git a/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java
index 54700e2..51d445c 100755
--- a/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java
+++ b/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java
@@ -240,6 +240,18 @@ public class RealmAdapter implements RealmModel {
     }
 
     @Override
+    public boolean isRevokeRefreshToken() {
+        if (updated != null) return updated.isRevokeRefreshToken();
+        return cached.isRevokeRefreshToken();
+    }
+
+    @Override
+    public void setRevokeRefreshToken(boolean revokeRefreshToken) {
+        getDelegateForUpdate();
+        updated.setRevokeRefreshToken(revokeRefreshToken);
+    }
+
+    @Override
     public int getSsoSessionIdleTimeout() {
         if (updated != null) return updated.getSsoSessionIdleTimeout();
         return cached.getSsoSessionIdleTimeout();
diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedRealm.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedRealm.java
index 27bab74..5193588 100755
--- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedRealm.java
+++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedRealm.java
@@ -55,6 +55,7 @@ public class CachedRealm implements Serializable {
     private int failureFactor;
     //--- end brute force settings
 
+    private boolean revokeRefreshToken;
     private int ssoSessionIdleTimeout;
     private int ssoSessionMaxLifespan;
     private int accessTokenLifespan;
@@ -136,6 +137,7 @@ public class CachedRealm implements Serializable {
         failureFactor = model.getFailureFactor();
         //--- end brute force settings
 
+        revokeRefreshToken = model.isRevokeRefreshToken();
         ssoSessionIdleTimeout = model.getSsoSessionIdleTimeout();
         ssoSessionMaxLifespan = model.getSsoSessionMaxLifespan();
         accessTokenLifespan = model.getAccessTokenLifespan();
@@ -313,6 +315,10 @@ public class CachedRealm implements Serializable {
         return editUsernameAllowed;
     }
 
+    public boolean isRevokeRefreshToken() {
+        return revokeRefreshToken;
+    }
+
     public int getSsoSessionIdleTimeout() {
         return ssoSessionIdleTimeout;
     }
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 fbfb700..27c4824 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
@@ -76,6 +76,8 @@ public class RealmEntity {
     @Column(name="EDIT_USERNAME_ALLOWED")
     protected boolean editUsernameAllowed;
 
+    @Column(name="REVOKE_REFRESH_TOKEN")
+    private boolean revokeRefreshToken;
     @Column(name="SSO_IDLE_TIMEOUT")
     private int ssoSessionIdleTimeout;
     @Column(name="SSO_MAX_LIFESPAN")
@@ -288,6 +290,14 @@ public class RealmEntity {
         this.editUsernameAllowed = editUsernameAllowed;
     }
 
+    public boolean isRevokeRefreshToken() {
+        return revokeRefreshToken;
+    }
+
+    public void setRevokeRefreshToken(boolean revokeRefreshToken) {
+        this.revokeRefreshToken = revokeRefreshToken;
+    }
+
     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 6268735..2c8e2ad 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
@@ -337,6 +337,16 @@ public class RealmAdapter implements RealmModel {
     }
 
     @Override
+    public boolean isRevokeRefreshToken() {
+        return realm.isRevokeRefreshToken();
+    }
+
+    @Override
+    public void setRevokeRefreshToken(boolean revokeRefreshToken) {
+        realm.setRevokeRefreshToken(revokeRefreshToken);
+    }
+
+    @Override
     public int getAccessTokenLifespan() {
         return realm.getAccessTokenLifespan();
     }
diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java
index a6166cd..05ae8bc 100755
--- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java
+++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java
@@ -311,6 +311,16 @@ public class RealmAdapter extends AbstractMongoAdapter<MongoRealmEntity> impleme
         updateRealm();
     }
 
+    @Override
+    public boolean isRevokeRefreshToken() {
+        return realm.isRevokeRefreshToken();
+    }
+
+    @Override
+    public void setRevokeRefreshToken(boolean revokeRefreshToken) {
+        realm.setRevokeRefreshToken(revokeRefreshToken);
+        updateRealm();
+    }
 
     @Override
     public int getSsoSessionIdleTimeout() {
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 e899186..0888dab 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
@@ -161,6 +161,15 @@ public class TokenManager {
         }
 
         int currentTime = Time.currentTime();
+
+        if (realm.isRevokeRefreshToken() && !refreshToken.getType().equals(TokenUtil.TOKEN_TYPE_OFFLINE)) {
+            if (refreshToken.getIssuedAt() < validation.clientSession.getTimestamp()) {
+                throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale token");
+            }
+
+            validation.clientSession.setTimestamp(currentTime);
+        }
+
         validation.userSession.setLastSessionRefresh(currentTime);
 
         AccessTokenResponseBuilder responseBuilder = responseBuilder(realm, authorizedClient, event, session, validation.userSession, validation.clientSession)
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java
index 363a1e9..742f5d6 100755
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java
@@ -30,6 +30,7 @@ import org.keycloak.enums.SslRequired;
 import org.keycloak.events.Details;
 import org.keycloak.events.Errors;
 import org.keycloak.events.Event;
+import org.keycloak.events.EventType;
 import org.keycloak.models.KeycloakSession;
 import org.keycloak.models.RealmModel;
 import org.keycloak.models.UserSessionModel;
@@ -181,6 +182,93 @@ public class RefreshTokenTest {
         Time.setOffset(0);
     }
 
+    @Test
+    public void refreshTokenReuseTokenWithoutRefreshTokensRevoked() throws Exception {
+        try {
+            oauth.doLogin("test-user@localhost", "password");
+
+            Event loginEvent = events.expectLogin().assertEvent();
+
+            String sessionId = loginEvent.getSessionId();
+            String codeId = loginEvent.getDetails().get(Details.CODE_ID);
+
+            String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+
+            AccessTokenResponse response1 = oauth.doAccessTokenRequest(code, "password");
+            RefreshToken refreshToken1 = oauth.verifyRefreshToken(response1.getRefreshToken());
+
+            events.expectCodeToToken(codeId, sessionId).assertEvent();
+
+            Time.setOffset(2);
+
+            AccessTokenResponse response2 = oauth.doRefreshTokenRequest(response1.getRefreshToken(), "password");
+            Assert.assertEquals(200, response2.getStatusCode());
+
+            events.expectRefresh(refreshToken1.getId(), sessionId).assertEvent();
+
+            AccessTokenResponse response3 = oauth.doRefreshTokenRequest(response1.getRefreshToken(), "password");
+
+            Assert.assertEquals(200, response3.getStatusCode());
+
+            events.expectRefresh(refreshToken1.getId(), sessionId).assertEvent();
+        } finally {
+            Time.setOffset(0);
+        }
+    }
+
+    @Test
+    public void refreshTokenReuseTokenWithRefreshTokensRevoked() throws Exception {
+        try {
+            keycloakRule.configure(new KeycloakRule.KeycloakSetup() {
+                @Override
+                public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) {
+                    appRealm.setRevokeRefreshToken(true);
+                }
+            });
+
+            oauth.doLogin("test-user@localhost", "password");
+
+            Event loginEvent = events.expectLogin().assertEvent();
+
+            String sessionId = loginEvent.getSessionId();
+            String codeId = loginEvent.getDetails().get(Details.CODE_ID);
+
+            String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+
+            AccessTokenResponse response1 = oauth.doAccessTokenRequest(code, "password");
+            RefreshToken refreshToken1 = oauth.verifyRefreshToken(response1.getRefreshToken());
+
+            events.expectCodeToToken(codeId, sessionId).assertEvent();
+
+            Time.setOffset(2);
+
+            AccessTokenResponse response2 = oauth.doRefreshTokenRequest(response1.getRefreshToken(), "password");
+            RefreshToken refreshToken2 = oauth.verifyRefreshToken(response2.getRefreshToken());
+
+            Assert.assertEquals(200, response2.getStatusCode());
+
+            events.expectRefresh(refreshToken1.getId(), sessionId).assertEvent();
+
+            AccessTokenResponse response3 = oauth.doRefreshTokenRequest(response1.getRefreshToken(), "password");
+
+            Assert.assertEquals(400, response3.getStatusCode());
+
+            events.expectRefresh(refreshToken1.getId(), sessionId).removeDetail(Details.TOKEN_ID).removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
+
+            oauth.doRefreshTokenRequest(response2.getRefreshToken(), "password");
+
+            events.expectRefresh(refreshToken2.getId(), sessionId).assertEvent();
+        } finally {
+            Time.setOffset(0);
+            keycloakRule.configure(new KeycloakRule.KeycloakSetup() {
+                @Override
+                public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) {
+                    appRealm.setRevokeRefreshToken(false);
+                }
+            });
+        }
+    }
+
     PrivateKey privateKey;
     PublicKey publicKey;