mirror of
https://github.com/bmlong137/alfresco-keycloak.git
synced 2025-09-10 14:11:09 +00:00
Service to obtain tokens for integrations
This commit is contained in:
@@ -55,6 +55,20 @@
|
||||
</property>
|
||||
</bean>
|
||||
|
||||
<bean id="${moduleId}.AccessTokenService" class="org.alfresco.repo.management.subsystems.ChainingSubsystemProxyFactory">
|
||||
<property name="applicationContextManager">
|
||||
<ref bean="Authentication" />
|
||||
</property>
|
||||
<property name="interfaces">
|
||||
<list>
|
||||
<value>${project.artifactId}.token.AccessTokenService</value>
|
||||
</list>
|
||||
</property>
|
||||
<property name="defaultTarget">
|
||||
<bean class="${project.artifactId}.token.NoOpAccessTokenServiceImpl" />
|
||||
</property>
|
||||
</bean>
|
||||
|
||||
<bean id="${moduleId}.authenticationListener" class="org.alfresco.repo.management.subsystems.ChainingSubsystemProxyFactory">
|
||||
<property name="applicationContextManager">
|
||||
<ref bean="Authentication" />
|
||||
|
@@ -159,6 +159,7 @@
|
||||
|
||||
<bean id="idmClient" class="${project.artifactId}.client.IDMClientImpl">
|
||||
<property name="deployment" ref="keycloakDeployment" />
|
||||
<property name="accessTokenService" ref="accessTokenService.impl" />
|
||||
<property name="userName" value="${keycloak.synchronization.user}" />
|
||||
<property name="password" value="${keycloak.synchronization.password}" />
|
||||
</bean>
|
||||
@@ -170,6 +171,10 @@
|
||||
<property name="groupLoadBatchSize" value="${keycloak.synchronization.groupLoadBatchSize}" />
|
||||
</bean>
|
||||
|
||||
<bean id="accessTokenService.impl" class="${project.artifactId}.token.AccessTokenServiceImpl">
|
||||
<property name="deployment" ref="keycloakDeployment" />
|
||||
</bean>
|
||||
|
||||
<bean id="roleService.impl" class="${project.artifactId}.roles.RoleServiceImpl">
|
||||
<property name="adapterConfig" ref="keycloakAdapterConfig" />
|
||||
<property name="idmClient" ref="idmClient" />
|
||||
|
@@ -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<AuthorityExtractor> authorityExtractors;
|
||||
|
||||
protected Collection<UserProcessor> 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<NameValuePair> 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;
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
if (this.accessToken == null)
|
||||
{
|
||||
this.tokenLock.readLock().unlock();
|
||||
}
|
||||
|
||||
if (validToken == null)
|
||||
if (this.userName != null && !this.userName.isEmpty())
|
||||
{
|
||||
this.tokenLock.writeLock().lock();
|
||||
try
|
||||
{
|
||||
if (this.token != null && this.token.isActive()
|
||||
&& (!this.token.canRefresh() || !this.token.shouldRefresh(this.deployment.getTokenMinimumTimeToLive())))
|
||||
{
|
||||
validToken = this.token.getToken();
|
||||
}
|
||||
|
||||
if (validToken == null)
|
||||
{
|
||||
this.obtainOrRefreshAccessToken();
|
||||
|
||||
validToken = this.token.getToken();
|
||||
}
|
||||
}
|
||||
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());
|
||||
this.accessToken = this.accessTokenService.obtainAccessToken(this.userName, this.password);
|
||||
}
|
||||
else
|
||||
{
|
||||
response = this.userName != null && !this.userName.isEmpty() ? this.getAccessToken(this.userName, this.password)
|
||||
: this.getAccessToken();
|
||||
this.accessToken = this.accessTokenService.obtainAccessToken();
|
||||
}
|
||||
}
|
||||
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<List<NameValuePair>> 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<NameValuePair> 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();
|
||||
}
|
||||
}
|
||||
|
@@ -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<AccessToken> 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<List<NameValuePair>> 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<NameValuePair> 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> T readResponseEntity(final HttpEntity entity, final Class<T> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
@@ -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();
|
||||
|
||||
}
|
@@ -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<String, RefreshableAccessTokenHolder> refresher;
|
||||
|
||||
private Supplier<RefreshableAccessTokenHolder> 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<String, RefreshableAccessTokenHolder> 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<String, RefreshableAccessTokenHolder> refresher, final Supplier<RefreshableAccessTokenHolder> 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;
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
@@ -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);
|
||||
}
|
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user