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); + } + +}