Merge 1.3.0 changes back to master.
diff --git a/extensions/guacamole-auth-cas/pom.xml b/extensions/guacamole-auth-cas/pom.xml
index f4b30e4..2ffe818 100644
--- a/extensions/guacamole-auth-cas/pom.xml
+++ b/extensions/guacamole-auth-cas/pom.xml
@@ -183,6 +183,26 @@
             <scope>provided</scope>
         </dependency>
 
+        <!-- JUnit -->
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-api</artifactId>
+            <version>5.6.0</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-params</artifactId>
+            <version>5.6.0</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-engine</artifactId>
+            <version>5.6.0</version>
+            <scope>test</scope>
+        </dependency>
+
     </dependencies>
 
 </project>
diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/AuthenticationProviderService.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/AuthenticationProviderService.java
index 9f171e8..8150f97 100644
--- a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/AuthenticationProviderService.java
+++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/AuthenticationProviderService.java
@@ -20,9 +20,7 @@
 package org.apache.guacamole.auth.cas;
 
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import java.util.Arrays;
-import java.util.Map;
 import javax.servlet.http.HttpServletRequest;
 import org.apache.guacamole.form.Field;
 import org.apache.guacamole.GuacamoleException;
@@ -54,12 +52,6 @@
     private TicketValidationService ticketService;
 
     /**
-     * Provider for AuthenticatedUser objects.
-     */
-    @Inject
-    private Provider<CASAuthenticatedUser> authenticatedUserProvider;
-
-    /**
      * Returns an AuthenticatedUser representing the user authenticated by the
      * given credentials.
      *
@@ -82,13 +74,7 @@
         if (request != null) {
             String ticket = request.getParameter(CASTicketField.PARAMETER_NAME);
             if (ticket != null) {
-                Map<String, String> tokens = ticketService.validateTicket(ticket, credentials);
-                String username = credentials.getUsername();
-                if (username != null) {
-                    CASAuthenticatedUser authenticatedUser = authenticatedUserProvider.get();
-                    authenticatedUser.init(username, credentials, tokens);
-                    return authenticatedUser;
-                }
+                return ticketService.validateTicket(ticket, credentials);
             }
         }
 
diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/CASGuacamoleProperties.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/CASGuacamoleProperties.java
index 2ee42db..7bb363f 100644
--- a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/CASGuacamoleProperties.java
+++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/CASGuacamoleProperties.java
@@ -19,7 +19,10 @@
 
 package org.apache.guacamole.auth.cas.conf;
 
+import org.apache.guacamole.auth.cas.group.GroupFormat;
+import org.apache.guacamole.properties.EnumGuacamoleProperty;
 import org.apache.guacamole.properties.URIGuacamoleProperty;
+import org.apache.guacamole.properties.StringGuacamoleProperty;
 
 /**
  * Provides properties required for use of the CAS authentication provider.
@@ -68,5 +71,51 @@
         public String getName() { return "cas-clearpass-key"; }
 
     };
+  
+    /**
+     * The name of the CAS attribute used for group membership, such as
+     * "memberOf". This attribute is case sensitive.
+     */
+    public static final StringGuacamoleProperty CAS_GROUP_ATTRIBUTE =
+            new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "cas-group-attribute"; }
+
+    };
+
+    /**
+     * The format used by CAS to represent group names. Possible formats are
+     * "plain" (simple text names) or "ldap" (fully-qualified LDAP DNs).
+     */
+    public static final EnumGuacamoleProperty<GroupFormat> CAS_GROUP_FORMAT =
+            new EnumGuacamoleProperty<GroupFormat>(GroupFormat.class) {
+
+        @Override
+        public String getName() { return "cas-group-format"; }
+
+    };
+
+    /**
+     * The LDAP base DN to require for all CAS groups.
+     */
+    public static final LdapNameGuacamoleProperty CAS_GROUP_LDAP_BASE_DN =
+            new LdapNameGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "cas-group-ldap-base-dn"; }
+
+    };
+
+    /**
+     * The LDAP attribute to require for the names of CAS groups.
+     */
+    public static final StringGuacamoleProperty CAS_GROUP_LDAP_ATTRIBUTE =
+            new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "cas-group-ldap-attribute"; }
+
+    };
 
 }
diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/ConfigurationService.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/ConfigurationService.java
index 680f170..ce5edd8 100644
--- a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/ConfigurationService.java
+++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/ConfigurationService.java
@@ -22,8 +22,14 @@
 import com.google.inject.Inject;
 import java.net.URI;
 import java.security.PrivateKey;
+import javax.naming.ldap.LdapName;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleServerException;
+import org.apache.guacamole.auth.cas.group.GroupFormat;
 import org.apache.guacamole.environment.Environment;
+import org.apache.guacamole.auth.cas.group.GroupParser;
+import org.apache.guacamole.auth.cas.group.LDAPGroupParser;
+import org.apache.guacamole.auth.cas.group.PlainGroupParser;
 
 /**
  * Service for retrieving configuration information regarding the CAS service.
@@ -85,4 +91,102 @@
         return environment.getProperty(CASGuacamoleProperties.CAS_CLEARPASS_KEY);
     }
 
+    /**
+     * Returns the CAS attribute that should be used to determine group
+     * memberships in CAS, such as "memberOf". If no attribute has been
+     * specified, null is returned.
+     *
+     * @return
+     *     The attribute name used to determine group memberships in CAS,
+     *     null if not defined.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getGroupAttribute() throws GuacamoleException {
+        return environment.getProperty(CASGuacamoleProperties.CAS_GROUP_ATTRIBUTE);
+    }
+
+    /**
+     * Returns the format that CAS is expected to use for its group names, such
+     * as {@link GroupFormat#PLAIN} (simple plain-text names) or
+     * {@link GroupFormat#LDAP} (fully-qualified LDAP DNs). If not specified,
+     * PLAIN is used by default.
+     *
+     * @return
+     *     The format that CAS is expected to use for its group names.
+     *
+     * @throws GuacamoleException
+     *     If the format specified within guacamole.properties is not valid.
+     */
+    public GroupFormat getGroupFormat() throws GuacamoleException {
+        return environment.getProperty(CASGuacamoleProperties.CAS_GROUP_FORMAT, GroupFormat.PLAIN);
+    }
+
+    /**
+     * Returns the base DN that all LDAP-formatted CAS groups must reside
+     * beneath. Any groups that are not beneath this base DN should be ignored.
+     * If no such base DN is provided, the tree structure of the ancestors of
+     * LDAP-formatted CAS groups should not be considered.
+     *
+     * @return
+     *     The base DN that all LDAP-formatted CAS groups must reside beneath,
+     *     or null if the tree structure of the ancestors of LDAP-formatted
+     *     CAS groups should not be considered.
+     *
+     * @throws GuacamoleException
+     *     If the provided base DN is not a valid LDAP DN.
+     */
+    public LdapName getGroupLDAPBaseDN() throws GuacamoleException {
+        return environment.getProperty(CASGuacamoleProperties.CAS_GROUP_LDAP_BASE_DN);
+    }
+
+    /**
+     * Returns the LDAP attribute that should be required for all LDAP-formatted
+     * CAS groups. Any groups that do not use this attribute as the last
+     * (leftmost) attribute of their DN should be ignored. If no such LDAP
+     * attribute is provided, the last (leftmost) attribute should still be
+     * used to determine the group name, but the specific attribute involved
+     * should not be considered.
+     *
+     * @return
+     *     The LDAP attribute that should be required for all LDAP-formatted
+     *     CAS groups, or null if any attribute should be allowed.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getGroupLDAPAttribute() throws GuacamoleException {
+        return environment.getProperty(CASGuacamoleProperties.CAS_GROUP_LDAP_ATTRIBUTE);
+    }
+
+    /**
+     * Returns a GroupParser instance that can be used to parse CAS group
+     * names. The parser returned will take into account the configured CAS
+     * group format, as well as any configured LDAP-specific restrictions.
+     *
+     * @return
+     *     A GroupParser instance that can be used to parse CAS group names as
+     *     configured in guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public GroupParser getGroupParser() throws GuacamoleException {
+        switch (getGroupFormat()) {
+
+            // Simple, plain-text groups
+            case PLAIN:
+                return new PlainGroupParser();
+
+            // LDAP DNs
+            case LDAP:
+                return new LDAPGroupParser(getGroupLDAPAttribute(), getGroupLDAPBaseDN());
+
+            default:
+                throw new GuacamoleServerException("Unsupported CAS group format: " + getGroupFormat());
+
+        }
+    }
+
 }
diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/LdapNameGuacamoleProperty.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/LdapNameGuacamoleProperty.java
new file mode 100644
index 0000000..5469376
--- /dev/null
+++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/conf/LdapNameGuacamoleProperty.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.guacamole.auth.cas.conf;
+
+import javax.naming.InvalidNameException;
+import javax.naming.ldap.LdapName;
+import org.apache.guacamole.properties.GuacamoleProperty;
+import org.apache.guacamole.GuacamoleServerException;
+
+/**
+ * A GuacamoleProperty whose value is an LDAP DN.
+ */
+public abstract class LdapNameGuacamoleProperty implements GuacamoleProperty<LdapName>  {
+
+    @Override
+    public LdapName parseValue(String value) throws GuacamoleServerException {
+
+        // Consider null/empty values to be empty
+        if (value == null || value.isEmpty())
+            return null;
+
+        // Parse provided value as an LDAP DN
+        try {
+            return new LdapName(value);
+        }
+        catch (InvalidNameException e) {
+            throw new GuacamoleServerException("Invalid LDAP distinguished name.", e);
+        }
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/group/GroupFormat.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/group/GroupFormat.java
new file mode 100644
index 0000000..4e315da
--- /dev/null
+++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/group/GroupFormat.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.guacamole.auth.cas.group;
+
+import org.apache.guacamole.properties.EnumGuacamoleProperty.PropertyValue;
+
+/**
+ * Possible formats of group names received from CAS.
+ */
+public enum GroupFormat {
+
+    /**
+     * Simple, plain-text group names.
+     */
+    @PropertyValue("plain")
+    PLAIN,
+
+    /**
+     * Group names formatted as LDAP DNs.
+     */
+    @PropertyValue("ldap")
+    LDAP
+
+}
diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/group/GroupParser.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/group/GroupParser.java
new file mode 100644
index 0000000..5c31d5d
--- /dev/null
+++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/group/GroupParser.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.guacamole.auth.cas.group;
+
+/**
+ * Parser which converts the group names returned by CAS into names usable by
+ * Guacamole. The format of a CAS group name may vary by the underlying
+ * authentication backend. For example, a CAS deployment backed by LDAP may
+ * provide group names as LDAP DNs, which must be transformed into normal group
+ * names to be usable within Guacamole.
+ *
+ * @see LDAPGroupParser
+ */
+public interface GroupParser {
+
+    /**
+     * Parses the given CAS group name into a group name usable by Guacamole.
+     *
+     * @param casGroup
+     *     The group name retrieved from CAS.
+     *
+     * @return
+     *     A group name usable by Guacamole, or null if the group is not valid.
+     */
+    String parse(String casGroup);
+
+}
diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/group/LDAPGroupParser.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/group/LDAPGroupParser.java
new file mode 100644
index 0000000..9a33ef7
--- /dev/null
+++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/group/LDAPGroupParser.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.guacamole.auth.cas.group;
+
+import javax.naming.InvalidNameException;
+import javax.naming.ldap.LdapName;
+import javax.naming.ldap.Rdn;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * GroupParser that converts group names from LDAP DNs into normal group names,
+ * using the last (leftmost) attribute of the DN as the name. Groups may
+ * optionally be restricted to only those beneath a specific base DN, or only
+ * those using a specific attribute as their last (leftmost) attribute.
+ */
+public class LDAPGroupParser implements GroupParser {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(LDAPGroupParser.class);
+
+    /**
+     * The LDAP attribute to require for all accepted group names. If null, any
+     * LDAP attribute will be allowed.
+     */
+    private final String nameAttribute;
+
+    /**
+     * The base DN to require for all accepted group names. If null, ancestor
+     * tree structure will not be considered in accepting/rejecting a group.
+     */
+    private final LdapName baseDn;
+
+    /**
+     * Creates a new LDAPGroupParser which applies the given restrictions on
+     * any provided group names.
+     *
+     * @param nameAttribute
+     *     The LDAP attribute to require for all accepted group names. This
+     *     restriction applies to the last (leftmost) attribute only, which is
+     *     always used to determine the name of the group. If null, any LDAP
+     *     attribute will be allowed in the last (leftmost) position.
+     *
+     * @param baseDn
+     *     The base DN to require for all accepted group names. If null,
+     *     ancestor tree structure will not be considered in
+     *     accepting/rejecting a group.
+     */
+    public LDAPGroupParser(String nameAttribute, LdapName baseDn) {
+        this.nameAttribute = nameAttribute;
+        this.baseDn = baseDn;
+    }
+
+    @Override
+    public String parse(String casGroup) {
+
+        // Reject null/empty group names
+        if (casGroup == null || casGroup.isEmpty())
+            return null;
+
+        // Parse group as an LDAP DN
+        LdapName group;
+        try {
+            group = new LdapName(casGroup);
+        }
+        catch (InvalidNameException e) {
+            logger.debug("CAS group \"{}\" has been rejected as it is not a "
+                    + "valid LDAP DN.", casGroup, e);
+            return null;
+        }
+
+        // Reject any group that is not beneath the base DN
+        if (baseDn != null && !group.startsWith(baseDn))
+            return null;
+
+        // If a specific name attribute is defined, restrict to groups that
+        // use that attribute to distinguish themselves
+        Rdn last = group.getRdn(group.size() - 1);
+        if (nameAttribute != null && !nameAttribute.equalsIgnoreCase(last.getType()))
+            return null;
+
+        // The group name is the string value of the final attribute in the DN
+        return last.getValue().toString();
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/group/PlainGroupParser.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/group/PlainGroupParser.java
new file mode 100644
index 0000000..04c6c05
--- /dev/null
+++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/group/PlainGroupParser.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.guacamole.auth.cas.group;
+
+/**
+ * GroupParser which simply passes through all CAS group names untouched.
+ */
+public class PlainGroupParser implements GroupParser {
+
+    @Override
+    public String parse(String casGroup) {
+        return casGroup;
+    }
+
+}
diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/ticket/TicketValidationService.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/ticket/TicketValidationService.java
index fce4760..17ef923 100644
--- a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/ticket/TicketValidationService.java
+++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/ticket/TicketValidationService.java
@@ -21,6 +21,7 @@
 
 import com.google.common.io.BaseEncoding;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.net.URI;
 import java.security.InvalidKeyException;
 import java.security.NoSuchAlgorithmException;
@@ -30,13 +31,17 @@
 import javax.crypto.IllegalBlockSizeException;
 import javax.crypto.NoSuchPaddingException;
 import java.nio.charset.Charset;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.Map.Entry;
+import java.util.Set;
+import java.util.stream.Collectors;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleSecurityException;
 import org.apache.guacamole.GuacamoleServerException;
 import org.apache.guacamole.auth.cas.conf.ConfigurationService;
+import org.apache.guacamole.auth.cas.user.CASAuthenticatedUser;
 import org.apache.guacamole.net.auth.Credentials;
 import org.apache.guacamole.token.TokenName;
 import org.jasig.cas.client.authentication.AttributePrincipal;
@@ -69,6 +74,46 @@
     private ConfigurationService confService;
 
     /**
+     * Provider for AuthenticatedUser objects.
+     */
+    @Inject
+    private Provider<CASAuthenticatedUser> authenticatedUserProvider;
+
+    /**
+     * Converts the given CAS attribute value object (whose type is variable)
+     * to a Set of String values. If the value is already a Collection of some
+     * kind, its values are converted to Strings and returned as the members of
+     * the Set. If the value is not already a Collection, it is assumed to be a
+     * single value, converted to a String, and used as the sole member of the
+     * set.
+     *
+     * @param obj
+     *     The CAS attribute value to convert to a Set of Strings.
+     *
+     * @return
+     *     A Set of all String values contained within the given CAS attribute
+     *     value.
+     */
+    private Set<String> toStringSet(Object obj) {
+
+        // Consider null to represent no provided values
+        if (obj == null)
+            return Collections.emptySet();
+
+        // If the provided object is already a Collection, produce a Collection
+        // where we know for certain that all values are Strings
+        if (obj instanceof Collection) {
+            return ((Collection<?>) obj).stream()
+                    .map(Object::toString)
+                    .collect(Collectors.toSet());
+        }
+
+        // Otherwise, assume we have only a single value
+        return Collections.singleton(obj.toString());
+
+    }
+
+    /**
      * Validates and parses the given ID ticket, returning a map of all
      * available tokens for the given user based on attributes provided by the
      * CAS server.  If the ticket is invalid an exception is thrown.
@@ -81,62 +126,80 @@
      *     password values in.
      *
      * @return
-     *     A Map all of tokens for the user parsed from attributes returned
-     *     by the CAS server.
+     *     A CASAuthenticatedUser instance containing the ticket data returned by the CAS server.
      *
      * @throws GuacamoleException
      *     If the ID ticket is not valid or guacamole.properties could
      *     not be parsed.
      */
-    public Map<String, String> validateTicket(String ticket,
+    public CASAuthenticatedUser validateTicket(String ticket,
             Credentials credentials) throws GuacamoleException {
 
-        // Retrieve the configured CAS URL, establish a ticket validator,
-        // and then attempt to validate the supplied ticket.  If that succeeds,
-        // grab the principal returned by the validator.
+        // Create a ticket validator that uses the configured CAS URL
         URI casServerUrl = confService.getAuthorizationEndpoint();
         Cas20ProxyTicketValidator validator = new Cas20ProxyTicketValidator(casServerUrl.toString());
         validator.setAcceptAnyProxy(true);
         validator.setEncoding("UTF-8");
+
+        // Attempt to validate the supplied ticket
+        Assertion assertion;
         try {
-            Map<String, String> tokens = new HashMap<>();
             URI confRedirectURI = confService.getRedirectURI();
-            Assertion a = validator.validate(ticket, confRedirectURI.toString());
-            AttributePrincipal principal =  a.getPrincipal();
-            Map<String, Object> ticketAttrs =
-                    new HashMap<>(principal.getAttributes());
-
-            // Retrieve username and set the credentials.
-            String username = principal.getName();
-            if (username == null)
-                throw new GuacamoleSecurityException("No username provided by CAS.");
-            
-            credentials.setUsername(username);
-
-            // Retrieve password, attempt decryption, and set credentials.
-            Object credObj = ticketAttrs.remove("credential");
-            if (credObj != null) {
-                String clearPass = decryptPassword(credObj.toString());
-                if (clearPass != null && !clearPass.isEmpty())
-                    credentials.setPassword(clearPass);
-            }
-            
-            // Convert remaining attributes that have values to Strings
-            for (Entry <String, Object> attr : ticketAttrs.entrySet()) {
-                String tokenName = TokenName.canonicalize(attr.getKey(),
-                        CAS_ATTRIBUTE_TOKEN_PREFIX);
-                Object value = attr.getValue();
-                if (value != null)
-                    tokens.put(tokenName, value.toString());
-            }
-
-            return tokens;
-
-        } 
+            assertion = validator.validate(ticket, confRedirectURI.toString());
+        }
         catch (TicketValidationException e) {
             throw new GuacamoleException("Ticket validation failed.", e);
         }
 
+        // Pull user principal and associated attributes
+        AttributePrincipal principal =  assertion.getPrincipal();
+        Map<String, Object> ticketAttrs = new HashMap<>(principal.getAttributes());
+
+        // Retrieve user identity from principal
+        String username = principal.getName();
+        if (username == null)
+            throw new GuacamoleSecurityException("No username provided by CAS.");
+
+        // Update credentials with username provided by CAS for sake of
+        // ${GUAC_USERNAME} token
+        credentials.setUsername(username);
+
+        // Retrieve password, attempt decryption, and set credentials.
+        Object credObj = ticketAttrs.remove("credential");
+        if (credObj != null) {
+            String clearPass = decryptPassword(credObj.toString());
+            if (clearPass != null && !clearPass.isEmpty())
+                credentials.setPassword(clearPass);
+        }
+
+        Set<String> effectiveGroups;
+
+        // Parse effective groups from principal attributes if a specific
+        // group attribute has been configured
+        String groupAttribute = confService.getGroupAttribute();
+        if (groupAttribute != null) {
+            effectiveGroups = toStringSet(ticketAttrs.get(groupAttribute)).stream()
+                    .map(confService.getGroupParser()::parse)
+                    .collect(Collectors.toSet());
+        }
+
+        // Otherwise, assume no effective groups
+        else
+            effectiveGroups = Collections.emptySet();
+
+        // Convert remaining attributes that have values to Strings
+        Map<String, String> tokens = new HashMap<>(ticketAttrs.size());
+        ticketAttrs.forEach((key, value) -> {
+            if (value != null) {
+                String tokenName = TokenName.canonicalize(key, CAS_ATTRIBUTE_TOKEN_PREFIX);
+                tokens.put(tokenName, value.toString());
+            }
+        });
+
+        CASAuthenticatedUser authenticatedUser = authenticatedUserProvider.get();
+        authenticatedUser.init(username, credentials, tokens, effectiveGroups);
+        return authenticatedUser;
+
     }
 
     /**
diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/user/CASAuthenticatedUser.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/user/CASAuthenticatedUser.java
index 1b3a948..b79344e 100644
--- a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/user/CASAuthenticatedUser.java
+++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/user/CASAuthenticatedUser.java
@@ -22,6 +22,7 @@
 import com.google.inject.Inject;
 import java.util.Collections;
 import java.util.Map;
+import java.util.Set;
 import org.apache.guacamole.net.auth.AbstractAuthenticatedUser;
 import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.auth.Credentials;
@@ -51,6 +52,11 @@
     private Map<String, String> tokens;
 
     /**
+     * The unique identifiers of all user groups which this user is a member of.
+     */
+    private Set<String> effectiveGroups;
+
+    /**
      * Initializes this AuthenticatedUser using the given username and
      * credentials, and an empty map of parameter tokens.
      *
@@ -61,7 +67,7 @@
      *     The credentials provided when this user was authenticated.
      */
     public void init(String username, Credentials credentials) {
-        this.init(username, credentials, Collections.emptyMap());
+        this.init(username, credentials, Collections.emptyMap(), Collections.emptySet());
     }
     
     /**
@@ -79,9 +85,10 @@
      *     as tokens when connections are established with this user.
      */
     public void init(String username, Credentials credentials,
-            Map<String, String> tokens) {
+            Map<String, String> tokens, Set<String> effectiveGroups) {
         this.credentials = credentials;
         this.tokens = Collections.unmodifiableMap(tokens);
+        this.effectiveGroups = effectiveGroups;
         setIdentifier(username.toLowerCase());
     }
 
@@ -107,4 +114,9 @@
         return credentials;
     }
 
+    @Override
+    public Set<String> getEffectiveUserGroups() {
+        return effectiveGroups;
+    }
+
 }
diff --git a/extensions/guacamole-auth-cas/src/test/java/org/apache/guacamole/auth/cas/group/LDAPGroupParserTest.java b/extensions/guacamole-auth-cas/src/test/java/org/apache/guacamole/auth/cas/group/LDAPGroupParserTest.java
new file mode 100644
index 0000000..ffff0a7
--- /dev/null
+++ b/extensions/guacamole-auth-cas/src/test/java/org/apache/guacamole/auth/cas/group/LDAPGroupParserTest.java
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.guacamole.auth.cas.group;
+
+import javax.naming.InvalidNameException;
+import javax.naming.ldap.LdapName;
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test which confirms that the LDAPGroupParser implementation of GroupParser
+ * parses CAS groups correctly.
+ */
+public class LDAPGroupParserTest {
+
+    /**
+     * LdapName instance representing the LDAP DN: "dc=example,dc=net".
+     */
+    private final LdapName exampleBaseDn;
+
+    /**
+     * Creates a new LDAPGroupParserTest that verifies the functionality of
+     * LDAPGroupParser.
+     *
+     * @throws InvalidNameException
+     *     If the static string LDAP DN of any test instance of LdapName is
+     *     unexpectedly invalid.
+     */
+    public LDAPGroupParserTest() throws InvalidNameException {
+        exampleBaseDn = new LdapName("dc=example,dc=net");
+    }
+
+    /**
+     * Verifies that LDAPGroupParser correctly parses LDAP-based CAS groups
+     * when no restrictions are enforced on LDAP attributes or the base DN.
+     */
+    @Test
+    public void testParseRestrictNothing() {
+
+        GroupParser parser = new LDAPGroupParser(null, null);
+
+        // null input should be rejected as null
+        assertNull(parser.parse(null));
+
+        // Invalid DNs should be rejected as null
+        assertNull(parser.parse(""));
+        assertNull(parser.parse("foo"));
+
+        // Valid DNs should be accepted
+        assertEquals("bar", parser.parse("foo=bar"));
+        assertEquals("baz", parser.parse("CN=baz,dc=example,dc=com"));
+        assertEquals("baz", parser.parse("ou=baz,dc=example,dc=net"));
+        assertEquals("foo", parser.parse("ou=foo,cn=baz,dc=example,dc=net"));
+        assertEquals("foo", parser.parse("cn=foo,DC=example,dc=net"));
+        assertEquals("bar", parser.parse("CN=bar,OU=groups,dc=example,Dc=net"));
+
+    }
+
+    /**
+     * Verifies that LDAPGroupParser correctly parses LDAP-based CAS groups
+     * when restrictions are enforced on LDAP attributes only.
+     */
+    @Test
+    public void testParseRestrictAttribute() {
+
+        GroupParser parser = new LDAPGroupParser("cn", null);
+
+        // null input should be rejected as null
+        assertNull(parser.parse(null));
+
+        // Invalid DNs should be rejected as null
+        assertNull(parser.parse(""));
+        assertNull(parser.parse("foo"));
+
+        // Valid DNs not using the correct attribute should be rejected as null
+        assertNull(parser.parse("foo=bar"));
+        assertNull(parser.parse("ou=baz,dc=example,dc=com"));
+        assertNull(parser.parse("ou=foo,cn=baz,dc=example,dc=com"));
+
+        // Valid DNs using the correct attribute should be accepted
+        assertEquals("foo", parser.parse("cn=foo,DC=example,dc=net"));
+        assertEquals("bar", parser.parse("CN=bar,OU=groups,dc=example,Dc=net"));
+        assertEquals("baz", parser.parse("CN=baz,dc=example,dc=com"));
+
+    }
+
+    /**
+     * Verifies that LDAPGroupParser correctly parses LDAP-based CAS groups
+     * when restrictions are enforced on the LDAP base DN only.
+     */
+    @Test
+    public void testParseRestrictBaseDN() {
+
+        GroupParser parser = new LDAPGroupParser(null, exampleBaseDn);
+
+        // null input should be rejected as null
+        assertNull(parser.parse(null));
+
+        // Invalid DNs should be rejected as null
+        assertNull(parser.parse(""));
+        assertNull(parser.parse("foo"));
+
+        // Valid DNs outside the base DN should be rejected as null
+        assertNull(parser.parse("foo=bar"));
+        assertNull(parser.parse("CN=baz,dc=example,dc=com"));
+
+        // Valid DNs beneath the base DN should be accepted
+        assertEquals("foo", parser.parse("cn=foo,DC=example,dc=net"));
+        assertEquals("bar", parser.parse("CN=bar,OU=groups,dc=example,Dc=net"));
+        assertEquals("baz", parser.parse("ou=baz,dc=example,dc=net"));
+        assertEquals("foo", parser.parse("ou=foo,cn=baz,dc=example,dc=net"));
+
+    }
+
+    /**
+     * Verifies that LDAPGroupParser correctly parses LDAP-based CAS groups
+     * when restrictions are enforced on both LDAP attributes and the base DN.
+     */
+    @Test
+    public void testParseRestrictAll() {
+
+        GroupParser parser = new LDAPGroupParser("cn", exampleBaseDn);
+
+        // null input should be rejected as null
+        assertNull(parser.parse(null));
+
+        // Invalid DNs should be rejected as null
+        assertNull(parser.parse(""));
+        assertNull(parser.parse("foo"));
+
+        // Valid DNs outside the base DN should be rejected as null
+        assertNull(parser.parse("foo=bar"));
+        assertNull(parser.parse("CN=baz,dc=example,dc=com"));
+
+        // Valid DNs beneath the base DN but not using the correct attribute
+        // should be rejected as null
+        assertNull(parser.parse("ou=baz,dc=example,dc=net"));
+        assertNull(parser.parse("ou=foo,cn=baz,dc=example,dc=net"));
+
+        // Valid DNs beneath the base DN and using the correct attribute should
+        // be accepted
+        assertEquals("foo", parser.parse("cn=foo,DC=example,dc=net"));
+        assertEquals("bar", parser.parse("CN=bar,OU=groups,dc=example,Dc=net"));
+
+    }
+
+}
diff --git a/guacamole-docker/bin/start.sh b/guacamole-docker/bin/start.sh
index 5123a8a..e58ba9e 100755
--- a/guacamole-docker/bin/start.sh
+++ b/guacamole-docker/bin/start.sh
@@ -678,6 +678,10 @@
     set_property            "cas-authorization-endpoint"       "$CAS_AUTHORIZATION_ENDPOINT"
     set_property            "cas-redirect-uri"                 "$CAS_REDIRECT_URI"
     set_optional_property   "cas-clearpass-key"                "$CAS_CLEARPASS_KEY"
+    set_optional_property   "cas-group-attribute"              "$CAS_GROUP_ATTRIBUTE"
+    set_optional_property   "cas-group-format"                 "$CAS_GROUP_FORMAT"
+    set_optional_property   "cas-group-ldap-base-dn"           "$CAS_GROUP_LDAP_BASE_DN"
+    set_optional_property   "cas-group-ldap-attribute"         "$CAS_GROUP_LDAP_ATTRIBUTE"
 
     # Add required .jar files to GUACAMOLE_EXT
     ln -s /opt/guacamole/cas/guacamole-auth-*.jar   "$GUACAMOLE_EXT"