KEYCLOAK-3303: Allow reuse of refresh tokens.

- Configurable max reuse count.
This commit is contained in:
Gabriel Lavoie 2017-09-28 15:29:46 -04:00
parent 4c71e2ec17
commit 134daeac7f
19 changed files with 392 additions and 14 deletions

View file

@ -38,6 +38,7 @@ public class RealmRepresentation {
protected String displayNameHtml; protected String displayNameHtml;
protected Integer notBefore; protected Integer notBefore;
protected Boolean revokeRefreshToken; protected Boolean revokeRefreshToken;
protected Integer refreshTokenMaxReuse;
protected Integer accessTokenLifespan; protected Integer accessTokenLifespan;
protected Integer accessTokenLifespanForImplicitFlow; protected Integer accessTokenLifespanForImplicitFlow;
protected Integer ssoSessionIdleTimeout; protected Integer ssoSessionIdleTimeout;
@ -240,6 +241,14 @@ public class RealmRepresentation {
this.revokeRefreshToken = revokeRefreshToken; this.revokeRefreshToken = revokeRefreshToken;
} }
public Integer getRefreshTokenMaxReuse() {
return refreshTokenMaxReuse;
}
public void setRefreshTokenMaxReuse(Integer refreshTokenMaxReuse) {
this.refreshTokenMaxReuse = refreshTokenMaxReuse;
}
public Integer getAccessTokenLifespan() { public Integer getAccessTokenLifespan() {
return accessTokenLifespan; return accessTokenLifespan;
} }

View file

@ -357,6 +357,18 @@ public class RealmAdapter implements CachedRealmModel {
updated.setRevokeRefreshToken(revokeRefreshToken); updated.setRevokeRefreshToken(revokeRefreshToken);
} }
@Override
public int getRefreshTokenMaxReuse() {
if (isUpdated()) return updated.getRefreshTokenMaxReuse();
return cached.getRefreshTokenMaxReuse();
}
@Override
public void setRefreshTokenMaxReuse(int refreshTokenMaxReuse) {
getDelegateForUpdate();
updated.setRefreshTokenMaxReuse(refreshTokenMaxReuse);
}
@Override @Override
public int getSsoSessionIdleTimeout() { public int getSsoSessionIdleTimeout() {
if (isUpdated()) return updated.getSsoSessionIdleTimeout(); if (isUpdated()) return updated.getSsoSessionIdleTimeout();

View file

@ -75,6 +75,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
//--- end brute force settings //--- end brute force settings
protected boolean revokeRefreshToken; protected boolean revokeRefreshToken;
protected int refreshTokenMaxReuse;
protected int ssoSessionIdleTimeout; protected int ssoSessionIdleTimeout;
protected int ssoSessionMaxLifespan; protected int ssoSessionMaxLifespan;
protected int offlineSessionIdleTimeout; protected int offlineSessionIdleTimeout;
@ -170,6 +171,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
//--- end brute force settings //--- end brute force settings
revokeRefreshToken = model.isRevokeRefreshToken(); revokeRefreshToken = model.isRevokeRefreshToken();
refreshTokenMaxReuse = model.getRefreshTokenMaxReuse();
ssoSessionIdleTimeout = model.getSsoSessionIdleTimeout(); ssoSessionIdleTimeout = model.getSsoSessionIdleTimeout();
ssoSessionMaxLifespan = model.getSsoSessionMaxLifespan(); ssoSessionMaxLifespan = model.getSsoSessionMaxLifespan();
offlineSessionIdleTimeout = model.getOfflineSessionIdleTimeout(); offlineSessionIdleTimeout = model.getOfflineSessionIdleTimeout();
@ -374,6 +376,10 @@ public class CachedRealm extends AbstractExtendableRevisioned {
return revokeRefreshToken; return revokeRefreshToken;
} }
public int getRefreshTokenMaxReuse() {
return refreshTokenMaxReuse;
}
public int getSsoSessionIdleTimeout() { public int getSsoSessionIdleTimeout() {
return ssoSessionIdleTimeout; return ssoSessionIdleTimeout;
} }

View file

@ -155,6 +155,56 @@ public class AuthenticatedClientSessionAdapter implements AuthenticatedClientSes
update(task); update(task);
} }
@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 @Override
public String getAction() { public String getAction() {
return entity.getAction(); return entity.getAction();

View file

@ -46,6 +46,9 @@ public class AuthenticatedClientSessionEntity implements Serializable {
private Set<String> protocolMappers; private Set<String> protocolMappers;
private Map<String, String> notes = new ConcurrentHashMap<>(); private Map<String, String> notes = new ConcurrentHashMap<>();
private String currentRefreshToken;
private int currentRefreshTokenUseCount;
public String getAuthMethod() { public String getAuthMethod() {
return authMethod; return authMethod;
} }
@ -102,6 +105,21 @@ public class AuthenticatedClientSessionEntity implements Serializable {
this.notes = notes; 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> { 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.getProtocolMappers(), KeycloakMarshallUtil.STRING_EXT, output);
KeycloakMarshallUtil.writeCollection(session.getRoles(), 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<>()); Set<String> roles = KeycloakMarshallUtil.readCollection(input, KeycloakMarshallUtil.STRING_EXT, new KeycloakMarshallUtil.HashSetBuilder<>());
sessionEntity.setRoles(roles); sessionEntity.setRoles(roles);
sessionEntity.setCurrentRefreshToken(MarshallUtil.unmarshallString(input));
sessionEntity.setCurrentRefreshTokenUseCount(MarshallUtil.unmarshallInt(input));
return sessionEntity; return sessionEntity;
} }

View file

@ -390,6 +390,16 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
realm.setRevokeRefreshToken(revokeRefreshToken); realm.setRevokeRefreshToken(revokeRefreshToken);
} }
@Override
public int getRefreshTokenMaxReuse() {
return realm.getRefreshTokenMaxReuse();
}
@Override
public void setRefreshTokenMaxReuse(int revokeRefreshTokenReuseCount) {
realm.setRefreshTokenMaxReuse(revokeRefreshTokenReuseCount);
}
@Override @Override
public int getAccessTokenLifespan() { public int getAccessTokenLifespan() {
return realm.getAccessTokenLifespan(); return realm.getAccessTokenLifespan();

View file

@ -102,6 +102,8 @@ public class RealmEntity {
@Column(name="REVOKE_REFRESH_TOKEN") @Column(name="REVOKE_REFRESH_TOKEN")
private boolean revokeRefreshToken; private boolean revokeRefreshToken;
@Column(name="REFRESH_TOKEN_MAX_REUSE")
private int refreshTokenMaxReuse;
@Column(name="SSO_IDLE_TIMEOUT") @Column(name="SSO_IDLE_TIMEOUT")
private int ssoSessionIdleTimeout; private int ssoSessionIdleTimeout;
@Column(name="SSO_MAX_LIFESPAN") @Column(name="SSO_MAX_LIFESPAN")
@ -340,6 +342,14 @@ public class RealmEntity {
this.revokeRefreshToken = revokeRefreshToken; this.revokeRefreshToken = revokeRefreshToken;
} }
public int getRefreshTokenMaxReuse() {
return refreshTokenMaxReuse;
}
public void setRefreshTokenMaxReuse(int revokeRefreshTokenCount) {
this.refreshTokenMaxReuse = revokeRefreshTokenCount;
}
public int getSsoSessionIdleTimeout() { public int getSsoSessionIdleTimeout() {
return ssoSessionIdleTimeout; return ssoSessionIdleTimeout;
} }

View file

@ -112,4 +112,10 @@
baseTableName="RESOURCE_SERVER_SCOPE" baseColumnNames="RESOURCE_SERVER_ID" baseTableName="RESOURCE_SERVER_SCOPE" baseColumnNames="RESOURCE_SERVER_ID"
referencedTableName="RESOURCE_SERVER" referencedColumnNames="ID"/> referencedTableName="RESOURCE_SERVER" referencedColumnNames="ID"/>
</changeSet> </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> </databaseChangeLog>

View file

@ -140,6 +140,26 @@ public class PersistentAuthenticatedClientSessionAdapter implements Authenticate
model.setTimestamp(timestamp); model.setTimestamp(timestamp);
} }
@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 @Override
public String getAction() { public String getAction() {
return getData().getAction(); return getData().getAction();

View file

@ -255,6 +255,7 @@ public class ModelToRepresentation {
rep.setResetPasswordAllowed(realm.isResetPasswordAllowed()); rep.setResetPasswordAllowed(realm.isResetPasswordAllowed());
rep.setEditUsernameAllowed(realm.isEditUsernameAllowed()); rep.setEditUsernameAllowed(realm.isEditUsernameAllowed());
rep.setRevokeRefreshToken(realm.isRevokeRefreshToken()); rep.setRevokeRefreshToken(realm.isRevokeRefreshToken());
rep.setRefreshTokenMaxReuse(realm.getRefreshTokenMaxReuse());
rep.setAccessTokenLifespan(realm.getAccessTokenLifespan()); rep.setAccessTokenLifespan(realm.getAccessTokenLifespan());
rep.setAccessTokenLifespanForImplicitFlow(realm.getAccessTokenLifespanForImplicitFlow()); rep.setAccessTokenLifespanForImplicitFlow(realm.getAccessTokenLifespanForImplicitFlow());
rep.setSsoSessionIdleTimeout(realm.getSsoSessionIdleTimeout()); rep.setSsoSessionIdleTimeout(realm.getSsoSessionIdleTimeout());

View file

@ -165,6 +165,9 @@ public class RepresentationToModel {
if (rep.getRevokeRefreshToken() != null) newRealm.setRevokeRefreshToken(rep.getRevokeRefreshToken()); if (rep.getRevokeRefreshToken() != null) newRealm.setRevokeRefreshToken(rep.getRevokeRefreshToken());
else newRealm.setRevokeRefreshToken(false); else newRealm.setRevokeRefreshToken(false);
if (rep.getRefreshTokenMaxReuse() != null) newRealm.setRefreshTokenMaxReuse(rep.getRefreshTokenMaxReuse());
else newRealm.setRefreshTokenMaxReuse(0);
if (rep.getAccessTokenLifespan() != null) newRealm.setAccessTokenLifespan(rep.getAccessTokenLifespan()); if (rep.getAccessTokenLifespan() != null) newRealm.setAccessTokenLifespan(rep.getAccessTokenLifespan());
else newRealm.setAccessTokenLifespan(300); else newRealm.setAccessTokenLifespan(300);
@ -841,6 +844,7 @@ public class RepresentationToModel {
realm.setActionTokenGeneratedByUserLifespan(rep.getActionTokenGeneratedByUserLifespan()); realm.setActionTokenGeneratedByUserLifespan(rep.getActionTokenGeneratedByUserLifespan());
if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore()); if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore());
if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken()); 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.getAccessTokenLifespan() != null) realm.setAccessTokenLifespan(rep.getAccessTokenLifespan());
if (rep.getAccessTokenLifespanForImplicitFlow() != null) if (rep.getAccessTokenLifespanForImplicitFlow() != null)
realm.setAccessTokenLifespanForImplicitFlow(rep.getAccessTokenLifespanForImplicitFlow()); realm.setAccessTokenLifespanForImplicitFlow(rep.getAccessTokenLifespanForImplicitFlow());

View file

@ -30,6 +30,12 @@ public interface AuthenticatedClientSessionModel extends CommonClientSessionMode
void setUserSession(UserSessionModel userSession); void setUserSession(UserSessionModel userSession);
UserSessionModel getUserSession(); UserSessionModel getUserSession();
String getCurrentRefreshToken();
void setCurrentRefreshToken(String currentRefreshToken);
int getCurrentRefreshTokenUseCount();
void setCurrentRefreshTokenUseCount(int currentRefreshTokenUseCount);
String getNote(String name); String getNote(String name);
void setNote(String name, String value); void setNote(String name, String value);
void removeNote(String name); void removeNote(String name);

View file

@ -159,6 +159,9 @@ public interface RealmModel extends RoleContainerModel {
boolean isRevokeRefreshToken(); boolean isRevokeRefreshToken();
void setRevokeRefreshToken(boolean revokeRefreshToken); void setRevokeRefreshToken(boolean revokeRefreshToken);
int getRefreshTokenMaxReuse();
void setRefreshTokenMaxReuse(int revokeRefreshTokenCount);
int getSsoSessionIdleTimeout(); int getSsoSessionIdleTimeout();
void setSsoSessionIdleTimeout(int seconds); void setSsoSessionIdleTimeout(int seconds);

View file

@ -250,17 +250,9 @@ public class TokenManager {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token. Token client and authorized client don't match"); throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token. Token client and authorized client don't match");
} }
validateTokenReuse(session, realm, refreshToken, validation);
int currentTime = Time.currentTime(); 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");
}
}
validation.clientSession.setTimestamp(currentTime); validation.clientSession.setTimestamp(currentTime);
validation.userSession.setLastSessionRefresh(currentTime); validation.userSession.setLastSessionRefresh(currentTime);
@ -278,6 +270,36 @@ public class TokenManager {
return new RefreshResult(res, TokenUtil.TOKEN_TYPE_OFFLINE.equals(refreshToken.getType())); 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 { public RefreshToken verifyRefreshToken(KeycloakSession session, RealmModel realm, String encodedRefreshToken) throws OAuthErrorException {
return verifyRefreshToken(session, realm, encodedRefreshToken, true); return verifyRefreshToken(session, realm, encodedRefreshToken, true);
} }

View file

@ -27,11 +27,9 @@ import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.RefreshToken; import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.util.ClientManager; 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 privateKey;
String publicKey; String publicKey;

View file

@ -53,6 +53,13 @@ public class RealmManager {
return this; return this;
} }
public RealmManager refreshTokenMaxReuse(int refreshTokenMaxReuse) {
RealmRepresentation rep = realm.toRepresentation();
rep.setRefreshTokenMaxReuse(refreshTokenMaxReuse);
realm.update(rep);
return this;
}
public void generateKeys() { public void generateKeys() {
RealmRepresentation rep = realm.toRepresentation(); RealmRepresentation rep = realm.toRepresentation();

View file

@ -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=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) 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=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 sso-session-idle=SSO Session Idle
seconds=Seconds seconds=Seconds
minutes=Minutes minutes=Minutes

View file

@ -1088,6 +1088,10 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
} }
}, true); }, true);
$scope.changeRevokeRefreshToken = function() {
};
$scope.save = function() { $scope.save = function() {
$scope.realm.accessTokenLifespan = $scope.realm.accessTokenLifespan.toSeconds(); $scope.realm.accessTokenLifespan = $scope.realm.accessTokenLifespan.toSeconds();
$scope.realm.accessTokenLifespanForImplicitFlow = $scope.realm.accessTokenLifespanForImplicitFlow.toSeconds(); $scope.realm.accessTokenLifespanForImplicitFlow = $scope.realm.accessTokenLifespanForImplicitFlow.toSeconds();

View file

@ -7,13 +7,25 @@
<label class="col-md-2 control-label" for="revokeRefreshToken">{{:: 'revoke-refresh-token' | translate}}</label> <label class="col-md-2 control-label" for="revokeRefreshToken">{{:: 'revoke-refresh-token' | translate}}</label>
<div class="col-md-6"> <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> </div>
<kc-tooltip>{{:: 'revoke-refresh-token.tooltip' | translate}} <kc-tooltip>{{:: 'revoke-refresh-token.tooltip' | translate}}
</kc-tooltip> </kc-tooltip>
</div> </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"> <div class="form-group">
<label class="col-md-2 control-label" for="ssoSessionIdleTimeout">{{:: 'sso-session-idle' | translate}}</label> <label class="col-md-2 control-label" for="ssoSessionIdleTimeout">{{:: 'sso-session-idle' | translate}}</label>