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 e4033a915a..416cebf7a7 100755 --- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java @@ -52,6 +52,8 @@ public class RealmRepresentation { protected Integer offlineSessionMaxLifespan; protected Integer clientSessionIdleTimeout; protected Integer clientSessionMaxLifespan; + protected Integer clientOfflineSessionIdleTimeout; + protected Integer clientOfflineSessionMaxLifespan; protected Integer accessCodeLifespan; protected Integer accessCodeLifespanUserAction; protected Integer accessCodeLifespanLogin; @@ -387,6 +389,22 @@ public class RealmRepresentation { this.clientSessionMaxLifespan = clientSessionMaxLifespan; } + public Integer getClientOfflineSessionIdleTimeout() { + return clientOfflineSessionIdleTimeout; + } + + public void setClientOfflineSessionIdleTimeout(Integer clientOfflineSessionIdleTimeout) { + this.clientOfflineSessionIdleTimeout = clientOfflineSessionIdleTimeout; + } + + public Integer getClientOfflineSessionMaxLifespan() { + return clientOfflineSessionMaxLifespan; + } + + public void setClientOfflineSessionMaxLifespan(Integer clientOfflineSessionMaxLifespan) { + this.clientOfflineSessionMaxLifespan = clientOfflineSessionMaxLifespan; + } + 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 c1d4a97aa0..5d365b19d0 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 @@ -506,6 +506,32 @@ public class RealmAdapter implements CachedRealmModel { updated.setClientSessionMaxLifespan(seconds); } + @Override + public int getClientOfflineSessionIdleTimeout() { + if (isUpdated()) + return updated.getClientOfflineSessionIdleTimeout(); + return cached.getClientOfflineSessionIdleTimeout(); + } + + @Override + public void setClientOfflineSessionIdleTimeout(int seconds) { + getDelegateForUpdate(); + updated.setClientOfflineSessionIdleTimeout(seconds); + } + + @Override + public int getClientOfflineSessionMaxLifespan() { + if (isUpdated()) + return updated.getClientOfflineSessionMaxLifespan(); + return cached.getClientOfflineSessionMaxLifespan(); + } + + @Override + public void setClientOfflineSessionMaxLifespan(int seconds) { + getDelegateForUpdate(); + updated.setClientOfflineSessionMaxLifespan(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 dcc2a6a08a..ad9aa68448 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 @@ -88,6 +88,8 @@ public class CachedRealm extends AbstractExtendableRevisioned { protected int offlineSessionMaxLifespan; protected int clientSessionIdleTimeout; protected int clientSessionMaxLifespan; + protected int clientOfflineSessionIdleTimeout; + protected int clientOfflineSessionMaxLifespan; protected int accessTokenLifespan; protected int accessTokenLifespanForImplicitFlow; protected int accessCodeLifespan; @@ -201,6 +203,8 @@ public class CachedRealm extends AbstractExtendableRevisioned { offlineSessionMaxLifespan = model.getOfflineSessionMaxLifespan(); clientSessionIdleTimeout = model.getClientSessionIdleTimeout(); clientSessionMaxLifespan = model.getClientSessionMaxLifespan(); + clientOfflineSessionIdleTimeout = model.getClientOfflineSessionIdleTimeout(); + clientOfflineSessionMaxLifespan = model.getClientOfflineSessionMaxLifespan(); accessTokenLifespan = model.getAccessTokenLifespan(); accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow(); accessCodeLifespan = model.getAccessCodeLifespan(); @@ -459,6 +463,14 @@ public class CachedRealm extends AbstractExtendableRevisioned { return clientSessionMaxLifespan; } + public int getClientOfflineSessionIdleTimeout() { + return clientOfflineSessionIdleTimeout; + } + + public int getClientOfflineSessionMaxLifespan() { + return clientOfflineSessionMaxLifespan; + } + 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 f23ab2b8d8..055bc17da9 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 @@ -543,6 +543,26 @@ public class RealmAdapter implements RealmModel, JpaModel { setAttribute(RealmAttributes.CLIENT_SESSION_MAX_LIFESPAN, seconds); } + @Override + public int getClientOfflineSessionIdleTimeout() { + return getAttribute(RealmAttributes.CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT, 0); + } + + @Override + public void setClientOfflineSessionIdleTimeout(int seconds) { + setAttribute(RealmAttributes.CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT, seconds); + } + + @Override + public int getClientOfflineSessionMaxLifespan() { + return getAttribute(RealmAttributes.CLIENT_OFFLINE_SESSION_MAX_LIFESPAN, 0); + } + + @Override + public void setClientOfflineSessionMaxLifespan(int seconds) { + setAttribute(RealmAttributes.CLIENT_OFFLINE_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 0de3c5a968..daadf51946 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 @@ -36,6 +36,8 @@ public interface RealmAttributes { String OFFLINE_SESSION_MAX_LIFESPAN = "offlineSessionMaxLifespan"; String CLIENT_SESSION_IDLE_TIMEOUT = "clientSessionIdleTimeout"; String CLIENT_SESSION_MAX_LIFESPAN = "clientSessionMaxLifespan"; + String CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT = "clientOfflineSessionIdleTimeout"; + String CLIENT_OFFLINE_SESSION_MAX_LIFESPAN = "clientOfflineSessionMaxLifespan"; 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 79a8952b74..e270b08df1 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 @@ -366,6 +366,8 @@ public class ModelToRepresentation { rep.setOfflineSessionMaxLifespan(realm.getOfflineSessionMaxLifespan()); rep.setClientSessionIdleTimeout(realm.getClientSessionIdleTimeout()); rep.setClientSessionMaxLifespan(realm.getClientSessionMaxLifespan()); + rep.setClientOfflineSessionIdleTimeout(realm.getClientOfflineSessionIdleTimeout()); + rep.setClientOfflineSessionMaxLifespan(realm.getClientOfflineSessionMaxLifespan()); 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 004e924b7b..8e7782e22b 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 @@ -225,6 +225,11 @@ public class RepresentationToModel { if (rep.getClientSessionMaxLifespan() != null) newRealm.setClientSessionMaxLifespan(rep.getClientSessionMaxLifespan()); + if (rep.getClientOfflineSessionIdleTimeout() != null) + newRealm.setClientOfflineSessionIdleTimeout(rep.getClientOfflineSessionIdleTimeout()); + if (rep.getClientOfflineSessionMaxLifespan() != null) + newRealm.setClientOfflineSessionMaxLifespan(rep.getClientOfflineSessionMaxLifespan()); + if (rep.getAccessCodeLifespan() != null) newRealm.setAccessCodeLifespan(rep.getAccessCodeLifespan()); else newRealm.setAccessCodeLifespan(60); @@ -1097,6 +1102,10 @@ public class RepresentationToModel { realm.setClientSessionIdleTimeout(rep.getClientSessionIdleTimeout()); if (rep.getClientSessionMaxLifespan() != null) realm.setClientSessionMaxLifespan(rep.getClientSessionMaxLifespan()); + if (rep.getClientOfflineSessionIdleTimeout() != null) + realm.setClientOfflineSessionIdleTimeout(rep.getClientOfflineSessionIdleTimeout()); + if (rep.getClientOfflineSessionMaxLifespan() != null) + realm.setClientOfflineSessionMaxLifespan(rep.getClientOfflineSessionMaxLifespan()); 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 c117cce315..b262c3c6dd 100755 --- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java +++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java @@ -202,6 +202,12 @@ public interface RealmModel extends RoleContainerModel { int getClientSessionMaxLifespan(); void setClientSessionMaxLifespan(int seconds); + int getClientOfflineSessionIdleTimeout(); + void setClientOfflineSessionIdleTimeout(int seconds); + + int getClientOfflineSessionMaxLifespan(); + void setClientOfflineSessionMaxLifespan(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 832a8a9d39..aa2b695eec 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java @@ -46,6 +46,8 @@ public final class OIDCConfigAttributes { 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 CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT = "client.offline.session.idle.timeout"; + public static final String CLIENT_OFFLINE_SESSION_MAX_LIFESPAN = "client.offline.session.max.lifespan"; public static final String PKCE_CODE_CHALLENGE_METHOD = "pkce.code.challenge.method"; public static final String TOKEN_ENDPOINT_AUTH_SIGNING_ALG = "token.endpoint.auth.signing.alg"; 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 b0240e8d0d..976a1422aa 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -689,6 +689,21 @@ public class TokenManager { if (realm.isOfflineSessionMaxLifespanEnabled()) { int sessionExpires = userSession.getStarted() + realm.getOfflineSessionMaxLifespan(); expiration = expiration <= sessionExpires ? expiration : sessionExpires; + + int clientOfflineSessionMaxLifespan; + String clientOfflineSessionMaxLifespanPerClient = client + .getAttribute(OIDCConfigAttributes.CLIENT_OFFLINE_SESSION_MAX_LIFESPAN); + if (clientOfflineSessionMaxLifespanPerClient != null + && !clientOfflineSessionMaxLifespanPerClient.trim().isEmpty()) { + clientOfflineSessionMaxLifespan = Integer.parseInt(clientOfflineSessionMaxLifespanPerClient); + } else { + clientOfflineSessionMaxLifespan = realm.getClientOfflineSessionMaxLifespan(); + } + + if (clientOfflineSessionMaxLifespan > 0) { + int clientOfflineSessionExpiration = userSession.getStarted() + clientOfflineSessionMaxLifespan; + return expiration < clientOfflineSessionExpiration ? expiration : clientOfflineSessionExpiration; + } } } else { int sessionExpires = userSession.getStarted() @@ -696,19 +711,19 @@ public class TokenManager { ? realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan()); 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(); - } + 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; + if (clientSessionMaxLifespan > 0) { + int clientSessionExpiration = userSession.getStarted() + clientSessionMaxLifespan; + return expiration < clientSessionExpiration ? expiration : clientSessionExpiration; + } } return expiration; @@ -842,9 +857,41 @@ public class TokenManager { } private int getOfflineExpiration() { - int expiration = Time.currentTime() + realm.getOfflineSessionIdleTimeout(); int sessionExpires = userSession.getStarted() + realm.getOfflineSessionMaxLifespan(); + int clientOfflineSessionMaxLifespan; + String clientOfflineSessionMaxLifespanPerClient = client + .getAttribute(OIDCConfigAttributes.CLIENT_OFFLINE_SESSION_MAX_LIFESPAN); + if (clientOfflineSessionMaxLifespanPerClient != null + && !clientOfflineSessionMaxLifespanPerClient.trim().isEmpty()) { + clientOfflineSessionMaxLifespan = Integer.parseInt(clientOfflineSessionMaxLifespanPerClient); + } else { + clientOfflineSessionMaxLifespan = realm.getClientOfflineSessionMaxLifespan(); + } + + if (clientOfflineSessionMaxLifespan > 0) { + int clientOfflineSessionMaxExpiration = userSession.getStarted() + clientOfflineSessionMaxLifespan; + sessionExpires = sessionExpires < clientOfflineSessionMaxExpiration ? sessionExpires + : clientOfflineSessionMaxExpiration; + } + + int expiration = Time.currentTime() + realm.getOfflineSessionIdleTimeout(); + + int clientOfflineSessionIdleTimeout; + String clientOfflineSessionIdleTimeoutPerClient = client + .getAttribute(OIDCConfigAttributes.CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT); + if (clientOfflineSessionIdleTimeoutPerClient != null + && !clientOfflineSessionIdleTimeoutPerClient.trim().isEmpty()) { + clientOfflineSessionIdleTimeout = Integer.parseInt(clientOfflineSessionIdleTimeoutPerClient); + } else { + clientOfflineSessionIdleTimeout = realm.getClientOfflineSessionIdleTimeout(); + } + + if (clientOfflineSessionIdleTimeout > 0) { + int clientOfflineSessionIdleExpiration = Time.currentTime() + clientOfflineSessionIdleTimeout; + expiration = expiration < clientOfflineSessionIdleExpiration ? expiration : clientOfflineSessionIdleExpiration; + } + 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 e2a2bfc979..38d8b03093 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 @@ -624,6 +624,10 @@ public class RealmTest extends AbstractAdminTest { Assert.assertEquals(realm.getClientSessionIdleTimeout(), storedRealm.getClientSessionIdleTimeout()); if (realm.getClientSessionMaxLifespan() != null) Assert.assertEquals(realm.getClientSessionMaxLifespan(), storedRealm.getClientSessionMaxLifespan()); + if (realm.getClientOfflineSessionIdleTimeout() != null) + Assert.assertEquals(realm.getClientOfflineSessionIdleTimeout(), storedRealm.getClientOfflineSessionIdleTimeout()); + if (realm.getClientOfflineSessionMaxLifespan() != null) + Assert.assertEquals(realm.getClientOfflineSessionMaxLifespan(), storedRealm.getClientOfflineSessionMaxLifespan()); 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 fc428829ef..d373f9c942 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 @@ -1162,6 +1162,53 @@ public class AccessTokenTest extends AbstractKeycloakTest { } } + @Test + public void testClientOfflineSessionMaxLifespan() 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(); + Boolean originalOfflineSessionMaxLifespanEnabled = rep.getOfflineSessionMaxLifespanEnabled(); + Integer originalClientOfflineSessionMaxLifespan = rep.getClientOfflineSessionMaxLifespan(); + + try { + rep.setOfflineSessionMaxLifespanEnabled(true); + realm.update(rep); + + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + 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.setClientOfflineSessionMaxLifespan(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_OFFLINE_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.setOfflineSessionMaxLifespanEnabled(originalOfflineSessionMaxLifespanEnabled); + rep.setClientOfflineSessionMaxLifespan(originalClientOfflineSessionMaxLifespan); + realm.update(rep); + clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_OFFLINE_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/OfflineTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java index 4a3ff18c94..52ac4647d3 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 @@ -39,6 +39,7 @@ import org.keycloak.models.AdminRoles; import org.keycloak.models.Constants; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.SessionTimeoutHelper; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.representations.AccessToken; import org.keycloak.representations.RefreshToken; import org.keycloak.representations.idm.ClientRepresentation; @@ -78,6 +79,7 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertNull; +import static org.keycloak.testsuite.Assert.assertExpiration; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.admin.ApiUtil.findRealmRoleByName; import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername; @@ -881,4 +883,104 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } } + @Test + public void testClientOfflineSessionMaxLifespan() throws Exception { + ClientResource client = ApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client"); + ClientRepresentation clientRepresentation = client.toRepresentation(); + + RealmResource realm = adminClient.realm("test"); + RealmRepresentation rep = realm.toRepresentation(); + Boolean originalOfflineSessionMaxLifespanEnabled = rep.getOfflineSessionMaxLifespanEnabled(); + Integer originalOfflineSessionMaxLifespan = rep.getOfflineSessionMaxLifespan(); + int offlineSessionMaxLifespan = rep.getOfflineSessionIdleTimeout() - 100; + Integer originalClientOfflineSessionMaxLifespan = rep.getClientOfflineSessionMaxLifespan(); + + try { + rep.setOfflineSessionMaxLifespanEnabled(true); + rep.setOfflineSessionMaxLifespan(offlineSessionMaxLifespan); + realm.update(rep); + + 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 response = oauth.doAccessTokenRequest(code, "secret1"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getRefreshExpiresIn(), offlineSessionMaxLifespan); + + rep.setClientOfflineSessionMaxLifespan(offlineSessionMaxLifespan - 100); + realm.update(rep); + + String refreshToken = response.getRefreshToken(); + response = oauth.doRefreshTokenRequest(refreshToken, "secret1"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getRefreshExpiresIn(), offlineSessionMaxLifespan - 100); + + clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_OFFLINE_SESSION_MAX_LIFESPAN, + Integer.toString(offlineSessionMaxLifespan - 200)); + client.update(clientRepresentation); + + refreshToken = response.getRefreshToken(); + response = oauth.doRefreshTokenRequest(refreshToken, "secret1"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getRefreshExpiresIn(), offlineSessionMaxLifespan - 200); + } finally { + rep.setOfflineSessionMaxLifespanEnabled(originalOfflineSessionMaxLifespanEnabled); + rep.setOfflineSessionMaxLifespan(originalOfflineSessionMaxLifespan); + rep.setClientOfflineSessionMaxLifespan(originalClientOfflineSessionMaxLifespan); + realm.update(rep); + clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_OFFLINE_SESSION_MAX_LIFESPAN, null); + client.update(clientRepresentation); + } + } + + @Test + public void testClientOfflineSessionIdleTimeout() throws Exception { + ClientResource client = ApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client"); + ClientRepresentation clientRepresentation = client.toRepresentation(); + + RealmResource realm = adminClient.realm("test"); + RealmRepresentation rep = realm.toRepresentation(); + Boolean originalOfflineSessionMaxLifespanEnabled = rep.getOfflineSessionMaxLifespanEnabled(); + int offlineSessionIdleTimeout = rep.getOfflineSessionIdleTimeout(); + Integer originalClientOfflineSessionIdleTimeout = rep.getClientOfflineSessionIdleTimeout(); + + try { + rep.setOfflineSessionMaxLifespanEnabled(true); + realm.update(rep); + + 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 response = oauth.doAccessTokenRequest(code, "secret1"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getRefreshExpiresIn(), offlineSessionIdleTimeout); + + rep.setClientOfflineSessionIdleTimeout(offlineSessionIdleTimeout - 100); + realm.update(rep); + + String refreshToken = response.getRefreshToken(); + response = oauth.doRefreshTokenRequest(refreshToken, "secret1"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getRefreshExpiresIn(), offlineSessionIdleTimeout - 100); + + clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT, + Integer.toString(offlineSessionIdleTimeout - 200)); + client.update(clientRepresentation); + + refreshToken = response.getRefreshToken(); + response = oauth.doRefreshTokenRequest(refreshToken, "secret1"); + assertEquals(200, response.getStatusCode()); + assertExpiration(response.getRefreshExpiresIn(), offlineSessionIdleTimeout - 200); + } finally { + rep.setOfflineSessionMaxLifespanEnabled(originalOfflineSessionMaxLifespanEnabled); + rep.setClientOfflineSessionIdleTimeout(originalClientOfflineSessionIdleTimeout); + realm.update(rep); + clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT, null); + client.update(clientRepresentation); + } + } } 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 ba75233a7d..a8fa850100 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 @@ -282,4 +282,14 @@ public class RealmBuilder { rep.setClientSessionMaxLifespan(clientSessionMaxLifespan); return this; } + + public RealmBuilder clientOfflineSessionIdleTimeout(int clientOfflineSessionIdleTimeout) { + rep.setClientOfflineSessionIdleTimeout(clientOfflineSessionIdleTimeout); + return this; + } + + public RealmBuilder clientOfflineSessionMaxLifespan(int clientOfflineSessionMaxLifespan) { + rep.setClientOfflineSessionMaxLifespan(clientOfflineSessionMaxLifespan); + 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 95a3aec187..56fc56d883 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 @@ -132,6 +132,10 @@ 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. +client-offline-session-idle=Client Offline Session Idle +client-offline-session-idle.tooltip=Time a client offline session is allowed to be idle before it expires. Offline tokens are invalidated when a client offline session is expired. If not set it uses the Offline Session Idle value. +client-offline-session-max=Client Offline Session Max +client-offline-session-max.tooltip=Max time before a client offline session is expired. Offline tokens are invalidated when a client offline session is expired. If not set, it uses the Offline 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 e823ff36ce..b06ee78de1 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 @@ -1114,6 +1114,8 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro $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']); + $scope.clientOfflineSessionIdleTimeout = TimeUnit2.asUnit(client.attributes['client.offline.session.idle.timeout']); + $scope.clientOfflineSessionMaxLifespan = TimeUnit2.asUnit(client.attributes['client.offline.session.max.lifespan']); if(client.origin) { if ($scope.access.viewRealm) { @@ -1464,6 +1466,22 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro } } + $scope.updateClientOfflineSessionIdleTimeout = function() { + if ($scope.clientOfflineSessionIdleTimeout.time) { + $scope.clientEdit.attributes['client.offline.session.idle.timeout'] = $scope.clientOfflineSessionIdleTimeout.toSeconds(); + } else { + $scope.clientEdit.attributes['client.offline.session.idle.timeout'] = null; + } + } + + $scope.updateClientOfflineSessionMaxLifespan = function() { + if ($scope.clientOfflineSessionMaxLifespan.time) { + $scope.clientEdit.attributes['client.offline.session.max.lifespan'] = $scope.clientOfflineSessionMaxLifespan.toSeconds(); + } else { + $scope.clientEdit.attributes['client.offline.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 1c9778c60f..a8cba878c8 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 @@ -1162,6 +1162,8 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.offlineSessionMaxLifespan = TimeUnit2.asUnit(realm.offlineSessionMaxLifespan); $scope.realm.clientSessionIdleTimeout = TimeUnit2.asUnit(realm.clientSessionIdleTimeout); $scope.realm.clientSessionMaxLifespan = TimeUnit2.asUnit(realm.clientSessionMaxLifespan); + $scope.realm.clientOfflineSessionIdleTimeout = TimeUnit2.asUnit(realm.clientOfflineSessionIdleTimeout); + $scope.realm.clientOfflineSessionMaxLifespan = TimeUnit2.asUnit(realm.clientOfflineSessionMaxLifespan); $scope.realm.accessCodeLifespan = TimeUnit2.asUnit(realm.accessCodeLifespan); $scope.realm.accessCodeLifespanLogin = TimeUnit2.asUnit(realm.accessCodeLifespanLogin); $scope.realm.accessCodeLifespanUserAction = TimeUnit2.asUnit(realm.accessCodeLifespanUserAction); @@ -1219,6 +1221,8 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.offlineSessionMaxLifespan = $scope.realm.offlineSessionMaxLifespan.toSeconds(); $scope.realm.clientSessionIdleTimeout = $scope.realm.clientSessionIdleTimeout.toSeconds(); $scope.realm.clientSessionMaxLifespan = $scope.realm.clientSessionMaxLifespan.toSeconds(); + $scope.realm.clientOfflineSessionIdleTimeout = $scope.realm.clientOfflineSessionIdleTimeout.toSeconds(); + $scope.realm.clientOfflineSessionMaxLifespan = $scope.realm.clientOfflineSessionMaxLifespan.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 4c726b8c06..c2130d2a18 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 @@ -579,6 +579,42 @@ {{:: 'client-session-max.tooltip' | translate}} +
+ +
+ +
+ {{:: 'client-offline-session-idle.tooltip' | translate}} +
+ +
+ +
+ +
+ {{:: 'client-offline-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 6d3bd6b84d..deee768579 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,38 @@ {{:: 'offline-session-max.tooltip' | translate}}
+
+ +
+ +
+ {{:: 'client-offline-session-idle.tooltip' | translate}} +
+ +
+ +
+ +
+ {{:: 'client-offline-session-max.tooltip' | translate}} +
+