diff --git a/repository/src/main/config/module-context.xml b/repository/src/main/config/module-context.xml
index fb8b27b..cbff8c5 100644
--- a/repository/src/main/config/module-context.xml
+++ b/repository/src/main/config/module-context.xml
@@ -55,6 +55,20 @@
+
+
+
+
+
+
+ ${project.artifactId}.token.AccessTokenService
+
+
+
+
+
+
+
diff --git a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml
index 72eae2b..59e7836 100644
--- a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml
+++ b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml
@@ -159,6 +159,7 @@
+
@@ -170,6 +171,10 @@
+
+
+
+
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java
index 883f0d4..a742027 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java
@@ -15,8 +15,6 @@
*/
package de.acosix.alfresco.keycloak.repo.authentication;
-import java.io.IOException;
-import java.io.InputStream;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
@@ -38,33 +36,18 @@ import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.namespace.QName;
import org.alfresco.util.PropertyCheck;
-import org.apache.http.HttpEntity;
-import org.apache.http.HttpResponse;
-import org.apache.http.NameValuePair;
-import org.apache.http.client.HttpClient;
-import org.apache.http.client.entity.UrlEncodedFormEntity;
-import org.apache.http.client.methods.HttpPost;
-import org.apache.http.message.BasicNameValuePair;
-import org.apache.http.util.EntityUtils;
-import org.keycloak.OAuth2Constants;
import org.keycloak.adapters.KeycloakDeployment;
-import org.keycloak.adapters.ServerRequest;
-import org.keycloak.adapters.authentication.ClientCredentialsProviderUtils;
-import org.keycloak.adapters.rotation.AdapterTokenVerifier;
-import org.keycloak.adapters.rotation.AdapterTokenVerifier.VerifiedTokens;
-import org.keycloak.common.VerificationException;
-import org.keycloak.common.util.KeycloakUriBuilder;
-import org.keycloak.constants.ServiceUrlConstants;
import org.keycloak.representations.AccessToken;
-import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.IDToken;
-import org.keycloak.util.JsonSerialization;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
+import de.acosix.alfresco.keycloak.repo.token.AccessTokenClient;
+import de.acosix.alfresco.keycloak.repo.token.AccessTokenException;
+import de.acosix.alfresco.keycloak.repo.token.AccessTokenRefreshException;
import de.acosix.alfresco.keycloak.repo.util.AlfrescoCompatibilityUtil;
import de.acosix.alfresco.keycloak.repo.util.RefreshableAccessTokenHolder;
import net.sf.acegisecurity.Authentication;
@@ -73,6 +56,8 @@ import net.sf.acegisecurity.GrantedAuthorityImpl;
import net.sf.acegisecurity.providers.UsernamePasswordAuthenticationToken;
/**
+ * This component provides Keycloak-integrated user/password authentication support to an Alfresco instance.
+ *
* @author Axel Faust
*/
public class KeycloakAuthenticationComponent extends AbstractAuthenticationComponent
@@ -101,6 +86,8 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
protected KeycloakDeployment deployment;
+ protected AccessTokenClient accessTokenClient;
+
protected Collection authorityExtractors;
protected Collection userProcessors;
@@ -115,6 +102,7 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
PropertyCheck.mandatory(this, "applicationContext", this.applicationContext);
PropertyCheck.mandatory(this, "keycloakDeployment", this.deployment);
+ this.accessTokenClient = new AccessTokenClient(this.deployment);
this.authorityExtractors = Collections
.unmodifiableList(new ArrayList<>(this.applicationContext.getBeansOfType(AuthorityExtractor.class, false, true).values()));
this.userProcessors = Collections
@@ -261,27 +249,12 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
{
try
{
- final AccessTokenResponse response = ServerRequest.invokeRefresh(this.deployment, ticketToken.getRefreshToken());
- final VerifiedTokens tokens = AdapterTokenVerifier.verifyTokens(response.getToken(), response.getIdToken(),
- this.deployment);
-
- result = new RefreshableAccessTokenHolder(response, tokens);
+ result = this.accessTokenClient.refreshAccessToken(ticketToken.getRefreshToken());
}
- catch (final ServerRequest.HttpFailure httpFailure)
+ catch (final AccessTokenRefreshException atrex)
{
- LOGGER.error("Error refreshing Keycloak authentication - {} {}", httpFailure.getStatus(), httpFailure.getError());
- throw new AuthenticationException(
- "Failed to refresh Keycloak authentication: " + httpFailure.getStatus() + " " + httpFailure.getError());
- }
- catch (final VerificationException vex)
- {
- LOGGER.error("Error refreshing Keycloak authentication - access token verification failed", vex);
- throw new AuthenticationException("Failed to refresh Keycloak authentication", vex);
- }
- catch (final IOException ioex)
- {
- LOGGER.error("Error refreshing Keycloak authentication - unexpected IO exception", ioex);
- throw new AuthenticationException("Failed to refresh Keycloak authentication", ioex);
+ LOGGER.error("Error refreshing Keycloak authentication", atrex);
+ throw new AuthenticationException("Failed to refresh Keycloak authentication", atrex);
}
}
else if (this.failExpiredTicketTokens && !ticketToken.isActive())
@@ -311,38 +284,30 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
throw new AuthenticationException("Simple login via user name + password is not allowed");
}
- final AccessTokenResponse response;
- final VerifiedTokens tokens;
+ final RefreshableAccessTokenHolder accessTokenHolder;
String realUserName = userName;
try
{
- response = this.getAccessTokenImpl(userName, new String(password));
- tokens = AdapterTokenVerifier.verifyTokens(response.getToken(), response.getIdToken(), this.deployment);
-
- realUserName = tokens.getAccessToken().getPreferredUsername();
+ accessTokenHolder = this.accessTokenClient.obtainAccessToken(userName, new String(password));
+ realUserName = accessTokenHolder.getAccessToken().getPreferredUsername();
// for potential one-off authentication, we do not care particularly about the token TTL - so no validation here
if (Boolean.TRUE.equals(this.lastTokenResponseStoreEnabled.get()))
{
- this.lastTokenResponse.set(new RefreshableAccessTokenHolder(response, tokens));
+ this.lastTokenResponse.set(accessTokenHolder);
}
}
- catch (final VerificationException vex)
+ catch (final AccessTokenException atex)
{
- LOGGER.error("Error authenticating against Keycloak - access token verification failed", vex);
- throw new AuthenticationException("Failed to authenticate against Keycloak", vex);
- }
- catch (final IOException ioex)
- {
- LOGGER.error("Error authenticating against Keycloak - unexpected IO exception", ioex);
- throw new AuthenticationException("Failed to authenticate against Keycloak", ioex);
+ LOGGER.error("Error authenticating against Keycloak", atex);
+ throw new AuthenticationException("Failed to authenticate against Keycloak", atex);
}
- // TODO Override setCurrentUser to perform user existence validation and role retrieval for non-Keycloak logins (e.g. via public API
- // setCurrentUser)
+ // TODO Override setCurrentUser to perform user existence validation and role retrieval for non-Keycloak logins
+ // (e.g. via public API setCurrentUser)
this.setCurrentUser(realUserName);
- this.handleUserTokens(tokens.getAccessToken(), tokens.getIdToken(), true);
+ this.handleUserTokens(accessTokenHolder.getAccessToken(), accessTokenHolder.getIdToken(), true);
}
/**
@@ -453,75 +418,4 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
{
return this.allowGuestLogin;
}
-
- /**
- * Retrieves an OIDC access token with the specific token request parameter up to the caller to define via the provided consumer.
- *
- * @param userName
- * the user to use for synchronisation access
- * @param password
- * the password of the user
- * @return the access token
- * @throws IOException
- * when errors occur in the HTTP interaction
- */
- // implementing this method locally avoids having the dependency on Keycloak authz-client
- // authz-client does not support refresh, so would be of limited value anyway
- protected AccessTokenResponse getAccessTokenImpl(final String userName, final String password) throws IOException
- {
- AccessTokenResponse tokenResponse = null;
- final HttpClient client = this.deployment.getClient();
-
- final HttpPost post = new HttpPost(KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl())
- .path(ServiceUrlConstants.TOKEN_PATH).build(this.deployment.getRealm()));
- final List formParams = new ArrayList<>();
-
- formParams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD));
- formParams.add(new BasicNameValuePair("username", userName));
- formParams.add(new BasicNameValuePair("password", password));
-
- ClientCredentialsProviderUtils.setClientCredentials(this.deployment, post, formParams);
-
- final UrlEncodedFormEntity form = new UrlEncodedFormEntity(formParams, "UTF-8");
- post.setEntity(form);
-
- final HttpResponse response = client.execute(post);
- final int status = response.getStatusLine().getStatusCode();
- final HttpEntity entity = response.getEntity();
-
- if (status != 200)
- {
- final String statusReason = response.getStatusLine().getReasonPhrase();
- LOGGER.debug("Failed to retrieve access token due to HTTP {}: {}", status, statusReason);
- EntityUtils.consumeQuietly(entity);
-
- LOGGER.debug("Failed to authenticate user against Keycloak. Status: {} Reason: {}", status, statusReason);
- throw new AuthenticationException("Failed to authenticate against Keycloak - Status: " + status + ", Reason: " + statusReason);
- }
-
- if (entity == null)
- {
- LOGGER.debug("Failed to authenticate against Keycloak - Response did not contain a message body");
- throw new AuthenticationException("Failed to authenticate against Keycloak - Response did not contain a message body");
- }
-
- final InputStream is = entity.getContent();
- try
- {
- tokenResponse = JsonSerialization.readValue(is, AccessTokenResponse.class);
- }
- finally
- {
- try
- {
- is.close();
- }
- catch (final IOException e)
- {
- LOGGER.trace("Error closing entity stream", e);
- }
- }
-
- return tokenResponse;
- }
}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClientImpl.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClientImpl.java
index 82f982f..f147678 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClientImpl.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClientImpl.java
@@ -21,10 +21,7 @@ import com.fasterxml.jackson.databind.MappingIterator;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
-import java.util.ArrayList;
-import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Consumer;
import org.alfresco.error.AlfrescoRuntimeException;
@@ -33,24 +30,11 @@ import org.alfresco.util.ParameterCheck;
import org.alfresco.util.PropertyCheck;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
-import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
-import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
-import org.apache.http.client.methods.HttpPost;
-import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
-import org.keycloak.OAuth2Constants;
import org.keycloak.adapters.KeycloakDeployment;
-import org.keycloak.adapters.ServerRequest;
-import org.keycloak.adapters.authentication.ClientCredentialsProviderUtils;
-import org.keycloak.adapters.rotation.AdapterTokenVerifier;
-import org.keycloak.common.VerificationException;
import org.keycloak.common.util.KeycloakUriBuilder;
-import org.keycloak.common.util.Time;
-import org.keycloak.constants.ServiceUrlConstants;
-import org.keycloak.representations.AccessToken;
-import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
@@ -60,7 +44,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
-import de.acosix.alfresco.keycloak.repo.util.RefreshableAccessTokenHolder;
+import de.acosix.alfresco.keycloak.repo.token.AccessTokenHolder;
+import de.acosix.alfresco.keycloak.repo.token.AccessTokenService;
/**
* Implements the API for a client to the Keycloak admin ReST API specific to IDM structures.
@@ -72,15 +57,15 @@ public class IDMClientImpl implements InitializingBean, IDMClient
private static final Logger LOGGER = LoggerFactory.getLogger(IDMClientImpl.class);
- protected final ReentrantReadWriteLock tokenLock = new ReentrantReadWriteLock(true);
-
protected KeycloakDeployment deployment;
+ protected AccessTokenService accessTokenService;
+
protected String userName;
protected String password;
- protected RefreshableAccessTokenHolder token;
+ protected AccessTokenHolder accessToken;
/**
* {@inheritDoc}
@@ -89,6 +74,7 @@ public class IDMClientImpl implements InitializingBean, IDMClient
public void afterPropertiesSet()
{
PropertyCheck.mandatory(this, "keycloakDeployment", this.deployment);
+ PropertyCheck.mandatory(this, "accessTokenService", this.accessTokenService);
}
/**
@@ -100,6 +86,15 @@ public class IDMClientImpl implements InitializingBean, IDMClient
this.deployment = deployment;
}
+ /**
+ * @param accessTokenService
+ * the accessTokenService to set
+ */
+ public void setAccessTokenService(final AccessTokenService accessTokenService)
+ {
+ this.accessTokenService = accessTokenService;
+ }
+
/**
* @param userName
* the userName to set
@@ -607,214 +602,24 @@ public class IDMClientImpl implements InitializingBean, IDMClient
*/
protected String getValidAccessTokenForRequest()
{
- String validToken = null;
-
- this.tokenLock.readLock().lock();
- try
+ if (this.accessToken == null)
{
- if (this.token != null && this.token.isActive()
- && (!this.token.canRefresh() || !this.token.shouldRefresh(this.deployment.getTokenMinimumTimeToLive())))
+ synchronized (this)
{
- validToken = this.token.getToken();
- }
- }
- finally
- {
- this.tokenLock.readLock().unlock();
- }
-
- if (validToken == null)
- {
- this.tokenLock.writeLock().lock();
- try
- {
- if (this.token != null && this.token.isActive()
- && (!this.token.canRefresh() || !this.token.shouldRefresh(this.deployment.getTokenMinimumTimeToLive())))
+ if (this.accessToken == null)
{
- validToken = this.token.getToken();
- }
-
- if (validToken == null)
- {
- this.obtainOrRefreshAccessToken();
-
- validToken = this.token.getToken();
+ if (this.userName != null && !this.userName.isEmpty())
+ {
+ this.accessToken = this.accessTokenService.obtainAccessToken(this.userName, this.password);
+ }
+ else
+ {
+ this.accessToken = this.accessTokenService.obtainAccessToken();
+ }
}
}
- finally
- {
- this.tokenLock.writeLock().unlock();
- }
}
- return validToken;
- }
-
- /**
- * Retrieves or refreshes an access token, depending on the presence of valid refresh token for a previous access token.
- */
- protected void obtainOrRefreshAccessToken()
- {
- AccessTokenResponse response;
-
- try
- {
- if (this.token != null && this.token.canRefresh())
- {
- response = ServerRequest.invokeRefresh(this.deployment, this.token.getRefreshToken());
- }
- else
- {
- response = this.userName != null && !this.userName.isEmpty() ? this.getAccessToken(this.userName, this.password)
- : this.getAccessToken();
- }
- }
- catch (final IOException ioex)
- {
- LOGGER.error("Error retrieving / refreshing access token", ioex);
- throw new AlfrescoRuntimeException("Error retrieving / refreshing access token", ioex);
- }
- catch (final ServerRequest.HttpFailure httpFailure)
- {
- LOGGER.error("Refreshing access token failed: {} {}", httpFailure.getStatus(), httpFailure.getError());
- throw new AlfrescoRuntimeException("Failed to refresh access token: " + httpFailure.getStatus() + " " + httpFailure.getError());
- }
-
- final String tokenString = response.getToken();
-
- final AdapterTokenVerifier.VerifiedTokens tokens;
- try
- {
- tokens = AdapterTokenVerifier.verifyTokens(tokenString, response.getIdToken(), this.deployment);
- }
- catch (final VerificationException vex)
- {
- LOGGER.error("Token verification failed", vex);
- throw new AlfrescoRuntimeException("Failed to verify token", vex);
- }
-
- final AccessToken accessToken = tokens.getAccessToken();
-
- if ((accessToken.getExp() - this.deployment.getTokenMinimumTimeToLive()) <= Time.currentTime())
- {
- throw new AlfrescoRuntimeException("Failed to retrieve / refresh the access token with a longer time-to-live than the minimum");
- }
-
- this.tokenLock.writeLock().lock();
- try
- {
- this.token = new RefreshableAccessTokenHolder(response, tokens);
- }
- finally
- {
- this.tokenLock.writeLock().unlock();
- }
-
- if (response.getNotBeforePolicy() > this.deployment.getNotBefore())
- {
- this.deployment.updateNotBefore(response.getNotBeforePolicy());
- }
- }
-
- /**
- * Retrieves an access token using client credentials.
- *
- * @return the access token
- * @throws IOException
- * when errors occur in the HTTP interaction
- */
- protected AccessTokenResponse getAccessToken() throws IOException
- {
- LOGGER.debug("Retrieving access token with client credentrials");
- final AccessTokenResponse tokenResponse = this.getAccessTokenImpl(formParams -> {
- formParams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS));
- });
-
- return tokenResponse;
- }
-
- /**
- * Retrieves an access token for a specified synchronisation user.
- *
- * @param userName
- * the user to use for synchronisation access
- * @param password
- * the password of the user
- * @return the access token
- * @throws IOException
- * when errors occur in the HTTP interaction
- */
- protected AccessTokenResponse getAccessToken(final String userName, final String password) throws IOException
- {
- LOGGER.debug("Retrieving access token for user {}", userName);
- final AccessTokenResponse tokenResponse = this.getAccessTokenImpl(formParams -> {
- formParams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD));
- formParams.add(new BasicNameValuePair("username", userName));
- formParams.add(new BasicNameValuePair("password", password));
- });
-
- return tokenResponse;
- }
-
- /**
- * Retrieves an OIDC access token with the specific token request parameter up to the caller to define via the provided consumer.
- *
- * @param postParamProvider
- * a provider of HTTP POST parameters for the access token request
- * @return the access token
- * @throws IOException
- * when errors occur in the HTTP interaction
- */
- // implementing this method locally avoids having the dependency on Keycloak authz-client
- // authz-client does not support refresh, so would be of limited value anyway
- protected AccessTokenResponse getAccessTokenImpl(final Consumer> postParamProvider) throws IOException
- {
- AccessTokenResponse tokenResponse = null;
- final HttpClient client = this.deployment.getClient();
-
- final HttpPost post = new HttpPost(KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl())
- .path(ServiceUrlConstants.TOKEN_PATH).build(this.deployment.getRealm()));
- final List formParams = new ArrayList<>();
-
- postParamProvider.accept(formParams);
-
- ClientCredentialsProviderUtils.setClientCredentials(this.deployment, post, formParams);
-
- final UrlEncodedFormEntity form = new UrlEncodedFormEntity(formParams, "UTF-8");
- post.setEntity(form);
-
- final HttpResponse response = client.execute(post);
- final int status = response.getStatusLine().getStatusCode();
- final HttpEntity entity = response.getEntity();
- if (status != 200)
- {
- final String statusReason = response.getStatusLine().getReasonPhrase();
- LOGGER.debug("Failed to retrieve access token due to HTTP {}: {}", status, statusReason);
- EntityUtils.consumeQuietly(entity);
- throw new AlfrescoRuntimeException("Failed to retrieve access token due to HTTP error " + status + ": " + statusReason);
- }
- if (entity == null)
- {
- throw new AlfrescoRuntimeException("Response to access token request did not contain a response body");
- }
-
- final InputStream is = entity.getContent();
- try
- {
- tokenResponse = JsonSerialization.readValue(is, AccessTokenResponse.class);
- }
- finally
- {
- try
- {
- is.close();
- }
- catch (final IOException e)
- {
- LOGGER.trace("Error closing entity stream", e);
- }
- }
-
- return tokenResponse;
+ return this.accessToken.getAccessToken();
}
}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenClient.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenClient.java
new file mode 100644
index 0000000..ba92edb
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenClient.java
@@ -0,0 +1,306 @@
+package de.acosix.alfresco.keycloak.repo.token;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.alfresco.util.ParameterCheck;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.message.BasicNameValuePair;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.TokenVerifier;
+import org.keycloak.adapters.KeycloakDeployment;
+import org.keycloak.adapters.ServerRequest;
+import org.keycloak.adapters.ServerRequest.HttpFailure;
+import org.keycloak.adapters.authentication.ClientCredentialsProviderUtils;
+import org.keycloak.adapters.rotation.AdapterTokenVerifier;
+import org.keycloak.adapters.rotation.AdapterTokenVerifier.VerifiedTokens;
+import org.keycloak.common.VerificationException;
+import org.keycloak.common.util.KeycloakUriBuilder;
+import org.keycloak.common.util.Time;
+import org.keycloak.constants.ServiceUrlConstants;
+import org.keycloak.representations.AccessToken;
+import org.keycloak.representations.AccessTokenResponse;
+import org.keycloak.util.JsonSerialization;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import de.acosix.alfresco.keycloak.repo.util.RefreshableAccessTokenHolder;
+
+/**
+ * Instances of this class provide the most common, low-level access token client logic that may be used across multiple higher-level
+ * components in this module.
+ *
+ * @author Axel Faust
+ */
+public class AccessTokenClient
+{
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(AccessTokenClient.class);
+
+ protected final KeycloakDeployment deployment;
+
+ public AccessTokenClient(final KeycloakDeployment deployment)
+ {
+ ParameterCheck.mandatory("deployment", deployment);
+ this.deployment = deployment;
+ }
+
+ /**
+ * Obtains an access token for the service account of the client used to integrate this Alfresco isntance with Keycloak. This requires
+ * that a service account has been enabled / configured in Keycloak.
+ *
+ * @return the access token
+ * @throws AccessTokenException
+ * if the access token cannot be obtained
+ */
+ public RefreshableAccessTokenHolder obtainAccessToken()
+ {
+ LOGGER.debug("Obtaining client access token");
+ try
+ {
+ final AccessTokenResponse response = this.getAccessTokenImpl(formParams -> {
+ formParams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS));
+ });
+ final VerifiedTokens verifiedTokens = this.verifyAccessTokenResponse(response);
+ final RefreshableAccessTokenHolder refreshableToken = new RefreshableAccessTokenHolder(response, verifiedTokens);
+ LOGGER.debug("Obtained client access token {}", response.getToken());
+ return refreshableToken;
+ }
+ catch (final IOException ioex)
+ {
+ throw new AccessTokenException("Failed to obtain accses token", ioex);
+ }
+ }
+
+ /**
+ * Obtains an access token for a specific user using a direct access grant. This requires that the client used to integrate this
+ * Alfresco instance with Keycloak is configured to allow direct access grants.
+ *
+ * @param user
+ * the name of the user
+ * @param password
+ * the password provided by / for the user
+ * @return the access token
+ * @throws AccessTokenException
+ * if the access token cannot be obtained
+ */
+ public RefreshableAccessTokenHolder obtainAccessToken(final String user, final String password)
+ {
+ LOGGER.debug("Obtaining access token for user {}", user);
+ try
+ {
+ final AccessTokenResponse response = this.getAccessTokenImpl(formParams -> {
+ formParams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD));
+ formParams.add(new BasicNameValuePair("username", user));
+ formParams.add(new BasicNameValuePair("password", password));
+ });
+ final VerifiedTokens verifiedTokens = this.verifyAccessTokenResponse(response);
+ final RefreshableAccessTokenHolder refreshableToken = new RefreshableAccessTokenHolder(response, verifiedTokens);
+ LOGGER.debug("Obtained user access token {}", response.getToken());
+ return refreshableToken;
+ }
+ catch (final IOException ioex)
+ {
+ throw new AccessTokenException("Failed to obtain accses token", ioex);
+ }
+ }
+
+ /**
+ * Exchanges an access token provided by a client / end user to this service for an access token to another client / service, retaining
+ * the original identity but enabling that client / service to know the access is being delegated through this Alfresco instance.
+ *
+ * @param accessToken
+ * the access token to exchange
+ * @param client
+ * the client / service for which to obtain an access token
+ * @return the access token to the requested client / service
+ * @throws AccessTokenException
+ * if the token cannot be exchanged
+ */
+ public RefreshableAccessTokenHolder exchangeToken(final String accessToken, final String client)
+ {
+ LOGGER.debug("Exchanging {} for access token to client {}", accessToken, client);
+ try
+ {
+ final AccessTokenResponse response = this.getAccessTokenImpl(formParams -> {
+ formParams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE));
+ formParams.add(new BasicNameValuePair(OAuth2Constants.AUDIENCE, client));
+ formParams.add(new BasicNameValuePair(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE));
+ });
+ final VerifiedTokens verifiedTokens = this.verifyAccessTokenResponse(response, client);
+ final RefreshableAccessTokenHolder refreshableToken = new RefreshableAccessTokenHolder(response, verifiedTokens);
+ LOGGER.debug("Obtained exchanged token {}", response.getToken());
+ return refreshableToken;
+ }
+ catch (final IOException ioex)
+ {
+ throw new AccessTokenException("Failed to exchange accses token", ioex);
+ }
+ }
+
+ /**
+ * Refreshes an access token via a previously obtained refresh token.
+ *
+ * @param refreshToken
+ * the refresh token with which to retrieve a fresh access token
+ * @return the fresh access token
+ * @throws AccessTokenRefreshException
+ * if the refresh failed
+ */
+ public RefreshableAccessTokenHolder refreshAccessToken(final String refreshToken)
+ {
+ LOGGER.debug("Performing direct refresh via refresh token {}", refreshToken);
+ try
+ {
+ final AccessTokenResponse response = ServerRequest.invokeRefresh(this.deployment, refreshToken);
+ final VerifiedTokens verifiedTokens = this.verifyAccessTokenResponse(response);
+ final RefreshableAccessTokenHolder refreshableToken = new RefreshableAccessTokenHolder(response, verifiedTokens);
+ LOGGER.debug("Refreshed access token {}", response.getToken());
+ return refreshableToken;
+ }
+ catch (final IOException | HttpFailure ioex)
+ {
+ LOGGER.debug("Failed direct refresh due to {}", ioex.getMessage());
+ throw new AccessTokenRefreshException("Failed to refresh access token due HTTP / IO error", ioex);
+ }
+ catch (final AccessTokenVerificationException verex)
+ {
+ LOGGER.debug("Failed direct refresh due to {}", verex.getMessage());
+ throw new AccessTokenRefreshException("Failed to refresh access token due to verification error", verex);
+ }
+ }
+
+ protected VerifiedTokens verifyAccessTokenResponse(final AccessTokenResponse response)
+ {
+ final VerifiedTokens tokens;
+ try
+ {
+ tokens = AdapterTokenVerifier.verifyTokens(response.getToken(), response.getIdToken(), this.deployment);
+ }
+ catch (final VerificationException vex)
+ {
+ throw new AccessTokenVerificationException("Failed to verify token", vex);
+ }
+
+ if ((tokens.getAccessToken().getExp() - this.deployment.getTokenMinimumTimeToLive()) <= Time.currentTime())
+ {
+ throw new AccessTokenVerificationException(
+ "Failed to retrieve / refresh the access token with a longer time-to-live than the minimum");
+ }
+
+ return tokens;
+ }
+
+ protected VerifiedTokens verifyAccessTokenResponse(final AccessTokenResponse response, final String client)
+ {
+ final VerifiedTokens tokens;
+ try
+ {
+ final TokenVerifier tokenVerifier = AdapterTokenVerifier.createVerifier(response.getToken(), this.deployment, true,
+ AccessToken.class);
+ tokenVerifier.audience(client);
+ tokenVerifier.issuedFor(this.deployment.getResourceName());
+
+ tokens = new VerifiedTokens(tokenVerifier.verify().getToken(), null);
+ }
+ catch (final VerificationException vex)
+ {
+ throw new AccessTokenVerificationException("Failed to verify token", vex);
+ }
+
+ if ((tokens.getAccessToken().getExp() - this.deployment.getTokenMinimumTimeToLive()) <= Time.currentTime())
+ {
+ throw new AccessTokenVerificationException(
+ "Failed to retrieve / refresh the access token with a longer time-to-live than the minimum");
+ }
+
+ return tokens;
+ }
+
+ /**
+ * Retrieves an OIDC access token with the specific token request parameter up to the caller to define via the provided consumer.
+ *
+ * @param postParamProvider
+ * a provider of HTTP POST parameters for the access token request
+ * @return the access token
+ * @throws IOException
+ * when errors occur in the HTTP interaction
+ */
+ // implementing this method locally avoids having the dependency on Keycloak authz-client
+ // authz-client does not support refresh, so would be of limited value anyway
+ protected AccessTokenResponse getAccessTokenImpl(final Consumer> postParamProvider) throws IOException
+ {
+ AccessTokenResponse tokenResponse = null;
+ final HttpClient client = this.deployment.getClient();
+
+ final HttpPost post = new HttpPost(KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl())
+ .path(ServiceUrlConstants.TOKEN_PATH).build(this.deployment.getRealm()));
+ final List formParams = new ArrayList<>();
+
+ postParamProvider.accept(formParams);
+
+ ClientCredentialsProviderUtils.setClientCredentials(this.deployment, post, formParams);
+
+ final UrlEncodedFormEntity form = new UrlEncodedFormEntity(formParams, "UTF-8");
+ post.setEntity(form);
+
+ final HttpResponse response = client.execute(post);
+ final int status = response.getStatusLine().getStatusCode();
+ final HttpEntity entity = response.getEntity();
+ if (status != 200)
+ {
+ final String statusReason = response.getStatusLine().getReasonPhrase();
+ if (entity != null)
+ {
+ final ErrorResponse error = this.readResponseEntity(entity, ErrorResponse.class);
+ if ("unauthorized_client".equals(error.getError()))
+ {
+ // configuration error
+ LOGGER.error("Unable to retrieve access token due to invalid client configuration: {}", error.getErrorDescription());
+ throw new AccessTokenUnsupportedException(error.getErrorDescription());
+ }
+ // TODO Other types of more specific exceptions
+ LOGGER.debug("Failed to retrieve access token due to {}: {}", error.getError(), error.getErrorDescription());
+ throw new AccessTokenException("{0}: {1}", new Object[] { error.getError(), error.getErrorDescription() });
+ }
+ LOGGER.debug("Failed to retrieve access token due to HTTP {}: {}", status, statusReason);
+ throw new AccessTokenException("Failed to retrieve access token due to HTTP " + status + ": " + statusReason);
+ }
+ if (entity == null)
+ {
+ throw new AccessTokenException("Response to access token request did not contain a response body");
+ }
+
+ tokenResponse = this.readResponseEntity(entity, AccessTokenResponse.class);
+
+ return tokenResponse;
+ }
+
+ protected T readResponseEntity(final HttpEntity entity, final Class responseCls) throws IOException
+ {
+ final InputStream is = entity.getContent();
+ try
+ {
+ return JsonSerialization.readValue(is, responseCls);
+ }
+ finally
+ {
+ try
+ {
+ is.close();
+ }
+ catch (final IOException e)
+ {
+ LOGGER.trace("Error closing entity stream", e);
+ }
+ }
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenException.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenException.java
new file mode 100644
index 0000000..ee61401
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenException.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2019 - 2020 Acosix GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.acosix.alfresco.keycloak.repo.token;
+
+import org.alfresco.error.AlfrescoRuntimeException;
+
+/**
+ * Instances of this class signal errors / problems of any kind within the access token service.
+ *
+ * @author Axel Faust
+ */
+public class AccessTokenException extends AlfrescoRuntimeException
+{
+
+ private static final long serialVersionUID = 5504292228618468389L;
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ */
+ public AccessTokenException(final String msgId)
+ {
+ super(msgId);
+ }
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ * @param cause
+ * the underlying cause of this exception
+ */
+ public AccessTokenException(final String msgId, final Throwable cause)
+ {
+ super(msgId, cause);
+ }
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ * @param msgParams
+ * the parameters for constructing a human readable message based on pattern-based message
+ */
+ public AccessTokenException(final String msgId, final Object[] msgParams)
+ {
+ super(msgId, msgParams);
+ }
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ * @param msgParams
+ * the parameters for constructing a human readable message based on pattern-based message
+ * @param cause
+ * the underlying cause of this exception
+ */
+ public AccessTokenException(final String msgId, final Object[] msgParams, final Throwable cause)
+ {
+ super(msgId, msgParams, cause);
+ }
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenHolder.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenHolder.java
new file mode 100644
index 0000000..788527b
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenHolder.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2019 - 2020 Acosix GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.acosix.alfresco.keycloak.repo.token;
+
+/**
+ * Instances of this interface act as holders of a specific access authentication, encapsulating functionality which may dynamically change
+ * the value of the effective access token, such as automatic refresh and re-obtaining of the access token when necessary.
+ *
+ * @author Axel Faust
+ */
+public interface AccessTokenHolder
+{
+
+ /**
+ * Retrieves the access token from this instance. The result of this operation must never be externally cached as this operation
+ * transparently handles validation, potential refresh and re-obtaining of the underlying access token when necessary.
+ *
+ * @return the valid access token
+ * @throws AccessTokenRefreshException
+ * if a necessary refresh of the access token fails or cannot not be performed due to the way the access token was
+ * originally obtained
+ */
+ String getAccessToken();
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenHolderImpl.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenHolderImpl.java
new file mode 100644
index 0000000..e47e839
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenHolderImpl.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2019 - 2020 Acosix GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.acosix.alfresco.keycloak.repo.token;
+
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import de.acosix.alfresco.keycloak.repo.util.RefreshableAccessTokenHolder;
+
+/**
+ * Instances of this class provide the technical implementation of the holder interface.
+ *
+ * @author Axel Faust
+ */
+public class AccessTokenHolderImpl implements AccessTokenHolder
+{
+
+ private final ReentrantReadWriteLock tokenLock = new ReentrantReadWriteLock(true);
+
+ private RefreshableAccessTokenHolder token;
+
+ private final int minimumTimeToLive;
+
+ private final Function refresher;
+
+ private Supplier obtainer;
+
+ /**
+ * Constructs a new instance of this class to wrap the provided initial access token.
+ *
+ * @param token
+ * the initial access token
+ * @param minimumTimeToLive
+ * the minimum time to live for access tokens expected by the Keycloak deployment
+ * @param refresher
+ * the callback function to refresh an expired access token
+ */
+ public AccessTokenHolderImpl(final RefreshableAccessTokenHolder token, final int minimumTimeToLive,
+ final Function refresher)
+ {
+ this.token = token;
+ this.minimumTimeToLive = minimumTimeToLive;
+ this.refresher = refresher;
+ }
+
+ /**
+ * Constructs a new instance of this class to wrap the provided initial access token.
+ *
+ * @param token
+ * the initial access token
+ * @param minimumTimeToLive
+ * the minimum time to live for access tokens expected by the Keycloak deployment
+ * @param refresher
+ * the callback function to refresh an expired access token
+ * @param obtainer
+ * the supplier to re-obtain the access token after both access token and its refresh token have expired
+ */
+ public AccessTokenHolderImpl(final RefreshableAccessTokenHolder token, final int minimumTimeToLive,
+ final Function refresher, final Supplier obtainer)
+ {
+ this.token = token;
+ this.minimumTimeToLive = minimumTimeToLive;
+ this.refresher = refresher;
+ this.obtainer = obtainer;
+ }
+
+ @Override
+ public String getAccessToken()
+ {
+ String validToken = null;
+
+ this.tokenLock.readLock().lock();
+ try
+ {
+ if (this.token != null && this.token.isActive()
+ && (!this.token.canRefresh() || !this.token.shouldRefresh(this.minimumTimeToLive)))
+ {
+ validToken = this.token.getToken();
+ }
+ }
+ finally
+ {
+ this.tokenLock.readLock().unlock();
+ }
+
+ if (validToken == null)
+ {
+ this.tokenLock.writeLock().lock();
+ try
+ {
+ if (this.token != null && this.token.isActive()
+ && (!this.token.canRefresh() || !this.token.shouldRefresh(this.minimumTimeToLive)))
+ {
+ validToken = this.token.getToken();
+ }
+
+ if (validToken == null)
+ {
+ if (this.token != null && this.token.canRefresh())
+ {
+ this.token = this.refresher.apply(this.token.getRefreshToken());
+ }
+ else if (this.obtainer != null)
+ {
+ this.token = this.obtainer.get();
+ }
+ else
+ {
+ throw new AccessTokenRefreshException(
+ "The way this access token was originally obtained does not allow to re-obtain it after expiration of the token and its associated refresh token");
+ }
+
+ validToken = this.token.getToken();
+ }
+ }
+ finally
+ {
+ this.tokenLock.writeLock().unlock();
+ }
+ }
+
+ return validToken;
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenRefreshException.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenRefreshException.java
new file mode 100644
index 0000000..f3b5e9e
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenRefreshException.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2019 - 2020 Acosix GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.acosix.alfresco.keycloak.repo.token;
+
+/**
+ * Instances of this class signal errors / problems refreshing an access token.
+ *
+ * @author Axel Faust
+ */
+public class AccessTokenRefreshException extends AccessTokenException
+{
+
+ private static final long serialVersionUID = -8643258624236748350L;
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ */
+ public AccessTokenRefreshException(final String msgId)
+ {
+ super(msgId);
+ }
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ * @param cause
+ * the underlying cause of this exception
+ */
+ public AccessTokenRefreshException(final String msgId, final Throwable cause)
+ {
+ super(msgId, cause);
+ }
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ * @param msgParams
+ * the parameters for constructing a human readable message based on pattern-based message
+ */
+ public AccessTokenRefreshException(final String msgId, final Object[] msgParams)
+ {
+ super(msgId, msgParams);
+ }
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ * @param msgParams
+ * the parameters for constructing a human readable message based on pattern-based message
+ * @param cause
+ * the underlying cause of this exception
+ */
+ public AccessTokenRefreshException(final String msgId, final Object[] msgParams, final Throwable cause)
+ {
+ super(msgId, msgParams, cause);
+ }
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenService.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenService.java
new file mode 100644
index 0000000..e0f2510
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenService.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2019 - 2020 Acosix GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.acosix.alfresco.keycloak.repo.token;
+
+/**
+ * Instances of this interface allow for the retrieval of access tokens in the Keycloak realm to which this Alfresco instance is connected.
+ *
+ * @author Axel Faust
+ */
+public interface AccessTokenService
+{
+
+ /**
+ * Obtains a generic realm access token for the specific client of this Alfresco instance.
+ *
+ * @return the holder for the access token
+ * @throws IllegalStateException
+ * if no access token for the client can be obtained, e.g. if no service account for it has been configured in Keycloak
+ */
+ AccessTokenHolder obtainAccessToken();
+
+ /**
+ * Obtains a generic realm access token for a specific user of the realm.
+ *
+ * @param user
+ * the name of the user
+ * @param password
+ * the password of the user
+ * @return the holder for the access token
+ * @throws IllegalStateException
+ * if no access token for the client can be obtained, e.g. if direct access grants have not been enabled for the specific
+ * client of this Alfresco instance
+ */
+ AccessTokenHolder obtainAccessToken(String user, String password);
+
+ /**
+ * Performs a token exchange operation to obtain an access token for a specific client, acting in the name / context of the user for
+ * which the original access token was issued.
+ *
+ * @param accessToken
+ * the access token to exchange for token to another client
+ * @param client
+ * the client for which to obtain an access token
+ * @return the holder for the access token
+ * @throws IllegalStateException
+ * if no access token for the client can be obtained, e.g. if no service account for it has been configured in Keycloak
+ */
+ AccessTokenHolder exchangeToken(String accessToken, String client);
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenServiceImpl.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenServiceImpl.java
new file mode 100644
index 0000000..4f6babe
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenServiceImpl.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2019 - 2020 Acosix GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.acosix.alfresco.keycloak.repo.token;
+
+import org.alfresco.util.ParameterCheck;
+import org.alfresco.util.PropertyCheck;
+import org.keycloak.adapters.KeycloakDeployment;
+import org.springframework.beans.factory.InitializingBean;
+
+import de.acosix.alfresco.keycloak.repo.util.RefreshableAccessTokenHolder;
+
+/**
+ * Instances of this class provide the technical implementation of the service interface.
+ *
+ * @author Axel Faust
+ */
+public class AccessTokenServiceImpl implements AccessTokenService, InitializingBean
+{
+
+ protected KeycloakDeployment deployment;
+
+ protected AccessTokenClient accessTokenClient;
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public void afterPropertiesSet()
+ {
+ PropertyCheck.mandatory(this, "deployment", this.deployment);
+ this.accessTokenClient = new AccessTokenClient(this.deployment);
+ }
+
+ /**
+ * @param deployment
+ * the deployment to set
+ */
+ public void setDeployment(final KeycloakDeployment deployment)
+ {
+ this.deployment = deployment;
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public AccessTokenHolder obtainAccessToken()
+ {
+ final RefreshableAccessTokenHolder refreshableToken = this.accessTokenClient.obtainAccessToken();
+
+ return new AccessTokenHolderImpl(refreshableToken, this.deployment.getTokenMinimumTimeToLive(),
+ this.accessTokenClient::refreshAccessToken, () -> {
+ try
+ {
+ return this.accessTokenClient.obtainAccessToken();
+ }
+ catch (final AccessTokenException atex)
+ {
+ throw new AccessTokenRefreshException("Error re-obtaining access token as part of refresh", atex);
+ }
+ });
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public AccessTokenHolder obtainAccessToken(final String user, final String password)
+ {
+ ParameterCheck.mandatoryString("user", user);
+ ParameterCheck.mandatoryString("password", password);
+
+ final RefreshableAccessTokenHolder refreshableToken = this.accessTokenClient.obtainAccessToken(user, password);
+
+ return new AccessTokenHolderImpl(refreshableToken, this.deployment.getTokenMinimumTimeToLive(),
+ this.accessTokenClient::refreshAccessToken, () -> {
+ try
+ {
+ return this.accessTokenClient.obtainAccessToken(user, password);
+ }
+ catch (final AccessTokenException atex)
+ {
+ throw new AccessTokenRefreshException("Error re-obtaining access token as part of refresh", atex);
+ }
+ });
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public AccessTokenHolder exchangeToken(final String accessToken, final String client)
+ {
+ ParameterCheck.mandatoryString("accessToken", accessToken);
+ ParameterCheck.mandatoryString("client", client);
+
+ final RefreshableAccessTokenHolder refreshableToken = this.accessTokenClient.exchangeToken(accessToken, client);
+
+ return new AccessTokenHolderImpl(refreshableToken, this.deployment.getTokenMinimumTimeToLive(), refreshToken -> {
+ try
+ {
+ final String newAccessToken = this.accessTokenClient.refreshAccessToken(refreshToken).getToken();
+ return this.accessTokenClient.exchangeToken(newAccessToken, client);
+ }
+ catch (final AccessTokenException atex)
+ {
+ throw new AccessTokenRefreshException("Error re-obtaining access token as part of refresh", atex);
+ }
+ });
+ }
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenUnsupportedException.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenUnsupportedException.java
new file mode 100644
index 0000000..b6dc2aa
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenUnsupportedException.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2019 - 2020 Acosix GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.acosix.alfresco.keycloak.repo.token;
+
+/**
+ * Instances of this class signal errors / problems obtaining an access token because of a configuration error or other constellation making
+ * the attempt fundamentally impossible.
+ *
+ * @author Axel Faust
+ */
+public class AccessTokenUnsupportedException extends AccessTokenException
+{
+
+ private static final long serialVersionUID = -7719788367606723647L;
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ */
+ public AccessTokenUnsupportedException(final String msgId)
+ {
+ super(msgId);
+ }
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ * @param cause
+ * the underlying cause of this exception
+ */
+ public AccessTokenUnsupportedException(final String msgId, final Throwable cause)
+ {
+ super(msgId, cause);
+ }
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ * @param msgParams
+ * the parameters for constructing a human readable message based on pattern-based message
+ */
+ public AccessTokenUnsupportedException(final String msgId, final Object[] msgParams)
+ {
+ super(msgId, msgParams);
+ }
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ * @param msgParams
+ * the parameters for constructing a human readable message based on pattern-based message
+ * @param cause
+ * the underlying cause of this exception
+ */
+ public AccessTokenUnsupportedException(final String msgId, final Object[] msgParams, final Throwable cause)
+ {
+ super(msgId, msgParams, cause);
+ }
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenVerificationException.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenVerificationException.java
new file mode 100644
index 0000000..79c9f9a
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/AccessTokenVerificationException.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2019 - 2020 Acosix GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.acosix.alfresco.keycloak.repo.token;
+
+/**
+ * Instances of this class signal errors / problems verifying an access token retrieved from Keycloak.
+ *
+ * @author Axel Faust
+ */
+public class AccessTokenVerificationException extends AccessTokenException
+{
+
+ private static final long serialVersionUID = 6057892110861316386L;
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ */
+ public AccessTokenVerificationException(final String msgId)
+ {
+ super(msgId);
+ }
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ * @param cause
+ * the underlying cause of this exception
+ */
+ public AccessTokenVerificationException(final String msgId, final Throwable cause)
+ {
+ super(msgId, cause);
+ }
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ * @param msgParams
+ * the parameters for constructing a human readable message based on pattern-based message
+ */
+ public AccessTokenVerificationException(final String msgId, final Object[] msgParams)
+ {
+ super(msgId, msgParams);
+ }
+
+ /**
+ * Constructs a new instance of this class
+ *
+ * @param msgId
+ * the message i18n key or actual message for the exception
+ * @param msgParams
+ * the parameters for constructing a human readable message based on pattern-based message
+ * @param cause
+ * the underlying cause of this exception
+ */
+ public AccessTokenVerificationException(final String msgId, final Object[] msgParams, final Throwable cause)
+ {
+ super(msgId, msgParams, cause);
+ }
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/ErrorResponse.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/ErrorResponse.java
new file mode 100644
index 0000000..c31434f
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/ErrorResponse.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2019 - 2020 Acosix GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.acosix.alfresco.keycloak.repo.token;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Instances of this class represent error details from Keycloak responses.
+ *
+ * @author Axel Faust
+ */
+public class ErrorResponse
+{
+
+ @JsonProperty("error")
+ protected String error;
+
+ @JsonProperty("error_description")
+ protected String errorDescription;
+
+ /**
+ * @return the error
+ */
+ public String getError()
+ {
+ return this.error;
+ }
+
+ /**
+ * @param error
+ * the error to set
+ */
+ public void setError(final String error)
+ {
+ this.error = error;
+ }
+
+ /**
+ * @return the errorDescription
+ */
+ public String getErrorDescription()
+ {
+ return this.errorDescription;
+ }
+
+ /**
+ * @param errorDescription
+ * the errorDescription to set
+ */
+ public void setErrorDescription(final String errorDescription)
+ {
+ this.errorDescription = errorDescription;
+ }
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/NoOpAccessTokenServiceImpl.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/NoOpAccessTokenServiceImpl.java
new file mode 100644
index 0000000..d12a0b7
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/token/NoOpAccessTokenServiceImpl.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2019 - 2020 Acosix GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.acosix.alfresco.keycloak.repo.token;
+
+/**
+ * This no-op implementation class of an access token service may be used as a default implemenation in a subsystem proxy to avoid failing
+ * if no Keycloak subsystem instance is active.
+ *
+ * @author Axel Faust
+ */
+public class NoOpAccessTokenServiceImpl implements AccessTokenService
+{
+
+ private static final String UNSUPPORTED_MESSAGE = "A Keycloak subsystem is not configured / enabled";
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public AccessTokenHolder obtainAccessToken()
+ {
+ throw new AccessTokenUnsupportedException(UNSUPPORTED_MESSAGE);
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public AccessTokenHolder obtainAccessToken(final String user, final String password)
+ {
+ throw new AccessTokenUnsupportedException(UNSUPPORTED_MESSAGE);
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public AccessTokenHolder exchangeToken(final String accessToken, final String client)
+ {
+ throw new AccessTokenUnsupportedException(UNSUPPORTED_MESSAGE);
+ }
+
+}