diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java index 685b975d2e..0182b25d0b 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java @@ -90,25 +90,43 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv PersistentAuthenticatedClientSessionAdapter adapter = new PersistentAuthenticatedClientSessionAdapter(session, clientSession); PersistentClientSessionModel model = adapter.getUpdatedModel(); - PersistentClientSessionEntity entity = new PersistentClientSessionEntity(); + String userSessionId = clientSession.getUserSession().getId(); + String clientId; + String clientStorageProvider; + String externalClientId; StorageId clientStorageId = new StorageId(clientSession.getClient().getId()); if (clientStorageId.isLocal()) { - entity.setClientId(clientStorageId.getId()); - entity.setClientStorageProvider(PersistentClientSessionEntity.LOCAL); - entity.setExternalClientId(PersistentClientSessionEntity.LOCAL); - + clientId = clientStorageId.getId(); + clientStorageProvider = PersistentClientSessionEntity.LOCAL; + externalClientId = PersistentClientSessionEntity.LOCAL; } else { - entity.setClientId(PersistentClientSessionEntity.EXTERNAL); - entity.setClientStorageProvider(clientStorageId.getProviderId()); - entity.setExternalClientId(clientStorageId.getExternalId()); + clientId = PersistentClientSessionEntity.EXTERNAL; + clientStorageProvider = clientStorageId.getProviderId(); + externalClientId = clientStorageId.getExternalId(); } - entity.setTimestamp(clientSession.getTimestamp()); String offlineStr = offlineToString(offline); - entity.setOffline(offlineStr); - entity.setUserSessionId(clientSession.getUserSession().getId()); + boolean exists = false; + + PersistentClientSessionEntity entity = em.find(PersistentClientSessionEntity.class, new PersistentClientSessionEntity.Key(userSessionId, clientId, clientStorageProvider, externalClientId, offlineStr)); + if (entity != null) { + // client session can already exist in some circumstances (EG. in case it was already present, but expired in the infinispan, but not yet expired in the DB) + exists = true; + } else { + entity = new PersistentClientSessionEntity(); + entity.setUserSessionId(userSessionId); + entity.setClientId(clientId); + entity.setClientStorageProvider(clientStorageProvider); + entity.setExternalClientId(externalClientId); + entity.setOffline(offlineStr); + } + + entity.setTimestamp(clientSession.getTimestamp()); entity.setData(model.getData()); - em.persist(entity); - em.flush(); + + if (!exists) { + em.persist(entity); + em.flush(); + } } @Override 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 6a22a816a7..6f27b5b753 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 @@ -57,6 +57,7 @@ import org.keycloak.testsuite.arquillian.annotation.DisableFeature; import org.keycloak.testsuite.auth.page.AuthRealm; import org.keycloak.testsuite.pages.AccountApplicationsPage; import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.util.ClientBuilder; import org.keycloak.testsuite.util.ClientManager; import org.keycloak.testsuite.util.OAuthClient; @@ -743,6 +744,64 @@ public class OfflineTokenTest extends AbstractKeycloakTest { testOfflineSessionExpiration(IDLE_LIFESPAN, MAX_LIFESPAN, IDLE_LIFESPAN + SessionTimeoutHelper.IDLE_TIMEOUT_WINDOW_SECONDS + 60); } + // Issue 13706 + @Test + public void offlineTokenReauthenticationWhenOfflinClientSessionExpired() throws Exception { + // expect that offline session expired by idle timeout + final int MAX_LIFESPAN = 360000; + final int IDLE_LIFESPAN = 900; + + getTestingClient().testing().setTestingInfinispanTimeService(); + + int prev[] = null; + try (RealmAttributeUpdater rau = new RealmAttributeUpdater(adminClient.realm("test")).setSsoSessionIdleTimeout(900).update()) { + // Step 1 - offline login with "offline-client" + prev = changeOfflineSessionSettings(true, MAX_LIFESPAN, IDLE_LIFESPAN); + + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.clientId("offline-client"); + oauth.redirectUri(offlineClientAppUri); + + oauth.doLogin("test-user@localhost", "password"); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "secret1"); + assertOfflineToken(tokenResponse); + + // Step 2 - set some offset to refresh SSO session and offline user session. But use different client, so that we don't refresh offlineClientSession of client "offline-client" + setTimeOffset(800); + oauth.clientId("test-app"); + oauth.redirectUri(APP_ROOT + "/auth"); + oauth.openLoginForm(); + + code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + tokenResponse = oauth.doAccessTokenRequest(code, "password"); + assertOfflineToken(tokenResponse); + + // Step 3 - set bigger time offset and login with the original client "offline-token". Login should be successful and offline client session for "offline-client" should be re-created now + setTimeOffset(900 + SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 20); + oauth.clientId("offline-client"); + oauth.redirectUri(offlineClientAppUri); + oauth.openLoginForm(); + + code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + tokenResponse = oauth.doAccessTokenRequest(code, "secret1"); + assertOfflineToken(tokenResponse); + + } finally { + getTestingClient().testing().revertTestingInfinispanTimeService(); + changeOfflineSessionSettings(false, prev[0], prev[1]); + } + } + + // Asserts that refresh token in the tokenResponse is offlineToken. Return parsed offline token + private RefreshToken assertOfflineToken(OAuthClient.AccessTokenResponse tokenResponse) { + Assert.assertEquals(200, tokenResponse.getStatusCode()); + String offlineTokenString = tokenResponse.getRefreshToken(); + RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); + assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); + return offlineToken; + } + @Test public void offlineTokenRequest_ClientES256_RealmPS256() throws Exception { conductOfflineTokenRequest(Algorithm.HS256, Algorithm.ES256, Algorithm.PS256);