KEYCLOAK-18368 Invalidate client session after refresh token re-use
This commit is contained in:
parent
ead667aaac
commit
91865fa93e
9 changed files with 194 additions and 31 deletions
|
@ -148,6 +148,15 @@ public class JsonWebToken implements Serializable, Token {
|
||||||
return !isExpired() && isNotBefore(allowedTimeSkew);
|
return !isExpired() && isNotBefore(allowedTimeSkew);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param sessionStarted Time in seconds
|
||||||
|
* @return true if the particular token was issued before the given session start time. Which means that token cannot be issued by the particular session
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isIssuedBeforeSessionStart(long sessionStarted) {
|
||||||
|
return getIat() + 1 < sessionStarted;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getIat() {
|
public Long getIat() {
|
||||||
return iat;
|
return iat;
|
||||||
}
|
}
|
||||||
|
|
|
@ -188,6 +188,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider {
|
||||||
AuthenticatedClientSessionEntity entity = new AuthenticatedClientSessionEntity(clientSessionId);
|
AuthenticatedClientSessionEntity entity = new AuthenticatedClientSessionEntity(clientSessionId);
|
||||||
entity.setRealmId(realm.getId());
|
entity.setRealmId(realm.getId());
|
||||||
entity.setTimestamp(Time.currentTime());
|
entity.setTimestamp(Time.currentTime());
|
||||||
|
entity.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(entity.getTimestamp()));
|
||||||
|
|
||||||
InfinispanChangelogBasedTransaction<String, UserSessionEntity> userSessionUpdateTx = getTransaction(false);
|
InfinispanChangelogBasedTransaction<String, UserSessionEntity> userSessionUpdateTx = getTransaction(false);
|
||||||
InfinispanChangelogBasedTransaction<UUID, AuthenticatedClientSessionEntity> clientSessionUpdateTx = getClientSessionTransaction(false);
|
InfinispanChangelogBasedTransaction<UUID, AuthenticatedClientSessionEntity> clientSessionUpdateTx = getClientSessionTransaction(false);
|
||||||
|
@ -773,6 +774,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider {
|
||||||
|
|
||||||
// update timestamp to current time
|
// update timestamp to current time
|
||||||
offlineClientSession.setTimestamp(Time.currentTime());
|
offlineClientSession.setTimestamp(Time.currentTime());
|
||||||
|
offlineClientSession.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(offlineClientSession.getTimestamp()));
|
||||||
|
|
||||||
session.getProvider(UserSessionPersisterProvider.class).createClientSession(clientSession, true);
|
session.getProvider(UserSessionPersisterProvider.class).createClientSession(clientSession, true);
|
||||||
|
|
||||||
|
|
|
@ -157,6 +157,7 @@ public class MapUserSessionProvider<UK, CK> implements UserSessionProvider {
|
||||||
public AuthenticatedClientSessionModel createClientSession(RealmModel realm, ClientModel client, UserSessionModel userSession) {
|
public AuthenticatedClientSessionModel createClientSession(RealmModel realm, ClientModel client, UserSessionModel userSession) {
|
||||||
MapAuthenticatedClientSessionEntity<CK> entity =
|
MapAuthenticatedClientSessionEntity<CK> entity =
|
||||||
new MapAuthenticatedClientSessionEntity<>(clientSessionStore.getKeyConvertor().yieldNewUniqueKey(), userSession.getId(), realm.getId(), client.getId(), false);
|
new MapAuthenticatedClientSessionEntity<>(clientSessionStore.getKeyConvertor().yieldNewUniqueKey(), userSession.getId(), realm.getId(), client.getId(), false);
|
||||||
|
entity.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(entity.getTimestamp()));
|
||||||
setClientSessionExpiration(entity, realm, client);
|
setClientSessionExpiration(entity, realm, client);
|
||||||
|
|
||||||
LOG.tracef("createClientSession(%s, %s, %s)%s", realm, client, userSession, getShortStackTrace());
|
LOG.tracef("createClientSession(%s, %s, %s)%s", realm, client, userSession, getShortStackTrace());
|
||||||
|
@ -472,7 +473,9 @@ public class MapUserSessionProvider<UK, CK> implements UserSessionProvider {
|
||||||
LOG.tracef("createOfflineClientSession(%s, %s)%s", clientSession, offlineUserSession, getShortStackTrace());
|
LOG.tracef("createOfflineClientSession(%s, %s)%s", clientSession, offlineUserSession, getShortStackTrace());
|
||||||
|
|
||||||
MapAuthenticatedClientSessionEntity<CK> clientSessionEntity = createAuthenticatedClientSessionInstance(clientSession, offlineUserSession, true);
|
MapAuthenticatedClientSessionEntity<CK> clientSessionEntity = createAuthenticatedClientSessionInstance(clientSession, offlineUserSession, true);
|
||||||
clientSessionEntity.setTimestamp(Time.currentTime());
|
int currentTime = Time.currentTime();
|
||||||
|
clientSessionEntity.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(currentTime));
|
||||||
|
clientSessionEntity.setTimestamp(currentTime);
|
||||||
setClientSessionExpiration(clientSessionEntity, clientSession.getRealm(), clientSession.getClient());
|
setClientSessionExpiration(clientSessionEntity, clientSession.getRealm(), clientSession.getClient());
|
||||||
|
|
||||||
Optional<MapUserSessionEntity<UK>> userSessionEntity = getOfflineUserSessionEntityStream(clientSession.getRealm(), offlineUserSession.getId()).findFirst();
|
Optional<MapUserSessionEntity<UK>> userSessionEntity = getOfflineUserSessionEntityStream(clientSession.getRealm(), offlineUserSession.getId()).findFirst();
|
||||||
|
|
|
@ -37,8 +37,16 @@ public interface AuthenticatedClientSessionModel extends CommonClientSessionMode
|
||||||
public static final SearchableModelField<AuthenticatedClientSessionModel> TIMESTAMP = new SearchableModelField<>("timestamp", Integer.class);
|
public static final SearchableModelField<AuthenticatedClientSessionModel> TIMESTAMP = new SearchableModelField<>("timestamp", Integer.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String STARTED_AT_NOTE = "startedAt";
|
||||||
|
|
||||||
String getId();
|
String getId();
|
||||||
|
|
||||||
|
default int getStarted() {
|
||||||
|
String started = getNote(STARTED_AT_NOTE);
|
||||||
|
// Fallback to 0 if "started" note is not available. This can happen for the offline sessions migrated from old version where "startedAt" note was not yet available
|
||||||
|
return started == null ? 0 : Integer.parseInt(started);
|
||||||
|
}
|
||||||
|
|
||||||
int getTimestamp();
|
int getTimestamp();
|
||||||
void setTimestamp(int timestamp);
|
void setTimestamp(int timestamp);
|
||||||
|
|
||||||
|
|
|
@ -67,6 +67,7 @@ import org.keycloak.representations.RefreshToken;
|
||||||
import org.keycloak.services.ErrorResponseException;
|
import org.keycloak.services.ErrorResponseException;
|
||||||
import org.keycloak.services.managers.AuthenticationManager;
|
import org.keycloak.services.managers.AuthenticationManager;
|
||||||
import org.keycloak.services.managers.AuthenticationSessionManager;
|
import org.keycloak.services.managers.AuthenticationSessionManager;
|
||||||
|
import org.keycloak.services.managers.ResourceAdminManager;
|
||||||
import org.keycloak.services.managers.UserSessionCrossDCManager;
|
import org.keycloak.services.managers.UserSessionCrossDCManager;
|
||||||
import org.keycloak.services.managers.UserSessionManager;
|
import org.keycloak.services.managers.UserSessionManager;
|
||||||
import org.keycloak.services.resources.IdentityBrokerService;
|
import org.keycloak.services.resources.IdentityBrokerService;
|
||||||
|
@ -155,7 +156,7 @@ public class TokenManager {
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "User disabled", "User disabled");
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "User disabled", "User disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldToken.getIssuedAt() + 1 < userSession.getStarted()) {
|
if (oldToken.isIssuedBeforeSessionStart(userSession.getStarted())) {
|
||||||
logger.debug("Refresh toked issued before the user session started");
|
logger.debug("Refresh toked issued before the user session started");
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh toked issued before the user session started");
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh toked issued before the user session started");
|
||||||
}
|
}
|
||||||
|
@ -174,6 +175,11 @@ public class TokenManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (oldToken.isIssuedBeforeSessionStart(clientSession.getStarted())) {
|
||||||
|
logger.debug("Refresh toked issued before the client session started");
|
||||||
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh toked issued before the client session started");
|
||||||
|
}
|
||||||
|
|
||||||
if (!client.getClientId().equals(oldToken.getIssuedFor())) {
|
if (!client.getClientId().equals(oldToken.getIssuedFor())) {
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Unmatching clients", "Unmatching clients");
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Unmatching clients", "Unmatching clients");
|
||||||
}
|
}
|
||||||
|
@ -259,10 +265,17 @@ public class TokenManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (valid && (token.getIssuedAt() + 1 < userSession.getStarted())) {
|
if (valid && (token.isIssuedBeforeSessionStart(userSession.getStarted()))) {
|
||||||
valid = false;
|
valid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AuthenticatedClientSessionModel clientSession = userSession == null ? null : userSession.getAuthenticatedClientSessionByClient(client.getId());
|
||||||
|
if (clientSession != null) {
|
||||||
|
if (valid && (token.isIssuedBeforeSessionStart(clientSession.getStarted()))) {
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String tokenType = token.getType();
|
String tokenType = token.getType();
|
||||||
if (realm.isRevokeRefreshToken()
|
if (realm.isRevokeRefreshToken()
|
||||||
&& (tokenType.equals(TokenUtil.TOKEN_TYPE_REFRESH) || tokenType.equals(TokenUtil.TOKEN_TYPE_OFFLINE))
|
&& (tokenType.equals(TokenUtil.TOKEN_TYPE_REFRESH) || tokenType.equals(TokenUtil.TOKEN_TYPE_OFFLINE))
|
||||||
|
@ -273,7 +286,6 @@ public class TokenManager {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
int currentTime = Time.currentTime();
|
int currentTime = Time.currentTime();
|
||||||
userSession.setLastSessionRefresh(currentTime);
|
userSession.setLastSessionRefresh(currentTime);
|
||||||
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
|
|
||||||
if (clientSession != null) {
|
if (clientSession != null) {
|
||||||
clientSession.setTimestamp(currentTime);
|
clientSession.setTimestamp(currentTime);
|
||||||
}
|
}
|
||||||
|
@ -296,7 +308,8 @@ public class TokenManager {
|
||||||
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
|
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return validateTokenReuse(session, realm, token, clientSession, false);
|
validateTokenReuse(session, realm, token, clientSession, false);
|
||||||
|
return true;
|
||||||
} catch (OAuthErrorException e) {
|
} catch (OAuthErrorException e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -403,14 +416,23 @@ public class TokenManager {
|
||||||
TokenValidation validation) throws OAuthErrorException {
|
TokenValidation validation) throws OAuthErrorException {
|
||||||
if (realm.isRevokeRefreshToken()) {
|
if (realm.isRevokeRefreshToken()) {
|
||||||
AuthenticatedClientSessionModel clientSession = validation.clientSessionCtx.getClientSession();
|
AuthenticatedClientSessionModel clientSession = validation.clientSessionCtx.getClientSession();
|
||||||
if (validateTokenReuse(session, realm, refreshToken, clientSession, true)) {
|
try {
|
||||||
|
validateTokenReuse(session, realm, refreshToken, clientSession, true);
|
||||||
int currentCount = clientSession.getCurrentRefreshTokenUseCount();
|
int currentCount = clientSession.getCurrentRefreshTokenUseCount();
|
||||||
clientSession.setCurrentRefreshTokenUseCount(currentCount + 1);
|
clientSession.setCurrentRefreshTokenUseCount(currentCount + 1);
|
||||||
|
} catch (OAuthErrorException oee) {
|
||||||
|
if (logger.isDebugEnabled()) {
|
||||||
|
logger.debugf("Failed validation of refresh token %s due it was used before. Realm: %s, client: %s, user: %s, user session: %s. Will detach client session from user session",
|
||||||
|
refreshToken.getId(), realm.getName(), clientSession.getClient().getClientId(), clientSession.getUserSession().getUser().getUsername(), clientSession.getUserSession().getId());
|
||||||
|
}
|
||||||
|
clientSession.detachFromUserSession();
|
||||||
|
throw oee;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean validateTokenReuse(KeycloakSession session, RealmModel realm, AccessToken refreshToken,
|
// Will throw OAuthErrorException if validation fails
|
||||||
|
private void validateTokenReuse(KeycloakSession session, RealmModel realm, AccessToken refreshToken,
|
||||||
AuthenticatedClientSessionModel clientSession, boolean refreshFlag) throws OAuthErrorException {
|
AuthenticatedClientSessionModel clientSession, boolean refreshFlag) throws OAuthErrorException {
|
||||||
int clusterStartupTime = session.getProvider(ClusterProvider.class).getClusterStartupTime();
|
int clusterStartupTime = session.getProvider(ClusterProvider.class).getClusterStartupTime();
|
||||||
|
|
||||||
|
@ -426,7 +448,7 @@ public class TokenManager {
|
||||||
clientSession.setCurrentRefreshToken(refreshToken.getId());
|
clientSession.setCurrentRefreshToken(refreshToken.getId());
|
||||||
clientSession.setCurrentRefreshTokenUseCount(0);
|
clientSession.setCurrentRefreshTokenUseCount(0);
|
||||||
} else {
|
} else {
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -435,7 +457,7 @@ public class TokenManager {
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Maximum allowed refresh token reuse exceeded",
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Maximum allowed refresh token reuse exceeded",
|
||||||
"Maximum allowed refresh token reuse exceeded");
|
"Maximum allowed refresh token reuse exceeded");
|
||||||
}
|
}
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public RefreshToken verifyRefreshToken(KeycloakSession session, RealmModel realm, ClientModel client, HttpRequest request, String encodedRefreshToken, boolean checkExpiration) throws OAuthErrorException {
|
public RefreshToken verifyRefreshToken(KeycloakSession session, RealmModel realm, ClientModel client, HttpRequest request, String encodedRefreshToken, boolean checkExpiration) throws OAuthErrorException {
|
||||||
|
|
|
@ -287,13 +287,13 @@ public class UserInfoEndpoint {
|
||||||
UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), false, client.getId());
|
UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), false, client.getId());
|
||||||
UserSessionModel offlineUserSession = null;
|
UserSessionModel offlineUserSession = null;
|
||||||
if (AuthenticationManager.isSessionValid(realm, userSession)) {
|
if (AuthenticationManager.isSessionValid(realm, userSession)) {
|
||||||
checkTokenIssuedAt(token, userSession, event);
|
checkTokenIssuedAt(token, userSession, event, client);
|
||||||
event.session(userSession);
|
event.session(userSession);
|
||||||
return userSession;
|
return userSession;
|
||||||
} else {
|
} else {
|
||||||
offlineUserSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), true, client.getId());
|
offlineUserSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), true, client.getId());
|
||||||
if (AuthenticationManager.isOfflineSessionValid(realm, offlineUserSession)) {
|
if (AuthenticationManager.isOfflineSessionValid(realm, offlineUserSession)) {
|
||||||
checkTokenIssuedAt(token, offlineUserSession, event);
|
checkTokenIssuedAt(token, offlineUserSession, event, client);
|
||||||
event.session(offlineUserSession);
|
event.session(offlineUserSession);
|
||||||
return offlineUserSession;
|
return offlineUserSession;
|
||||||
}
|
}
|
||||||
|
@ -314,8 +314,14 @@ public class UserInfoEndpoint {
|
||||||
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Session expired");
|
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Session expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkTokenIssuedAt(AccessToken token, UserSessionModel userSession, EventBuilder event) throws CorsErrorResponseException {
|
private void checkTokenIssuedAt(AccessToken token, UserSessionModel userSession, EventBuilder event, ClientModel client) throws CorsErrorResponseException {
|
||||||
if (token.getIssuedAt() + 1 < userSession.getStarted()) {
|
if (token.isIssuedBeforeSessionStart(userSession.getStarted())) {
|
||||||
|
event.error(Errors.INVALID_TOKEN);
|
||||||
|
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Stale token");
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
|
||||||
|
if (token.isIssuedBeforeSessionStart(clientSession.getStarted())) {
|
||||||
event.error(Errors.INVALID_TOKEN);
|
event.error(Errors.INVALID_TOKEN);
|
||||||
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Stale token");
|
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Stale token");
|
||||||
}
|
}
|
||||||
|
|
|
@ -105,10 +105,10 @@ public class LastSessionRefreshCrossDCTest extends AbstractAdminCrossDCTest {
|
||||||
Assert.assertNull("Expecting no access token present", tokenResponse.getAccessToken());
|
Assert.assertNull("Expecting no access token present", tokenResponse.getAccessToken());
|
||||||
Assert.assertNotNull(tokenResponse.getError());
|
Assert.assertNotNull(tokenResponse.getError());
|
||||||
|
|
||||||
// try refresh with new token on DC2. It should pass.
|
// try refresh with new token on DC2. It should fail because client session not valid anymore
|
||||||
tokenResponse = oauth.doRefreshTokenRequest(refreshToken2, "password");
|
tokenResponse = oauth.doRefreshTokenRequest(refreshToken2, "password");
|
||||||
Assert.assertNotNull(tokenResponse.getAccessToken());
|
Assert.assertNull("Expecting no access token present", tokenResponse.getAccessToken());
|
||||||
Assert.assertNull(tokenResponse.getError());
|
Assert.assertNotNull(tokenResponse.getError());
|
||||||
|
|
||||||
// Revert
|
// Revert
|
||||||
realmRep = testRealm().toRepresentation();
|
realmRep = testRealm().toRepresentation();
|
||||||
|
|
|
@ -392,8 +392,15 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
|
||||||
.clearDetails()
|
.clearDetails()
|
||||||
.assertEvent();
|
.assertEvent();
|
||||||
|
|
||||||
// Refresh with new refreshToken is successful now
|
// Refresh with new refreshToken fails as well (client session was invalidated because of attempt to refresh with revoked refresh token)
|
||||||
testRefreshWithOfflineToken(token, offlineToken2, offlineTokenString2, offlineToken2.getSessionState(), userId);
|
OAuthClient.AccessTokenResponse response2 = oauth.doRefreshTokenRequest(offlineTokenString2, "secret1");
|
||||||
|
Assert.assertEquals(400, response2.getStatusCode());
|
||||||
|
events.expectRefresh(offlineToken2.getId(), offlineToken2.getSessionState())
|
||||||
|
.client("offline-client")
|
||||||
|
.error(Errors.INVALID_TOKEN)
|
||||||
|
.user(userId)
|
||||||
|
.clearDetails()
|
||||||
|
.assertEvent();
|
||||||
|
|
||||||
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false);
|
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false);
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
*/
|
*/
|
||||||
package org.keycloak.testsuite.oauth;
|
package org.keycloak.testsuite.oauth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
import org.jboss.arquillian.graphene.page.Page;
|
import org.jboss.arquillian.graphene.page.Page;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
|
@ -24,6 +25,7 @@ import org.junit.BeforeClass;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.keycloak.OAuth2Constants;
|
import org.keycloak.OAuth2Constants;
|
||||||
|
import org.keycloak.OAuthErrorException;
|
||||||
import org.keycloak.admin.client.resource.ClientResource;
|
import org.keycloak.admin.client.resource.ClientResource;
|
||||||
import org.keycloak.admin.client.resource.RealmResource;
|
import org.keycloak.admin.client.resource.RealmResource;
|
||||||
import org.keycloak.admin.client.resource.UserResource;
|
import org.keycloak.admin.client.resource.UserResource;
|
||||||
|
@ -31,6 +33,7 @@ import org.keycloak.common.enums.SslRequired;
|
||||||
import org.keycloak.crypto.Algorithm;
|
import org.keycloak.crypto.Algorithm;
|
||||||
import org.keycloak.events.Details;
|
import org.keycloak.events.Details;
|
||||||
import org.keycloak.events.Errors;
|
import org.keycloak.events.Errors;
|
||||||
|
import org.keycloak.events.EventType;
|
||||||
import org.keycloak.jose.jws.JWSHeader;
|
import org.keycloak.jose.jws.JWSHeader;
|
||||||
import org.keycloak.jose.jws.JWSInput;
|
import org.keycloak.jose.jws.JWSInput;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
|
@ -42,9 +45,11 @@ 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.IDToken;
|
||||||
import org.keycloak.representations.RefreshToken;
|
import org.keycloak.representations.RefreshToken;
|
||||||
|
import org.keycloak.representations.UserInfo;
|
||||||
import org.keycloak.representations.idm.ClientRepresentation;
|
import org.keycloak.representations.idm.ClientRepresentation;
|
||||||
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.UserSessionRepresentation;
|
||||||
import org.keycloak.testsuite.AbstractKeycloakTest;
|
import org.keycloak.testsuite.AbstractKeycloakTest;
|
||||||
import org.keycloak.testsuite.AssertEvents;
|
import org.keycloak.testsuite.AssertEvents;
|
||||||
import org.keycloak.testsuite.admin.ApiUtil;
|
import org.keycloak.testsuite.admin.ApiUtil;
|
||||||
|
@ -61,6 +66,7 @@ import org.keycloak.testsuite.util.TokenSignatureUtil;
|
||||||
import org.keycloak.testsuite.util.UserManager;
|
import org.keycloak.testsuite.util.UserManager;
|
||||||
import org.keycloak.testsuite.util.WaitUtils;
|
import org.keycloak.testsuite.util.WaitUtils;
|
||||||
import org.keycloak.util.BasicAuthHelper;
|
import org.keycloak.util.BasicAuthHelper;
|
||||||
|
import org.keycloak.util.JsonSerialization;
|
||||||
|
|
||||||
import javax.ws.rs.client.Client;
|
import javax.ws.rs.client.Client;
|
||||||
import javax.ws.rs.client.Entity;
|
import javax.ws.rs.client.Entity;
|
||||||
|
@ -83,6 +89,7 @@ import static org.junit.Assert.assertFalse;
|
||||||
import static org.junit.Assert.assertNotNull;
|
import static org.junit.Assert.assertNotNull;
|
||||||
import static org.junit.Assert.assertNull;
|
import static org.junit.Assert.assertNull;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.junit.Assert.fail;
|
||||||
import static org.keycloak.protocol.oidc.OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT;
|
import static org.keycloak.protocol.oidc.OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT;
|
||||||
import static org.keycloak.protocol.oidc.OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN;
|
import static org.keycloak.protocol.oidc.OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN;
|
||||||
import static org.keycloak.testsuite.Assert.assertExpiration;
|
import static org.keycloak.testsuite.Assert.assertExpiration;
|
||||||
|
@ -388,10 +395,11 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
|
||||||
|
|
||||||
events.expectRefresh(refreshToken1.getId(), sessionId).removeDetail(Details.TOKEN_ID).removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
|
events.expectRefresh(refreshToken1.getId(), sessionId).removeDetail(Details.TOKEN_ID).removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
|
||||||
|
|
||||||
|
// Client session invalidated hence old refresh token not valid anymore
|
||||||
setTimeOffset(6);
|
setTimeOffset(6);
|
||||||
oauth.doRefreshTokenRequest(response2.getRefreshToken(), "password");
|
OAuthClient.AccessTokenResponse response4 = oauth.doRefreshTokenRequest(response2.getRefreshToken(), "password");
|
||||||
|
assertEquals(400, response4.getStatusCode());
|
||||||
events.expectRefresh(refreshToken2.getId(), sessionId).assertEvent();
|
events.expectRefresh(refreshToken2.getId(), sessionId).removeDetail(Details.TOKEN_ID).removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
|
||||||
} finally {
|
} finally {
|
||||||
setTimeOffset(0);
|
setTimeOffset(0);
|
||||||
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false);
|
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false);
|
||||||
|
@ -459,13 +467,14 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
|
||||||
.removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
|
.removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
|
||||||
|
|
||||||
setTimeOffset(10);
|
setTimeOffset(10);
|
||||||
// Refresh token from reuse is still valid.
|
// Refresh token from reuse is not valid. Client session was invalidated
|
||||||
OAuthClient.AccessTokenResponse responseUseOfValidRefreshToken =
|
OAuthClient.AccessTokenResponse responseUseOfValidRefreshToken =
|
||||||
oauth.doRefreshTokenRequest(responseFirstReuse.getRefreshToken(), "password");
|
oauth.doRefreshTokenRequest(responseFirstReuse.getRefreshToken(), "password");
|
||||||
|
|
||||||
assertEquals(200, responseUseOfValidRefreshToken.getStatusCode());
|
assertEquals(400, responseUseOfValidRefreshToken.getStatusCode());
|
||||||
|
|
||||||
events.expectRefresh(newTokenFirstReuse.getId(), sessionId).assertEvent();
|
events.expectRefresh(newTokenFirstReuse.getId(), sessionId).removeDetail(Details.TOKEN_ID)
|
||||||
|
.removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
|
||||||
} finally {
|
} finally {
|
||||||
setTimeOffset(0);
|
setTimeOffset(0);
|
||||||
RealmManager.realm(adminClient.realm("test"))
|
RealmManager.realm(adminClient.realm("test"))
|
||||||
|
@ -551,10 +560,11 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
|
||||||
|
|
||||||
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false);
|
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false);
|
||||||
|
|
||||||
// Config changed, token can be reused again
|
// Config changed, token cannot be used again at this point due the client session invalidated
|
||||||
processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
|
OAuthClient.AccessTokenResponse responseReuseExceeded2 = oauth.doRefreshTokenRequest(initialResponse.getRefreshToken(), "password");
|
||||||
processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
|
assertEquals(400, responseReuseExceeded2.getStatusCode());
|
||||||
processExpectedValidRefresh(sessionId, initialRefreshToken, initialResponse.getRefreshToken());
|
events.expectRefresh(initialRefreshToken.getId(), sessionId).removeDetail(Details.TOKEN_ID)
|
||||||
|
.removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
|
||||||
} finally {
|
} finally {
|
||||||
setTimeOffset(0);
|
setTimeOffset(0);
|
||||||
RealmManager.realm(adminClient.realm("test"))
|
RealmManager.realm(adminClient.realm("test"))
|
||||||
|
@ -563,6 +573,105 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Doublecheck that with "revokeRefreshToken" and revoked tokens, the SSO re-authentication won't cause old tokens to be valid again
|
||||||
|
@Test
|
||||||
|
public void refreshTokenReuseTokenWithRefreshTokensRevokedAndSSOReauthentication() throws Exception {
|
||||||
|
try {
|
||||||
|
// Initial login
|
||||||
|
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(true);
|
||||||
|
|
||||||
|
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 response1 = oauth.doAccessTokenRequest(code, "password");
|
||||||
|
RefreshToken refreshToken1 = oauth.parseRefreshToken(response1.getRefreshToken());
|
||||||
|
|
||||||
|
events.expectCodeToToken(codeId, sessionId).assertEvent();
|
||||||
|
|
||||||
|
// Refresh token for the first time - should pass
|
||||||
|
setTimeOffset(2);
|
||||||
|
|
||||||
|
OAuthClient.AccessTokenResponse response2 = oauth.doRefreshTokenRequest(response1.getRefreshToken(), "password");
|
||||||
|
RefreshToken refreshToken2 = oauth.parseRefreshToken(response2.getRefreshToken());
|
||||||
|
|
||||||
|
assertEquals(200, response2.getStatusCode());
|
||||||
|
|
||||||
|
events.expectRefresh(refreshToken1.getId(), sessionId).assertEvent();
|
||||||
|
|
||||||
|
// Client sessions is available now
|
||||||
|
Assert.assertTrue(hasClientSessionForTestApp());
|
||||||
|
|
||||||
|
// Refresh token for the second time - should fail and invalidate client session
|
||||||
|
setTimeOffset(4);
|
||||||
|
|
||||||
|
OAuthClient.AccessTokenResponse response3 = oauth.doRefreshTokenRequest(response1.getRefreshToken(), "password");
|
||||||
|
|
||||||
|
assertEquals(400, response3.getStatusCode());
|
||||||
|
|
||||||
|
events.expectRefresh(refreshToken1.getId(), sessionId).removeDetail(Details.TOKEN_ID).removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
|
||||||
|
|
||||||
|
// No client sessions available after revoke
|
||||||
|
Assert.assertFalse(hasClientSessionForTestApp());
|
||||||
|
|
||||||
|
// Introspection with the accessToken from the first authentication. This should fail
|
||||||
|
String introspectionResponse = oauth.introspectAccessTokenWithClientCredential("test-app", "password", response1.getAccessToken());
|
||||||
|
JsonNode jsonNode = JsonSerialization.mapper.readTree(introspectionResponse);
|
||||||
|
Assert.assertFalse(jsonNode.get("active").asBoolean());
|
||||||
|
events.clear();
|
||||||
|
|
||||||
|
// SSO re-authentication
|
||||||
|
setTimeOffset(6);
|
||||||
|
|
||||||
|
oauth.openLoginForm();
|
||||||
|
|
||||||
|
loginEvent = events.expectLogin().assertEvent();
|
||||||
|
sessionId = loginEvent.getSessionId();
|
||||||
|
codeId = loginEvent.getDetails().get(Details.CODE_ID);
|
||||||
|
code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||||
|
|
||||||
|
OAuthClient.AccessTokenResponse response4 = oauth.doAccessTokenRequest(code, "password");
|
||||||
|
RefreshToken refreshToken4 = oauth.parseRefreshToken(response4.getRefreshToken());
|
||||||
|
events.expectCodeToToken(codeId, sessionId).assertEvent();
|
||||||
|
|
||||||
|
// Client sessions should be available again now after re-authentication
|
||||||
|
Assert.assertTrue(hasClientSessionForTestApp());
|
||||||
|
|
||||||
|
// Introspection again with the accessToken from the very first authentication. This should fail as the access token was obtained for the old client session before SSO re-authentication
|
||||||
|
introspectionResponse = oauth.introspectAccessTokenWithClientCredential("test-app", "password", response1.getAccessToken());
|
||||||
|
jsonNode = JsonSerialization.mapper.readTree(introspectionResponse);
|
||||||
|
Assert.assertFalse(jsonNode.get("active").asBoolean());
|
||||||
|
|
||||||
|
// Try userInfo with the same old access token. Should fail as well
|
||||||
|
UserInfo userInfo = oauth.doUserInfoRequest(response1.getAccessToken());
|
||||||
|
Assert.assertNull(userInfo.getSubject());
|
||||||
|
Assert.assertEquals(userInfo.getOtherClaims().get(OAuth2Constants.ERROR), OAuthErrorException.INVALID_TOKEN);
|
||||||
|
events.clear();
|
||||||
|
|
||||||
|
// Try to refresh with one of the old refresh tokens before SSO re-authentication - should fail
|
||||||
|
setTimeOffset(8);
|
||||||
|
|
||||||
|
OAuthClient.AccessTokenResponse response5 = oauth.doRefreshTokenRequest(response2.getRefreshToken(), "password");
|
||||||
|
assertEquals(400, response5.getStatusCode());
|
||||||
|
events.expectRefresh(refreshToken2.getId(), sessionId).removeDetail(Details.TOKEN_ID).removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent();
|
||||||
|
} finally {
|
||||||
|
setTimeOffset(0);
|
||||||
|
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if "test-user@localhost" has any user session with client session for "test-app"
|
||||||
|
private boolean hasClientSessionForTestApp() {
|
||||||
|
List<UserSessionRepresentation> userSessions = ApiUtil.findUserByUsernameId(adminClient.realm("test"), "test-user@localhost").getUserSessions();
|
||||||
|
return userSessions.stream()
|
||||||
|
.anyMatch(userSession -> userSession.getClients().containsValue("test-app"));
|
||||||
|
}
|
||||||
|
|
||||||
private void processExpectedValidRefresh(String sessionId, RefreshToken requestToken, String refreshToken) {
|
private void processExpectedValidRefresh(String sessionId, RefreshToken requestToken, String refreshToken) {
|
||||||
OAuthClient.AccessTokenResponse response2 = oauth.doRefreshTokenRequest(refreshToken, "password");
|
OAuthClient.AccessTokenResponse response2 = oauth.doRefreshTokenRequest(refreshToken, "password");
|
||||||
|
|
||||||
|
@ -572,9 +681,6 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
String privateKey;
|
|
||||||
String publicKey;
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void refreshTokenClientDisabled() throws Exception {
|
public void refreshTokenClientDisabled() throws Exception {
|
||||||
oauth.doLogin("test-user@localhost", "password");
|
oauth.doLogin("test-user@localhost", "password");
|
||||||
|
|
Loading…
Reference in a new issue