keycloak-uncached
Changes
model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java 6(+6 -0)
model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java 50(+50 -0)
model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/AuthenticatedClientSessionEntity.java 24(+24 -0)
server-spi-private/src/main/java/org/keycloak/models/session/PersistentAuthenticatedClientSessionAdapter.java 20(+20 -0)
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java 174(+172 -2)
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>