diff --git a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java index 8af7c94b5e..e4033a915a 100755 --- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java @@ -50,6 +50,8 @@ public class RealmRepresentation { // KEYCLOAK-7688 Offline Session Max for Offline Token protected Boolean offlineSessionMaxLifespanEnabled; protected Integer offlineSessionMaxLifespan; + protected Integer clientSessionIdleTimeout; + protected Integer clientSessionMaxLifespan; protected Integer accessCodeLifespan; protected Integer accessCodeLifespanUserAction; protected Integer accessCodeLifespanLogin; @@ -369,6 +371,22 @@ public class RealmRepresentation { this.offlineSessionMaxLifespan = offlineSessionMaxLifespan; } + public Integer getClientSessionIdleTimeout() { + return clientSessionIdleTimeout; + } + + public void setClientSessionIdleTimeout(Integer clientSessionIdleTimeout) { + this.clientSessionIdleTimeout = clientSessionIdleTimeout; + } + + public Integer getClientSessionMaxLifespan() { + return clientSessionMaxLifespan; + } + + public void setClientSessionMaxLifespan(Integer clientSessionMaxLifespan) { + this.clientSessionMaxLifespan = clientSessionMaxLifespan; + } + public List getScopeMappings() { return scopeMappings; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java index 82f0c2e4ac..c1d4a97aa0 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java @@ -480,6 +480,32 @@ public class RealmAdapter implements CachedRealmModel { updated.setOfflineSessionMaxLifespan(seconds); } + @Override + public int getClientSessionIdleTimeout() { + if (isUpdated()) + return updated.getClientSessionIdleTimeout(); + return cached.getClientSessionIdleTimeout(); + } + + @Override + public void setClientSessionIdleTimeout(int seconds) { + getDelegateForUpdate(); + updated.setClientSessionIdleTimeout(seconds); + } + + @Override + public int getClientSessionMaxLifespan() { + if (isUpdated()) + return updated.getClientSessionMaxLifespan(); + return cached.getClientSessionMaxLifespan(); + } + + @Override + public void setClientSessionMaxLifespan(int seconds) { + getDelegateForUpdate(); + updated.setClientSessionMaxLifespan(seconds); + } + @Override public int getAccessTokenLifespan() { if (isUpdated()) return updated.getAccessTokenLifespan(); diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java index 167039dc46..dcc2a6a08a 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java @@ -86,6 +86,8 @@ public class CachedRealm extends AbstractExtendableRevisioned { // KEYCLOAK-7688 Offline Session Max for Offline Token protected boolean offlineSessionMaxLifespanEnabled; protected int offlineSessionMaxLifespan; + protected int clientSessionIdleTimeout; + protected int clientSessionMaxLifespan; protected int accessTokenLifespan; protected int accessTokenLifespanForImplicitFlow; protected int accessCodeLifespan; @@ -197,6 +199,8 @@ public class CachedRealm extends AbstractExtendableRevisioned { // KEYCLOAK-7688 Offline Session Max for Offline Token offlineSessionMaxLifespanEnabled = model.isOfflineSessionMaxLifespanEnabled(); offlineSessionMaxLifespan = model.getOfflineSessionMaxLifespan(); + clientSessionIdleTimeout = model.getClientSessionIdleTimeout(); + clientSessionMaxLifespan = model.getClientSessionMaxLifespan(); accessTokenLifespan = model.getAccessTokenLifespan(); accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow(); accessCodeLifespan = model.getAccessCodeLifespan(); @@ -447,6 +451,14 @@ public class CachedRealm extends AbstractExtendableRevisioned { return offlineSessionMaxLifespan; } + public int getClientSessionIdleTimeout() { + return clientSessionIdleTimeout; + } + + public int getClientSessionMaxLifespan() { + return clientSessionMaxLifespan; + } + public int getAccessTokenLifespan() { return accessTokenLifespan; } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java index 2c54002e4e..c81776dd3a 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java @@ -523,6 +523,26 @@ public class RealmAdapter implements RealmModel, JpaModel { setAttribute(RealmAttributes.OFFLINE_SESSION_MAX_LIFESPAN, seconds); } + @Override + public int getClientSessionIdleTimeout() { + return getAttribute(RealmAttributes.CLIENT_SESSION_IDLE_TIMEOUT, 0); + } + + @Override + public void setClientSessionIdleTimeout(int seconds) { + setAttribute(RealmAttributes.CLIENT_SESSION_IDLE_TIMEOUT, seconds); + } + + @Override + public int getClientSessionMaxLifespan() { + return getAttribute(RealmAttributes.CLIENT_SESSION_MAX_LIFESPAN, 0); + } + + @Override + public void setClientSessionMaxLifespan(int seconds) { + setAttribute(RealmAttributes.CLIENT_SESSION_MAX_LIFESPAN, seconds); + } + @Override public int getAccessCodeLifespan() { return realm.getAccessCodeLifespan(); diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmAttributes.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmAttributes.java index edd6d8552c..0de3c5a968 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmAttributes.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmAttributes.java @@ -34,7 +34,8 @@ public interface RealmAttributes { String OFFLINE_SESSION_MAX_LIFESPAN_ENABLED = "offlineSessionMaxLifespanEnabled"; String OFFLINE_SESSION_MAX_LIFESPAN = "offlineSessionMaxLifespan"; - + String CLIENT_SESSION_IDLE_TIMEOUT = "clientSessionIdleTimeout"; + String CLIENT_SESSION_MAX_LIFESPAN = "clientSessionMaxLifespan"; String WEBAUTHN_POLICY_RP_ENTITY_NAME = "webAuthnPolicyRpEntityName"; String WEBAUTHN_POLICY_SIGNATURE_ALGORITHMS = "webAuthnPolicySignatureAlgorithms"; diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index e1ae205af0..79a8952b74 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -364,6 +364,8 @@ public class ModelToRepresentation { // KEYCLOAK-7688 Offline Session Max for Offline Token rep.setOfflineSessionMaxLifespanEnabled(realm.isOfflineSessionMaxLifespanEnabled()); rep.setOfflineSessionMaxLifespan(realm.getOfflineSessionMaxLifespan()); + rep.setClientSessionIdleTimeout(realm.getClientSessionIdleTimeout()); + rep.setClientSessionMaxLifespan(realm.getClientSessionMaxLifespan()); rep.setAccessCodeLifespan(realm.getAccessCodeLifespan()); rep.setAccessCodeLifespanUserAction(realm.getAccessCodeLifespanUserAction()); rep.setAccessCodeLifespanLogin(realm.getAccessCodeLifespanLogin()); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 6210bff931..004e924b7b 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -220,6 +220,11 @@ public class RepresentationToModel { newRealm.setOfflineSessionMaxLifespan(rep.getOfflineSessionMaxLifespan()); else newRealm.setOfflineSessionMaxLifespan(Constants.DEFAULT_OFFLINE_SESSION_MAX_LIFESPAN); + if (rep.getClientSessionIdleTimeout() != null) + newRealm.setClientSessionIdleTimeout(rep.getClientSessionIdleTimeout()); + if (rep.getClientSessionMaxLifespan() != null) + newRealm.setClientSessionMaxLifespan(rep.getClientSessionMaxLifespan()); + if (rep.getAccessCodeLifespan() != null) newRealm.setAccessCodeLifespan(rep.getAccessCodeLifespan()); else newRealm.setAccessCodeLifespan(60); @@ -1088,6 +1093,10 @@ public class RepresentationToModel { if (rep.getOfflineSessionMaxLifespanEnabled() != null) realm.setOfflineSessionMaxLifespanEnabled(rep.getOfflineSessionMaxLifespanEnabled()); if (rep.getOfflineSessionMaxLifespan() != null) realm.setOfflineSessionMaxLifespan(rep.getOfflineSessionMaxLifespan()); + if (rep.getClientSessionIdleTimeout() != null) + realm.setClientSessionIdleTimeout(rep.getClientSessionIdleTimeout()); + if (rep.getClientSessionMaxLifespan() != null) + realm.setClientSessionMaxLifespan(rep.getClientSessionMaxLifespan()); if (rep.getRequiredCredentials() != null) { realm.updateRequiredCredentials(rep.getRequiredCredentials()); } diff --git a/server-spi/src/main/java/org/keycloak/models/RealmModel.java b/server-spi/src/main/java/org/keycloak/models/RealmModel.java index d1a1507324..c117cce315 100755 --- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java +++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java @@ -196,6 +196,12 @@ public interface RealmModel extends RoleContainerModel { int getOfflineSessionMaxLifespan(); void setOfflineSessionMaxLifespan(int seconds); + int getClientSessionIdleTimeout(); + void setClientSessionIdleTimeout(int seconds); + + int getClientSessionMaxLifespan(); + void setClientSessionMaxLifespan(int seconds); + void setAccessTokenLifespan(int seconds); int getAccessTokenLifespanForImplicitFlow(); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java index d1d52266e9..5ddac4e681 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java @@ -44,7 +44,8 @@ public final class OIDCConfigAttributes { public static final String ACCESS_TOKEN_SIGNED_RESPONSE_ALG = "access.token.signed.response.alg"; public static final String ACCESS_TOKEN_LIFESPAN = "access.token.lifespan"; - + public static final String CLIENT_SESSION_IDLE_TIMEOUT = "client.session.idle.timeout"; + public static final String CLIENT_SESSION_MAX_LIFESPAN = "client.session.max.lifespan"; public static final String PKCE_CODE_CHALLENGE_METHOD = "pkce.code.challenge.method"; private OIDCConfigAttributes() { 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 2342f4ef56..00256664e2 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -687,6 +687,19 @@ public class TokenManager { expiration = expiration <= sessionExpires ? expiration : sessionExpires; } + int clientSessionMaxLifespan; + String clientSessionMaxLifespanPerClient = client.getAttribute(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN); + if (clientSessionMaxLifespanPerClient != null && !clientSessionMaxLifespanPerClient.trim().isEmpty()) { + clientSessionMaxLifespan = Integer.parseInt(clientSessionMaxLifespanPerClient); + } else { + clientSessionMaxLifespan = realm.getClientSessionMaxLifespan(); + } + + if (clientSessionMaxLifespan > 0) { + int clientSessionExpiration = userSession.getStarted() + clientSessionMaxLifespan; + return expiration < clientSessionExpiration ? expiration : clientSessionExpiration; + } + return expiration; } @@ -777,10 +790,41 @@ public class TokenManager { } private int getRefreshExpiration() { - int sessionExpires = userSession.getStarted() + (userSession.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0 ? - realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan()); - int expiration = Time.currentTime() + (userSession.isRememberMe() && realm.getSsoSessionIdleTimeoutRememberMe() > 0 ? - realm.getSsoSessionIdleTimeoutRememberMe() : realm.getSsoSessionIdleTimeout()); + int sessionExpires = userSession.getStarted() + + (userSession.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0 + ? realm.getSsoSessionMaxLifespanRememberMe() + : realm.getSsoSessionMaxLifespan()); + + int clientSessionMaxLifespan; + String clientSessionMaxLifespanPerClient = client.getAttribute(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN); + if (clientSessionMaxLifespanPerClient != null && !clientSessionMaxLifespanPerClient.trim().isEmpty()) { + clientSessionMaxLifespan = Integer.parseInt(clientSessionMaxLifespanPerClient); + } else { + clientSessionMaxLifespan = realm.getClientSessionMaxLifespan(); + } + + if (clientSessionMaxLifespan > 0) { + int clientSessionMaxExpiration = userSession.getStarted() + clientSessionMaxLifespan; + sessionExpires = sessionExpires < clientSessionMaxExpiration ? sessionExpires : clientSessionMaxExpiration; + } + + int expiration = Time.currentTime() + (userSession.isRememberMe() && realm.getSsoSessionIdleTimeoutRememberMe() > 0 + ? realm.getSsoSessionIdleTimeoutRememberMe() + : realm.getSsoSessionIdleTimeout()); + + int clientSessionIdleTimeout; + String clientSessionIdleTimeoutPerClient = client.getAttribute(OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT); + if (clientSessionIdleTimeoutPerClient != null && !clientSessionIdleTimeoutPerClient.trim().isEmpty()) { + clientSessionIdleTimeout = Integer.parseInt(clientSessionIdleTimeoutPerClient); + } else { + clientSessionIdleTimeout = realm.getClientSessionIdleTimeout(); + } + + if (clientSessionIdleTimeout > 0) { + int clientSessionIdleExpiration = Time.currentTime() + clientSessionIdleTimeout; + expiration = expiration < clientSessionIdleExpiration ? expiration : clientSessionIdleExpiration; + } + return expiration <= sessionExpires ? expiration : sessionExpires; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java index 3b0184580a..e2a2bfc979 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java @@ -620,6 +620,10 @@ public class RealmTest extends AbstractAdminTest { if (realm.getSsoSessionMaxLifespan() != null) assertEquals(realm.getSsoSessionMaxLifespan(), storedRealm.getSsoSessionMaxLifespan()); if (realm.getSsoSessionIdleTimeoutRememberMe() != null) Assert.assertEquals(realm.getSsoSessionIdleTimeoutRememberMe(), storedRealm.getSsoSessionIdleTimeoutRememberMe()); if (realm.getSsoSessionMaxLifespanRememberMe() != null) Assert.assertEquals(realm.getSsoSessionMaxLifespanRememberMe(), storedRealm.getSsoSessionMaxLifespanRememberMe()); + if (realm.getClientSessionIdleTimeout() != null) + Assert.assertEquals(realm.getClientSessionIdleTimeout(), storedRealm.getClientSessionIdleTimeout()); + if (realm.getClientSessionMaxLifespan() != null) + Assert.assertEquals(realm.getClientSessionMaxLifespan(), storedRealm.getClientSessionMaxLifespan()); if (realm.getRequiredCredentials() != null) { assertNotNull(storedRealm.getRequiredCredentials()); for (String cred : realm.getRequiredCredentials()) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AccessTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AccessTokenTest.java index 87f3ea8cc1..fc428829ef 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AccessTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AccessTokenTest.java @@ -1121,6 +1121,47 @@ public class AccessTokenTest extends AbstractKeycloakTest { } } + @Test + public void testClientSessionMaxLifespan() throws Exception { + ClientResource client = ApiUtil.findClientByClientId(adminClient.realm("test"), "test-app"); + ClientRepresentation clientRepresentation = client.toRepresentation(); + + RealmResource realm = adminClient.realm("test"); + RealmRepresentation rep = realm.toRepresentation(); + int accessTokenLifespan = rep.getAccessTokenLifespan(); + Integer originalClientSessionMaxLifespan = rep.getClientSessionMaxLifespan(); + + try { + oauth.doLogin("test-user@localhost", "password"); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getExpiresIn(), accessTokenLifespan); + + rep.setClientSessionMaxLifespan(accessTokenLifespan - 100); + realm.update(rep); + + String refreshToken = response.getRefreshToken(); + response = oauth.doRefreshTokenRequest(refreshToken, "password"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getExpiresIn(), accessTokenLifespan - 100); + + clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN, + Integer.toString(accessTokenLifespan - 200)); + client.update(clientRepresentation); + + refreshToken = response.getRefreshToken(); + response = oauth.doRefreshTokenRequest(refreshToken, "password"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getExpiresIn(), accessTokenLifespan - 200); + } finally { + rep.setClientSessionMaxLifespan(originalClientSessionMaxLifespan); + realm.update(rep); + clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN, null); + client.update(clientRepresentation); + } + } + @Test public void accessTokenRequest_ClientPS384_RealmRS256() throws Exception { conductAccessTokenRequest(Algorithm.HS256, Algorithm.PS384, Algorithm.RS256); 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 e0aa9ec8ee..c9780a8c6c 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 @@ -24,6 +24,7 @@ import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.common.enums.SslRequired; @@ -35,10 +36,12 @@ import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.SessionTimeoutHelper; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.representations.AccessToken; import org.keycloak.representations.RefreshToken; +import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.testsuite.AbstractKeycloakTest; @@ -77,6 +80,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.keycloak.testsuite.Assert.assertExpiration; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername; import static org.keycloak.testsuite.arquillian.AuthServerTestEnricher.AUTH_SERVER_SSL_REQUIRED; @@ -1046,6 +1050,93 @@ public class RefreshTokenTest extends AbstractKeycloakTest { assertNotNull(response.getRefreshToken()); } + @Test + public void testClientSessionMaxLifespan() throws Exception { + ClientResource client = ApiUtil.findClientByClientId(adminClient.realm("test"), "test-app"); + ClientRepresentation clientRepresentation = client.toRepresentation(); + + RealmResource realm = adminClient.realm("test"); + RealmRepresentation rep = realm.toRepresentation(); + Integer originalSsoSessionMaxLifespan = rep.getSsoSessionMaxLifespan(); + int ssoSessionMaxLifespan = rep.getSsoSessionIdleTimeout() - 100; + Integer originalClientSessionMaxLifespan = rep.getClientSessionMaxLifespan(); + + try { + rep.setSsoSessionMaxLifespan(ssoSessionMaxLifespan); + realm.update(rep); + + oauth.doLogin("test-user@localhost", "password"); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getRefreshExpiresIn(), ssoSessionMaxLifespan); + + rep.setClientSessionMaxLifespan(ssoSessionMaxLifespan - 100); + realm.update(rep); + + String refreshToken = response.getRefreshToken(); + response = oauth.doRefreshTokenRequest(refreshToken, "password"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getRefreshExpiresIn(), ssoSessionMaxLifespan - 100); + + clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN, + Integer.toString(ssoSessionMaxLifespan - 200)); + client.update(clientRepresentation); + + refreshToken = response.getRefreshToken(); + response = oauth.doRefreshTokenRequest(refreshToken, "password"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getRefreshExpiresIn(), ssoSessionMaxLifespan - 200); + } finally { + rep.setSsoSessionMaxLifespan(originalSsoSessionMaxLifespan); + rep.setClientSessionMaxLifespan(originalClientSessionMaxLifespan); + realm.update(rep); + clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN, null); + client.update(clientRepresentation); + } + } + + @Test + public void testClientSessionIdleTimeout() throws Exception { + ClientResource client = ApiUtil.findClientByClientId(adminClient.realm("test"), "test-app"); + ClientRepresentation clientRepresentation = client.toRepresentation(); + + RealmResource realm = adminClient.realm("test"); + RealmRepresentation rep = realm.toRepresentation(); + int ssoSessionIdleTimeout = rep.getSsoSessionIdleTimeout(); + Integer originalClientSessionIdleTimeout = rep.getClientSessionIdleTimeout(); + + try { + oauth.doLogin("test-user@localhost", "password"); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getRefreshExpiresIn(), ssoSessionIdleTimeout); + + rep.setClientSessionIdleTimeout(ssoSessionIdleTimeout - 100); + realm.update(rep); + + String refreshToken = response.getRefreshToken(); + response = oauth.doRefreshTokenRequest(refreshToken, "password"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getRefreshExpiresIn(), ssoSessionIdleTimeout - 100); + + clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT, + Integer.toString(ssoSessionIdleTimeout - 200)); + client.update(clientRepresentation); + + refreshToken = response.getRefreshToken(); + response = oauth.doRefreshTokenRequest(refreshToken, "password"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getRefreshExpiresIn(), ssoSessionIdleTimeout - 200); + } finally { + rep.setClientSessionIdleTimeout(originalClientSessionIdleTimeout); + realm.update(rep); + clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT, null); + client.update(clientRepresentation); + } + } + @Test public void tokenRefreshRequest_ClientRS384_RealmRS384() throws Exception { conductTokenRefreshRequest(Algorithm.HS256, Algorithm.RS384, Algorithm.RS384); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmBuilder.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmBuilder.java index 970ffe4546..ba75233a7d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmBuilder.java @@ -272,4 +272,14 @@ public class RealmBuilder { rep.setOfflineSessionMaxLifespanEnabled(offlineSessionMaxLifespanEnabled); return this; } + + public RealmBuilder clientSessionIdleTimeout(int clientSessionIdleTimeout) { + rep.setClientSessionIdleTimeout(clientSessionIdleTimeout); + return this; + } + + public RealmBuilder clientSessionMaxLifespan(int clientSessionMaxLifespan) { + rep.setClientSessionMaxLifespan(clientSessionMaxLifespan); + return this; + } } diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index aa6dc99698..6b5c42f457 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -128,7 +128,10 @@ offline-session-max-limited=Offline Session Max Limited offline-session-max-limited.tooltip=Enable Offline Session Max. offline-session-max=Offline Session Max offline-session-max.tooltip=Max time before an offline session is expired regardless of activity. - +client-session-idle=Client Session Idle +client-session-idle.tooltip=Time a client session is allowed to be idle before it expires. Tokens are invalidated when a client session is expired. If not set it uses the standard SSO Session Idle value. +client-session-max=Client Session Max +client-session-max.tooltip=Max time before a client session is expired. Tokens are invalidated when a client session is expired. If not set, it uses the standard SSO Session Max value. access-token-lifespan=Access Token Lifespan access-token-lifespan.tooltip=Max time before an access token is expired. This value is recommended to be short relative to the SSO timeout. access-token-lifespan-for-implicit-flow=Access Token Lifespan For Implicit Flow diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js index 29ca7c7eb9..7768762c74 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js @@ -1090,6 +1090,8 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro $scope.accessTokenLifespan = TimeUnit2.asUnit(client.attributes['access.token.lifespan']); $scope.samlAssertionLifespan = TimeUnit2.asUnit(client.attributes['saml.assertion.lifespan']); + $scope.clientSessionIdleTimeout = TimeUnit2.asUnit(client.attributes['client.session.idle.timeout']); + $scope.clientSessionMaxLifespan = TimeUnit2.asUnit(client.attributes['client.session.max.lifespan']); if(client.origin) { if ($scope.access.viewRealm) { @@ -1424,6 +1426,22 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro } } + $scope.updateClientSessionIdleTimeout = function() { + if ($scope.clientSessionIdleTimeout.time) { + $scope.clientEdit.attributes['client.session.idle.timeout'] = $scope.clientSessionIdleTimeout.toSeconds(); + } else { + $scope.clientEdit.attributes['client.session.idle.timeout'] = null; + } + } + + $scope.updateClientSessionMaxLifespan = function() { + if ($scope.clientSessionMaxLifespan.time) { + $scope.clientEdit.attributes['client.session.max.lifespan'] = $scope.clientSessionMaxLifespan.toSeconds(); + } else { + $scope.clientEdit.attributes['client.session.max.lifespan'] = null; + } + } + function configureAuthorizationServices() { if ($scope.clientEdit.authorizationServicesEnabled) { if ($scope.accessType == 'public') { diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index 63f97b2798..e420aac9d1 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -1160,6 +1160,8 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.offlineSessionIdleTimeout = TimeUnit2.asUnit(realm.offlineSessionIdleTimeout); // KEYCLOAK-7688 Offline Session Max for Offline Token $scope.realm.offlineSessionMaxLifespan = TimeUnit2.asUnit(realm.offlineSessionMaxLifespan); + $scope.realm.clientSessionIdleTimeout = TimeUnit2.asUnit(realm.clientSessionIdleTimeout); + $scope.realm.clientSessionMaxLifespan = TimeUnit2.asUnit(realm.clientSessionMaxLifespan); $scope.realm.accessCodeLifespan = TimeUnit2.asUnit(realm.accessCodeLifespan); $scope.realm.accessCodeLifespanLogin = TimeUnit2.asUnit(realm.accessCodeLifespanLogin); $scope.realm.accessCodeLifespanUserAction = TimeUnit2.asUnit(realm.accessCodeLifespanUserAction); @@ -1215,6 +1217,8 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.offlineSessionIdleTimeout = $scope.realm.offlineSessionIdleTimeout.toSeconds(); // KEYCLOAK-7688 Offline Session Max for Offline Token $scope.realm.offlineSessionMaxLifespan = $scope.realm.offlineSessionMaxLifespan.toSeconds(); + $scope.realm.clientSessionIdleTimeout = $scope.realm.clientSessionIdleTimeout.toSeconds(); + $scope.realm.clientSessionMaxLifespan = $scope.realm.clientSessionMaxLifespan.toSeconds(); $scope.realm.accessCodeLifespan = $scope.realm.accessCodeLifespan.toSeconds(); $scope.realm.accessCodeLifespanUserAction = $scope.realm.accessCodeLifespanUserAction.toSeconds(); $scope.realm.accessCodeLifespanLogin = $scope.realm.accessCodeLifespanLogin.toSeconds(); diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html index e3e263d6b0..4c726b8c06 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html @@ -547,6 +547,38 @@ {{:: 'saml-assertion-lifespan.tooltip' | translate}} +
+ +
+ + +
+ {{:: 'client-session-idle.tooltip' | translate}} +
+ +
+ +
+ + +
+ {{:: 'client-session-max.tooltip' | translate}} +
+
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html b/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html index 5d8d6187ad..6d3bd6b84d 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html @@ -145,6 +145,36 @@ {{:: 'offline-session-max.tooltip' | translate}}
+
+ +
+ + +
+ {{:: 'client-session-idle.tooltip' | translate}} +
+ +
+ +
+ + +
+ {{:: 'client-session-max.tooltip' | translate}} +
+