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 f781622..46c438a 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
@@ -201,6 +201,12 @@
+
+
+
+
+
+
diff --git a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties
index 3a2a50c..8753126 100644
--- a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties
+++ b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties
@@ -13,6 +13,10 @@ keycloak.authentication.mapPersonPropertiesOnLogin=true
keycloak.authentication.authenticateFTP=true
keycloak.authentication.silentRemoteUserValidationFailure=true
+# Authority Sync plugin
+keycloak.authentication.syncAuthoritiesOnLogin=false
+keycloak.authentication.syncAuthorityMembershipOnLogin=false
+
keycloak.authentication.bodyBufferLimit=10485760
keycloak.adapter.auth-server-url=http://localhost:8180/auth
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenAuthoritySync.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenAuthoritySync.java
new file mode 100644
index 0000000..c6a6c2b
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenAuthoritySync.java
@@ -0,0 +1,249 @@
+package de.acosix.alfresco.keycloak.repo.authentication;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.alfresco.repo.security.authentication.AbstractAuthenticationComponent;
+import org.alfresco.repo.security.authentication.AuthenticationUtil;
+import org.alfresco.repo.security.authentication.AuthenticationUtil.RunAsWork;
+import org.alfresco.repo.transaction.AlfrescoTransactionSupport;
+import org.alfresco.repo.transaction.AlfrescoTransactionSupport.TxnReadState;
+import org.alfresco.service.cmr.repository.DuplicateChildNodeNameException;
+import org.alfresco.service.cmr.security.AuthorityService;
+import org.alfresco.service.cmr.security.AuthorityType;
+import org.keycloak.representations.AccessToken;
+import org.keycloak.representations.IDToken;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import de.acosix.alfresco.keycloak.repo.util.AlfrescoCompatibilityUtil;
+import net.sf.acegisecurity.GrantedAuthority;
+
+public class KeycloakTokenAuthoritySync implements TokenProcessor {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakTokenAuthoritySync.class);
+
+ private static final String NAME = "AuthoritySync";
+
+ protected boolean enabled;
+
+ protected boolean syncAuthorityMembershipOnLogin;
+
+ protected AuthorityService authorityService;
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ /**
+ * @param syncAuthorityMembershipOnLogin
+ * the syncAuthorityMembershipOnLogin to set
+ */
+ public void setSyncAuthorityMembershipOnLogin(final boolean syncAuthorityMembershipOnLogin) {
+ this.syncAuthorityMembershipOnLogin = syncAuthorityMembershipOnLogin;
+ }
+
+ public void setAuthorityService(AuthorityService authorityService) {
+ this.authorityService = authorityService;
+ }
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ public int getPriority() {
+ return 16;
+ }
+
+ @Override
+ public void handleUserTokens(AbstractAuthenticationComponent authComponent, AccessToken accessToken,
+ IDToken idToken, boolean freshLogin) {
+ if (!this.enabled)
+ return;
+
+ if (freshLogin) {
+ boolean requiresNew = AlfrescoTransactionSupport.getTransactionReadState() == TxnReadState.TXN_READ_ONLY;
+ authComponent.getTransactionService().getRetryingTransactionHelper().doInTransaction(() -> {
+ GrantedAuthority[] authorities = authComponent.getCurrentAuthentication().getAuthorities();
+ this.syncAuthorities(Arrays.asList(authorities), accessToken, idToken);
+ return null;
+ }, false, requiresNew);
+
+ if (this.syncAuthorityMembershipOnLogin)
+ {
+ authComponent.getTransactionService().getRetryingTransactionHelper().doInTransaction(() -> {
+ String userName = authComponent.getCurrentUserName();
+ GrantedAuthority[] authorities = authComponent.getCurrentAuthentication().getAuthorities();
+ this.syncAuthorityMemberships(userName, Arrays.asList(authorities), accessToken, idToken);
+ return null;
+ }, false, requiresNew);
+ }
+ }
+ }
+
+ /**
+ * Synchronizes the groups of the current user, but not the membership, as Alfresco user groups.
+ * @param authorities
+ * the Alfresco authorities to persist as user groups
+ * @param accessToken
+ * the access token
+ * @param idToken
+ * the ID token
+ */
+ protected void syncAuthorities(final Collection authorities, final AccessToken accessToken, final IDToken idToken)
+ {
+ LOGGER.debug("Synchronizing user groups {}", authorities);
+
+ AuthenticationUtil.runAsSystem(new RunAsLoggableWork() {
+ @Override
+ public Void doLoggedWork() {
+ for (GrantedAuthority authority : authorities)
+ {
+ String authorityId = authority.getAuthority();
+
+ if (AuthorityType.GROUP.equals(AuthorityType.getAuthorityType(authorityId)))
+ {
+ // we only persist groups (not roles) in Alfresco
+
+ // if it didn't exist, then we need to associate the user to the group
+ if (!authorityService.authorityExists(authorityId))
+ {
+ // group does not yet exist; create one
+
+ if (LOGGER.isDebugEnabled())
+ LOGGER.debug("Creating authority {}", authorityId);
+
+ String authorityShortName = authorityService.getShortName(authorityId);
+ authorityShortName = normalizeAuthority(authorityShortName);
+
+ if (LOGGER.isDebugEnabled())
+ LOGGER.debug("Creating group {}", authorityShortName);
+
+ try {
+ authorityService.createAuthority(AuthorityType.GROUP, authorityShortName);
+ } catch (DuplicateChildNodeNameException dcnne) {
+ LOGGER.debug("Group {} already created; race condition?", authorityShortName);
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+ });
+ }
+
+ /**
+ * Synchronizes the membership of the current user with the authority, as an Alfresco user group.
+ * @param authorities
+ * the Alfresco authorities to persist as user groups
+ * @param accessToken
+ * the access token
+ * @param idToken
+ * the ID token
+ */
+ protected void syncAuthorityMemberships(String userName, final Collection authorities, final AccessToken accessToken, final IDToken idToken)
+ {
+ if (LOGGER.isDebugEnabled())
+ LOGGER.debug("Synchronizing user group membership for user {}", AlfrescoCompatibilityUtil.maskUsername(userName));
+
+ String userAuthorityId = this.authorityService.getName(AuthorityType.USER, userName);
+ Set persistedAuthorityIds = new HashSet<>(this.authorityService.getAuthoritiesForUser(userName));
+
+ if (LOGGER.isDebugEnabled())
+ LOGGER.debug("Current authorities for user {}: {}", AlfrescoCompatibilityUtil.maskUsername(userName), persistedAuthorityIds);
+
+ AuthenticationUtil.runAsSystem(new RunAsLoggableWork() {
+ @Override
+ public Void doLoggedWork() throws Exception {
+ for (GrantedAuthority authority : authorities)
+ {
+ String authorityId = authority.getAuthority();
+ LOGGER.trace("Inspecting authority '{}' to grant membership", authorityId);
+
+ if (AuthorityType.GROUP.equals(AuthorityType.getAuthorityType(authorityId)))
+ {
+ // we only persist groups in Alfresco
+
+ // remove if it exists; any remaining GROUP authorities will be unlinked from user later in this method
+ persistedAuthorityIds.remove(authorityId);
+ // we cannot assume persistedAuthorityIds has only registered groups; it includes authorities from keycloak
+
+ authorityId = normalizeAuthority(authorityId);
+
+ if (LOGGER.isDebugEnabled())
+ LOGGER.debug("Adding user {} to group {}", AlfrescoCompatibilityUtil.maskUsername(userName), authorityId);
+
+ try {
+ // add the user to the existing group
+ authorityService.addAuthority(authorityId, userAuthorityId);
+ } catch (DuplicateChildNodeNameException dcnne) {
+ if (LOGGER.isTraceEnabled())
+ LOGGER.trace("User {} is already a member of group {}", AlfrescoCompatibilityUtil.maskUsername(userName), authorityId);
+ }
+ }
+ }
+
+ if (LOGGER.isDebugEnabled())
+ LOGGER.debug("Removing user {} from authorities: {}", AlfrescoCompatibilityUtil.maskUsername(userName), persistedAuthorityIds);
+
+ // revoke user from groups
+ for (String persistedAuthorityId : persistedAuthorityIds)
+ {
+ LOGGER.trace("Inspecting authority '{}' to revoke membership", persistedAuthorityId);
+
+ if (AuthorityType.GROUP.equals(AuthorityType.getAuthorityType(persistedAuthorityId)))
+ {
+ // disassociate persisted groups only
+ persistedAuthorityId = normalizeAuthority(persistedAuthorityId);
+
+ if (LOGGER.isDebugEnabled())
+ LOGGER.debug("Removing user {} from group {}", AlfrescoCompatibilityUtil.maskUsername(userName), persistedAuthorityId);
+
+ authorityService.removeAuthority(persistedAuthorityId, userAuthorityId);
+ }
+ }
+
+ return null;
+ }
+ });
+ }
+
+ private String normalizeAuthority(String authorityIdOrShortName) {
+ // in case we need to filter out special characters
+ return authorityIdOrShortName;
+ }
+
+
+
+ private interface RunAsLoggableWork extends RunAsWork {
+
+ default T doWork() throws Exception {
+ long time = System.currentTimeMillis();
+ LOGGER.trace("doWork()");
+
+ T result;
+ try {
+ result = this.doLoggedWork();
+ } catch (Exception e) {
+ LOGGER.error("An unhandled exception occurred", e);
+ throw e;
+ } catch (Throwable t) {
+ LOGGER.error("An unhandled error occurred", t);
+ throw t;
+ }
+
+ LOGGER.trace("doWork(): completed in {} ms", System.currentTimeMillis() - time);
+
+ return result;
+ }
+
+ T doLoggedWork() throws Exception;
+
+ }
+
+}