KEYCLOAK-3303: Allow reuse of refresh tokens.
- Configurable max reuse count.
This commit is contained in:
parent
4c71e2ec17
commit
134daeac7f
19 changed files with 392 additions and 14 deletions
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue