From 26fe8dc7d80a9caf7de59a80d0ce8f2828672faf Mon Sep 17 00:00:00 2001 From: Ruchika Jha Date: Thu, 11 Dec 2025 12:58:04 +0000 Subject: [PATCH] Added validation for client session timeout post comparing the realm session timeouts Closes #41019 Signed-off-by: ruchikajha95 Signed-off-by: Alexander Schwartz --- .../topics/changes/changes-26_5_0.adoc | 16 +++- .../keycloak/services/messages/Messages.java | 6 ++ .../DefaultClientValidationProvider.java | 73 ++++++++++++++++++- .../org/keycloak/tests/admin/ClientTest.java | 68 +++++++++++++++++ .../admin/messages/messages_en.properties | 9 +++ 5 files changed, 170 insertions(+), 2 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 1aa322cc99e..7822376b7be 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc @@ -1,12 +1,26 @@ // ------------------------ Breaking changes ------------------------ // == Breaking changes -= Loopback Hostname Verification on Windows +Breaking changes are identified as those that might require changes for existing users to their configurations or applications. +In minor or patch releases, {project_name} will only introduce breaking changes to fix bugs. + +=== Loopback Hostname Verification on Windows Setups on Windows that previously relied on custom machine names or non-standard hostnames for loopback (for example, `127.0.0.1` resolving to a custom name) may require updates to their trusted domain configuration. Only `localhost` and `*.localhost` are now recognized for loopback verification. {project_name} now consistently normalizes loopback addresses to `localhost` for domain verification across all platforms. This change ensures predictable behavior for trusted domain checks, regardless of the underlying OS. +=== 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. +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. + +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. + // ------------------------ Notable changes ------------------------ // == Notable changes diff --git a/services/src/main/java/org/keycloak/services/messages/Messages.java b/services/src/main/java/org/keycloak/services/messages/Messages.java index c3dc8f34442..f44438b6add 100755 --- a/services/src/main/java/org/keycloak/services/messages/Messages.java +++ b/services/src/main/java/org/keycloak/services/messages/Messages.java @@ -347,4 +347,10 @@ public class Messages { public static final String CONFIRM_ORGANIZATION_MEMBERSHIP = "organization.confirm-membership"; public static final String CONFIRM_ORGANIZATION_MEMBERSHIP_TITLE = "organization.confirm-membership.title"; public static final String REGISTER_ORGANIZATION_MEMBER = "organization.member.register.title"; + + // Client sessions + public static final String CLIENT_IDLE_REMEMBERME = "clientIdleExceedsRealmRememberMeIdle"; + public static final String CLIENT_IDLE = "clientSessionIdleTimeoutExceedsRealm"; + public static final String CLIENT_MAXLIFE_SPAN = "clientSessionMaxLifespanExceedsRealm"; + public static final String CLIENT_MAXLIFESPAN_REMEMBERME = "clientSessionMaxLifespanExceedsRealmRememberMeMaxSpan"; } diff --git a/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java b/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java index 79209630ec1..514073abcca 100644 --- a/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java +++ b/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java @@ -28,6 +28,7 @@ import java.util.Set; import org.keycloak.authentication.authenticators.util.LoAUtil; import org.keycloak.models.ClientModel; +import org.keycloak.models.RealmModel; import org.keycloak.protocol.ProtocolMapperConfigException; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCConfigAttributes; @@ -41,6 +42,7 @@ import org.keycloak.protocol.saml.SamlConfigAttributes; import org.keycloak.protocol.saml.SamlProtocol; import org.keycloak.representations.idm.ProtocolMapperRepresentation; import org.keycloak.representations.oidc.OIDCClientRepresentation; +import org.keycloak.services.messages.Messages; import org.keycloak.services.util.ResolveRelative; import org.keycloak.utils.StringUtil; @@ -193,6 +195,7 @@ public class DefaultClientValidationProvider implements ClientValidationProvider validateJwks(context); validateDefaultAcrValues(context); validateMinimumAcrValue(context); + validateClientSessionTimeout(context); return context.toResult(); } @@ -205,7 +208,7 @@ public class DefaultClientValidationProvider implements ClientValidationProvider new CibaClientValidation(context).validate(); validateDefaultAcrValues(context); validateMinimumAcrValue(context); - + //context.getSession().getContext().getRealm(). return context.toResult(); } @@ -219,6 +222,7 @@ public class DefaultClientValidationProvider implements ClientValidationProvider private void validateUrls(ValidationContext context) { ClientModel client = context.getObjectToValidate(); + // Use a fake URL for validating relative URLs as we may not be validating clients in the context of a request (import at startup) String authServerUrl = "https://localhost/auth"; @@ -421,4 +425,71 @@ public class DefaultClientValidationProvider implements ClientValidationProvider } } } + private void validateClientSessionTimeout(ValidationContext context) { + ClientModel clientModel = context.getObjectToValidate(); + if (clientModel == null ) return; + RealmModel realmModel = clientModel.getRealm(); + if (realmModel == null ) return; + + //Realm values + int realmIdle = realmModel.getSsoSessionIdleTimeout(); + int realmMax = realmModel.getSsoSessionMaxLifespan(); + int realmRememberIdle = realmModel.getSsoSessionIdleTimeoutRememberMe(); + int realmRememberMax = realmModel.getSsoSessionMaxLifespanRememberMe(); + + boolean rememberMeEnabled = realmModel.isRememberMe(); + + Integer clientIdle = parseIntAttribute(clientModel.getAttribute(OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT)); + Integer clientMax = parseIntAttribute(clientModel.getAttribute(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN)); + + if(!rememberMeEnabled) { + // Client idle Timeout validation on Remember me disabled + if (clientIdle != null && clientIdle > realmIdle) { + context.addError( + OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT, + "Client session idle timeout cannot exceed realm SSO session idle timeout.", + Messages.CLIENT_IDLE + ); + } + + // Max Lifespan validation on Remember me disabled + if (clientMax != null && clientMax > realmMax) { + context.addError( + OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN, + "Client session max lifespan cannot exceed realm SSO session max lifespan.", + Messages.CLIENT_MAXLIFE_SPAN + ); + } + } else { + int allowedMaxIdleTimeIfRememberMeEnabled = Math.max(realmIdle, realmRememberIdle); + int allowedMaxSpanIfRememberMeEnabled = Math.max(realmMax,realmRememberMax); + + //Client idle Timeout validation on Remember me enabled + if (clientIdle != null && clientIdle > allowedMaxIdleTimeIfRememberMeEnabled) { + context.addError( + OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT, + "Client session idle timeout cannot exceed realm SSO session idle timeout and RememberMe idle timeout.", + Messages.CLIENT_IDLE_REMEMBERME + ); + } + + // Max Lifespan validation on Remember me enabled + if (clientMax != null && clientMax > allowedMaxSpanIfRememberMeEnabled) { + context.addError( + OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN, + "Client session max lifespan cannot exceed realm SSO session max lifespan and RememberMe Max span.", + Messages.CLIENT_MAXLIFESPAN_REMEMBERME + ); + } + } + + } + + private Integer parseIntAttribute(String value) { + try { + return (value == null || value.isEmpty()) ? null : Integer.parseInt(value); + } catch (NumberFormatException e) { + return null; + } + } } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/ClientTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/ClientTest.java index e917c15eecd..abffe6565a7 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/ClientTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/ClientTest.java @@ -54,6 +54,7 @@ import org.keycloak.representations.adapters.action.TestAvailabilityAction; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.OAuth2ErrorRepresentation; import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserSessionRepresentation; @@ -1051,4 +1052,71 @@ public class ClientTest { return realm; } } + + @Test + public void testClientSessionTimeoutValidation() { + ClientRepresentation clientRepresentation = createClient(); + clientRepresentation.setAttributes(new HashMap<>()); + ClientResource clientResource = managedRealm.admin().clients().get(clientRepresentation.getId()); + ClientRepresentation rep = clientResource.toRepresentation(); + if (rep.getAttributes() == null) { + rep.setAttributes(new HashMap<>()); + } + + RealmRepresentation oldRepresentation = managedRealm.admin().toRepresentation(); + managedRealm.cleanup().add(rr -> { + rr.update(oldRepresentation); + }); + + // Remember-Me Disabled + RealmRepresentation realm = managedRealm.admin().toRepresentation(); + realm.setRememberMe(false); + realm.setSsoSessionIdleTimeout(300); + realm.setSsoSessionMaxLifespan(600); + managedRealm.admin().update(realm); + + // Happy path + rep = clientResource.toRepresentation(); + rep.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT, "200"); + rep.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN, "500"); + managedRealm.admin().clients().get(rep.getId()).update(rep); + + // Failing due to idle time + rep = clientResource.toRepresentation(); + rep.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT, "400"); + rep.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN, "500"); + createOrUpdateClientExpectingValidationErrors(rep, false, + "Client session idle timeout cannot exceed realm SSO session idle timeout."); + + // Fix idle, break max lifespan + rep.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT, "200"); + rep.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN, "700"); + createOrUpdateClientExpectingValidationErrors(rep, false, + "Client session max lifespan cannot exceed realm SSO session max lifespan."); + + // Remember-Me Enabled + realm = managedRealm.admin().toRepresentation(); + realm.setRememberMe(true); + realm.setSsoSessionIdleTimeoutRememberMe(500); + realm.setSsoSessionMaxLifespanRememberMe(900); + managedRealm.admin().update(realm); + + // Happy path + rep = clientResource.toRepresentation(); + rep.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT, "400"); + rep.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN, "800"); + managedRealm.admin().clients().get(rep.getId()).update(rep); + + // Failing due to idle time + rep.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT, "550"); + rep.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN, "800"); + createOrUpdateClientExpectingValidationErrors(rep, false, + "Client session idle timeout cannot exceed realm SSO session idle timeout and RememberMe idle timeout."); + + // Failing due to lifetime + rep.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT, "300"); + rep.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN, "950"); + createOrUpdateClientExpectingValidationErrors(rep, false, + "Client session max lifespan cannot exceed realm SSO session max lifespan and RememberMe Max span."); + } } diff --git a/themes/src/main/resources/theme/base/admin/messages/messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/messages_en.properties index 47cec4eaa25..df039d8fba0 100644 --- a/themes/src/main/resources/theme/base/admin/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/messages_en.properties @@ -46,6 +46,15 @@ pairwiseRedirectURIsMismatch=Client redirect URIs does not match redirect URIs f duplicatedJwksSettings=The "Use JWKS" switch and the switch "Use JWKS URL" cannot be ON at the same time. +# Shown to an admin when they enter a client specific SSO idle timeout that fails validation +clientIdleExceedsRealmRememberMeIdle=Client session idle timeout cannot exceed realm SSO session idle timeout and RememberMe idle timeout. +# Shown to an admin when they enter a client specific SSO idle timeout that fails validation +clientSessionIdleTimeoutExceedsRealm=Client session idle timeout cannot exceed realm SSO session idle timeout. +# Shown to an admin when they enter a client specific SSO max lifetime that fails validation +clientSessionMaxLifespanExceedsRealm=Client session max lifespan cannot exceed realm SSO session max lifespan. +# Shown to an admin when they enter a client specific SSO max lifetime that fails validation +clientSessionMaxLifespanExceedsRealmRememberMeMaxSpan=Client session max lifespan cannot exceed realm SSO session max lifespan and RememberMe Max span. + error-invalid-value=Invalid value. error-invalid-blank=Please specify value. error-empty=Please specify value.