From 91865fa93e3169cf31d03c8c37578c14a0ebff60 Mon Sep 17 00:00:00 2001 From: mposolda Date: Wed, 2 Jun 2021 07:53:09 +0200 Subject: [PATCH] KEYCLOAK-18368 Invalidate client session after refresh token re-use --- .../representations/JsonWebToken.java | 9 ++ .../InfinispanUserSessionProvider.java | 2 + .../userSession/MapUserSessionProvider.java | 5 +- .../AuthenticatedClientSessionModel.java | 8 ++ .../keycloak/protocol/oidc/TokenManager.java | 38 +++-- .../oidc/endpoints/UserInfoEndpoint.java | 14 +- .../LastSessionRefreshCrossDCTest.java | 6 +- .../testsuite/oauth/OfflineTokenTest.java | 11 +- .../testsuite/oauth/RefreshTokenTest.java | 132 ++++++++++++++++-- 9 files changed, 194 insertions(+), 31 deletions(-) diff --git a/core/src/main/java/org/keycloak/representations/JsonWebToken.java b/core/src/main/java/org/keycloak/representations/JsonWebToken.java index b61615b820..ba8a0feae2 100755 --- a/core/src/main/java/org/keycloak/representations/JsonWebToken.java +++ b/core/src/main/java/org/keycloak/representations/JsonWebToken.java @@ -148,6 +148,15 @@ public class JsonWebToken implements Serializable, Token { 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() { return iat; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java index 5f44334e33..69e7d014dd 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java @@ -188,6 +188,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { AuthenticatedClientSessionEntity entity = new AuthenticatedClientSessionEntity(clientSessionId); entity.setRealmId(realm.getId()); entity.setTimestamp(Time.currentTime()); + entity.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(entity.getTimestamp())); InfinispanChangelogBasedTransaction userSessionUpdateTx = getTransaction(false); InfinispanChangelogBasedTransaction clientSessionUpdateTx = getClientSessionTransaction(false); @@ -773,6 +774,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { // update timestamp to current time offlineClientSession.setTimestamp(Time.currentTime()); + offlineClientSession.getNotes().put(AuthenticatedClientSessionModel.STARTED_AT_NOTE, String.valueOf(offlineClientSession.getTimestamp())); session.getProvider(UserSessionPersisterProvider.class).createClientSession(clientSession, true); diff --git a/model/map/src/main/java/org/keycloak/models/map/userSession/MapUserSessionProvider.java b/model/map/src/main/java/org/keycloak/models/map/userSession/MapUserSessionProvider.java index fc404c9b65..556867cbd6 100644 --- a/model/map/src/main/java/org/keycloak/models/map/userSession/MapUserSessionProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/userSession/MapUserSessionProvider.java @@ -157,6 +157,7 @@ public class MapUserSessionProvider implements UserSessionProvider { public AuthenticatedClientSessionModel createClientSession(RealmModel realm, ClientModel client, UserSessionModel userSession) { MapAuthenticatedClientSessionEntity entity = 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); LOG.tracef("createClientSession(%s, %s, %s)%s", realm, client, userSession, getShortStackTrace()); @@ -472,7 +473,9 @@ public class MapUserSessionProvider implements UserSessionProvider { LOG.tracef("createOfflineClientSession(%s, %s)%s", clientSession, offlineUserSession, getShortStackTrace()); MapAuthenticatedClientSessionEntity 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()); Optional> userSessionEntity = getOfflineUserSessionEntityStream(clientSession.getRealm(), offlineUserSession.getId()).findFirst(); diff --git a/server-spi/src/main/java/org/keycloak/models/AuthenticatedClientSessionModel.java b/server-spi/src/main/java/org/keycloak/models/AuthenticatedClientSessionModel.java index a6410a9dc3..8a0cabee2d 100644 --- a/server-spi/src/main/java/org/keycloak/models/AuthenticatedClientSessionModel.java +++ b/server-spi/src/main/java/org/keycloak/models/AuthenticatedClientSessionModel.java @@ -37,8 +37,16 @@ public interface AuthenticatedClientSessionModel extends CommonClientSessionMode public static final SearchableModelField TIMESTAMP = new SearchableModelField<>("timestamp", Integer.class); } + String STARTED_AT_NOTE = "startedAt"; + 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(); void setTimestamp(int timestamp); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 9969ee86b7..360229e310 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -67,6 +67,7 @@ import org.keycloak.representations.RefreshToken; import org.keycloak.services.ErrorResponseException; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.services.managers.ResourceAdminManager; import org.keycloak.services.managers.UserSessionCrossDCManager; import org.keycloak.services.managers.UserSessionManager; import org.keycloak.services.resources.IdentityBrokerService; @@ -155,7 +156,7 @@ public class TokenManager { 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"); 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())) { 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; } + AuthenticatedClientSessionModel clientSession = userSession == null ? null : userSession.getAuthenticatedClientSessionByClient(client.getId()); + if (clientSession != null) { + if (valid && (token.isIssuedBeforeSessionStart(clientSession.getStarted()))) { + valid = false; + } + } + String tokenType = token.getType(); if (realm.isRevokeRefreshToken() && (tokenType.equals(TokenUtil.TOKEN_TYPE_REFRESH) || tokenType.equals(TokenUtil.TOKEN_TYPE_OFFLINE)) @@ -273,7 +286,6 @@ public class TokenManager { if (valid) { int currentTime = Time.currentTime(); userSession.setLastSessionRefresh(currentTime); - AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); if (clientSession != null) { clientSession.setTimestamp(currentTime); } @@ -296,7 +308,8 @@ public class TokenManager { AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); try { - return validateTokenReuse(session, realm, token, clientSession, false); + validateTokenReuse(session, realm, token, clientSession, false); + return true; } catch (OAuthErrorException e) { return false; } @@ -403,14 +416,23 @@ public class TokenManager { TokenValidation validation) throws OAuthErrorException { if (realm.isRevokeRefreshToken()) { AuthenticatedClientSessionModel clientSession = validation.clientSessionCtx.getClientSession(); - if (validateTokenReuse(session, realm, refreshToken, clientSession, true)) { + try { + validateTokenReuse(session, realm, refreshToken, clientSession, true); int currentCount = clientSession.getCurrentRefreshTokenUseCount(); 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 { int clusterStartupTime = session.getProvider(ClusterProvider.class).getClusterStartupTime(); @@ -426,7 +448,7 @@ public class TokenManager { clientSession.setCurrentRefreshToken(refreshToken.getId()); clientSession.setCurrentRefreshTokenUseCount(0); } else { - return true; + return; } } @@ -435,7 +457,7 @@ public class TokenManager { throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "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 { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java index 4d349e945b..19705bba70 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java @@ -287,13 +287,13 @@ public class UserInfoEndpoint { UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), false, client.getId()); UserSessionModel offlineUserSession = null; if (AuthenticationManager.isSessionValid(realm, userSession)) { - checkTokenIssuedAt(token, userSession, event); + checkTokenIssuedAt(token, userSession, event, client); event.session(userSession); return userSession; } else { offlineUserSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), true, client.getId()); if (AuthenticationManager.isOfflineSessionValid(realm, offlineUserSession)) { - checkTokenIssuedAt(token, offlineUserSession, event); + checkTokenIssuedAt(token, offlineUserSession, event, client); event.session(offlineUserSession); return offlineUserSession; } @@ -314,8 +314,14 @@ public class UserInfoEndpoint { throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Session expired"); } - private void checkTokenIssuedAt(AccessToken token, UserSessionModel userSession, EventBuilder event) throws CorsErrorResponseException { - if (token.getIssuedAt() + 1 < userSession.getStarted()) { + private void checkTokenIssuedAt(AccessToken token, UserSessionModel userSession, EventBuilder event, ClientModel client) throws CorsErrorResponseException { + 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); throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Stale token"); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/LastSessionRefreshCrossDCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/LastSessionRefreshCrossDCTest.java index ca64e888fe..b0f5dc04a6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/LastSessionRefreshCrossDCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/crossdc/LastSessionRefreshCrossDCTest.java @@ -105,10 +105,10 @@ public class LastSessionRefreshCrossDCTest extends AbstractAdminCrossDCTest { Assert.assertNull("Expecting no access token present", tokenResponse.getAccessToken()); 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"); - Assert.assertNotNull(tokenResponse.getAccessToken()); - Assert.assertNull(tokenResponse.getError()); + Assert.assertNull("Expecting no access token present", tokenResponse.getAccessToken()); + Assert.assertNotNull(tokenResponse.getError()); // Revert realmRep = testRealm().toRepresentation(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java index 9404002535..944a496f07 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java @@ -392,8 +392,15 @@ public class OfflineTokenTest extends AbstractKeycloakTest { .clearDetails() .assertEvent(); - // Refresh with new refreshToken is successful now - testRefreshWithOfflineToken(token, offlineToken2, offlineTokenString2, offlineToken2.getSessionState(), userId); + // Refresh with new refreshToken fails as well (client session was invalidated because of attempt to refresh with revoked refresh token) + 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); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java index 6ace997d27..218cfd00f4 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java @@ -16,6 +16,7 @@ */ package org.keycloak.testsuite.oauth; +import com.fasterxml.jackson.databind.JsonNode; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.jboss.arquillian.graphene.page.Page; import org.junit.Assert; @@ -24,6 +25,7 @@ import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.RealmResource; 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.events.Details; import org.keycloak.events.Errors; +import org.keycloak.events.EventType; import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.RealmModel; @@ -42,9 +45,11 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.representations.AccessToken; import org.keycloak.representations.IDToken; import org.keycloak.representations.RefreshToken; +import org.keycloak.representations.UserInfo; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserSessionRepresentation; import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.AssertEvents; 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.WaitUtils; import org.keycloak.util.BasicAuthHelper; +import org.keycloak.util.JsonSerialization; import javax.ws.rs.client.Client; 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.assertNull; 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_MAX_LIFESPAN; 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(); + // Client session invalidated hence old refresh token not valid anymore setTimeOffset(6); - oauth.doRefreshTokenRequest(response2.getRefreshToken(), "password"); - - events.expectRefresh(refreshToken2.getId(), sessionId).assertEvent(); + OAuthClient.AccessTokenResponse response4 = oauth.doRefreshTokenRequest(response2.getRefreshToken(), "password"); + assertEquals(400, response4.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); @@ -459,13 +467,14 @@ public class RefreshTokenTest extends AbstractKeycloakTest { .removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent(); setTimeOffset(10); - // Refresh token from reuse is still valid. + // Refresh token from reuse is not valid. Client session was invalidated OAuthClient.AccessTokenResponse responseUseOfValidRefreshToken = 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 { setTimeOffset(0); RealmManager.realm(adminClient.realm("test")) @@ -551,10 +560,11 @@ public class RefreshTokenTest extends AbstractKeycloakTest { 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()); + // Config changed, token cannot be used again at this point due the client session invalidated + OAuthClient.AccessTokenResponse responseReuseExceeded2 = oauth.doRefreshTokenRequest(initialResponse.getRefreshToken(), "password"); + assertEquals(400, responseReuseExceeded2.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")) @@ -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 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) { OAuthClient.AccessTokenResponse response2 = oauth.doRefreshTokenRequest(refreshToken, "password"); @@ -572,9 +681,6 @@ public class RefreshTokenTest extends AbstractKeycloakTest { } - String privateKey; - String publicKey; - @Test public void refreshTokenClientDisabled() throws Exception { oauth.doLogin("test-user@localhost", "password");