From 60b369c6228fe88cd7a0f538078fd8ce9f0a0a67 Mon Sep 17 00:00:00 2001 From: Ruchika Jha Date: Mon, 5 Jan 2026 08:50:56 +0000 Subject: [PATCH] Validate client session timeout and lifetime settings on realm settings edit Closes #44910 Signed-off-by: Ruchika Signed-off-by: Ryan Emerson Signed-off-by: Alexander Schwartz Co-authored-by: Ryan Emerson Co-authored-by: Alexander Schwartz --- .../topics/changes/changes-26_5_0.adoc | 15 +++-- .../datastore/DefaultExportImportManager.java | 21 +++++++ .../tests/admin/realm/RealmUpdateTest.java | 59 +++++++++++++++++++ .../testsuite/oauth/RefreshTokenTest.java | 47 +-------------- 4 files changed, 92 insertions(+), 50 deletions(-) diff --git a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc index 01c49365f6b..53134230521 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc @@ -12,14 +12,21 @@ Setups on Windows that previously relied on custom machine names or non-standard === Validation of client session timeouts -Previous versions did not validate client specific settings against the realm settings for SSO session idle and max lifetime, including the remember me settings of the realm. +Previous versions did not validate client settings against the realm settings for SSO session idle and max lifetime, including the remember me settings of the realm. This leads to those settings either not being effective with unexpected results to administrators, or to refresh tokens issued where the user session might have already expired when the client was trying to refresh the token. -{project_name} now validates that a client specific settings for Client Session Idle and Client Session Max does not exceed the realm settings when a client is created or updated, and will show a validation error when the validation fails. -It does currently not validate the client settings when the realm SSO settings or remember me changes, though this might change in future releases. +{project_name} now validates when creating or updating realms that on the realm level the Client session settings do not exceed the SSO session settings. -You are only affected by the change if you have configured a client-specific Client Session Idle and Client Session Max setting in the Advanced tab of the client configuration that exceeds the realm settings. +It also validates that a client specific settings for Client Session Idle and Client Session Max does not exceed the realm settings when a client is created or updated, and will show a validation error when the validation fails. +It does currently not validate an individual's client settings when the realm SSO settings or remember me changes, though this might change in future releases. + +You are only affected by the change: + +* if you have configured a client-specific Client Session Idle and Client Session Max setting in the Advanced tab of the client configuration that exceeds the realm settings, or +* if you configured on the realm level client session settings that exceed the realm session settings. + +If you are affected by the change, you will see error messages the next time you update or import an affected client or realm. // ------------------------ Notable changes ------------------------ // == Notable changes diff --git a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java index 174d221752f..78162a4c881 100644 --- a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java @@ -900,6 +900,7 @@ public class DefaultExportImportManager implements ExportImportManager { updateCibaSettings(rep, realm); updateParSettings(rep, realm); + validateClientAndRealmTimeouts(realm); session.clientPolicy().updateRealmModelFromRepresentation(realm, rep); if (rep.getSmtpServer() != null) { @@ -1733,4 +1734,24 @@ public class DefaultExportImportManager implements ExportImportManager { } } } + + private void validateClientAndRealmTimeouts(RealmModel realm) { + if (realm.isRememberMe()) { + if (realm.getClientSessionIdleTimeout() > Math.max(realm.getSsoSessionIdleTimeout(), realm.getSsoSessionIdleTimeoutRememberMe())) { + throw new ModelException("Client session idle timeout cannot exceed realm SSO session idle timeout and RememberMe idle timeout."); + } + + if (realm.getClientSessionMaxLifespan() > Math.max(realm.getSsoSessionMaxLifespan(), realm.getSsoSessionMaxLifespanRememberMe())) { + throw new ModelException("Client session max lifespan cannot exceed realm SSO session max lifespan and RememberMe Max span."); + } + } else { + if (realm.getClientSessionIdleTimeout() > realm.getSsoSessionIdleTimeout()) { + throw new ModelException("Client Session Idle Timeout cannot be greater than Realm SSO Idle Timeout."); + } + + if (realm.getClientSessionMaxLifespan() > realm.getSsoSessionMaxLifespan()) { + throw new ModelException("Client session max lifespan cannot exceed realm SSO session max lifespan."); + } + } + } } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/realm/RealmUpdateTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/realm/RealmUpdateTest.java index 625d5d0c0f5..c0957f95d1c 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/realm/RealmUpdateTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/realm/RealmUpdateTest.java @@ -288,4 +288,63 @@ public class RealmUpdateTest extends AbstractRealmTest { assertEquals(expected.isAdminEventsEnabled(), actual.isAdminEventsEnabled()); assertEquals(expected.isAdminEventsDetailsEnabled(), actual.isAdminEventsDetailsEnabled()); } + + @Test + public void testRealmClientSessionTimeoutValidation() { + RealmRepresentation rep = managedRealm.admin().toRepresentation(); + // Remember-Me Disabled + rep.setRememberMe(false); + rep.setSsoSessionIdleTimeout(300); + rep.setSsoSessionMaxLifespan(600); + + // Invalid: client idle > realm idle + rep.setClientSessionIdleTimeout(400); + rep.setClientSessionMaxLifespan(500); + + try { + managedRealm.admin().update(rep); + Assertions.fail("Expected validation error for client idle timeout"); + } catch (Exception e) { + assertEquals("HTTP 400 Bad Request", e.getMessage()); + } + + // Fix idle, break max lifespan + rep.setClientSessionIdleTimeout(200); + rep.setClientSessionMaxLifespan(700); + + try { + managedRealm.admin().update(rep); + Assertions.fail("Expected validation error for client max lifespan"); + } catch (Exception e) { + assertEquals("HTTP 400 Bad Request", e.getMessage()); + } + // Remember-Me Enabled + rep = managedRealm.admin().toRepresentation(); + rep.setRememberMe(true); + rep.setSsoSessionIdleTimeout(300); + rep.setSsoSessionIdleTimeoutRememberMe(500); + rep.setSsoSessionMaxLifespan(600); + rep.setSsoSessionMaxLifespanRememberMe(900); + + // Invalid: exceeds allowed remember-me idle + rep.setClientSessionIdleTimeout(550); + rep.setClientSessionMaxLifespan(800); + + try { + managedRealm.admin().update(rep); + Assertions.fail("Expected validation error for remember-me idle timeout"); + } catch (Exception e) { + assertEquals("HTTP 400 Bad Request", e.getMessage()); + } + // Fix idle, break max lifespan + rep.setClientSessionIdleTimeout(300); + rep.setClientSessionMaxLifespan(950); + + try { + managedRealm.admin().update(rep); + Assertions.fail("Expected validation error for remember-me max lifespan"); + } catch (Exception e) { + assertEquals("HTTP 400 Bad Request", e.getMessage()); + } + } } 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 c8522a7959e..ac336330bba 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 @@ -1464,52 +1464,6 @@ public class RefreshTokenTest extends AbstractKeycloakTest { } } - @Test - public void refreshTokenUserClientMaxLifespanGreaterThanSession() throws Exception { - RealmResource realmResource = adminClient.realm("test"); - getTestingClient().testing().setTestingInfinispanTimeService(); - try (Closeable ignored = new RealmAttributeUpdater(realmResource) - .updateWith(r -> { - r.setSsoSessionMaxLifespan(3600); - r.setSsoSessionIdleTimeout(7200); - r.setClientSessionMaxLifespan(5000); - r.setClientSessionIdleTimeout(7200); - }).update()) { - - oauth.doLogin("test-user@localhost", "password"); - EventRepresentation loginEvent = events.expectLogin().assertEvent(); - - String sessionId = loginEvent.getSessionId(); - - String code = oauth.parseLoginResponse().getCode(); - AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code); - assertTrue("Invalid ExpiresIn", 0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 3600); - String clientSessionId = getClientSessionUuid(sessionId, loginEvent.getClientId()); - assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - - events.poll(); - - setTimeOffset(1800); - String refreshId = oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getId(); - tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); - assertTrue("Invalid ExpiresIn", 0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 1800); - assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - events.expectRefresh(refreshId, sessionId).assertEvent(); - - setTimeOffset(3700); - tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); - assertEquals(400, tokenResponse.getStatusCode()); - assertNull(tokenResponse.getAccessToken()); - assertNull(tokenResponse.getRefreshToken()); - events.expect(EventType.REFRESH_TOKEN).error(Errors.INVALID_TOKEN).user((String) null).assertEvent(); - assertEquals(0, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - } finally { - getTestingClient().testing().revertTestingInfinispanTimeService(); - events.clear(); - resetTimeOffset(); - } - } - @Test public void refreshTokenUserSessionMaxLifespanModifiedAfterTokenRefresh() throws Exception { RealmResource realmResource = adminClient.realm("test"); @@ -1537,6 +1491,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { RealmRepresentation rep = realmResource.toRepresentation(); rep.setSsoSessionMaxLifespan(3600); + rep.setClientSessionMaxLifespan(3600); realmResource.update(rep); setTimeOffset(3700);