From 7ec3f86efbc7a3e0a9bfdfc33486492e31558073 Mon Sep 17 00:00:00 2001 From: mposolda Date: Wed, 16 Sep 2015 22:57:07 +0200 Subject: [PATCH] KEYCLOAK-904 Offline tokens --- .../META-INF/jpa-changelog-1.6.0.xml | 44 ++ .../META-INF/jpa-changelog-master.xml | 1 + .../main/resources/META-INF/persistence.xml | 2 + ...DefaultMongoConnectionFactoryProvider.java | 2 + .../java/org/keycloak/OAuth2Constants.java | 3 + .../representations/RefreshToken.java | 4 +- .../idm/ClientRepresentation.java | 9 + .../org/keycloak/util/RefreshTokenUtil.java | 62 +++ .../java/org/keycloak/events/Details.java | 1 + .../freemarker/model/ApplicationsBean.java | 21 +- .../theme/base/account/applications.ftl | 9 +- .../account/messages/messages_en.properties | 2 + .../resources/partials/client-detail.html | 7 + .../adapters/OAuthRequestAuthenticator.java | 6 + .../RefreshableKeycloakSecurityContext.java | 7 +- integration/js/src/main/resources/keycloak.js | 4 + .../java/org/keycloak/models/ClientModel.java | 3 + .../models/OfflineClientSessionModel.java | 44 ++ .../models/OfflineUserSessionModel.java | 26 ++ .../java/org/keycloak/models/UserModel.java | 10 + .../org/keycloak/models/UserSessionModel.java | 1 + .../models/entities/ClientEntity.java | 9 + .../entities/OfflineClientSessionEntity.java | 35 ++ .../entities/OfflineUserSessionEntity.java | 37 ++ .../keycloak/models/entities/UserEntity.java | 9 + .../models/utils/ModelToRepresentation.java | 1 + .../models/utils/RepresentationToModel.java | 2 + .../models/utils/UserModelDelegate.java | 43 ++ .../models/file/adapter/ClientAdapter.java | 10 + .../models/file/adapter/UserAdapter.java | 144 +++++- .../cache/infinispan/ClientAdapter.java | 12 + .../infinispan/DefaultCacheUserProvider.java | 1 + .../models/cache/infinispan/UserAdapter.java | 48 ++ .../models/cache/entities/CachedClient.java | 6 + .../models/cache/entities/CachedUser.java | 20 + .../keycloak/models/jpa/ClientAdapter.java | 10 + .../keycloak/models/jpa/JpaUserProvider.java | 14 + .../org/keycloak/models/jpa/UserAdapter.java | 123 +++++ .../models/jpa/entities/ClientEntity.java | 11 + .../entities/OfflineClientSessionEntity.java | 81 ++++ .../entities/OfflineUserSessionEntity.java | 59 +++ .../models/jpa/entities/UserEntity.java | 28 ++ .../keycloak/adapters/ClientAdapter.java | 11 + .../keycloak/adapters/MongoUserProvider.java | 32 ++ .../mongo/keycloak/adapters/UserAdapter.java | 144 ++++++ .../infinispan/UserSessionAdapter.java | 6 + .../infinispan/compat/UserSessionAdapter.java | 6 +- .../keycloak/protocol/oidc/TokenManager.java | 115 +++-- .../oidc/endpoints/TokenEndpoint.java | 18 +- .../managers/HttpAuthenticationChallenge.java | 11 - .../offline/OfflineClientSessionAdapter.java | 289 ++++++++++++ .../offline/OfflineUserSessionAdapter.java | 215 +++++++++ .../offline/OfflineUserSessionManager.java | 182 ++++++++ .../services/resources/AccountService.java | 2 + .../org/keycloak/testsuite/AssertEvents.java | 3 + .../org/keycloak/testsuite/OAuthClient.java | 17 + .../testsuite/account/AccountTest.java | 2 +- .../keycloak/testsuite/model/CacheTest.java | 48 ++ .../keycloak/testsuite/model/ImportTest.java | 2 + .../testsuite/model/UserModelTest.java | 55 +++ .../testsuite/oauth/AccessTokenTest.java | 26 +- .../testsuite/oauth/OfflineTokenTest.java | 441 ++++++++++++++++++ .../pages/AccountApplicationsPage.java | 18 + .../src/test/resources/model/testrealm.json | 1 + .../oidc/offline-client-keycloak.json | 10 + 65 files changed, 2568 insertions(+), 57 deletions(-) create mode 100644 connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.6.0.xml create mode 100644 core/src/main/java/org/keycloak/util/RefreshTokenUtil.java create mode 100644 model/api/src/main/java/org/keycloak/models/OfflineClientSessionModel.java create mode 100644 model/api/src/main/java/org/keycloak/models/OfflineUserSessionModel.java create mode 100644 model/api/src/main/java/org/keycloak/models/entities/OfflineClientSessionEntity.java create mode 100644 model/api/src/main/java/org/keycloak/models/entities/OfflineUserSessionEntity.java create mode 100644 model/jpa/src/main/java/org/keycloak/models/jpa/entities/OfflineClientSessionEntity.java create mode 100644 model/jpa/src/main/java/org/keycloak/models/jpa/entities/OfflineUserSessionEntity.java delete mode 100644 services/src/main/java/org/keycloak/services/managers/HttpAuthenticationChallenge.java create mode 100644 services/src/main/java/org/keycloak/services/offline/OfflineClientSessionAdapter.java create mode 100644 services/src/main/java/org/keycloak/services/offline/OfflineUserSessionAdapter.java create mode 100644 services/src/main/java/org/keycloak/services/offline/OfflineUserSessionManager.java create mode 100644 testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java create mode 100644 testsuite/integration/src/test/resources/oidc/offline-client-keycloak.json diff --git a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.6.0.xml b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.6.0.xml new file mode 100644 index 0000000000..061eaa3e55 --- /dev/null +++ b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.6.0.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-master.xml b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-master.xml index ca5d0e9b38..6cd96c626c 100755 --- a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-master.xml +++ b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-master.xml @@ -9,4 +9,5 @@ + diff --git a/connections/jpa/src/main/resources/META-INF/persistence.xml b/connections/jpa/src/main/resources/META-INF/persistence.xml index 7a55d1edf4..f8f33be9ad 100755 --- a/connections/jpa/src/main/resources/META-INF/persistence.xml +++ b/connections/jpa/src/main/resources/META-INF/persistence.xml @@ -29,6 +29,8 @@ org.keycloak.models.jpa.entities.AuthenticationExecutionEntity org.keycloak.models.jpa.entities.AuthenticatorConfigEntity org.keycloak.models.jpa.entities.RequiredActionProviderEntity + org.keycloak.models.jpa.entities.OfflineUserSessionEntity + org.keycloak.models.jpa.entities.OfflineClientSessionEntity org.keycloak.events.jpa.EventEntity diff --git a/connections/mongo/src/main/java/org/keycloak/connections/mongo/DefaultMongoConnectionFactoryProvider.java b/connections/mongo/src/main/java/org/keycloak/connections/mongo/DefaultMongoConnectionFactoryProvider.java index 423bf3e5e0..90e8c988ef 100755 --- a/connections/mongo/src/main/java/org/keycloak/connections/mongo/DefaultMongoConnectionFactoryProvider.java +++ b/connections/mongo/src/main/java/org/keycloak/connections/mongo/DefaultMongoConnectionFactoryProvider.java @@ -48,6 +48,8 @@ public class DefaultMongoConnectionFactoryProvider implements MongoConnectionPro "org.keycloak.models.entities.AuthenticationFlowEntity", "org.keycloak.models.entities.AuthenticatorConfigEntity", "org.keycloak.models.entities.RequiredActionProviderEntity", + "org.keycloak.models.entities.OfflineUserSessionEntity", + "org.keycloak.models.entities.OfflineClientSessionEntity", }; private static final Logger logger = Logger.getLogger(DefaultMongoConnectionFactoryProvider.class); diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index e17d21ff09..022dadd867 100644 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -38,6 +38,9 @@ public interface OAuth2Constants { // https://tools.ietf.org/html/draft-jones-oauth-jwt-bearer-03#section-2.2 String CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + // http://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + String OFFLINE_ACCESS = "offline_access"; + } diff --git a/core/src/main/java/org/keycloak/representations/RefreshToken.java b/core/src/main/java/org/keycloak/representations/RefreshToken.java index 7bc1edf9a0..ff1ce6862b 100755 --- a/core/src/main/java/org/keycloak/representations/RefreshToken.java +++ b/core/src/main/java/org/keycloak/representations/RefreshToken.java @@ -1,6 +1,7 @@ package org.keycloak.representations; import org.codehaus.jackson.annotate.JsonProperty; +import org.keycloak.util.RefreshTokenUtil; import java.util.HashMap; import java.util.Map; @@ -11,9 +12,8 @@ import java.util.Map; */ public class RefreshToken extends AccessToken { - private RefreshToken() { - type("REFRESH"); + type(RefreshTokenUtil.TOKEN_TYPE_REFRESH); } /** diff --git a/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java index 020445e406..42618a14e5 100755 --- a/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java @@ -24,6 +24,7 @@ public class ClientRepresentation { protected Boolean bearerOnly; protected Boolean consentRequired; protected Boolean serviceAccountsEnabled; + protected Boolean offlineTokensEnabled; protected Boolean directGrantsOnly; protected Boolean publicClient; protected Boolean frontchannelLogout; @@ -162,6 +163,14 @@ public class ClientRepresentation { this.serviceAccountsEnabled = serviceAccountsEnabled; } + public Boolean isOfflineTokensEnabled() { + return offlineTokensEnabled; + } + + public void setOfflineTokensEnabled(Boolean offlineTokensEnabled) { + this.offlineTokensEnabled = offlineTokensEnabled; + } + public Boolean isDirectGrantsOnly() { return directGrantsOnly; } diff --git a/core/src/main/java/org/keycloak/util/RefreshTokenUtil.java b/core/src/main/java/org/keycloak/util/RefreshTokenUtil.java new file mode 100644 index 0000000000..0a759a5abc --- /dev/null +++ b/core/src/main/java/org/keycloak/util/RefreshTokenUtil.java @@ -0,0 +1,62 @@ +package org.keycloak.util; + +import java.io.IOException; + +import org.keycloak.OAuth2Constants; +import org.keycloak.representations.RefreshToken; + +/** + * @author Marek Posolda + */ +public class RefreshTokenUtil { + + public static final String TOKEN_TYPE_REFRESH = "REFRESH"; + + public static final String TOKEN_TYPE_OFFLINE = "OFFLINE"; + + public static boolean isOfflineTokenRequested(String scopeParam) { + if (scopeParam == null) { + return false; + } + + String[] scopes = scopeParam.split(" "); + for (String scope : scopes) { + if (OAuth2Constants.OFFLINE_ACCESS.equals(scope)) { + return true; + } + } + return false; + } + + + /** + * Return refresh token or offline otkne + * + * @param decodedToken + * @return + */ + public static RefreshToken getRefreshToken(byte[] decodedToken) throws IOException { + return JsonSerialization.readValue(decodedToken, RefreshToken.class); + } + + private static RefreshToken getRefreshToken(String refreshToken) throws IOException { + byte[] decodedToken = Base64Url.decode(refreshToken); + return getRefreshToken(decodedToken); + } + + /** + * Return true if given refreshToken represents offline token + * + * @param refreshToken + * @return + */ + public static boolean isOfflineToken(String refreshToken) { + try { + RefreshToken token = getRefreshToken(refreshToken); + return token.getType().equals(TOKEN_TYPE_OFFLINE); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + } + +} diff --git a/events/api/src/main/java/org/keycloak/events/Details.java b/events/api/src/main/java/org/keycloak/events/Details.java index b7ec6779d7..b9c5338c3b 100755 --- a/events/api/src/main/java/org/keycloak/events/Details.java +++ b/events/api/src/main/java/org/keycloak/events/Details.java @@ -20,6 +20,7 @@ public interface Details { String REMEMBER_ME = "remember_me"; String TOKEN_ID = "token_id"; String REFRESH_TOKEN_ID = "refresh_token_id"; + String REFRESH_TOKEN_TYPE = "refresh_token_type"; String VALIDATE_ACCESS_TOKEN = "validate_access_token"; String UPDATED_REFRESH_TOKEN_ID = "updated_refresh_token_id"; String NODE_HOST = "node_host"; diff --git a/forms/account-freemarker/src/main/java/org/keycloak/account/freemarker/model/ApplicationsBean.java b/forms/account-freemarker/src/main/java/org/keycloak/account/freemarker/model/ApplicationsBean.java index 9d9035acbe..63e04db10d 100644 --- a/forms/account-freemarker/src/main/java/org/keycloak/account/freemarker/model/ApplicationsBean.java +++ b/forms/account-freemarker/src/main/java/org/keycloak/account/freemarker/model/ApplicationsBean.java @@ -1,9 +1,11 @@ package org.keycloak.account.freemarker.model; +import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.Set; +import org.keycloak.OAuth2Constants; import org.keycloak.models.ClientModel; import org.keycloak.models.UserConsentModel; import org.keycloak.models.ProtocolMapperModel; @@ -11,6 +13,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; import org.keycloak.models.UserModel; import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.services.offline.OfflineUserSessionManager; import org.keycloak.util.MultivaluedHashMap; /** @@ -21,6 +24,9 @@ public class ApplicationsBean { private List applications = new LinkedList(); public ApplicationsBean(RealmModel realm, UserModel user) { + + Set offlineClients = new OfflineUserSessionManager().findClientsWithOfflineToken(realm, user); + List realmClients = realm.getClients(); for (ClientModel client : realmClients) { // Don't show bearerOnly clients @@ -52,7 +58,13 @@ public class ApplicationsBean { } } - ApplicationEntry appEntry = new ApplicationEntry(realmRolesAvailable, resourceRolesAvailable, realmRolesGranted, resourceRolesGranted, client, claimsGranted); + List additionalGrants = new ArrayList<>(); + if (offlineClients.contains(client)) { + additionalGrants.add("${offlineAccess}"); + } + + ApplicationEntry appEntry = new ApplicationEntry(realmRolesAvailable, resourceRolesAvailable, realmRolesGranted, resourceRolesGranted, client, + claimsGranted, additionalGrants); applications.add(appEntry); } } @@ -82,16 +94,18 @@ public class ApplicationsBean { private final MultivaluedHashMap resourceRolesGranted; private final ClientModel client; private final List claimsGranted; + private final List additionalGrants; public ApplicationEntry(List realmRolesAvailable, MultivaluedHashMap resourceRolesAvailable, List realmRolesGranted, MultivaluedHashMap resourceRolesGranted, - ClientModel client, List claimsGranted) { + ClientModel client, List claimsGranted, List additionalGrants) { this.realmRolesAvailable = realmRolesAvailable; this.resourceRolesAvailable = resourceRolesAvailable; this.realmRolesGranted = realmRolesGranted; this.resourceRolesGranted = resourceRolesGranted; this.client = client; this.claimsGranted = claimsGranted; + this.additionalGrants = additionalGrants; } public List getRealmRolesAvailable() { @@ -118,6 +132,9 @@ public class ApplicationsBean { return claimsGranted; } + public List getAdditionalGrants() { + return additionalGrants; + } } // Same class used in OAuthGrantBean as well. Maybe should be merged into common-freemarker... diff --git a/forms/common-themes/src/main/resources/theme/base/account/applications.ftl b/forms/common-themes/src/main/resources/theme/base/account/applications.ftl index 4e01c02d64..b2bbdf2deb 100755 --- a/forms/common-themes/src/main/resources/theme/base/account/applications.ftl +++ b/forms/common-themes/src/main/resources/theme/base/account/applications.ftl @@ -18,6 +18,7 @@ ${msg("availablePermissions")} ${msg("grantedPermissions")} ${msg("grantedPersonalInfo")} + ${msg("additionalGrants")} ${msg("action")} @@ -76,7 +77,13 @@ - <#if application.client.consentRequired && application.claimsGranted?has_content> + <#list application.additionalGrants as grant> + ${advancedMsg(grant)}<#if grant_has_next>, + + + + + <#if (application.client.consentRequired && application.claimsGranted?has_content) || application.additionalGrants?has_content> diff --git a/forms/common-themes/src/main/resources/theme/base/account/messages/messages_en.properties b/forms/common-themes/src/main/resources/theme/base/account/messages/messages_en.properties index 2c3b8c7e48..b0ea19ccd5 100755 --- a/forms/common-themes/src/main/resources/theme/base/account/messages/messages_en.properties +++ b/forms/common-themes/src/main/resources/theme/base/account/messages/messages_en.properties @@ -85,9 +85,11 @@ application=Application availablePermissions=Available Permissions grantedPermissions=Granted Permissions grantedPersonalInfo=Granted Personal Info +additionalGrants=Additional Grants action=Action inResource=in fullAccess=Full Access +offlineAccess=Offline Access revoke=Revoke Grant configureAuthenticators=Configured Authenticators diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html index 06c939bc4c..303e4391a7 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html @@ -79,6 +79,13 @@ +
+ + Allows you to retrieve offline tokens for users. Offline token can be stored by client application and is valid even if user is not logged anymore. +
+ +
+
diff --git a/integration/adapter-core/src/main/java/org/keycloak/adapters/OAuthRequestAuthenticator.java b/integration/adapter-core/src/main/java/org/keycloak/adapters/OAuthRequestAuthenticator.java index d91b134eb0..3189ab2f7a 100755 --- a/integration/adapter-core/src/main/java/org/keycloak/adapters/OAuthRequestAuthenticator.java +++ b/integration/adapter-core/src/main/java/org/keycloak/adapters/OAuthRequestAuthenticator.java @@ -135,6 +135,9 @@ public class OAuthRequestAuthenticator { String idpHint = getQueryParamValue(AdapterConstants.KC_IDP_HINT); url = UriUtils.stripQueryParam(url, AdapterConstants.KC_IDP_HINT); + String scope = getQueryParamValue(OAuth2Constants.SCOPE); + url = UriUtils.stripQueryParam(url, OAuth2Constants.SCOPE); + KeycloakUriBuilder redirectUriBuilder = deployment.getAuthUrl().clone() .queryParam(OAuth2Constants.RESPONSE_TYPE, OAuth2Constants.CODE) .queryParam(OAuth2Constants.CLIENT_ID, deployment.getResourceName()) @@ -147,6 +150,9 @@ public class OAuthRequestAuthenticator { if (idpHint != null && idpHint.length() > 0) { redirectUriBuilder.queryParam(AdapterConstants.KC_IDP_HINT,idpHint); } + if (scope != null && scope.length() > 0) { + redirectUriBuilder.queryParam(OAuth2Constants.SCOPE, scope); + } return redirectUriBuilder.build().toString(); } diff --git a/integration/adapter-core/src/main/java/org/keycloak/adapters/RefreshableKeycloakSecurityContext.java b/integration/adapter-core/src/main/java/org/keycloak/adapters/RefreshableKeycloakSecurityContext.java index 52668de18d..b938a0bf62 100755 --- a/integration/adapter-core/src/main/java/org/keycloak/adapters/RefreshableKeycloakSecurityContext.java +++ b/integration/adapter-core/src/main/java/org/keycloak/adapters/RefreshableKeycloakSecurityContext.java @@ -117,7 +117,12 @@ public class RefreshableKeycloakSecurityContext extends KeycloakSecurityContext } this.token = token; - this.refreshToken = response.getRefreshToken(); + if (response.getRefreshToken() != null) { + if (log.isTraceEnabled()) { + log.trace("Setup new refresh token to the security context"); + } + this.refreshToken = response.getRefreshToken(); + } this.tokenString = tokenString; tokenStore.refreshCallback(this); return true; diff --git a/integration/js/src/main/resources/keycloak.js b/integration/js/src/main/resources/keycloak.js index 46d3b18559..f384e7b5ea 100755 --- a/integration/js/src/main/resources/keycloak.js +++ b/integration/js/src/main/resources/keycloak.js @@ -164,6 +164,10 @@ url += '&kc_idp_hint=' + options.idpHint; } + if (options && options.scope) { + url += '&scope=' + options.scope; + } + return url; } diff --git a/model/api/src/main/java/org/keycloak/models/ClientModel.java b/model/api/src/main/java/org/keycloak/models/ClientModel.java index daccf8e0aa..6461a339e7 100755 --- a/model/api/src/main/java/org/keycloak/models/ClientModel.java +++ b/model/api/src/main/java/org/keycloak/models/ClientModel.java @@ -109,6 +109,9 @@ public interface ClientModel extends RoleContainerModel { boolean isServiceAccountsEnabled(); void setServiceAccountsEnabled(boolean serviceAccountsEnabled); + boolean isOfflineTokensEnabled(); + void setOfflineTokensEnabled(boolean offlineTokensEnabled); + Set getScopeMappings(); void addScopeMapping(RoleModel role); void deleteScopeMapping(RoleModel role); diff --git a/model/api/src/main/java/org/keycloak/models/OfflineClientSessionModel.java b/model/api/src/main/java/org/keycloak/models/OfflineClientSessionModel.java new file mode 100644 index 0000000000..47ae23aa63 --- /dev/null +++ b/model/api/src/main/java/org/keycloak/models/OfflineClientSessionModel.java @@ -0,0 +1,44 @@ +package org.keycloak.models; + +/** + * @author Marek Posolda + */ +public class OfflineClientSessionModel { + + private String clientSessionId; + private String userSessionId; + private String clientId; + private String data; + + public String getClientSessionId() { + return clientSessionId; + } + + public void setClientSessionId(String clientSessionId) { + this.clientSessionId = clientSessionId; + } + + public String getUserSessionId() { + return userSessionId; + } + + public void setUserSessionId(String userSessionId) { + this.userSessionId = userSessionId; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } +} diff --git a/model/api/src/main/java/org/keycloak/models/OfflineUserSessionModel.java b/model/api/src/main/java/org/keycloak/models/OfflineUserSessionModel.java new file mode 100644 index 0000000000..9907783899 --- /dev/null +++ b/model/api/src/main/java/org/keycloak/models/OfflineUserSessionModel.java @@ -0,0 +1,26 @@ +package org.keycloak.models; + +/** + * @author Marek Posolda + */ +public class OfflineUserSessionModel { + + private String userSessionId; + private String data; + + public String getUserSessionId() { + return userSessionId; + } + + public void setUserSessionId(String userSessionId) { + this.userSessionId = userSessionId; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } +} diff --git a/model/api/src/main/java/org/keycloak/models/UserModel.java b/model/api/src/main/java/org/keycloak/models/UserModel.java index 3282e61955..51e8fc41c8 100755 --- a/model/api/src/main/java/org/keycloak/models/UserModel.java +++ b/model/api/src/main/java/org/keycloak/models/UserModel.java @@ -1,5 +1,6 @@ package org.keycloak.models; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; @@ -113,6 +114,15 @@ public interface UserModel { void updateConsent(UserConsentModel consent); boolean revokeConsentForClient(String clientInternalId); + void addOfflineUserSession(OfflineUserSessionModel offlineUserSession); + OfflineUserSessionModel getOfflineUserSession(String userSessionId); + Collection getOfflineUserSessions(); + boolean removeOfflineUserSession(String userSessionId); + void addOfflineClientSession(OfflineClientSessionModel offlineClientSession); + OfflineClientSessionModel getOfflineClientSession(String clientSessionId); + Collection getOfflineClientSessions(); + boolean removeOfflineClientSession(String clientSessionId); + public static enum RequiredAction { VERIFY_EMAIL, UPDATE_PROFILE, CONFIGURE_TOTP, UPDATE_PASSWORD } diff --git a/model/api/src/main/java/org/keycloak/models/UserSessionModel.java b/model/api/src/main/java/org/keycloak/models/UserSessionModel.java index ff3f19a00f..12ebd70c2f 100755 --- a/model/api/src/main/java/org/keycloak/models/UserSessionModel.java +++ b/model/api/src/main/java/org/keycloak/models/UserSessionModel.java @@ -40,6 +40,7 @@ public interface UserSessionModel { public String getNote(String name); public void setNote(String name, String value); public void removeNote(String name); + public Map getNotes(); State getState(); void setState(State state); diff --git a/model/api/src/main/java/org/keycloak/models/entities/ClientEntity.java b/model/api/src/main/java/org/keycloak/models/entities/ClientEntity.java index 52e972196b..83fa12dc4e 100755 --- a/model/api/src/main/java/org/keycloak/models/entities/ClientEntity.java +++ b/model/api/src/main/java/org/keycloak/models/entities/ClientEntity.java @@ -28,6 +28,7 @@ public class ClientEntity extends AbstractIdentifiableEntity { private boolean bearerOnly; private boolean consentRequired; private boolean serviceAccountsEnabled; + private boolean offlineTokensEnabled; private boolean directGrantsOnly; private int nodeReRegistrationTimeout; @@ -228,6 +229,14 @@ public class ClientEntity extends AbstractIdentifiableEntity { this.serviceAccountsEnabled = serviceAccountsEnabled; } + public boolean isOfflineTokensEnabled() { + return offlineTokensEnabled; + } + + public void setOfflineTokensEnabled(boolean offlineTokensEnabled) { + this.offlineTokensEnabled = offlineTokensEnabled; + } + public boolean isDirectGrantsOnly() { return directGrantsOnly; } diff --git a/model/api/src/main/java/org/keycloak/models/entities/OfflineClientSessionEntity.java b/model/api/src/main/java/org/keycloak/models/entities/OfflineClientSessionEntity.java new file mode 100644 index 0000000000..69ad60bb38 --- /dev/null +++ b/model/api/src/main/java/org/keycloak/models/entities/OfflineClientSessionEntity.java @@ -0,0 +1,35 @@ +package org.keycloak.models.entities; + +/** + * @author Marek Posolda + */ +public class OfflineClientSessionEntity { + + private String clientSessionId; + private String clientId; + private String data; + + public String getClientSessionId() { + return clientSessionId; + } + + public void setClientSessionId(String clientSessionId) { + this.clientSessionId = clientSessionId; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } +} diff --git a/model/api/src/main/java/org/keycloak/models/entities/OfflineUserSessionEntity.java b/model/api/src/main/java/org/keycloak/models/entities/OfflineUserSessionEntity.java new file mode 100644 index 0000000000..e7858983fb --- /dev/null +++ b/model/api/src/main/java/org/keycloak/models/entities/OfflineUserSessionEntity.java @@ -0,0 +1,37 @@ +package org.keycloak.models.entities; + +import java.util.List; + +/** + * @author Marek Posolda + */ +public class OfflineUserSessionEntity { + + private String userSessionId; + private String data; + private List offlineClientSessions; + + public String getUserSessionId() { + return userSessionId; + } + + public void setUserSessionId(String userSessionId) { + this.userSessionId = userSessionId; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + + public List getOfflineClientSessions() { + return offlineClientSessions; + } + + public void setOfflineClientSessions(List offlineClientSessions) { + this.offlineClientSessions = offlineClientSessions; + } +} diff --git a/model/api/src/main/java/org/keycloak/models/entities/UserEntity.java b/model/api/src/main/java/org/keycloak/models/entities/UserEntity.java index 8c82a8e13b..66020db921 100755 --- a/model/api/src/main/java/org/keycloak/models/entities/UserEntity.java +++ b/model/api/src/main/java/org/keycloak/models/entities/UserEntity.java @@ -28,6 +28,7 @@ public class UserEntity extends AbstractIdentifiableEntity { private List federatedIdentities; private String federationLink; private String serviceAccountClientLink; + private List offlineUserSessions; public String getUsername() { return username; @@ -157,5 +158,13 @@ public class UserEntity extends AbstractIdentifiableEntity { public void setServiceAccountClientLink(String serviceAccountClientLink) { this.serviceAccountClientLink = serviceAccountClientLink; } + + public List getOfflineUserSessions() { + return offlineUserSessions; + } + + public void setOfflineUserSessions(List offlineUserSessions) { + this.offlineUserSessions = offlineUserSessions; + } } diff --git a/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index bf2360e6a2..a3828803f2 100755 --- a/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -303,6 +303,7 @@ public class ModelToRepresentation { rep.setBearerOnly(clientModel.isBearerOnly()); rep.setConsentRequired(clientModel.isConsentRequired()); rep.setServiceAccountsEnabled(clientModel.isServiceAccountsEnabled()); + rep.setOfflineTokensEnabled(clientModel.isOfflineTokensEnabled()); rep.setDirectGrantsOnly(clientModel.isDirectGrantsOnly()); rep.setSurrogateAuthRequired(clientModel.isSurrogateAuthRequired()); rep.setBaseUrl(clientModel.getBaseUrl()); diff --git a/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 811655b4b6..3907f96648 100755 --- a/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -692,6 +692,7 @@ public class RepresentationToModel { if (resourceRep.isBearerOnly() != null) client.setBearerOnly(resourceRep.isBearerOnly()); if (resourceRep.isConsentRequired() != null) client.setConsentRequired(resourceRep.isConsentRequired()); if (resourceRep.isServiceAccountsEnabled() != null) client.setServiceAccountsEnabled(resourceRep.isServiceAccountsEnabled()); + if (resourceRep.isOfflineTokensEnabled() != null) client.setOfflineTokensEnabled(resourceRep.isOfflineTokensEnabled()); if (resourceRep.isDirectGrantsOnly() != null) client.setDirectGrantsOnly(resourceRep.isDirectGrantsOnly()); if (resourceRep.isPublicClient() != null) client.setPublicClient(resourceRep.isPublicClient()); if (resourceRep.isFrontchannelLogout() != null) client.setFrontchannelLogout(resourceRep.isFrontchannelLogout()); @@ -788,6 +789,7 @@ public class RepresentationToModel { if (rep.isBearerOnly() != null) resource.setBearerOnly(rep.isBearerOnly()); if (rep.isConsentRequired() != null) resource.setConsentRequired(rep.isConsentRequired()); if (rep.isServiceAccountsEnabled() != null) resource.setServiceAccountsEnabled(rep.isServiceAccountsEnabled()); + if (rep.isOfflineTokensEnabled() != null) resource.setOfflineTokensEnabled(rep.isOfflineTokensEnabled()); if (rep.isDirectGrantsOnly() != null) resource.setDirectGrantsOnly(rep.isDirectGrantsOnly()); if (rep.isPublicClient() != null) resource.setPublicClient(rep.isPublicClient()); if (rep.isFullScopeAllowed() != null) resource.setFullScopeAllowed(rep.isFullScopeAllowed()); diff --git a/model/api/src/main/java/org/keycloak/models/utils/UserModelDelegate.java b/model/api/src/main/java/org/keycloak/models/utils/UserModelDelegate.java index 4cd162bce6..f727c7500f 100755 --- a/model/api/src/main/java/org/keycloak/models/utils/UserModelDelegate.java +++ b/model/api/src/main/java/org/keycloak/models/utils/UserModelDelegate.java @@ -1,12 +1,15 @@ package org.keycloak.models.utils; import org.keycloak.models.ClientModel; +import org.keycloak.models.OfflineClientSessionModel; +import org.keycloak.models.OfflineUserSessionModel; import org.keycloak.models.UserConsentModel; import org.keycloak.models.RoleModel; import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserCredentialValueModel; import org.keycloak.models.UserModel; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; @@ -255,4 +258,44 @@ public class UserModelDelegate implements UserModel { public void setCreatedTimestamp(Long timestamp){ delegate.setCreatedTimestamp(timestamp); } + + @Override + public void addOfflineUserSession(OfflineUserSessionModel userSession) { + delegate.addOfflineUserSession(userSession); + } + + @Override + public OfflineUserSessionModel getOfflineUserSession(String userSessionId) { + return delegate.getOfflineUserSession(userSessionId); + } + + @Override + public Collection getOfflineUserSessions() { + return delegate.getOfflineUserSessions(); + } + + @Override + public boolean removeOfflineUserSession(String userSessionId) { + return delegate.removeOfflineUserSession(userSessionId); + } + + @Override + public void addOfflineClientSession(OfflineClientSessionModel clientSession) { + delegate.addOfflineClientSession(clientSession); + } + + @Override + public OfflineClientSessionModel getOfflineClientSession(String clientSessionId) { + return delegate.getOfflineClientSession(clientSessionId); + } + + @Override + public Collection getOfflineClientSessions() { + return delegate.getOfflineClientSessions(); + } + + @Override + public boolean removeOfflineClientSession(String clientSessionId) { + return delegate.removeOfflineClientSession(clientSessionId); + } } diff --git a/model/file/src/main/java/org/keycloak/models/file/adapter/ClientAdapter.java b/model/file/src/main/java/org/keycloak/models/file/adapter/ClientAdapter.java index 8003b70ded..366dc31e14 100755 --- a/model/file/src/main/java/org/keycloak/models/file/adapter/ClientAdapter.java +++ b/model/file/src/main/java/org/keycloak/models/file/adapter/ClientAdapter.java @@ -461,6 +461,16 @@ public class ClientAdapter implements ClientModel { entity.setServiceAccountsEnabled(serviceAccountsEnabled); } + @Override + public boolean isOfflineTokensEnabled() { + return entity.isOfflineTokensEnabled(); + } + + @Override + public void setOfflineTokensEnabled(boolean offlineTokensEnabled) { + entity.setOfflineTokensEnabled(offlineTokensEnabled); + } + @Override public boolean isDirectGrantsOnly() { return entity.isDirectGrantsOnly(); diff --git a/model/file/src/main/java/org/keycloak/models/file/adapter/UserAdapter.java b/model/file/src/main/java/org/keycloak/models/file/adapter/UserAdapter.java index 7461cbd4b2..d89ce63009 100755 --- a/model/file/src/main/java/org/keycloak/models/file/adapter/UserAdapter.java +++ b/model/file/src/main/java/org/keycloak/models/file/adapter/UserAdapter.java @@ -22,7 +22,10 @@ import org.keycloak.models.ClientModel; import static org.keycloak.models.utils.Pbkdf2PasswordEncoder.getSalt; import org.keycloak.models.ModelDuplicateException; +import org.keycloak.models.ModelException; import org.keycloak.models.OTPPolicy; +import org.keycloak.models.OfflineClientSessionModel; +import org.keycloak.models.OfflineUserSessionModel; import org.keycloak.models.UserConsentModel; import org.keycloak.models.PasswordPolicy; import org.keycloak.models.RealmModel; @@ -32,6 +35,8 @@ import org.keycloak.models.UserCredentialValueModel; import org.keycloak.models.UserModel; import org.keycloak.models.entities.CredentialEntity; import org.keycloak.models.entities.FederatedIdentityEntity; +import org.keycloak.models.entities.OfflineClientSessionEntity; +import org.keycloak.models.entities.OfflineUserSessionEntity; import org.keycloak.models.entities.RoleEntity; import org.keycloak.models.entities.UserEntity; import org.keycloak.models.utils.KeycloakModelUtils; @@ -39,6 +44,7 @@ import org.keycloak.models.utils.Pbkdf2PasswordEncoder; import org.keycloak.util.Time; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -216,7 +222,7 @@ public class UserAdapter implements UserModel, Comparable { @Override public Map> getAttributes() { - return user.getAttributes()==null ? Collections.>emptyMap() : Collections.unmodifiableMap((Map)user.getAttributes()); + return user.getAttributes()==null ? Collections.>emptyMap() : Collections.unmodifiableMap((Map) user.getAttributes()); } @Override @@ -568,6 +574,142 @@ public class UserAdapter implements UserModel, Comparable { return false; } + @Override + public void addOfflineUserSession(OfflineUserSessionModel userSession) { + if (user.getOfflineUserSessions() == null) { + user.setOfflineUserSessions(new ArrayList()); + } + + if (getUserSessionEntityById(userSession.getUserSessionId()) != null) { + throw new ModelDuplicateException("User session already exists with id " + userSession.getUserSessionId() + " for user " + user.getUsername()); + } + + OfflineUserSessionEntity entity = new OfflineUserSessionEntity(); + entity.setUserSessionId(userSession.getUserSessionId()); + entity.setData(userSession.getData()); + entity.setOfflineClientSessions(new ArrayList()); + user.getOfflineUserSessions().add(entity); + } + + @Override + public OfflineUserSessionModel getOfflineUserSession(String userSessionId) { + OfflineUserSessionEntity entity = getUserSessionEntityById(userSessionId); + return entity==null ? null : toModel(entity); + } + + @Override + public Collection getOfflineUserSessions() { + if (user.getOfflineUserSessions()==null) { + return Collections.emptyList(); + } else { + List result = new ArrayList<>(); + for (OfflineUserSessionEntity entity : user.getOfflineUserSessions()) { + result.add(toModel(entity)); + } + return result; + } + } + + private OfflineUserSessionModel toModel(OfflineUserSessionEntity entity) { + OfflineUserSessionModel model = new OfflineUserSessionModel(); + model.setUserSessionId(entity.getUserSessionId()); + model.setData(entity.getData()); + return model; + } + + @Override + public boolean removeOfflineUserSession(String userSessionId) { + OfflineUserSessionEntity entity = getUserSessionEntityById(userSessionId); + if (entity != null) { + user.getOfflineUserSessions().remove(entity); + return true; + } else { + return false; + } + } + + private OfflineUserSessionEntity getUserSessionEntityById(String userSessionId) { + if (user.getOfflineUserSessions() != null) { + for (OfflineUserSessionEntity entity : user.getOfflineUserSessions()) { + if (entity.getUserSessionId().equals(userSessionId)) { + return entity; + } + } + } + return null; + } + + @Override + public void addOfflineClientSession(OfflineClientSessionModel clientSession) { + OfflineUserSessionEntity userSessionEntity = getUserSessionEntityById(clientSession.getUserSessionId()); + if (userSessionEntity == null) { + throw new ModelException("OfflineUserSession with ID " + clientSession.getUserSessionId() + " doesn't exist for user " + user.getUsername()); + } + + OfflineClientSessionEntity clEntity = new OfflineClientSessionEntity(); + clEntity.setClientSessionId(clientSession.getClientSessionId()); + clEntity.setClientId(clientSession.getClientId()); + clEntity.setData(clientSession.getData()); + + userSessionEntity.getOfflineClientSessions().add(clEntity); + } + + @Override + public OfflineClientSessionModel getOfflineClientSession(String clientSessionId) { + if (user.getOfflineUserSessions() != null) { + for (OfflineUserSessionEntity userSession : user.getOfflineUserSessions()) { + for (OfflineClientSessionEntity clSession : userSession.getOfflineClientSessions()) { + if (clSession.getClientSessionId().equals(clientSessionId)) { + return toModel(clSession, userSession.getUserSessionId()); + } + } + } + } + + return null; + } + + private OfflineClientSessionModel toModel(OfflineClientSessionEntity cls, String userSessionId) { + OfflineClientSessionModel model = new OfflineClientSessionModel(); + model.setClientSessionId(cls.getClientSessionId()); + model.setClientId(cls.getClientId()); + model.setData(cls.getData()); + model.setUserSessionId(userSessionId); + return model; + } + + @Override + public Collection getOfflineClientSessions() { + List result = new ArrayList<>(); + + if (user.getOfflineUserSessions() != null) { + for (OfflineUserSessionEntity userSession : user.getOfflineUserSessions()) { + for (OfflineClientSessionEntity clSession : userSession.getOfflineClientSessions()) { + result.add(toModel(clSession, userSession.getUserSessionId())); + } + } + } + + return result; + } + + @Override + public boolean removeOfflineClientSession(String clientSessionId) { + if (user.getOfflineUserSessions() != null) { + for (OfflineUserSessionEntity userSession : user.getOfflineUserSessions()) { + for (OfflineClientSessionEntity clSession : userSession.getOfflineClientSessions()) { + if (clSession.getClientSessionId().equals(clientSessionId)) { + userSession.getOfflineClientSessions().remove(clSession); + return true; + } + } + } + } + + return false; + } + + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/ClientAdapter.java b/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/ClientAdapter.java index 9f79b157ed..582e5f1f1c 100755 --- a/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/ClientAdapter.java +++ b/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/ClientAdapter.java @@ -430,6 +430,18 @@ public class ClientAdapter implements ClientModel { updated.setServiceAccountsEnabled(serviceAccountsEnabled); } + @Override + public boolean isOfflineTokensEnabled() { + if (updated != null) return updated.isOfflineTokensEnabled(); + return cached.isOfflineTokensEnabled(); + } + + @Override + public void setOfflineTokensEnabled(boolean offlineTokensEnabled) { + getDelegateForUpdate(); + updated.setOfflineTokensEnabled(offlineTokensEnabled); + } + @Override public RoleModel getRole(String name) { if (updated != null) return updated.getRole(name); diff --git a/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/DefaultCacheUserProvider.java b/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/DefaultCacheUserProvider.java index 68b31edb3b..df7c144c9a 100644 --- a/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/DefaultCacheUserProvider.java +++ b/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/DefaultCacheUserProvider.java @@ -319,6 +319,7 @@ public class DefaultCacheUserProvider implements CacheUserProvider { @Override public void preRemove(RealmModel realm, ClientModel client) { + realmInvalidations.add(realm.getId()); // easier to just invalidate whole realm getDelegate().preRemove(realm, client); } diff --git a/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserAdapter.java b/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserAdapter.java index 5a74b01a35..769f1b47c3 100755 --- a/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserAdapter.java +++ b/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserAdapter.java @@ -348,4 +348,52 @@ public class UserAdapter implements UserModel { getDelegateForUpdate(); return updated.revokeConsentForClient(clientId); } + + @Override + public void addOfflineUserSession(OfflineUserSessionModel userSession) { + getDelegateForUpdate(); + updated.addOfflineUserSession(userSession); + } + + @Override + public OfflineUserSessionModel getOfflineUserSession(String userSessionId) { + if (updated != null) return updated.getOfflineUserSession(userSessionId); + return cached.getOfflineUserSessions().get(userSessionId); + } + + @Override + public Collection getOfflineUserSessions() { + if (updated != null) return updated.getOfflineUserSessions(); + return cached.getOfflineUserSessions().values(); + } + + @Override + public boolean removeOfflineUserSession(String userSessionId) { + getDelegateForUpdate(); + return updated.removeOfflineUserSession(userSessionId); + } + + @Override + public void addOfflineClientSession(OfflineClientSessionModel clientSession) { + getDelegateForUpdate(); + updated.addOfflineClientSession(clientSession); + } + + @Override + public OfflineClientSessionModel getOfflineClientSession(String clientSessionId) { + if (updated != null) return updated.getOfflineClientSession(clientSessionId); + return cached.getOfflineClientSessions().get(clientSessionId); + } + + @Override + public Collection getOfflineClientSessions() { + if (updated != null) return updated.getOfflineClientSessions(); + return cached.getOfflineClientSessions().values(); + } + + @Override + public boolean removeOfflineClientSession(String clientSessionId) { + getDelegateForUpdate(); + return updated.removeOfflineClientSession(clientSessionId); + } } diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java index 11447d02d7..b90c0773af 100755 --- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java +++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java @@ -47,6 +47,7 @@ public class CachedClient implements Serializable { private boolean bearerOnly; private boolean consentRequired; private boolean serviceAccountsEnabled; + private boolean offlineTokensEnabled; private Map roles = new HashMap(); private int nodeReRegistrationTimeout; private Map registeredNodes; @@ -81,6 +82,7 @@ public class CachedClient implements Serializable { bearerOnly = model.isBearerOnly(); consentRequired = model.isConsentRequired(); serviceAccountsEnabled = model.isServiceAccountsEnabled(); + offlineTokensEnabled = model.isOfflineTokensEnabled(); for (RoleModel role : model.getRoles()) { roles.put(role.getName(), role.getId()); cache.addCachedRole(new CachedClientRole(id, role, realm)); @@ -189,6 +191,10 @@ public class CachedClient implements Serializable { return serviceAccountsEnabled; } + public boolean isOfflineTokensEnabled() { + return offlineTokensEnabled; + } + public Map getRoles() { return roles; } diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedUser.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedUser.java index 9757c630cd..d38b6f998a 100755 --- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedUser.java +++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedUser.java @@ -1,5 +1,7 @@ package org.keycloak.models.cache.entities; +import org.keycloak.models.OfflineClientSessionModel; +import org.keycloak.models.OfflineUserSessionModel; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; import org.keycloak.models.UserCredentialValueModel; @@ -7,9 +9,11 @@ import org.keycloak.models.UserModel; import org.keycloak.util.MultivaluedHashMap; import java.io.Serializable; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -33,6 +37,8 @@ public class CachedUser implements Serializable { private MultivaluedHashMap attributes = new MultivaluedHashMap<>(); private Set requiredActions = new HashSet<>(); private Set roleMappings = new HashSet(); + private Map offlineUserSessions = new HashMap<>(); + private Map offlineClientSessions = new HashMap<>(); public CachedUser(RealmModel realm, UserModel user) { this.id = user.getId(); @@ -53,6 +59,12 @@ public class CachedUser implements Serializable { for (RoleModel role : user.getRoleMappings()) { roleMappings.add(role.getId()); } + for (OfflineUserSessionModel offlineSession : user.getOfflineUserSessions()) { + offlineUserSessions.put(offlineSession.getUserSessionId(), offlineSession); + } + for (OfflineClientSessionModel offlineSession : user.getOfflineClientSessions()) { + offlineClientSessions.put(offlineSession.getClientSessionId(), offlineSession); + } } public String getId() { @@ -118,4 +130,12 @@ public class CachedUser implements Serializable { public String getServiceAccountClientLink() { return serviceAccountClientLink; } + + public Map getOfflineUserSessions() { + return offlineUserSessions; + } + + public Map getOfflineClientSessions() { + return offlineClientSessions; + } } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/ClientAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/ClientAdapter.java index b0acc417fd..9fc7377a19 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/ClientAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/ClientAdapter.java @@ -481,6 +481,16 @@ public class ClientAdapter implements ClientModel { entity.setServiceAccountsEnabled(serviceAccountsEnabled); } + @Override + public boolean isOfflineTokensEnabled() { + return entity.isOfflineTokensEnabled(); + } + + @Override + public void setOfflineTokensEnabled(boolean offlineTokensEnabled) { + entity.setOfflineTokensEnabled(offlineTokensEnabled); + } + @Override public boolean isDirectGrantsOnly() { return entity.isDirectGrantsOnly(); diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java index d92e93ddea..8cee4ea66b 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java @@ -169,6 +169,10 @@ public class JpaUserProvider implements UserProvider { .setParameter("realmId", realm.getId()).executeUpdate(); num = em.createNamedQuery("deleteUserAttributesByRealm") .setParameter("realmId", realm.getId()).executeUpdate(); + num = em.createNamedQuery("deleteOfflineClientSessionsByRealm") + .setParameter("realmId", realm.getId()).executeUpdate(); + num = em.createNamedQuery("deleteOfflineUserSessionsByRealm") + .setParameter("realmId", realm.getId()).executeUpdate(); num = em.createNamedQuery("deleteUsersByRealm") .setParameter("realmId", realm.getId()).executeUpdate(); } @@ -195,6 +199,14 @@ public class JpaUserProvider implements UserProvider { .setParameter("realmId", realm.getId()) .setParameter("link", link.getId()) .executeUpdate(); + num = em.createNamedQuery("deleteOfflineClientSessionsByRealmAndLink") + .setParameter("realmId", realm.getId()) + .setParameter("link", link.getId()) + .executeUpdate(); + num = em.createNamedQuery("deleteOfflineUserSessionsByRealmAndLink") + .setParameter("realmId", realm.getId()) + .setParameter("link", link.getId()) + .executeUpdate(); num = em.createNamedQuery("deleteUsersByRealmAndLink") .setParameter("realmId", realm.getId()) .setParameter("link", link.getId()) @@ -212,6 +224,8 @@ public class JpaUserProvider implements UserProvider { em.createNamedQuery("deleteUserConsentProtMappersByClient").setParameter("clientId", client.getId()).executeUpdate(); em.createNamedQuery("deleteUserConsentRolesByClient").setParameter("clientId", client.getId()).executeUpdate(); em.createNamedQuery("deleteUserConsentsByClient").setParameter("clientId", client.getId()).executeUpdate(); + em.createNamedQuery("deleteOfflineClientSessionsByClient").setParameter("clientId", client.getId()).executeUpdate(); + em.createNamedQuery("deleteDetachedOfflineUserSessions").executeUpdate(); } @Override diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java index 67def6b977..bdcf1f1afb 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java @@ -2,6 +2,8 @@ package org.keycloak.models.jpa; import org.keycloak.models.ClientModel; import org.keycloak.models.OTPPolicy; +import org.keycloak.models.OfflineClientSessionModel; +import org.keycloak.models.OfflineUserSessionModel; import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.UserConsentModel; import org.keycloak.models.ModelDuplicateException; @@ -14,6 +16,8 @@ import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserCredentialValueModel; import org.keycloak.models.UserModel; import org.keycloak.models.jpa.entities.CredentialEntity; +import org.keycloak.models.jpa.entities.OfflineClientSessionEntity; +import org.keycloak.models.jpa.entities.OfflineUserSessionEntity; import org.keycloak.models.jpa.entities.UserConsentEntity; import org.keycloak.models.jpa.entities.UserConsentProtocolMapperEntity; import org.keycloak.models.jpa.entities.UserConsentRoleEntity; @@ -37,6 +41,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; @@ -750,6 +755,124 @@ public class UserAdapter implements UserModel { em.flush(); } + @Override + public void addOfflineUserSession(OfflineUserSessionModel offlineSession) { + OfflineUserSessionEntity entity = new OfflineUserSessionEntity(); + entity.setUser(user); + entity.setUserSessionId(offlineSession.getUserSessionId()); + entity.setData(offlineSession.getData()); + em.persist(entity); + user.getOfflineUserSessions().add(entity); + em.flush(); + } + + @Override + public OfflineUserSessionModel getOfflineUserSession(String userSessionId) { + for (OfflineUserSessionEntity entity : user.getOfflineUserSessions()) { + if (entity.getUserSessionId().equals(userSessionId)) { + return toModel(entity); + } + } + return null; + } + + private OfflineUserSessionModel toModel(OfflineUserSessionEntity entity) { + OfflineUserSessionModel model = new OfflineUserSessionModel(); + model.setUserSessionId(entity.getUserSessionId()); + model.setData(entity.getData()); + return model; + } + + @Override + public Collection getOfflineUserSessions() { + List result = new LinkedList<>(); + for (OfflineUserSessionEntity entity : user.getOfflineUserSessions()) { + result.add(toModel(entity)); + } + return result; + } + + @Override + public boolean removeOfflineUserSession(String userSessionId) { + OfflineUserSessionEntity found = null; + for (OfflineUserSessionEntity session : user.getOfflineUserSessions()) { + if (session.getUserSessionId().equals(userSessionId)) { + found = session; + break; + } + } + + if (found == null) { + return false; + } else { + user.getOfflineUserSessions().remove(found); + em.remove(found); + em.flush(); + return true; + } + } + + @Override + public void addOfflineClientSession(OfflineClientSessionModel clientSession) { + OfflineClientSessionEntity entity = new OfflineClientSessionEntity(); + entity.setUser(user); + entity.setClientSessionId(clientSession.getClientSessionId()); + entity.setUserSessionId(clientSession.getUserSessionId()); + entity.setClientId(clientSession.getClientId()); + entity.setData(clientSession.getData()); + em.persist(entity); + user.getOfflineClientSessions().add(entity); + em.flush(); + } + + @Override + public OfflineClientSessionModel getOfflineClientSession(String clientSessionId) { + for (OfflineClientSessionEntity entity : user.getOfflineClientSessions()) { + if (entity.getClientSessionId().equals(clientSessionId)) { + return toModel(entity); + } + } + return null; + } + + private OfflineClientSessionModel toModel(OfflineClientSessionEntity entity) { + OfflineClientSessionModel model = new OfflineClientSessionModel(); + model.setClientSessionId(entity.getClientSessionId()); + model.setClientId(entity.getClientId()); + model.setUserSessionId(entity.getUserSessionId()); + model.setData(entity.getData()); + return model; + } + + @Override + public Collection getOfflineClientSessions() { + List result = new LinkedList<>(); + for (OfflineClientSessionEntity entity : user.getOfflineClientSessions()) { + result.add(toModel(entity)); + } + return result; + } + + @Override + public boolean removeOfflineClientSession(String clientSessionId) { + OfflineClientSessionEntity found = null; + for (OfflineClientSessionEntity session : user.getOfflineClientSessions()) { + if (session.getClientSessionId().equals(clientSessionId)) { + found = session; + break; + } + } + + if (found == null) { + return false; + } else { + user.getOfflineClientSessions().remove(found); + em.remove(found); + em.flush(); + return true; + } + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientEntity.java index 1f6ac251b3..d853cb3f7c 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientEntity.java @@ -100,6 +100,9 @@ public class ClientEntity { @Column(name="SERVICE_ACCOUNTS_ENABLED") private boolean serviceAccountsEnabled; + @Column(name="OFFLINE_TOKENS_ENABLED") + private boolean offlineTokensEnabled; + @Column(name="NODE_REREG_TIMEOUT") private int nodeReRegistrationTimeout; @@ -316,6 +319,14 @@ public class ClientEntity { this.serviceAccountsEnabled = serviceAccountsEnabled; } + public boolean isOfflineTokensEnabled() { + return offlineTokensEnabled; + } + + public void setOfflineTokensEnabled(boolean offlineTokensEnabled) { + this.offlineTokensEnabled = offlineTokensEnabled; + } + public boolean isDirectGrantsOnly() { return directGrantsOnly; } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OfflineClientSessionEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OfflineClientSessionEntity.java new file mode 100644 index 0000000000..23081c44cd --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OfflineClientSessionEntity.java @@ -0,0 +1,81 @@ +package org.keycloak.models.jpa.entities; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; + +/** + * @author Marek Posolda + */ +@NamedQueries({ + @NamedQuery(name="deleteOfflineClientSessionsByRealm", query="delete from OfflineClientSessionEntity sess where sess.user IN (select u from UserEntity u where u.realmId=:realmId)"), + @NamedQuery(name="deleteOfflineClientSessionsByRealmAndLink", query="delete from OfflineClientSessionEntity sess where sess.user IN (select u from UserEntity u where u.realmId=:realmId and u.federationLink=:link)"), + @NamedQuery(name="deleteOfflineClientSessionsByClient", query="delete from OfflineClientSessionEntity sess where sess.clientId=:clientId") +}) +@Table(name="OFFLINE_CLIENT_SESSION") +@Entity +public class OfflineClientSessionEntity { + + @Id + @Column(name="CLIENT_SESSION_ID", length = 36) + protected String clientSessionId; + + @Column(name="USER_SESSION_ID", length = 36) + protected String userSessionId; + + @Column(name="CLIENT_ID", length = 36) + protected String clientId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="USER_ID") + protected UserEntity user; + + @Column(name="DATA") + protected String data; + + public String getClientSessionId() { + return clientSessionId; + } + + public void setClientSessionId(String clientSessionId) { + this.clientSessionId = clientSessionId; + } + + public String getUserSessionId() { + return userSessionId; + } + + public void setUserSessionId(String userSessionId) { + this.userSessionId = userSessionId; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public UserEntity getUser() { + return user; + } + + public void setUser(UserEntity user) { + this.user = user; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OfflineUserSessionEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OfflineUserSessionEntity.java new file mode 100644 index 0000000000..d2726fa674 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OfflineUserSessionEntity.java @@ -0,0 +1,59 @@ +package org.keycloak.models.jpa.entities; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; + +/** + * @author Marek Posolda + */ +@NamedQueries({ + @NamedQuery(name="deleteOfflineUserSessionsByRealm", query="delete from OfflineUserSessionEntity sess where sess.user IN (select u from UserEntity u where u.realmId=:realmId)"), + @NamedQuery(name="deleteOfflineUserSessionsByRealmAndLink", query="delete from OfflineUserSessionEntity sess where sess.user IN (select u from UserEntity u where u.realmId=:realmId and u.federationLink=:link)"), + @NamedQuery(name="deleteDetachedOfflineUserSessions", query="delete from OfflineUserSessionEntity sess where sess.userSessionId NOT IN (select c.userSessionId from OfflineClientSessionEntity c)") +}) +@Table(name="OFFLINE_USER_SESSION") +@Entity +public class OfflineUserSessionEntity { + + @Id + @Column(name="USER_SESSION_ID", length = 36) + protected String userSessionId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="USER_ID") + protected UserEntity user; + + @Column(name="DATA") + protected String data; + + public String getUserSessionId() { + return userSessionId; + } + + public void setUserSessionId(String userSessionId) { + this.userSessionId = userSessionId; + } + + public UserEntity getUser() { + return user; + } + + public void setUser(UserEntity user) { + this.user = user; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java index 2da1641586..24d23dbb93 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java @@ -3,9 +3,13 @@ package org.keycloak.models.jpa.entities; import org.keycloak.models.utils.KeycloakModelUtils; import javax.persistence.CascadeType; +import javax.persistence.CollectionTable; import javax.persistence.Column; +import javax.persistence.ElementCollection; import javax.persistence.Entity; import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.MapKeyColumn; import javax.persistence.NamedQueries; import javax.persistence.NamedQuery; import javax.persistence.OneToMany; @@ -14,6 +18,8 @@ import javax.persistence.UniqueConstraint; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; +import java.util.Map; /** * @author Bill Burke @@ -83,6 +89,12 @@ public class UserEntity { @Column(name="SERVICE_ACCOUNT_CLIENT_LINK") protected String serviceAccountClientLink; + @OneToMany(cascade = CascadeType.REMOVE, orphanRemoval = true, mappedBy="user") + protected Collection offlineUserSessions = new ArrayList<>(); + + @OneToMany(cascade = CascadeType.REMOVE, orphanRemoval = true, mappedBy="user") + protected Collection offlineClientSessions = new ArrayList<>(); + public String getId() { return id; } @@ -212,6 +224,22 @@ public class UserEntity { this.serviceAccountClientLink = serviceAccountClientLink; } + public Collection getOfflineUserSessions() { + return offlineUserSessions; + } + + public void setOfflineUserSessions(Collection offlineUserSessions) { + this.offlineUserSessions = offlineUserSessions; + } + + public Collection getOfflineClientSessions() { + return offlineClientSessions; + } + + public void setOfflineClientSessions(Collection offlineClientSessions) { + this.offlineClientSessions = offlineClientSessions; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/ClientAdapter.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/ClientAdapter.java index 26effca9c3..2cb863e550 100755 --- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/ClientAdapter.java +++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/ClientAdapter.java @@ -483,6 +483,17 @@ public class ClientAdapter extends AbstractMongoAdapter imple updateMongoEntity(); } + @Override + public boolean isOfflineTokensEnabled() { + return getMongoEntity().isOfflineTokensEnabled(); + } + + @Override + public void setOfflineTokensEnabled(boolean offlineTokensEnabled) { + getMongoEntity().setOfflineTokensEnabled(offlineTokensEnabled); + updateMongoEntity(); + } + @Override public boolean isDirectGrantsOnly() { return getMongoEntity().isDirectGrantsOnly(); diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java index 308d9fb1d4..14081c1a4f 100755 --- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java +++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java @@ -19,6 +19,8 @@ import org.keycloak.models.UserFederationProviderModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserProvider; import org.keycloak.models.entities.FederatedIdentityEntity; +import org.keycloak.models.entities.OfflineClientSessionEntity; +import org.keycloak.models.entities.OfflineUserSessionEntity; import org.keycloak.models.mongo.keycloak.entities.MongoUserConsentEntity; import org.keycloak.models.mongo.keycloak.entities.MongoUserEntity; import org.keycloak.models.utils.CredentialValidation; @@ -399,6 +401,36 @@ public class MongoUserProvider implements UserProvider { .and("clientId").is(client.getId()) .get(); getMongoStore().removeEntities(MongoUserConsentEntity.class, query, false, invocationContext); + + // Remove all offlineClientSessions + query = new QueryBuilder() + .and("offlineUserSessions.offlineClientSessions.clientId").is(client.getId()) + .get(); + List users = getMongoStore().loadEntities(MongoUserEntity.class, query, invocationContext); + for (MongoUserEntity user : users) { + boolean anyRemoved = false; + for (OfflineUserSessionEntity userSession : user.getOfflineUserSessions()) { + for (OfflineClientSessionEntity clientSession : userSession.getOfflineClientSessions()) { + if (clientSession.getClientId().equals(client.getId())) { + userSession.getOfflineClientSessions().remove(clientSession); + anyRemoved = true; + break; + } + } + + // Check if it was last clientSession. Then remove userSession too + if (userSession.getOfflineClientSessions().size() == 0) { + user.getOfflineUserSessions().remove(userSession); + anyRemoved = true; + break; + } + } + + if (anyRemoved) { + getMongoStore().updateEntity(user, invocationContext); + } + + } } @Override diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/UserAdapter.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/UserAdapter.java index 8130ff5519..a475bb6923 100755 --- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/UserAdapter.java +++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/UserAdapter.java @@ -8,6 +8,8 @@ import com.mongodb.QueryBuilder; import org.keycloak.connections.mongo.api.context.MongoStoreInvocationContext; import org.keycloak.models.ClientModel; import org.keycloak.models.OTPPolicy; +import org.keycloak.models.OfflineClientSessionModel; +import org.keycloak.models.OfflineUserSessionModel; import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.UserConsentModel; import org.keycloak.models.KeycloakSession; @@ -20,6 +22,8 @@ import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserCredentialValueModel; import org.keycloak.models.UserModel; import org.keycloak.models.entities.CredentialEntity; +import org.keycloak.models.entities.OfflineClientSessionEntity; +import org.keycloak.models.entities.OfflineUserSessionEntity; import org.keycloak.models.entities.UserConsentEntity; import org.keycloak.models.mongo.keycloak.entities.MongoUserConsentEntity; import org.keycloak.models.mongo.keycloak.entities.MongoUserEntity; @@ -30,6 +34,7 @@ import org.keycloak.models.utils.Pbkdf2PasswordEncoder; import org.keycloak.util.Time; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -627,6 +632,145 @@ public class UserAdapter extends AbstractMongoAdapter implement return getMongoStore().removeEntity(entity, invocationContext); } + @Override + public void addOfflineUserSession(OfflineUserSessionModel userSession) { + if (user.getOfflineUserSessions() == null) { + user.setOfflineUserSessions(new ArrayList()); + } + + if (getUserSessionEntityById(userSession.getUserSessionId()) != null) { + throw new ModelDuplicateException("User session already exists with id " + userSession.getUserSessionId() + " for user " + getMongoEntity().getUsername()); + } + + OfflineUserSessionEntity entity = new OfflineUserSessionEntity(); + entity.setUserSessionId(userSession.getUserSessionId()); + entity.setData(userSession.getData()); + entity.setOfflineClientSessions(new ArrayList()); + user.getOfflineUserSessions().add(entity); + updateUser(); + } + + @Override + public OfflineUserSessionModel getOfflineUserSession(String userSessionId) { + OfflineUserSessionEntity entity = getUserSessionEntityById(userSessionId); + return entity==null ? null : toModel(entity); + } + + @Override + public Collection getOfflineUserSessions() { + if (user.getOfflineUserSessions()==null) { + return Collections.emptyList(); + } else { + List result = new ArrayList<>(); + for (OfflineUserSessionEntity entity : user.getOfflineUserSessions()) { + result.add(toModel(entity)); + } + return result; + } + } + + private OfflineUserSessionModel toModel(OfflineUserSessionEntity entity) { + OfflineUserSessionModel model = new OfflineUserSessionModel(); + model.setUserSessionId(entity.getUserSessionId()); + model.setData(entity.getData()); + return model; + } + + @Override + public boolean removeOfflineUserSession(String userSessionId) { + OfflineUserSessionEntity entity = getUserSessionEntityById(userSessionId); + if (entity != null) { + user.getOfflineUserSessions().remove(entity); + updateUser(); + return true; + } else { + return false; + } + } + + private OfflineUserSessionEntity getUserSessionEntityById(String userSessionId) { + if (user.getOfflineUserSessions() != null) { + for (OfflineUserSessionEntity entity : user.getOfflineUserSessions()) { + if (entity.getUserSessionId().equals(userSessionId)) { + return entity; + } + } + } + return null; + } + + @Override + public void addOfflineClientSession(OfflineClientSessionModel clientSession) { + OfflineUserSessionEntity userSessionEntity = getUserSessionEntityById(clientSession.getUserSessionId()); + if (userSessionEntity == null) { + throw new ModelException("OfflineUserSession with ID " + clientSession.getUserSessionId() + " doesn't exist for user " + getMongoEntity().getUsername()); + } + + OfflineClientSessionEntity clEntity = new OfflineClientSessionEntity(); + clEntity.setClientSessionId(clientSession.getClientSessionId()); + clEntity.setClientId(clientSession.getClientId()); + clEntity.setData(clientSession.getData()); + + userSessionEntity.getOfflineClientSessions().add(clEntity); + updateUser(); + } + + @Override + public OfflineClientSessionModel getOfflineClientSession(String clientSessionId) { + if (user.getOfflineUserSessions() != null) { + for (OfflineUserSessionEntity userSession : user.getOfflineUserSessions()) { + for (OfflineClientSessionEntity clSession : userSession.getOfflineClientSessions()) { + if (clSession.getClientSessionId().equals(clientSessionId)) { + return toModel(clSession, userSession.getUserSessionId()); + } + } + } + } + + return null; + } + + private OfflineClientSessionModel toModel(OfflineClientSessionEntity cls, String userSessionId) { + OfflineClientSessionModel model = new OfflineClientSessionModel(); + model.setClientSessionId(cls.getClientSessionId()); + model.setClientId(cls.getClientId()); + model.setData(cls.getData()); + model.setUserSessionId(userSessionId); + return model; + } + + @Override + public Collection getOfflineClientSessions() { + List result = new ArrayList<>(); + + if (user.getOfflineUserSessions() != null) { + for (OfflineUserSessionEntity userSession : user.getOfflineUserSessions()) { + for (OfflineClientSessionEntity clSession : userSession.getOfflineClientSessions()) { + result.add(toModel(clSession, userSession.getUserSessionId())); + } + } + } + + return result; + } + + @Override + public boolean removeOfflineClientSession(String clientSessionId) { + if (user.getOfflineUserSessions() != null) { + for (OfflineUserSessionEntity userSession : user.getOfflineUserSessions()) { + for (OfflineClientSessionEntity clSession : userSession.getOfflineClientSessions()) { + if (clSession.getClientSessionId().equals(clientSessionId)) { + userSession.getOfflineClientSessions().remove(clSession); + updateUser(); + return true; + } + } + } + } + + return false; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java index 6cfc12182e..c7104fbfe2 100755 --- a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java +++ b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java @@ -14,6 +14,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; import java.util.List; +import java.util.Map; /** * @author Stian Thorgersen @@ -109,6 +110,11 @@ public class UserSessionAdapter implements UserSessionModel { } } + @Override + public Map getNotes() { + return entity.getNotes(); + } + @Override public State getState() { return entity.getState(); diff --git a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/UserSessionAdapter.java b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/UserSessionAdapter.java index fbe86820ad..a9db618583 100755 --- a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/UserSessionAdapter.java +++ b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/UserSessionAdapter.java @@ -10,6 +10,7 @@ import org.keycloak.models.sessions.infinispan.compat.entities.UserSessionEntity import java.util.LinkedList; import java.util.List; +import java.util.Map; /** * @author Stian Thorgersen @@ -144,5 +145,8 @@ public class UserSessionAdapter implements UserSessionModel { } - + @Override + public Map getNotes() { + return entity.getNotes(); + } } 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 5ee81912f3..46d995f4ca 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -3,8 +3,8 @@ package org.keycloak.protocol.oidc; import org.jboss.logging.Logger; import org.keycloak.ClientConnection; import org.keycloak.OAuthErrorException; -import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.events.Details; +import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.jose.jws.JWSInput; @@ -27,11 +27,15 @@ import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.IDToken; import org.keycloak.representations.RefreshToken; +import org.keycloak.services.ErrorResponseException; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.ClientSessionCode; +import org.keycloak.services.offline.OfflineUserSessionManager; +import org.keycloak.util.RefreshTokenUtil; import org.keycloak.util.Time; import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; import java.io.IOException; import java.util.HashSet; @@ -85,16 +89,31 @@ public class TokenManager { throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "User disabled", "User disabled"); } - UserSessionModel userSession = session.sessions().getUserSession(realm, oldToken.getSessionState()); - if (!AuthenticationManager.isSessionValid(realm, userSession)) { - AuthenticationManager.backchannelLogout(session, realm, userSession, uriInfo, connection, headers, true); - throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Session not active", "Session not active"); - } + UserSessionModel userSession = null; ClientSessionModel clientSession = null; - for (ClientSessionModel clientSessionModel : userSession.getClientSessions()) { - if (clientSessionModel.getId().equals(oldToken.getClientSession())) { - clientSession = clientSessionModel; - break; + if (RefreshTokenUtil.TOKEN_TYPE_OFFLINE.equals(oldToken.getType())) { + // Check if offline tokens still allowed for the client + clientSession = new OfflineUserSessionManager().findOfflineClientSession(realm, user, oldToken.getClientSession(), oldToken.getSessionState()); + if (clientSession != null) { + if (!clientSession.getClient().isOfflineTokensEnabled()) { + throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Offline tokens not allowed for client", "Offline tokens not allowed for client"); + } + + userSession = clientSession.getUserSession(); + } + } else { + // Find userSession regularly for online tokens + userSession = session.sessions().getUserSession(realm, oldToken.getSessionState()); + if (!AuthenticationManager.isSessionValid(realm, userSession)) { + AuthenticationManager.backchannelLogout(session, realm, userSession, uriInfo, connection, headers, true); + throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Session not active", "Session not active"); + } + + for (ClientSessionModel clientSessionModel : userSession.getClientSessions()) { + if (clientSessionModel.getId().equals(oldToken.getClientSession())) { + clientSession = clientSessionModel; + break; + } } } @@ -126,10 +145,12 @@ public class TokenManager { } - public AccessTokenResponse refreshAccessToken(KeycloakSession session, UriInfo uriInfo, ClientConnection connection, RealmModel realm, ClientModel authorizedClient, String encodedRefreshToken, EventBuilder event, HttpHeaders headers) throws OAuthErrorException { + public RefreshResult refreshAccessToken(KeycloakSession session, UriInfo uriInfo, ClientConnection connection, RealmModel realm, ClientModel authorizedClient, String encodedRefreshToken, EventBuilder event, HttpHeaders headers) throws OAuthErrorException { RefreshToken refreshToken = verifyRefreshToken(realm, encodedRefreshToken); - event.user(refreshToken.getSubject()).session(refreshToken.getSessionState()).detail(Details.REFRESH_TOKEN_ID, refreshToken.getId()); + event.user(refreshToken.getSubject()).session(refreshToken.getSessionState()) + .detail(Details.REFRESH_TOKEN_ID, refreshToken.getId()) + .detail(Details.REFRESH_TOKEN_TYPE, refreshToken.getType()); TokenValidation validation = validateToken(session, uriInfo, connection, realm, refreshToken, headers); // validate authorizedClient is same as validated client @@ -140,11 +161,17 @@ public class TokenManager { int currentTime = Time.currentTime(); validation.userSession.setLastSessionRefresh(currentTime); - AccessTokenResponse res = responseBuilder(realm, authorizedClient, event, session, validation.userSession, validation.clientSession) + AccessTokenResponseBuilder responseBuilder = responseBuilder(realm, authorizedClient, event, session, validation.userSession, validation.clientSession) .accessToken(validation.newToken) - .generateIDToken() - .generateRefreshToken().build(); - return res; + .generateIDToken(); + + // Don't generate refresh token again if refresh was triggered with offline token + if (!refreshToken.getType().equals(RefreshTokenUtil.TOKEN_TYPE_OFFLINE)) { + responseBuilder.generateRefreshToken(); + } + + AccessTokenResponse res = responseBuilder.build(); + return new RefreshResult(res, RefreshTokenUtil.TOKEN_TYPE_OFFLINE.equals(refreshToken.getType())); } public RefreshToken verifyRefreshToken(RealmModel realm, String encodedRefreshToken) throws OAuthErrorException { @@ -158,7 +185,7 @@ public class TokenManager { } catch (Exception e) { throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e); } - if (refreshToken.isExpired()) { + if (refreshToken.getExpiration() != 0 && refreshToken.isExpired()) { throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh token expired"); } @@ -172,18 +199,18 @@ public class TokenManager { IDToken idToken = null; try { if (!RSAProvider.verify(jws, realm.getPublicKey())) { - throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token"); + throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid IDToken"); } idToken = jws.readJsonContent(IDToken.class); } catch (IOException e) { - throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e); + throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid IDToken", e); } if (idToken.isExpired()) { - throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh token expired"); + throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "IDToken expired"); } if (idToken.getIssuedAt() < realm.getNotBefore()) { - throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale refresh token"); + throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale IDToken"); } return idToken; } @@ -250,9 +277,7 @@ public class TokenManager { if (client.isFullScopeAllowed()) return roleMappings; Set scopeMappings = client.getScopeMappings(); - if (client instanceof ClientModel) { - scopeMappings.addAll(((ClientModel) client).getRoles()); - } + scopeMappings.addAll(client.getRoles()); for (RoleModel role : roleMappings) { for (RoleModel desiredRole : scopeMappings) { @@ -409,7 +434,9 @@ public class TokenManager { return this; } - public AccessTokenResponseBuilder generateAccessToken(KeycloakSession session, String scopeParam, ClientModel client, UserModel user, UserSessionModel userSession, ClientSessionModel clientSession) { + public AccessTokenResponseBuilder generateAccessToken() { + UserModel user = userSession.getUser(); + String scopeParam = clientSession.getNote(OIDCLoginProtocol.SCOPE_PARAM); Set requestedRoles = getAccess(scopeParam, client, user); accessToken = createClientAccessToken(session, requestedRoles, realm, client, user, userSession, clientSession); return this; @@ -419,10 +446,24 @@ public class TokenManager { if (accessToken == null) { throw new IllegalStateException("accessToken not set"); } - refreshToken = new RefreshToken(accessToken); + + String scopeParam = clientSession.getNote(OIDCLoginProtocol.SCOPE_PARAM); + boolean offlineTokenRequested = RefreshTokenUtil.isOfflineTokenRequested(scopeParam); + if (offlineTokenRequested) { + if (!clientSession.getClient().isOfflineTokensEnabled()) { + event.error(Errors.INVALID_CLIENT); + throw new ErrorResponseException("invalid_client", "Offline tokens not allowed for the client", Response.Status.BAD_REQUEST); + } + + refreshToken = new RefreshToken(accessToken); + refreshToken.type(RefreshTokenUtil.TOKEN_TYPE_OFFLINE); + new OfflineUserSessionManager().persistOfflineSession(clientSession, userSession); + } else { + refreshToken = new RefreshToken(accessToken); + refreshToken.expiration(Time.currentTime() + realm.getSsoSessionIdleTimeout()); + } refreshToken.id(KeycloakModelUtils.generateId()); refreshToken.issuedNow(); - refreshToken.expiration(Time.currentTime() + realm.getSsoSessionIdleTimeout()); return this; } @@ -459,6 +500,7 @@ public class TokenManager { } else { event.detail(Details.REFRESH_TOKEN_ID, refreshToken.getId()); } + event.detail(Details.REFRESH_TOKEN_TYPE, refreshToken.getType()); } AccessTokenResponse res = new AccessTokenResponse(); @@ -489,4 +531,23 @@ public class TokenManager { } } + public class RefreshResult { + + private final AccessTokenResponse response; + private final boolean offlineToken; + + private RefreshResult(AccessTokenResponse response, boolean offlineToken) { + this.response = response; + this.offlineToken = offlineToken; + } + + public AccessTokenResponse getResponse() { + return response; + } + + public boolean isOfflineToken() { + return offlineToken; + } + } + } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 15c1f4689d..00970e2e1f 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -262,11 +262,14 @@ public class TokenEndpoint { AccessTokenResponse res; try { - res = tokenManager.refreshAccessToken(session, uriInfo, clientConnection, realm, client, refreshToken, event, headers); + TokenManager.RefreshResult result = tokenManager.refreshAccessToken(session, uriInfo, clientConnection, realm, client, refreshToken, event, headers); + res = result.getResponse(); - UserSessionModel userSession = session.sessions().getUserSession(realm, res.getSessionState()); - updateClientSessions(userSession.getClientSessions()); - updateUserSessionFromClientAuth(userSession); + if (!result.isOfflineToken()) { + UserSessionModel userSession = session.sessions().getUserSession(realm, res.getSessionState()); + updateClientSessions(userSession.getClientSessions()); + updateUserSessionFromClientAuth(userSession); + } } catch (OAuthErrorException e) { event.error(Errors.INVALID_TOKEN); @@ -337,6 +340,8 @@ public class TokenEndpoint { clientSession.setAuthMethod(OIDCLoginProtocol.LOGIN_PROTOCOL); clientSession.setAction(ClientSessionModel.Action.AUTHENTICATE.name()); clientSession.setNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())); + clientSession.setNote(OIDCLoginProtocol.SCOPE_PARAM, scope); + AuthenticationFlowModel flow = realm.getDirectGrantFlow(); String flowId = flow.getId(); AuthenticationProcessor processor = new AuthenticationProcessor(); @@ -363,7 +368,7 @@ public class TokenEndpoint { updateUserSessionFromClientAuth(userSession); AccessTokenResponse res = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSession) - .generateAccessToken(session, scope, client, user, userSession, clientSession) + .generateAccessToken() .generateRefreshToken() .generateIDToken() .build(); @@ -415,6 +420,7 @@ public class TokenEndpoint { ClientSessionModel clientSession = sessions.createClientSession(realm, client); clientSession.setAuthMethod(OIDCLoginProtocol.LOGIN_PROTOCOL); clientSession.setNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())); + clientSession.setNote(OIDCLoginProtocol.SCOPE_PARAM, scope); UserSessionModel userSession = sessions.createUserSession(realm, clientUser, clientUsername, clientConnection.getRemoteAddr(), ServiceAccountConstants.CLIENT_AUTH, false, null, null); event.session(userSession); @@ -429,7 +435,7 @@ public class TokenEndpoint { updateUserSessionFromClientAuth(userSession); AccessTokenResponse res = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSession) - .generateAccessToken(session, scope, client, clientUser, userSession, clientSession) + .generateAccessToken() .generateRefreshToken() .generateIDToken() .build(); diff --git a/services/src/main/java/org/keycloak/services/managers/HttpAuthenticationChallenge.java b/services/src/main/java/org/keycloak/services/managers/HttpAuthenticationChallenge.java deleted file mode 100644 index 0be8bcc27d..0000000000 --- a/services/src/main/java/org/keycloak/services/managers/HttpAuthenticationChallenge.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.keycloak.services.managers; - -import org.keycloak.login.LoginFormsProvider; - -/** - * @author Marek Posolda - */ -public interface HttpAuthenticationChallenge { - - void addChallenge(LoginFormsProvider loginFormsProvider); -} diff --git a/services/src/main/java/org/keycloak/services/offline/OfflineClientSessionAdapter.java b/services/src/main/java/org/keycloak/services/offline/OfflineClientSessionAdapter.java new file mode 100644 index 0000000000..50abfd402a --- /dev/null +++ b/services/src/main/java/org/keycloak/services/offline/OfflineClientSessionAdapter.java @@ -0,0 +1,289 @@ +package org.keycloak.services.offline; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.codehaus.jackson.annotate.JsonProperty; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionModel; +import org.keycloak.models.ModelException; +import org.keycloak.models.OfflineClientSessionModel; +import org.keycloak.models.OfflineUserSessionModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.util.JsonSerialization; + +/** + * @author Marek Posolda + */ +public class OfflineClientSessionAdapter implements ClientSessionModel { + + private final OfflineClientSessionModel model; + private final RealmModel realm; + private final ClientModel client; + private final OfflineUserSessionAdapter userSession; + + private OfflineClientSessionData data; + + public OfflineClientSessionAdapter(OfflineClientSessionModel model, RealmModel realm, ClientModel client, OfflineUserSessionAdapter userSession) { + this.model = model; + this.realm = realm; + this.client = client; + this.userSession = userSession; + } + + // lazily init representation + private OfflineClientSessionData getData() { + if (data == null) { + try { + data = JsonSerialization.readValue(model.getData(), OfflineClientSessionData.class); + } catch (IOException ioe) { + throw new ModelException(ioe); + } + } + + return data; + } + + @Override + public String getId() { + return model.getClientSessionId(); + } + + @Override + public RealmModel getRealm() { + return realm; + } + + @Override + public ClientModel getClient() { + return client; + } + + @Override + public UserSessionModel getUserSession() { + return userSession; + } + + @Override + public void setUserSession(UserSessionModel userSession) { + throw new IllegalStateException("Not supported setUserSession"); + } + + @Override + public String getRedirectUri() { + return data.getRedirectUri(); + } + + @Override + public void setRedirectUri(String uri) { + throw new IllegalStateException("Not supported setRedirectUri"); + } + + @Override + public int getTimestamp() { + return 0; + } + + @Override + public void setTimestamp(int timestamp) { + throw new IllegalStateException("Not supported setTimestamp"); + } + + @Override + public String getAction() { + return null; + } + + @Override + public void setAction(String action) { + throw new IllegalStateException("Not supported setAction"); + } + + @Override + public Set getRoles() { + return getData().getRoles(); + } + + @Override + public void setRoles(Set roles) { + throw new IllegalStateException("Not supported setRoles"); + } + + @Override + public Set getProtocolMappers() { + return getData().getProtocolMappers(); + } + + @Override + public void setProtocolMappers(Set protocolMappers) { + throw new IllegalStateException("Not supported setProtocolMappers"); + } + + @Override + public Map getExecutionStatus() { + return getData().getAuthenticatorStatus(); + } + + @Override + public void setExecutionStatus(String authenticator, ExecutionStatus status) { + throw new IllegalStateException("Not supported setExecutionStatus"); + } + + @Override + public void clearExecutionStatus() { + throw new IllegalStateException("Not supported clearExecutionStatus"); + } + + @Override + public UserModel getAuthenticatedUser() { + return userSession.getUser(); + } + + @Override + public void setAuthenticatedUser(UserModel user) { + throw new IllegalStateException("Not supported setAuthenticatedUser"); + } + + @Override + public String getAuthMethod() { + return getData().getAuthMethod(); + } + + @Override + public void setAuthMethod(String method) { + throw new IllegalStateException("Not supported setAuthMethod"); + } + + @Override + public String getNote(String name) { + return getData().getNotes()==null ? null : getData().getNotes().get(name); + } + + @Override + public void setNote(String name, String value) { + throw new IllegalStateException("Not supported setNote"); + } + + @Override + public void removeNote(String name) { + throw new IllegalStateException("Not supported removeNote"); + } + + @Override + public Map getNotes() { + return getData().getNotes(); + } + + @Override + public Set getRequiredActions() { + throw new IllegalStateException("Not supported getRequiredActions"); + } + + @Override + public void addRequiredAction(String action) { + throw new IllegalStateException("Not supported addRequiredAction"); + } + + @Override + public void removeRequiredAction(String action) { + throw new IllegalStateException("Not supported removeRequiredAction"); + } + + @Override + public void addRequiredAction(UserModel.RequiredAction action) { + throw new IllegalStateException("Not supported addRequiredAction"); + } + + @Override + public void removeRequiredAction(UserModel.RequiredAction action) { + throw new IllegalStateException("Not supported removeRequiredAction"); + } + + @Override + public void setUserSessionNote(String name, String value) { + throw new IllegalStateException("Not supported setUserSessionNote"); + } + + @Override + public Map getUserSessionNotes() { + throw new IllegalStateException("Not supported getUserSessionNotes"); + } + + @Override + public void clearUserSessionNotes() { + throw new IllegalStateException("Not supported clearUserSessionNotes"); + } + + protected static class OfflineClientSessionData { + + @JsonProperty("authMethod") + private String authMethod; + + @JsonProperty("redirectUri") + private String redirectUri; + + @JsonProperty("protocolMappers") + private Set protocolMappers; + + @JsonProperty("roles") + private Set roles; + + @JsonProperty("notes") + private Map notes; + + @JsonProperty("authenticatorStatus") + private Map authenticatorStatus = new HashMap<>(); + + public String getAuthMethod() { + return authMethod; + } + + public void setAuthMethod(String authMethod) { + this.authMethod = authMethod; + } + + public String getRedirectUri() { + return redirectUri; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } + + public Set getProtocolMappers() { + return protocolMappers; + } + + public void setProtocolMappers(Set protocolMappers) { + this.protocolMappers = protocolMappers; + } + + public Set getRoles() { + return roles; + } + + public void setRoles(Set roles) { + this.roles = roles; + } + + public Map getNotes() { + return notes; + } + + public void setNotes(Map notes) { + this.notes = notes; + } + + public Map getAuthenticatorStatus() { + return authenticatorStatus; + } + + public void setAuthenticatorStatus(Map authenticatorStatus) { + this.authenticatorStatus = authenticatorStatus; + } + } +} diff --git a/services/src/main/java/org/keycloak/services/offline/OfflineUserSessionAdapter.java b/services/src/main/java/org/keycloak/services/offline/OfflineUserSessionAdapter.java new file mode 100644 index 0000000000..bd01d3884e --- /dev/null +++ b/services/src/main/java/org/keycloak/services/offline/OfflineUserSessionAdapter.java @@ -0,0 +1,215 @@ +package org.keycloak.services.offline; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.codehaus.jackson.annotate.JsonProperty; +import org.keycloak.models.ClientSessionModel; +import org.keycloak.models.ModelException; +import org.keycloak.models.OfflineUserSessionModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.util.JsonSerialization; + +/** + * @author Marek Posolda + */ +public class OfflineUserSessionAdapter implements UserSessionModel { + + private final OfflineUserSessionModel model; + private final UserModel user; + + private OfflineUserSessionData data; + + public OfflineUserSessionAdapter(OfflineUserSessionModel model, UserModel user) { + this.model = model; + this.user = user; + } + + // lazily init representation + private OfflineUserSessionData getData() { + if (data == null) { + try { + data = JsonSerialization.readValue(model.getData(), OfflineUserSessionData.class); + } catch (IOException ioe) { + throw new ModelException(ioe); + } + } + + return data; + } + + @Override + public String getId() { + return model.getUserSessionId(); + } + + @Override + public String getBrokerSessionId() { + return getData().getBrokerSessionId(); + } + + @Override + public String getBrokerUserId() { + return getData().getBrokerUserId(); + } + + @Override + public UserModel getUser() { + return user; + } + + @Override + public String getLoginUsername() { + return user.getUsername(); + } + + @Override + public String getIpAddress() { + return getData().getIpAddress(); + } + + @Override + public String getAuthMethod() { + return getData().getAuthMethod(); + } + + @Override + public boolean isRememberMe() { + return getData().isRememberMe(); + } + + @Override + public int getStarted() { + return getData().getStarted(); + } + + @Override + public int getLastSessionRefresh() { + return 0; + } + + @Override + public void setLastSessionRefresh(int seconds) { + // Ignore + } + + @Override + public List getClientSessions() { + throw new IllegalStateException("Not yet supported"); + } + + @Override + public String getNote(String name) { + return getData().getNotes()==null ? null : getData().getNotes().get(name); + } + + @Override + public void setNote(String name, String value) { + throw new IllegalStateException("Illegal to set note offline session"); + + } + + @Override + public void removeNote(String name) { + throw new IllegalStateException("Illegal to remove note from offline session"); + } + + @Override + public Map getNotes() { + return getData().getNotes(); + } + + @Override + public State getState() { + return null; + } + + @Override + public void setState(State state) { + throw new IllegalStateException("Illegal to set state on offline session"); + } + + + protected static class OfflineUserSessionData { + + @JsonProperty("brokerSessionId") + private String brokerSessionId; + + @JsonProperty("brokerUserId") + private String brokerUserId; + + @JsonProperty("ipAddress") + private String ipAddress; + + @JsonProperty("authMethod") + private String authMethod; + + @JsonProperty("rememberMe") + private boolean rememberMe; + + @JsonProperty("started") + private int started; + + @JsonProperty("notes") + private Map notes; + + public String getBrokerSessionId() { + return brokerSessionId; + } + + public void setBrokerSessionId(String brokerSessionId) { + this.brokerSessionId = brokerSessionId; + } + + public String getBrokerUserId() { + return brokerUserId; + } + + public void setBrokerUserId(String brokerUserId) { + this.brokerUserId = brokerUserId; + } + + public String getIpAddress() { + return ipAddress; + } + + public void setIpAddress(String ipAddress) { + this.ipAddress = ipAddress; + } + + public String getAuthMethod() { + return authMethod; + } + + public void setAuthMethod(String authMethod) { + this.authMethod = authMethod; + } + + public boolean isRememberMe() { + return rememberMe; + } + + public void setRememberMe(boolean rememberMe) { + this.rememberMe = rememberMe; + } + + public int getStarted() { + return started; + } + + public void setStarted(int started) { + this.started = started; + } + + public Map getNotes() { + return notes; + } + + public void setNotes(Map notes) { + this.notes = notes; + } + + } +} diff --git a/services/src/main/java/org/keycloak/services/offline/OfflineUserSessionManager.java b/services/src/main/java/org/keycloak/services/offline/OfflineUserSessionManager.java new file mode 100644 index 0000000000..5e1a0dce65 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/offline/OfflineUserSessionManager.java @@ -0,0 +1,182 @@ +package org.keycloak.services.offline; + +import java.io.IOException; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.jboss.logging.Logger; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelException; +import org.keycloak.models.OfflineClientSessionModel; +import org.keycloak.models.OfflineUserSessionModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.util.JsonSerialization; + +/** + * TODO: Change to utils? + * + * @author Marek Posolda + */ +public class OfflineUserSessionManager { + + protected static Logger logger = Logger.getLogger(OfflineUserSessionManager.class); + + public void persistOfflineSession(ClientSessionModel clientSession, UserSessionModel userSession) { + UserModel user = userSession.getUser(); + ClientModel client = clientSession.getClient(); + + // First verify if we already have offlineToken for this user+client . If yes, then invalidate it (This is to avoid leaks) + Collection clientSessions = user.getOfflineClientSessions(); + for (OfflineClientSessionModel existing : clientSessions) { + if (existing.getClientId().equals(client.getId())) { + if (logger.isTraceEnabled()) { + logger.tracef("Removing existing offline token for user '%s' and client '%s' . ClientSessionID was '%s' . Offline token will be replaced with new one", + user.getUsername(), client.getClientId(), existing.getClientSessionId()); + } + + user.removeOfflineClientSession(existing.getClientSessionId()); + + // Check if userSession is ours. If not, then check if it has other clientSessions and remove it otherwise + if (!existing.getUserSessionId().equals(userSession.getId())) { + checkUserSessionHasClientSessions(user, existing.getUserSessionId()); + } + } + } + + // Verify if we already have UserSession with this ID. If yes, don't create another one + OfflineUserSessionModel userSessionRep = user.getOfflineUserSession(userSession.getId()); + if (userSessionRep == null) { + createOfflineUserSession(user, userSession); + } + + // Create clientRep and save to DB. + createOfflineClientSession(user, clientSession, userSession); + } + + // userSessionId is provided from offline token. It's used just to verify if it match the ID from clientSession representation + public ClientSessionModel findOfflineClientSession(RealmModel realm, UserModel user, String clientSessionId, String userSessionId) { + OfflineClientSessionModel clientSession = user.getOfflineClientSession(clientSessionId); + if (clientSession == null) { + return null; + } + + if (!userSessionId.equals(clientSession.getUserSessionId())) { + throw new ModelException("User session don't match. Offline client session " + clientSession.getClientSessionId() + ", It's user session " + clientSession.getUserSessionId() + + " Wanted user session: " + userSessionId); + } + + OfflineUserSessionModel userSession = user.getOfflineUserSession(userSessionId); + if (userSession == null) { + throw new ModelException("Found clientSession " + clientSessionId + " but not userSession " + userSessionId); + } + + OfflineUserSessionAdapter userSessionAdapter = new OfflineUserSessionAdapter(userSession, user); + + ClientModel client = realm.getClientById(clientSession.getClientId()); + OfflineClientSessionAdapter clientSessionAdapter = new OfflineClientSessionAdapter(clientSession, realm, client, userSessionAdapter); + + return clientSessionAdapter; + } + + public Set findClientsWithOfflineToken(RealmModel realm, UserModel user) { + Collection clientSessions = user.getOfflineClientSessions(); + Set clients = new HashSet<>(); + for (OfflineClientSessionModel clientSession : clientSessions) { + ClientModel client = realm.getClientById(clientSession.getClientId()); + clients.add(client); + } + return clients; + } + + public boolean revokeOfflineToken(UserModel user, ClientModel client) { + Collection clientSessions = user.getOfflineClientSessions(); + boolean anyRemoved = false; + for (OfflineClientSessionModel clientSession : clientSessions) { + if (clientSession.getClientId().equals(client.getId())) { + if (logger.isTraceEnabled()) { + logger.tracef("Removing existing offline token for user '%s' and client '%s' . ClientSessionID was '%s' .", + user.getUsername(), client.getClientId(), clientSession.getClientSessionId()); + } + + user.removeOfflineClientSession(clientSession.getClientSessionId()); + checkUserSessionHasClientSessions(user, clientSession.getUserSessionId()); + anyRemoved = true; + } + } + + return anyRemoved; + } + + private void createOfflineUserSession(UserModel user, UserSessionModel userSession) { + if (logger.isTraceEnabled()) { + logger.tracef("Creating new offline user session. UserSessionID: '%s' , Username: '%s'", userSession.getId(), user.getUsername()); + } + OfflineUserSessionAdapter.OfflineUserSessionData rep = new OfflineUserSessionAdapter.OfflineUserSessionData(); + rep.setBrokerUserId(userSession.getBrokerUserId()); + rep.setBrokerSessionId(userSession.getBrokerSessionId()); + rep.setIpAddress(userSession.getIpAddress()); + rep.setAuthMethod(userSession.getAuthMethod()); + rep.setRememberMe(userSession.isRememberMe()); + rep.setStarted(userSession.getStarted()); + rep.setNotes(userSession.getNotes()); + + try { + String stringRep = JsonSerialization.writeValueAsString(rep); + OfflineUserSessionModel sessionModel = new OfflineUserSessionModel(); + sessionModel.setUserSessionId(userSession.getId()); + sessionModel.setData(stringRep); + user.addOfflineUserSession(sessionModel); + } catch (IOException ioe) { + throw new ModelException(ioe); + } + } + + private void createOfflineClientSession(UserModel user, ClientSessionModel clientSession, UserSessionModel userSession) { + if (logger.isTraceEnabled()) { + logger.tracef("Creating new offline token client session. ClientSessionId: '%s', UserSessionID: '%s' , Username: '%s', Client: '%s'" , + clientSession.getId(), userSession.getId(), user.getUsername(), clientSession.getClient().getClientId()); + } + OfflineClientSessionAdapter.OfflineClientSessionData rep = new OfflineClientSessionAdapter.OfflineClientSessionData(); + rep.setAuthMethod(clientSession.getAuthMethod()); + rep.setRedirectUri(clientSession.getRedirectUri()); + rep.setProtocolMappers(clientSession.getProtocolMappers()); + rep.setRoles(clientSession.getRoles()); + rep.setNotes(clientSession.getNotes()); + rep.setAuthenticatorStatus(clientSession.getExecutionStatus()); + + try { + String stringRep = JsonSerialization.writeValueAsString(rep); + OfflineClientSessionModel clsModel = new OfflineClientSessionModel(); + clsModel.setClientSessionId(clientSession.getId()); + clsModel.setClientId(clientSession.getClient().getId()); + clsModel.setUserSessionId(userSession.getId()); + clsModel.setData(stringRep); + user.addOfflineClientSession(clsModel); + } catch (IOException ioe) { + throw new ModelException(ioe); + } + } + + // Check if userSession has any offline clientSessions attached to it. Remove userSession if not + private void checkUserSessionHasClientSessions(UserModel user, String userSessionId) { + Collection clientSessions = user.getOfflineClientSessions(); + + for (OfflineClientSessionModel clientSession : clientSessions) { + if (clientSession.getUserSessionId().equals(userSessionId)) { + return; + } + } + + if (logger.isTraceEnabled()) { + logger.tracef("Removing offline userSession for user %s as it doesn't have any client sessions attached. UserSessionID: %s", user.getUsername(), userSessionId); + } + user.removeOfflineUserSession(userSessionId); + } +} diff --git a/services/src/main/java/org/keycloak/services/resources/AccountService.java b/services/src/main/java/org/keycloak/services/resources/AccountService.java index 1756f12cab..14d3639c2b 100755 --- a/services/src/main/java/org/keycloak/services/resources/AccountService.java +++ b/services/src/main/java/org/keycloak/services/resources/AccountService.java @@ -58,6 +58,7 @@ import org.keycloak.services.managers.Auth; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.services.messages.Messages; +import org.keycloak.services.offline.OfflineUserSessionManager; import org.keycloak.services.util.ResolveRelative; import org.keycloak.services.validation.Validation; import org.keycloak.util.UriUtils; @@ -486,6 +487,7 @@ public class AccountService extends AbstractSecuredLocalService { // Revoke grant in UserModel UserModel user = auth.getUser(); user.revokeConsentForClient(client.getId()); + new OfflineUserSessionManager().revokeOfflineToken(user, client); // Logout clientSessions for this user and client AuthenticationManager.backchannelUserFromClient(session, realm, user, client, uriInfo, headers); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/AssertEvents.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/AssertEvents.java index 88c1ccc0c8..77b6d19f6f 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/AssertEvents.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/AssertEvents.java @@ -30,6 +30,7 @@ import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.services.managers.RealmManager; import org.keycloak.testsuite.rule.KeycloakRule; +import org.keycloak.util.RefreshTokenUtil; import java.util.HashMap; import java.util.HashSet; @@ -156,6 +157,7 @@ public class AssertEvents implements TestRule, EventListenerProviderFactory { .detail(Details.CODE_ID, codeId) .detail(Details.TOKEN_ID, isUUID()) .detail(Details.REFRESH_TOKEN_ID, isUUID()) + .detail(Details.REFRESH_TOKEN_TYPE, RefreshTokenUtil.TOKEN_TYPE_REFRESH) .detail(Details.CLIENT_AUTH_METHOD, ClientIdAndSecretAuthenticator.PROVIDER_ID) .session(sessionId); } @@ -164,6 +166,7 @@ public class AssertEvents implements TestRule, EventListenerProviderFactory { return expect(EventType.REFRESH_TOKEN) .detail(Details.TOKEN_ID, isUUID()) .detail(Details.REFRESH_TOKEN_ID, refreshTokenId) + .detail(Details.REFRESH_TOKEN_TYPE, RefreshTokenUtil.TOKEN_TYPE_REFRESH) .detail(Details.UPDATED_REFRESH_TOKEN_ID, isUUID()) .detail(Details.CLIENT_AUTH_METHOD, ClientIdAndSecretAuthenticator.PROVIDER_ID) .session(sessionId); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java index b06e4331b7..087f82faec 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java @@ -76,6 +76,8 @@ public class OAuthClient { private String state = "mystate"; + private String scope; + private String uiLocales = null; private PublicKey realmPublicKey; @@ -192,6 +194,9 @@ public class OAuthClient { if (clientSessionHost != null) { parameters.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_HOST, clientSessionHost)); } + if (scope != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, scope)); + } UrlEncodedFormEntity formEntity; try { @@ -218,6 +223,10 @@ public class OAuthClient { List parameters = new LinkedList(); parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS)); + if (scope != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, scope)); + } + UrlEncodedFormEntity formEntity; try { formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); @@ -390,6 +399,9 @@ public class OAuthClient { if(uiLocales != null){ b.queryParam(LocaleHelper.UI_LOCALES_PARAM, uiLocales); } + if (scope != null) { + b.queryParam(OAuth2Constants.SCOPE, scope); + } return b.build(realm).toString(); } @@ -452,6 +464,11 @@ public class OAuthClient { return this; } + public OAuthClient scope(String scope) { + this.scope = scope; + return this; + } + public OAuthClient uiLocales(String uiLocales){ this.uiLocales = uiLocales; return this; diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/account/AccountTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/account/AccountTest.java index 95d644eb82..1f401fa5de 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/account/AccountTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/account/AccountTest.java @@ -646,7 +646,7 @@ public class AccountTest { } } - // More tests (including revoke) are in OAuthGrantTest + // More tests (including revoke) are in OAuthGrantTest and OfflineTokenTest @Test public void applications() { applicationsPage.open(); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/CacheTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/CacheTest.java index 5bc3556269..597e7dd5d4 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/CacheTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/CacheTest.java @@ -1,6 +1,7 @@ package org.keycloak.testsuite.model; import java.util.List; +import java.util.Set; import org.junit.Assert; import org.junit.ClassRule; @@ -89,4 +90,51 @@ public class CacheTest { } } + // KEYCLOAK-1842 + @Test + public void testRoleMappingsInvalidatedWhenClientRemoved() { + KeycloakSession session = kc.startSession(); + try { + RealmModel realm = session.realms().getRealmByName("test"); + UserModel user = session.users().addUser(realm, "joel"); + ClientModel client = realm.addClient("foo"); + RoleModel fooRole = client.addRole("foo-role"); + user.grantRole(fooRole); + } finally { + session.getTransaction().commit(); + session.close(); + } + + // Remove client + session = kc.startSession(); + int grantedRolesCount; + try { + RealmModel realm = session.realms().getRealmByName("test"); + UserModel user = session.users().getUserByUsername("joel", realm); + grantedRolesCount = user.getRoleMappings().size(); + + ClientModel client = realm.getClientByClientId("foo"); + realm.removeClient(client.getId()); + } finally { + session.getTransaction().commit(); + session.close(); + } + + // Assert role mappings was removed from user as well + session = kc.startSession(); + try { + RealmModel realm = session.realms().getRealmByName("test"); + UserModel user = session.users().getUserByUsername("joel", realm); + Set roles = user.getRoleMappings(); + for (RoleModel role : roles) { + Assert.assertNotNull(role.getContainer()); + } + + Assert.assertEquals(roles.size(), grantedRolesCount - 1); + } finally { + session.getTransaction().commit(); + session.close(); + } + } + } diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ImportTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ImportTest.java index 2f33e4397d..d56056d152 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ImportTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ImportTest.java @@ -326,6 +326,8 @@ public class ImportTest extends AbstractModelTest { // Test service accounts Assert.assertFalse(application.isServiceAccountsEnabled()); Assert.assertTrue(otherApp.isServiceAccountsEnabled()); + Assert.assertFalse(application.isOfflineTokensEnabled()); + Assert.assertTrue(otherApp.isOfflineTokensEnabled()); Assert.assertNull(session.users().getUserByServiceAccountClient(application)); UserModel linked = session.users().getUserByServiceAccountClient(otherApp); Assert.assertNotNull(linked); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java index 258dd3cbd7..f0118c265d 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java @@ -4,6 +4,8 @@ import org.junit.Assert; import org.junit.Test; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OfflineClientSessionModel; +import org.keycloak.models.OfflineUserSessionModel; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserModel.RequiredAction; @@ -283,6 +285,59 @@ public class UserModelTest extends AbstractModelTest { Assert.assertNull(session.users().getUserByUsername("user1", realm)); } + @Test + public void testOfflineSessionsRemoved() { + RealmModel realm = realmManager.createRealm("original"); + ClientModel fooClient = realm.addClient("foo"); + ClientModel barClient = realm.addClient("bar"); + + UserModel user1 = session.users().addUser(realm, "user1"); + addOfflineUserSession(user1, "123", "something1"); + addOfflineClientSession(user1, "456", "123", fooClient.getId(), "something2"); + addOfflineClientSession(user1, "789", "123", barClient.getId(), "something3"); + + commit(); + + realm = realmManager.getRealmByName("original"); + realm.removeClient(barClient.getId()); + + commit(); + + realm = realmManager.getRealmByName("original"); + user1 = session.users().getUserByUsername("user1", realm); + Assert.assertEquals("something1", user1.getOfflineUserSession("123").getData()); + Assert.assertEquals("something2", user1.getOfflineClientSession("456").getData()); + Assert.assertNull(user1.getOfflineClientSession("789")); + + realm.removeClient(fooClient.getId()); + + commit(); + + realm = realmManager.getRealmByName("original"); + user1 = session.users().getUserByUsername("user1", realm); + Assert.assertNull(user1.getOfflineClientSession("456")); + Assert.assertNull(user1.getOfflineClientSession("789")); + Assert.assertNull(user1.getOfflineUserSession("123")); + Assert.assertEquals(0, user1.getOfflineUserSessions().size()); + Assert.assertEquals(0, user1.getOfflineClientSessions().size()); + } + + private void addOfflineUserSession(UserModel user, String userSessionId, String data) { + OfflineUserSessionModel model = new OfflineUserSessionModel(); + model.setUserSessionId(userSessionId); + model.setData(data); + user.addOfflineUserSession(model); + } + + private void addOfflineClientSession(UserModel user, String clientSessionId, String userSessionId, String clientId, String data) { + OfflineClientSessionModel model = new OfflineClientSessionModel(); + model.setClientSessionId(clientSessionId); + model.setUserSessionId(userSessionId); + model.setClientId(clientId); + model.setData(data); + user.addOfflineClientSession(model); + } + public static void assertEquals(UserModel expected, UserModel actual) { Assert.assertEquals(expected.getUsername(), actual.getUsername()); Assert.assertEquals(expected.getCreatedTimestamp(), actual.getCreatedTimestamp()); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/AccessTokenTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/AccessTokenTest.java index cbfc76f7a0..24f491178e 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/AccessTokenTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/AccessTokenTest.java @@ -180,7 +180,11 @@ public class AccessTokenTest { Assert.assertEquals("invalid_grant", response.getError()); Assert.assertEquals("Incorrect redirect_uri", response.getErrorDescription()); - events.expectCodeToToken(codeId, loginEvent.getSessionId()).error("invalid_code").removeDetail(Details.TOKEN_ID).removeDetail(Details.REFRESH_TOKEN_ID).assertEvent(); + events.expectCodeToToken(codeId, loginEvent.getSessionId()).error("invalid_code") + .removeDetail(Details.TOKEN_ID) + .removeDetail(Details.REFRESH_TOKEN_ID) + .removeDetail(Details.REFRESH_TOKEN_TYPE) + .assertEvent(); } @Test @@ -201,7 +205,13 @@ public class AccessTokenTest { assertNull(tokenResponse.getAccessToken()); assertNull(tokenResponse.getRefreshToken()); - events.expectCodeToToken(codeId, sessionId).removeDetail(Details.TOKEN_ID).user((String) null).session((String) null).removeDetail(Details.REFRESH_TOKEN_ID).error(Errors.INVALID_CODE).assertEvent(); + events.expectCodeToToken(codeId, sessionId) + .removeDetail(Details.TOKEN_ID) + .user((String) null) + .session((String) null) + .removeDetail(Details.REFRESH_TOKEN_ID) + .removeDetail(Details.REFRESH_TOKEN_TYPE) + .error(Errors.INVALID_CODE).assertEvent(); events.clear(); } @@ -230,7 +240,11 @@ public class AccessTokenTest { Assert.assertEquals(400, response.getStatusCode()); AssertEvents.ExpectedEvent expectedEvent = events.expectCodeToToken(codeId, null); - expectedEvent.error("invalid_code").removeDetail(Details.TOKEN_ID).removeDetail(Details.REFRESH_TOKEN_ID).user((String) null); + expectedEvent.error("invalid_code") + .removeDetail(Details.TOKEN_ID) + .removeDetail(Details.REFRESH_TOKEN_ID) + .removeDetail(Details.REFRESH_TOKEN_TYPE) + .user((String) null); expectedEvent.assertEvent(); events.clear(); @@ -264,7 +278,11 @@ public class AccessTokenTest { Assert.assertEquals(400, response.getStatusCode()); AssertEvents.ExpectedEvent expectedEvent = events.expectCodeToToken(codeId, null); - expectedEvent.error("invalid_code").removeDetail(Details.TOKEN_ID).removeDetail(Details.REFRESH_TOKEN_ID).user((String) null); + expectedEvent.error("invalid_code") + .removeDetail(Details.TOKEN_ID) + .removeDetail(Details.REFRESH_TOKEN_ID) + .removeDetail(Details.REFRESH_TOKEN_TYPE) + .user((String) null); expectedEvent.assertEvent(); events.clear(); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java new file mode 100644 index 0000000000..33e6ff4171 --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java @@ -0,0 +1,441 @@ +package org.keycloak.testsuite.oauth; + +import java.io.IOException; +import java.net.URL; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.UriBuilder; + +import org.junit.Assert; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.KeycloakSecurityContext; +import org.keycloak.OAuth2Constants; +import org.keycloak.adapters.RefreshableKeycloakSecurityContext; +import org.keycloak.constants.ServiceAccountConstants; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.Event; +import org.keycloak.events.EventType; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.models.ClientModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.RefreshToken; +import org.keycloak.services.managers.ClientManager; +import org.keycloak.services.managers.RealmManager; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.OAuthClient; +import org.keycloak.testsuite.pages.AccountApplicationsPage; +import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.rule.KeycloakRule; +import org.keycloak.testsuite.rule.WebResource; +import org.keycloak.testsuite.rule.WebRule; +import org.keycloak.util.JsonSerialization; +import org.keycloak.util.RefreshTokenUtil; +import org.keycloak.util.Time; +import org.keycloak.util.UriUtils; +import org.openqa.selenium.WebDriver; + +import static org.junit.Assert.assertEquals; + +/** + * @author Marek Posolda + */ +public class OfflineTokenTest { + + @ClassRule + public static KeycloakRule keycloakRule = new KeycloakRule(new KeycloakRule.KeycloakSetup() { + + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + // For testing + appRealm.setAccessTokenLifespan(10); + appRealm.setSsoSessionIdleTimeout(30); + + ClientModel app = new ClientManager(manager).createClient(appRealm, "offline-client"); + app.setSecret("secret1"); + String testAppRedirectUri = appRealm.getClientByClientId("test-app").getRedirectUris().iterator().next(); + offlineClientAppUri = UriUtils.getOrigin(testAppRedirectUri) + "/offline-client"; + app.setRedirectUris(new HashSet<>(Arrays.asList(offlineClientAppUri))); + app.setManagementUrl(offlineClientAppUri); + + new ClientManager(manager).enableServiceAccount(app); + UserModel serviceAccountUser = manager.getSession().users().getUserByUsername(ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client", appRealm); + RoleModel customerUserRole = appRealm.getClientByClientId("test-app").getRole("customer-user"); + serviceAccountUser.grantRole(customerUserRole); + + app.setOfflineTokensEnabled(true); + userId = manager.getSession().users().getUserByUsername("test-user@localhost", appRealm).getId(); + + URL url = getClass().getResource("/oidc/offline-client-keycloak.json"); + keycloakRule.createApplicationDeployment() + .name("offline-client").contextPath("/offline-client") + .servletClass(OfflineTokenServlet.class).adapterConfigPath(url.getPath()) + .role("user").deployApplication(); + } + + }); + + private static String userId; + private static String offlineClientAppUri; + + @Rule + public WebRule webRule = new WebRule(this); + + @WebResource + protected WebDriver driver; + + @WebResource + protected OAuthClient oauth; + + @WebResource + protected LoginPage loginPage; + + @WebResource + protected AccountApplicationsPage accountAppPage; + + @Rule + public AssertEvents events = new AssertEvents(keycloakRule); + + +// @Test +// public void testSleep() throws Exception { +// Thread.sleep(9999000); +// } + + @Test + public void offlineTokenDisabledForClient() throws Exception { + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.doLogin("test-user@localhost", "password"); + + Event loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); + + assertEquals(400, tokenResponse.getStatusCode()); + assertEquals("invalid_client", tokenResponse.getError()); + + events.expectCodeToToken(codeId, sessionId) + .error("invalid_client") + .clearDetails() + .assertEvent(); + } + + @Test + public void offlineTokenBrowserFlow() throws Exception { + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.clientId("offline-client"); + oauth.redirectUri(offlineClientAppUri); + oauth.doLogin("test-user@localhost", "password"); + + Event loginEvent = events.expectLogin() + .client("offline-client") + .detail(Details.REDIRECT_URI, offlineClientAppUri) + .assertEvent(); + + final String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "secret1"); + AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString = tokenResponse.getRefreshToken(); + RefreshToken offlineToken = oauth.verifyRefreshToken(offlineTokenString); + + events.expectCodeToToken(codeId, sessionId) + .client("offline-client") + .detail(Details.REFRESH_TOKEN_TYPE, RefreshTokenUtil.TOKEN_TYPE_OFFLINE) + .assertEvent(); + + Assert.assertEquals(RefreshTokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); + Assert.assertEquals(0, offlineToken.getExpiration()); + + testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, sessionId, userId); + } + + private void testRefreshWithOfflineToken(AccessToken oldToken, RefreshToken offlineToken, String offlineTokenString, + final String sessionId, String userId) { + // Change offset to big value to ensure userSession expired + Time.setOffset(99999); + Assert.assertFalse(oldToken.isActive()); + Assert.assertTrue(offlineToken.isActive()); + + // Assert userSession expired + keycloakRule.update(new KeycloakRule.KeycloakSetup() { + + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + manager.getSession().sessions().removeExpiredUserSessions(appRealm); + } + + }); + keycloakRule.update(new KeycloakRule.KeycloakSetup() { + + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + Assert.assertNull(manager.getSession().sessions().getUserSession(appRealm, sessionId)); + } + + }); + + + OAuthClient.AccessTokenResponse response = oauth.doRefreshTokenRequest(offlineTokenString, "secret1"); + AccessToken refreshedToken = oauth.verifyToken(response.getAccessToken()); + Assert.assertEquals(200, response.getStatusCode()); + Assert.assertEquals(sessionId, refreshedToken.getSessionState()); + + // Assert no refreshToken in the response + Assert.assertNull(response.getRefreshToken()); + Assert.assertNotEquals(oldToken.getId(), refreshedToken.getId()); + + Assert.assertEquals(userId, refreshedToken.getSubject()); + + Assert.assertEquals(1, refreshedToken.getRealmAccess().getRoles().size()); + Assert.assertTrue(refreshedToken.getRealmAccess().isUserInRole("user")); + + Assert.assertEquals(1, refreshedToken.getResourceAccess("test-app").getRoles().size()); + Assert.assertTrue(refreshedToken.getResourceAccess("test-app").isUserInRole("customer-user")); + + Event refreshEvent = events.expectRefresh(offlineToken.getId(), sessionId) + .client("offline-client") + .user(userId) + .removeDetail(Details.UPDATED_REFRESH_TOKEN_ID) + .detail(Details.REFRESH_TOKEN_TYPE, RefreshTokenUtil.TOKEN_TYPE_OFFLINE) + .assertEvent(); + Assert.assertNotEquals(oldToken.getId(), refreshEvent.getDetails().get(Details.TOKEN_ID)); + + Time.setOffset(0); + } + + @Test + public void offlineTokenDirectGrantFlow() throws Exception { + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.clientId("offline-client"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("secret1", "test-user@localhost", "password"); + + AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString = tokenResponse.getRefreshToken(); + RefreshToken offlineToken = oauth.verifyRefreshToken(offlineTokenString); + + events.expectLogin() + .client("offline-client") + .user(userId) + .session(token.getSessionState()) + .detail(Details.RESPONSE_TYPE, "token") + .detail(Details.TOKEN_ID, token.getId()) + .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) + .detail(Details.REFRESH_TOKEN_TYPE, RefreshTokenUtil.TOKEN_TYPE_OFFLINE) + .detail(Details.USERNAME, "test-user@localhost") + .removeDetail(Details.CODE_ID) + .removeDetail(Details.REDIRECT_URI) + .removeDetail(Details.CONSENT) + .assertEvent(); + + Assert.assertEquals(RefreshTokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); + Assert.assertEquals(0, offlineToken.getExpiration()); + + testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), userId); + } + + @Test + public void offlineTokenServiceAccountFlow() throws Exception { + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.clientId("offline-client"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest("secret1"); + + AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString = tokenResponse.getRefreshToken(); + RefreshToken offlineToken = oauth.verifyRefreshToken(offlineTokenString); + + String serviceAccountUserId = keycloakRule.getUser("test", ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client").getId(); + + events.expectClientLogin() + .client("offline-client") + .user(serviceAccountUserId) + .session(token.getSessionState()) + .detail(Details.TOKEN_ID, token.getId()) + .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) + .detail(Details.REFRESH_TOKEN_TYPE, RefreshTokenUtil.TOKEN_TYPE_OFFLINE) + .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client") + .assertEvent(); + + Assert.assertEquals(RefreshTokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); + Assert.assertEquals(0, offlineToken.getExpiration()); + + testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), serviceAccountUserId); + + + // Now retrieve another offline token and verify that previous offline token is not valid anymore + tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest("secret1"); + + AccessToken token2 = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString2 = tokenResponse.getRefreshToken(); + RefreshToken offlineToken2 = oauth.verifyRefreshToken(offlineTokenString2); + + events.expectClientLogin() + .client("offline-client") + .user(serviceAccountUserId) + .session(token2.getSessionState()) + .detail(Details.TOKEN_ID, token2.getId()) + .detail(Details.REFRESH_TOKEN_ID, offlineToken2.getId()) + .detail(Details.REFRESH_TOKEN_TYPE, RefreshTokenUtil.TOKEN_TYPE_OFFLINE) + .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client") + .assertEvent(); + + // Refresh with old offline token should fail + OAuthClient.AccessTokenResponse response = oauth.doRefreshTokenRequest(offlineTokenString, "secret1"); + Assert.assertEquals(400, response.getStatusCode()); + Assert.assertEquals("invalid_grant", response.getError()); + + events.expectRefresh(offlineToken.getId(), offlineToken.getSessionState()) + .error(Errors.INVALID_TOKEN) + .client("offline-client") + .user(serviceAccountUserId) + .removeDetail(Details.UPDATED_REFRESH_TOKEN_ID) + .removeDetail(Details.TOKEN_ID) + .detail(Details.REFRESH_TOKEN_TYPE, RefreshTokenUtil.TOKEN_TYPE_OFFLINE) + .assertEvent(); + + // Refresh with new offline token is ok + testRefreshWithOfflineToken(token2, offlineToken2, offlineTokenString2, token2.getSessionState(), serviceAccountUserId); + } + + @Test + public void testServlet() { + OfflineTokenServlet.tokenInfo = null; + + String servletUri = UriBuilder.fromUri(offlineClientAppUri) + .queryParam(OAuth2Constants.SCOPE, OAuth2Constants.OFFLINE_ACCESS) + .build().toString(); + driver.navigate().to(servletUri); + loginPage.login("test-user@localhost", "password"); + Assert.assertTrue(driver.getCurrentUrl().startsWith(offlineClientAppUri)); + + Assert.assertEquals(OfflineTokenServlet.tokenInfo.refreshToken.getType(), RefreshTokenUtil.TOKEN_TYPE_OFFLINE); + Assert.assertEquals(OfflineTokenServlet.tokenInfo.refreshToken.getExpiration(), 0); + + String accessTokenId = OfflineTokenServlet.tokenInfo.accessToken.getId(); + String refreshTokenId = OfflineTokenServlet.tokenInfo.refreshToken.getId(); + + // Assert access token will be refreshed, but offline token will be still the same + Time.setOffset(9999); + driver.navigate().to(offlineClientAppUri); + Assert.assertTrue(driver.getCurrentUrl().startsWith(offlineClientAppUri)); + Assert.assertEquals(OfflineTokenServlet.tokenInfo.refreshToken.getId(), refreshTokenId); + Assert.assertNotEquals(OfflineTokenServlet.tokenInfo.accessToken.getId(), accessTokenId); + + // Ensure that logout works for webapp (even if offline token will be still valid in Keycloak DB) + driver.navigate().to(offlineClientAppUri + "/logout"); + loginPage.assertCurrent(); + driver.navigate().to(offlineClientAppUri); + loginPage.assertCurrent(); + + Time.setOffset(0); + events.clear(); + } + + @Test + public void testServletWithRevoke() { + // Login to servlet first with offline token + String servletUri = UriBuilder.fromUri(offlineClientAppUri) + .queryParam(OAuth2Constants.SCOPE, OAuth2Constants.OFFLINE_ACCESS) + .build().toString(); + driver.navigate().to(servletUri); + loginPage.login("test-user@localhost", "password"); + Assert.assertTrue(driver.getCurrentUrl().startsWith(offlineClientAppUri)); + + Assert.assertEquals(OfflineTokenServlet.tokenInfo.refreshToken.getType(), RefreshTokenUtil.TOKEN_TYPE_OFFLINE); + + // Assert refresh works with increased time + Time.setOffset(9999); + driver.navigate().to(offlineClientAppUri); + Assert.assertTrue(driver.getCurrentUrl().startsWith(offlineClientAppUri)); + Time.setOffset(0); + + events.clear(); + + // Go to account service and revoke grant + accountAppPage.open(); + List additionalGrants = accountAppPage.getApplications().get("offline-client").getAdditionalGrants(); + Assert.assertEquals(additionalGrants.size(), 1); + Assert.assertEquals(additionalGrants.get(0), "Offline Access"); + accountAppPage.revokeGrant("offline-client"); + Assert.assertEquals(accountAppPage.getApplications().get("offline-client").getAdditionalGrants().size(), 0); + + events.expect(EventType.REVOKE_GRANT) + .client("account").detail(Details.REVOKED_CLIENT, "offline-client").assertEvent(); + + // Assert refresh doesn't work now (increase time one more time) + Time.setOffset(9999); + driver.navigate().to(offlineClientAppUri); + Assert.assertFalse(driver.getCurrentUrl().startsWith(offlineClientAppUri)); + loginPage.assertCurrent(); + Time.setOffset(0); + } + + public static class OfflineTokenServlet extends HttpServlet { + + private static TokenInfo tokenInfo; + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + if (req.getRequestURI().endsWith("logout")) { + + UriBuilder redirectUriBuilder = UriBuilder.fromUri(offlineClientAppUri); + if (req.getParameter(OAuth2Constants.SCOPE) != null) { + redirectUriBuilder.queryParam(OAuth2Constants.SCOPE, req.getParameter(OAuth2Constants.SCOPE)); + } + String redirectUri = redirectUriBuilder.build().toString(); + + String origin = UriUtils.getOrigin(req.getRequestURL().toString()); + String serverLogoutRedirect = UriBuilder.fromUri(origin + "/auth/realms/test/protocol/openid-connect/logout") + .queryParam("redirect_uri", redirectUri) + .build().toString(); + + resp.sendRedirect(serverLogoutRedirect); + return; + } + + StringBuilder response = new StringBuilder("Offline token servlet
");
+            RefreshableKeycloakSecurityContext ctx = (RefreshableKeycloakSecurityContext) req.getAttribute(KeycloakSecurityContext.class.getName());
+            String accessTokenPretty = JsonSerialization.writeValueAsPrettyString(ctx.getToken());
+            RefreshToken refreshToken = new JWSInput(ctx.getRefreshToken()).readJsonContent(RefreshToken.class);
+            String refreshTokenPretty = JsonSerialization.writeValueAsPrettyString(refreshToken);
+
+            response = response.append(accessTokenPretty)
+                    .append(refreshTokenPretty)
+                    .append("
"); + resp.getWriter().println(response.toString()); + + tokenInfo = new TokenInfo(ctx.getToken(), refreshToken); + } + + } + + private static class TokenInfo { + + private final AccessToken accessToken; + private final RefreshToken refreshToken; + + public TokenInfo(AccessToken accessToken, RefreshToken refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + } +} diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/AccountApplicationsPage.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/AccountApplicationsPage.java index f88db27424..b48559c277 100644 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/AccountApplicationsPage.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/AccountApplicationsPage.java @@ -77,6 +77,15 @@ public class AccountApplicationsPage extends AbstractAccountPage { currentEntry.addMapper(protMapper); } break; + case 5: + String additionalGrant = col.getText(); + if (additionalGrant.isEmpty()) break; + String[] grants = additionalGrant.split(","); + for (String grant : grants) { + grant = grant.trim(); + currentEntry.addAdditionalGrant(grant); + } + break; } } } @@ -89,6 +98,7 @@ public class AccountApplicationsPage extends AbstractAccountPage { private final List rolesAvailable = new ArrayList(); private final List rolesGranted = new ArrayList(); private final List protocolMappersGranted = new ArrayList(); + private final List additionalGrants = new ArrayList<>(); private void addAvailableRole(String role) { rolesAvailable.add(role); @@ -102,6 +112,10 @@ public class AccountApplicationsPage extends AbstractAccountPage { protocolMappersGranted.add(protocolMapper); } + private void addAdditionalGrant(String grant) { + additionalGrants.add(grant); + } + public List getRolesGranted() { return rolesGranted; } @@ -113,5 +127,9 @@ public class AccountApplicationsPage extends AbstractAccountPage { public List getProtocolMappersGranted() { return protocolMappersGranted; } + + public List getAdditionalGrants() { + return additionalGrants; + } } } diff --git a/testsuite/integration/src/test/resources/model/testrealm.json b/testsuite/integration/src/test/resources/model/testrealm.json index acbbdf5805..55a75b5038 100755 --- a/testsuite/integration/src/test/resources/model/testrealm.json +++ b/testsuite/integration/src/test/resources/model/testrealm.json @@ -164,6 +164,7 @@ "name": "Other Application", "enabled": true, "serviceAccountsEnabled": true, + "offlineTokensEnabled": true, "clientAuthenticatorType": "client-jwt", "protocolMappers" : [ { diff --git a/testsuite/integration/src/test/resources/oidc/offline-client-keycloak.json b/testsuite/integration/src/test/resources/oidc/offline-client-keycloak.json new file mode 100644 index 0000000000..bc7be17093 --- /dev/null +++ b/testsuite/integration/src/test/resources/oidc/offline-client-keycloak.json @@ -0,0 +1,10 @@ +{ + "realm": "test", + "resource": "offline-client", + "realm-public-key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "auth-server-url": "http://localhost:8081/auth", + "ssl-required" : "external", + "credentials": { + "secret": "secret1" + } +} \ No newline at end of file