Merge 1.5.0 changes back to master.
Please enter a commit message to explain why this merge is necessary,
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/AuthenticationProviderService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/AuthenticationProviderService.java
index afdaf33..5fedd23 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/AuthenticationProviderService.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/AuthenticationProviderService.java
@@ -110,4 +110,34 @@
             UserContext context, AuthenticatedUser authenticatedUser,
             Credentials credentials) throws GuacamoleException;
 
+    /**
+     * Decorates a UserContext instance for the given already-authenticated user.
+     * If no decoration is required, the original UserContext will be returned.
+     *
+     * @param authenticationProvider
+     *     The AuthenticationProvider on behalf of which the UserContext is
+     *     being decorated.
+     *
+     * @param context
+     *     The UserContext to decorate.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser associated with the UserContext being decorated.
+     *
+     * @param credentials
+     *     The credentials most recently submitted by the user. These
+     *     credentials are not guaranteed to be the same as the credentials
+     *     already associated with the AuthenticatedUser.
+     *
+     * @return
+     *     A decorated UserContext instance for the user identified by the given
+     *     credentials, or the original user context if no decoration is required.
+     *
+     * @throws GuacamoleException
+     *     If the an error occurs during decoration of the UserContext.
+     */
+    public UserContext decorateUserContext(AuthenticationProvider authenticationProvider,
+            UserContext context, AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException;
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingConnection.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingConnection.java
new file mode 100644
index 0000000..f25620b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingConnection.java
@@ -0,0 +1,124 @@
+/*
+ * 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.jdbc;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordMapper;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordModel;
+import org.apache.guacamole.auth.jdbc.connection.ModeledConnectionRecord;
+import org.apache.guacamole.net.GuacamoleTunnel;
+import org.apache.guacamole.net.auth.Connection;
+import org.apache.guacamole.net.auth.DelegatingConnection;
+import org.apache.guacamole.net.auth.User;
+import org.apache.guacamole.protocol.GuacamoleClientInformation;
+
+/**
+ * Connection implementation that creates a history record when the connection
+ * is established, and returns a HistoryTrackingTunnel to automatically set the
+ * end date when the connection is closed.
+ */
+public class HistoryTrackingConnection extends DelegatingConnection {
+
+    /**
+     * The current Guacamole user.
+     */
+    private final User currentUser;
+
+    /**
+     * The remote host that the user connected from.
+     */
+    private final String remoteHost;
+
+    /**
+     * The connection record mapper to use when writing history entries for
+     * established connections.
+     */
+    private final ConnectionRecordMapper connectionRecordMapper;
+
+    /**
+     * Creates a new HistoryConnection that wraps the given connection,
+     * automatically creating a history record when the connection is
+     * established, and returning a HistoryTrackingTunnel to set the end
+     * date on the history entry when the connection is closed.
+     *
+     * @param currentUser
+     *     The current Guacamole user.
+     *
+     * @param remoteHost
+     *     The remote host that the user connected from.
+     *
+     * @param connection
+     *     The connection to wrap.
+     *
+     * @param connectionRecordMapper
+     *     The connection record mapper that will be used to write the connection history records.
+     */
+    public HistoryTrackingConnection(User currentUser, String remoteHost, Connection connection, ConnectionRecordMapper connectionRecordMapper) {
+        super(connection);
+
+        this.currentUser = currentUser;
+        this.remoteHost = remoteHost;
+        this.connectionRecordMapper = connectionRecordMapper;
+    }
+
+    @Override
+    public GuacamoleTunnel connect(GuacamoleClientInformation info,
+            Map<String, String> tokens) throws GuacamoleException {
+
+        // Create a connection record model, starting at the current date/time
+        ConnectionRecordModel connectionRecordModel = new ConnectionRecordModel();
+        connectionRecordModel.setStartDate(new Date());
+
+        // Set the user information
+        connectionRecordModel.setUsername(this.currentUser.getIdentifier());
+        connectionRecordModel.setRemoteHost(this.remoteHost);
+
+        // Set the connection information
+        connectionRecordModel.setConnectionName(this.getDelegateConnection().getName());
+
+        // Insert the connection history record to mark the start of this connection
+        connectionRecordMapper.insert(connectionRecordModel);
+
+        // Include history record UUID as token
+        ModeledConnectionRecord modeledRecord = new ModeledConnectionRecord(connectionRecordModel);
+        Map<String, String> updatedTokens = new HashMap<>(tokens);
+        updatedTokens.put("HISTORY_UUID", modeledRecord.getUUID().toString());
+
+        // Connect, and wrap the tunnel for return
+        GuacamoleTunnel tunnel = super.connect(info, updatedTokens);
+        return new HistoryTrackingTunnel(
+            tunnel, this.connectionRecordMapper, connectionRecordModel);
+    }
+
+    /**
+     * Get the Connection wrapped by this HistoryTrackingConnection.
+     *
+     * @return
+     *     The wrapped Connection.
+     */
+    public Connection getWrappedConnection() {
+        return getDelegateConnection();
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingConnectionDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingConnectionDirectory.java
new file mode 100644
index 0000000..ddc3afb
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingConnectionDirectory.java
@@ -0,0 +1,96 @@
+/*
+ * 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.jdbc;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordMapper;
+import org.apache.guacamole.net.auth.Connection;
+import org.apache.guacamole.net.auth.DecoratingDirectory;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.User;
+
+/**
+ * A connection directory that returns HistoryTrackingConnection-wrapped connections
+ * when queried.
+ */
+public class HistoryTrackingConnectionDirectory extends DecoratingDirectory<Connection> {
+
+    /**
+     * The connection record mapper to use when writing history entries for
+     * established connections.
+     */
+    private final ConnectionRecordMapper connectionRecordMapper;
+
+    /**
+     * The user that directory operations are being performed for.
+     */
+    private final User user;
+
+    /**
+     * The remote host that the user connected from.
+     */
+    private final String remoteHost;
+
+    /**
+     * Create a new history tracking connection directory. Any connection retrieved from this
+     * directory will be wrapped in a HistoryTrackingConnection, enabling connection history
+     * records to be written with the provided connection record mapper.
+     *
+     * @param directory
+     *     The connection directory to wrap.
+     *
+     * @param user
+     *     The user associated with the connection directory.
+     *
+     * @param remoteHost
+     *     The remote host that the user connected from.
+     *
+     * @param connectionRecordMapper
+     *     The connection record mapper that will be used to write the connection history records.
+     */
+    public HistoryTrackingConnectionDirectory(Directory<Connection> directory, User user, String remoteHost, ConnectionRecordMapper connectionRecordMapper) {
+        super(directory);
+
+        this.user = user;
+        this.remoteHost = remoteHost;
+        this.connectionRecordMapper = connectionRecordMapper;
+    }
+
+    @Override
+    protected Connection decorate(Connection connection) throws GuacamoleException {
+
+         // Wrap the connection in a history-tracking layer
+         return new HistoryTrackingConnection(
+            this.user, this.remoteHost, connection, this.connectionRecordMapper);
+    }
+
+    @Override
+    protected Connection undecorate(Connection connection) throws GuacamoleException {
+
+        // If the connection was wrapped, unwrap it
+        if (connection instanceof HistoryTrackingConnection) {
+            return ((HistoryTrackingConnection) connection).getWrappedConnection();
+        }
+
+        // Otherwise, return the unwrapped connection directly
+        return connection;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingTunnel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingTunnel.java
new file mode 100644
index 0000000..aeccc3a
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingTunnel.java
@@ -0,0 +1,81 @@
+/*
+ * 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.jdbc;
+
+import java.util.Date;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordMapper;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordModel;
+import org.apache.guacamole.net.DelegatingGuacamoleTunnel;
+import org.apache.guacamole.net.GuacamoleTunnel;
+
+/**
+ * Tunnel implementation which automatically writes an end date for the
+ * provided connection history record model using the provided connection
+ * history mapper, when the tunnel is closed.
+ */
+public class HistoryTrackingTunnel extends DelegatingGuacamoleTunnel {
+
+    /**
+     * The connection for which this tunnel was established.
+     */
+    private final ConnectionRecordMapper connectionRecordMapper;
+
+    /**
+     * The user for which this tunnel was established.
+     */
+    private final ConnectionRecordModel connectionRecordModel;
+
+    /**
+     * Creates a new HistoryTrackingTunnel that wraps the given tunnel,
+     * automatically setting the end date for the provided connection history records,
+     * using the provided connection history record mapper.
+     *
+     * @param tunnel
+     *     The tunnel to wrap.
+     *
+     * @param connectionRecordMapper
+     *     The mapper to use when writing connection history records.
+     *
+     * @param connectionRecordModel
+     *     The connection history record model representing the in-progress connection.
+     */
+    public HistoryTrackingTunnel(GuacamoleTunnel tunnel,
+            ConnectionRecordMapper connectionRecordMapper, ConnectionRecordModel connectionRecordModel) {
+
+        super(tunnel);
+
+        // Store the connection record mapper and model for history tracking
+        this.connectionRecordMapper = connectionRecordMapper;
+        this.connectionRecordModel = connectionRecordModel;
+    }
+
+    @Override
+    public void close() throws GuacamoleException {
+
+        // Set the end date to complete the connection history record
+        this.connectionRecordModel.setEndDate(new Date());
+        this.connectionRecordMapper.updateEndDate(this.connectionRecordModel);
+
+        super.close();
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingUserContext.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingUserContext.java
new file mode 100644
index 0000000..2c13a7e
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingUserContext.java
@@ -0,0 +1,75 @@
+/*
+ * 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.jdbc;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordMapper;
+import org.apache.guacamole.net.auth.Connection;
+import org.apache.guacamole.net.auth.DelegatingUserContext;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.UserContext;
+
+/**
+ * DelegatingUserContext implementation which writes connection history records
+ * when connections are established and closed.
+ */
+public class HistoryTrackingUserContext extends DelegatingUserContext {
+
+    /**
+     * The remote host that the user associated with the user context
+     * connected from.
+     */
+    private final String remoteHost;
+
+    /**
+     * The connection record mapper to use when writing history entries for
+     * established connections.
+     */
+    private final ConnectionRecordMapper connectionRecordMapper;
+
+    /**
+     * Creates a new HistoryTrackingUserContext which wraps the given
+     * UserContext, allowing for tracking of connection history external to
+     * this authentication provider.
+     *
+     * @param userContext
+     *     The UserContext to wrap.
+     *
+     * @param remoteHost
+     *     The host that the user associated with the given user context connected from.
+     *
+     * @param connectionRecordMapper
+     *     The mapper to use when writing connection history entries to the DB.
+     */
+    public HistoryTrackingUserContext(UserContext userContext, String remoteHost, ConnectionRecordMapper connectionRecordMapper) {
+        super(userContext);
+
+        this.remoteHost = remoteHost;
+        this.connectionRecordMapper = connectionRecordMapper;
+    }
+
+    @Override
+    public Directory<Connection> getConnectionDirectory() throws GuacamoleException {
+        return new HistoryTrackingConnectionDirectory(
+                super.getConnectionDirectory(), self(),
+                this.remoteHost, this.connectionRecordMapper);
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/InjectedAuthenticationProvider.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/InjectedAuthenticationProvider.java
index fddb204..8ae2eea 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/InjectedAuthenticationProvider.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/InjectedAuthenticationProvider.java
@@ -90,4 +90,12 @@
                 authenticatedUser, credentials);
     }
 
+    @Override
+    public UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return authProviderService.decorateUserContext(this, context,
+                authenticatedUser, credentials);
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderService.java
index 2f38ebe..d2576ec 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderService.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderService.java
@@ -22,6 +22,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordMapper;
 import org.apache.guacamole.auth.jdbc.security.PasswordPolicyService;
 import org.apache.guacamole.auth.jdbc.sharing.user.SharedAuthenticatedUser;
 import org.apache.guacamole.auth.jdbc.user.ModeledAuthenticatedUser;
@@ -68,6 +69,12 @@
     @Inject
     private Provider<ModeledUserContext> userContextProvider;
 
+    /**
+     * Mapper for writing connection history.
+     */
+    @Inject
+    private ConnectionRecordMapper connectionRecordMapper;
+
     @Override
     public AuthenticatedUser authenticateUser(AuthenticationProvider authenticationProvider,
             Credentials credentials) throws GuacamoleException {
@@ -99,7 +106,7 @@
         ModeledUser user = userService.retrieveUser(authenticationProvider, authenticatedUser);
         ModeledUserContext context = userContextProvider.get();
         if (user != null && !user.isDisabled()) {
-            
+
             // Enforce applicable account restrictions
             if (databaseRestrictionsApplicable) {
 
@@ -125,18 +132,18 @@
             }
 
         }
-        
+
         // If no user account is found, and database-specific account
         // restrictions do not apply, get a skeleton user.
         else if (!databaseRestrictionsApplicable) {
             user = userService.retrieveSkeletonUser(authenticationProvider, authenticatedUser);
-            
+
             // If auto account creation is enabled, add user to DB.
             if (environment.autoCreateAbsentAccounts()) {
                 ModeledUser createdUser = userService.createObject(new PrivilegedModeledAuthenticatedUser(user.getCurrentUser()), user);
                 user.setModel(createdUser.getModel());
             }
-            
+
         }
 
         // Veto authentication result only if database-specific account
@@ -144,7 +151,7 @@
         else
             throw new GuacamoleInvalidCredentialsException("Invalid login",
                     CredentialsInfo.USERNAME_PASSWORD);
-        
+
         // Initialize the UserContext with the user account and return it.
         context.init(user.getCurrentUser());
         context.recordUserLogin();
@@ -162,4 +169,18 @@
 
     }
 
+    @Override
+    public UserContext decorateUserContext(AuthenticationProvider authenticationProvider,
+            UserContext context, AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // Track connection history only for external connections, and only if enabled in the config
+        if (environment.trackExternalConnectionHistory() && context.getAuthenticationProvider() != authenticationProvider) {
+            return new HistoryTrackingUserContext(context, credentials.getRemoteHostname(), connectionRecordMapper);
+        }
+
+        return context;
+
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCEnvironment.java
index 1954444..763793d 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCEnvironment.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCEnvironment.java
@@ -30,7 +30,7 @@
  * intended for use within JDBC based authentication providers.
  */
 public abstract class JDBCEnvironment extends DelegatingEnvironment {
-    
+
     /**
      * Constructs a new JDBCEnvironment using an underlying LocalEnviroment to
      * read properties from the file system.
@@ -68,12 +68,12 @@
     public abstract int getAbsoluteMaxConnections() throws GuacamoleException;
 
     /**
-     * Returns the default maximum number of concurrent connections to allow to 
-     * any one connection, unless specified differently on an individual 
+     * Returns the default maximum number of concurrent connections to allow to
+     * any one connection, unless specified differently on an individual
      * connection. Zero denotes unlimited.
-     * 
+     *
      * @return
-     *     The default maximum allowable number of concurrent connections 
+     *     The default maximum allowable number of concurrent connections
      *     to any connection.
      *
      * @throws GuacamoleException
@@ -82,10 +82,10 @@
     public abstract int getDefaultMaxConnections() throws GuacamoleException;
 
     /**
-     * Returns the default maximum number of concurrent connections to allow to 
-     * any one connection group, unless specified differently on an individual 
+     * Returns the default maximum number of concurrent connections to allow to
+     * any one connection group, unless specified differently on an individual
      * connection group. Zero denotes unlimited.
-     * 
+     *
      * @return
      *     The default maximum allowable number of concurrent connections
      *     to any connection group.
@@ -95,12 +95,12 @@
      */
     public abstract int getDefaultMaxGroupConnections()
             throws GuacamoleException;
-    
+
     /**
-     * Returns the default maximum number of concurrent connections to allow to 
+     * Returns the default maximum number of concurrent connections to allow to
      * any one connection by an individual user, unless specified differently on
      * an individual connection. Zero denotes unlimited.
-     * 
+     *
      * @return
      *     The default maximum allowable number of concurrent connections to
      *     any connection by an individual user.
@@ -110,12 +110,12 @@
      */
     public abstract int getDefaultMaxConnectionsPerUser()
             throws GuacamoleException;
-    
+
     /**
-     * Returns the default maximum number of concurrent connections to allow to 
-     * any one connection group by an individual user, unless specified 
+     * Returns the default maximum number of concurrent connections to allow to
+     * any one connection group by an individual user, unless specified
      * differently on an individual connection group. Zero denotes unlimited.
-     * 
+     *
      * @return
      *     The default maximum allowable number of concurrent connections to
      *     any connection group by an individual user.
@@ -149,19 +149,19 @@
      *     true if the database supports recursive queries, false otherwise.
      */
     public abstract boolean isRecursiveQuerySupported(SqlSession session);
-    
+
     /**
      * Returns a boolean value representing whether or not the JDBC module
      * should automatically create accounts within the database for users that
      * are successfully authenticated via other extensions. Returns true if
      * accounts should be auto-created, otherwise returns false.
-     * 
+     *
      * @return
      *     true if user accounts should be automatically created within the
      *     database when authentication succeeds from another extension;
      *     otherwise false.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If guacamole.properties cannot be parsed.
      */
     public abstract boolean autoCreateAbsentAccounts() throws GuacamoleException;
@@ -212,4 +212,19 @@
         }
     }
 
+    /**
+     * Returns a boolean value representing whether or not the JDBC module
+     * should automatically track connection history for external connections,
+     * i.e. connections not originated from within the JDBC auth provider
+     * itself.
+     *
+     * @return
+     *     true if connection history should be tracked for connections that
+     *     do not originate from within this JDBC auth provider, false otherwise.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public abstract boolean trackExternalConnectionHistory() throws GuacamoleException;
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharing/SharedAuthenticationProviderService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharing/SharedAuthenticationProviderService.java
index 40df1e3..8cd6edc 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharing/SharedAuthenticationProviderService.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharing/SharedAuthenticationProviderService.java
@@ -109,4 +109,13 @@
 
     }
 
+    @Override
+    public UserContext decorateUserContext(AuthenticationProvider authenticationProvider,
+            UserContext context, AuthenticatedUser authenticatedUser,
+            Credentials credentials) {
+
+        // There's no need to decorate the user context here
+        return context;
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLEnvironment.java
index ee3fd8a..d8344ec 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLEnvironment.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLEnvironment.java
@@ -99,7 +99,7 @@
      * allowed to any one connection group.
      */
     private final int DEFAULT_MAX_GROUP_CONNECTIONS = 0;
-    
+
     /**
      * The default SSL mode for connecting to MySQL servers.
      */
@@ -108,8 +108,8 @@
     /**
      * Constructs a new MySQLEnvironment, providing access to MySQL-specific
      * configuration options.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs while setting up the underlying JDBCEnvironment
      *     or while parsing legacy MySQL configuration options.
      */
@@ -177,12 +177,12 @@
      * database server hosting the Guacamole database. If unspecified, the
      * installed MySQL driver will be automatically detected by inspecting the
      * classes available in the classpath.
-     * 
+     *
      * @return
      *     The MySQL driver that will be used to communicate with the MySQL-
      *     compatible server.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If guacamole.properties cannot be parsed, or if no MySQL-compatible
      *     JDBC driver is present.
      */
@@ -210,15 +210,15 @@
         throw new GuacamoleServerException("No JDBC driver for MySQL/MariaDB is installed.");
 
     }
-    
+
     /**
      * Returns the hostname of the MySQL server hosting the Guacamole
      * authentication tables. If unspecified, this will be "localhost".
-     * 
+     *
      * @return
      *     The URL of the MySQL server.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public String getMySQLHostname() throws GuacamoleException {
@@ -227,30 +227,30 @@
             DEFAULT_HOSTNAME
         );
     }
-    
+
     /**
      * Returns the port number of the MySQL server hosting the Guacamole
      * authentication tables. If unspecified, this will be the default MySQL
      * port of 3306.
-     * 
+     *
      * @return
      *     The port number of the MySQL server.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public int getMySQLPort() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.MYSQL_PORT, DEFAULT_PORT);
     }
-    
+
     /**
-     * Returns the name of the MySQL database containing the Guacamole 
+     * Returns the name of the MySQL database containing the Guacamole
      * authentication tables.
-     * 
+     *
      * @return
      *     The name of the MySQL database.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value, or if the
      *     value was not set, as this property is required.
      */
@@ -262,7 +262,7 @@
     public String getUsername() throws GuacamoleException {
         return getRequiredProperty(MySQLGuacamoleProperties.MYSQL_USERNAME);
     }
-    
+
     @Override
     public String getPassword() throws GuacamoleException {
         return getRequiredProperty(MySQLGuacamoleProperties.MYSQL_PASSWORD);
@@ -303,15 +303,15 @@
         }
 
     }
-    
+
     /**
      * Return the MySQL SSL mode as configured in guacamole.properties, or the
      * default value of PREFERRED if not configured.
-     * 
+     *
      * @return
      *     The SSL mode to use when connecting to the MySQL server.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs retrieving the property value.
      */
     public MySQLSSLMode getMySQLSSLMode() throws GuacamoleException {
@@ -319,71 +319,71 @@
                 MySQLGuacamoleProperties.MYSQL_SSL_MODE,
                 DEFAULT_SSL_MODE);
     }
-    
+
     /**
      * Returns the File where the trusted certificate store is located as
      * configured in guacamole.properties, or null if no value has been
      * configured.  The trusted certificate store is used to validate server
      * certificates when making SSL connections to MySQL servers.
-     * 
+     *
      * @return
      *     The File where the trusted certificate store is located, or null
      *     if the value has not been configured.
-     * 
+     *
      * @throws GuacamoleException
      *     If guacamole.properties cannot be parsed.
      */
     public File getMySQLSSLTrustStore() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.MYSQL_SSL_TRUST_STORE);
     }
-    
+
     /**
      * Returns the password used to access the trusted certificate store as
      * configured in guacamole.properties, or null if no password has been
      * specified.
-     * 
+     *
      * @return
      *     The password used to access the trusted certificate store.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If guacamole.properties cannot be parsed.
      */
     public String getMySQLSSLTrustPassword() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.MYSQL_SSL_TRUST_PASSWORD);
     }
-    
+
     /**
      * Returns the File used to store the client SSL certificate as configured
      * in guacamole.properties, or null if no value has been specified.  This
      * file will be used to load the client certificate used for SSL connections
      * to MySQL servers, if the SSL connection is so configured to require
      * client certificate authentication.
-     * 
+     *
      * @return
      *     The File where the client SSL certificate is stored.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If guacamole.properties cannot be parsed.
      */
     public File getMySQLSSLClientStore() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.MYSQL_SSL_CLIENT_STORE);
     }
-    
+
     /**
      * Returns the password used to access the client certificate store as
      * configured in guacamole.properties, or null if no value has been
      * specified.
-     * 
+     *
      * @return
      *     The password used to access the client SSL certificate store.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If guacamole.properties cannot be parsed.
      */
     public String getMYSQLSSLClientPassword() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.MYSQL_SSL_CLIENT_PASSWORD);
     }
-    
+
     @Override
     public boolean autoCreateAbsentAccounts() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.MYSQL_AUTO_CREATE_ACCOUNTS,
@@ -393,15 +393,23 @@
     /**
      * Return the server timezone if configured in guacamole.properties, or
      * null if the configuration option is not present.
-     * 
+     *
      * @return
      *     The server timezone as configured in guacamole.properties.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs retrieving the configuration value.
      */
     public TimeZone getServerTimeZone() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.SERVER_TIMEZONE);
     }
 
+    @Override
+    public boolean trackExternalConnectionHistory() throws GuacamoleException {
+
+        // Track external connection history unless explicitly disabled
+        return getProperty(MySQLGuacamoleProperties.MYSQL_TRACK_EXTERNAL_CONNECTION_HISTORY,
+                true);
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLGuacamoleProperties.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLGuacamoleProperties.java
index 925f82a..6a5944f 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLGuacamoleProperties.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLGuacamoleProperties.java
@@ -35,7 +35,7 @@
      * This class should not be instantiated.
      */
     private MySQLGuacamoleProperties() {}
-    
+
     /**
      * The JDBC driver that should be used to talk to MySQL-compatible servers.
      */
@@ -48,7 +48,7 @@
     };
 
     /**
-     * The hostname of the MySQL server hosting the Guacamole authentication 
+     * The hostname of the MySQL server hosting the Guacamole authentication
      * tables.
      */
     public static final StringGuacamoleProperty MYSQL_HOSTNAME = new StringGuacamoleProperty() {
@@ -59,7 +59,7 @@
     };
 
     /**
-     * The port number of the MySQL server hosting the Guacamole authentication 
+     * The port number of the MySQL server hosting the Guacamole authentication
      * tables.
      */
     public static final IntegerGuacamoleProperty MYSQL_PORT = new IntegerGuacamoleProperty() {
@@ -70,7 +70,7 @@
     };
 
     /**
-     * The name of the MySQL database containing the Guacamole authentication 
+     * The name of the MySQL database containing the Guacamole authentication
      * tables.
      */
     public static final StringGuacamoleProperty MYSQL_DATABASE = new StringGuacamoleProperty() {
@@ -179,19 +179,19 @@
         public String getName() { return "mysql-default-max-group-connections-per-user"; }
 
     };
-    
+
     /**
      * The SSL mode used to connect to the MySQL Server.  By default the driver
      * will attempt SSL connections and fall back to plain-text if SSL fails.
      */
     public static final EnumGuacamoleProperty<MySQLSSLMode> MYSQL_SSL_MODE =
             new EnumGuacamoleProperty<MySQLSSLMode>(MySQLSSLMode.class) {
-        
+
         @Override
         public String getName() { return "mysql-ssl-mode" ; }
-        
+
     };
-    
+
     /**
      * The File where trusted SSL certificate authorities and server certificates
      * are stored.  By default no file is specified, and the default Java
@@ -199,24 +199,24 @@
      */
     public static final FileGuacamoleProperty MYSQL_SSL_TRUST_STORE =
             new FileGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "mysql-ssl-trust-store"; }
-        
+
     };
-    
+
     /**
      * The password to use to access the mysql-ssl-trust-store, if required.  By
      * default no password will be used to attempt to access the store.
      */
     public static final StringGuacamoleProperty MYSQL_SSL_TRUST_PASSWORD =
             new StringGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "mysql-ssl-trust-password"; }
-        
+
     };
-    
+
     /**
      * The File used to store the client certificate for configurations where
      * a client certificate is required for authentication.  By default no
@@ -224,24 +224,24 @@
      */
     public static final FileGuacamoleProperty MYSQL_SSL_CLIENT_STORE =
             new FileGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "mysql-ssl-client-store"; }
-        
+
     };
-    
+
     /**
      * The password to use to access the mysql-ssl-client-store file.  By
      * default no password will be used to attempt to access the file.
      */
     public static final StringGuacamoleProperty MYSQL_SSL_CLIENT_PASSWORD =
             new StringGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "mysql-ssl-client-password"; }
-        
+
     };
-    
+
     /**
      * Whether or not to automatically create accounts in the MySQL database for
      * users who successfully authenticate through another extension. By default
@@ -249,7 +249,7 @@
      */
     public static final BooleanGuacamoleProperty MYSQL_AUTO_CREATE_ACCOUNTS =
             new BooleanGuacamoleProperty() {
-    
+
         @Override
         public String getName() { return "mysql-auto-create-accounts"; }
     };
@@ -259,10 +259,23 @@
      */
     public static final TimeZoneGuacamoleProperty SERVER_TIMEZONE =
             new TimeZoneGuacamoleProperty() {
-                
+
         @Override
         public String getName() { return "mysql-server-timezone"; }
-                
+
+    };
+
+    /**
+     * Whether or not to track connection history for connections that do not originate
+     * from within the MySQL database. By default, external connection history will be
+     * tracked.
+     */
+    public static final BooleanGuacamoleProperty MYSQL_TRACK_EXTERNAL_CONNECTION_HISTORY =
+            new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-track-external-connection-history"; }
+
     };
 
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLEnvironment.java
index 2ead15b..ac08e0a 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLEnvironment.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLEnvironment.java
@@ -110,7 +110,7 @@
      * the values that should be used in the absence of the correct properties.
      */
     private final int DEFAULT_MAX_GROUP_CONNECTIONS = 0;
-    
+
     /**
      * The default value to use for SSL mode if none is explicitly configured.
      */
@@ -119,8 +119,8 @@
     /**
      * Constructs a new PostgreSQLEnvironment, providing access to PostgreSQL-specific
      * configuration options.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs while setting up the underlying JDBCEnvironment
      *     or while parsing legacy PostgreSQL configuration options.
      */
@@ -186,11 +186,11 @@
     /**
      * Returns the hostname of the PostgreSQL server hosting the Guacamole
      * authentication tables. If unspecified, this will be "localhost".
-     * 
+     *
      * @return
      *     The URL of the PostgreSQL server.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public String getPostgreSQLHostname() throws GuacamoleException {
@@ -199,16 +199,16 @@
             DEFAULT_HOSTNAME
         );
     }
-    
+
     /**
      * Returns the port number of the PostgreSQL server hosting the Guacamole
      * authentication tables. If unspecified, this will be the default
      * PostgreSQL port of 5432.
-     * 
+     *
      * @return
      *     The port number of the PostgreSQL server.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public int getPostgreSQLPort() throws GuacamoleException {
@@ -217,15 +217,15 @@
             DEFAULT_PORT
         );
     }
-    
+
     /**
      * Returns the name of the PostgreSQL database containing the Guacamole
      * authentication tables.
-     * 
+     *
      * @return
      *     The name of the PostgreSQL database.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value, or if the
      *     value was not set, as this property is required.
      */
@@ -242,16 +242,16 @@
     public String getPassword() throws GuacamoleException {
         return getRequiredProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_PASSWORD);
     }
-    
+
     /**
      * Returns the defaultStatementTimeout set for PostgreSQL connections.
      * If unspecified, this will default to 0,
      * and should not be passed through to the backend.
-     * 
+     *
      * @return
      *     The statement timeout (in seconds)
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public int getPostgreSQLDefaultStatementTimeout() throws GuacamoleException {
@@ -260,15 +260,15 @@
             DEFAULT_STATEMENT_TIMEOUT
         );
     }
-    
+
     /**
      * Returns the socketTimeout property to set on PostgreSQL connections.
      * If unspecified, this will default to 0 (no timeout)
-     * 
+     *
      * @return
      *     The socketTimeout to use when waiting on read operations (in seconds)
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public int getPostgreSQLSocketTimeout() throws GuacamoleException {
@@ -282,85 +282,93 @@
     public boolean isRecursiveQuerySupported(SqlSession session) {
         return true; // All versions of PostgreSQL support recursive queries through CTEs
     }
-    
+
     /**
      * Get the SSL mode to use to make the JDBC connection to the PostgreSQL
      * server.  If unspecified this will default to PREFER, attempting SSL
      * and falling back to plain-text if SSL fails.
-     * 
+     *
      * @return
      *     The enum value of the SSL mode to use to make the JDBC connection
      *     to the server.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs retrieving the value from guacamole.properties.
      */
     public PostgreSQLSSLMode getPostgreSQLSSLMode() throws GuacamoleException {
         return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_SSL_MODE,
                 DEFAULT_SSL_MODE);
     }
-    
+
     /**
      * Return the SSL client certificate file to use to make the connection
      * to the PostgreSQL server.
-     * 
+     *
      * @return
      *     The SSL client certificate file to use for the PostgreSQL connection.
-     * 
+     *
      * @throws GuacamoleException
      *     If an error occurs retrieving the value from guacamole.properties.
      */
     public File getPostgreSQLSSLClientCertFile() throws GuacamoleException {
         return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_SSL_CERT_FILE);
     }
-    
+
     /**
      * Return the SSL client private key file to use to make the connection to the
      * PostgreSQL server.
-     * 
+     *
      * @return
      *     The SSL client private key file to use for the PostgreSQL connection.
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs retrieving the value from guacamole.properties.
      */
     public File getPostgreSQLSSLClientKeyFile() throws GuacamoleException {
         return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_SSL_KEY_FILE);
     }
-    
+
     /**
      * Return the SSL client root certificate file to use to make the connection
      * to the PostgreSQL server.
-     * 
+     *
      * @return
      *     The SSL client root certificate file to use to make the connection
      *     to the PostgreSQL server.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs retrieving the value from guacamole.properties.
      */
     public File getPostgreSQLSSLClientRootCertFile() throws GuacamoleException {
         return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_SSL_ROOT_CERT_FILE);
     }
-    
+
     /**
      * Return the password to use to decrypt the private SSL key file when making
      * the connection to the PostgreSQL server.
-     * 
+     *
      * @return
      *     The password to use to decrypt the private SSL key file when making
      *     the connection to the PostgreSQL server.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs retrieving the value from guacamole.properties.
      */
     public String getPostgreSQLSSLClientKeyPassword() throws GuacamoleException {
         return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_SSL_KEY_PASSWORD);
     }
-    
+
     @Override
     public boolean autoCreateAbsentAccounts() throws GuacamoleException {
         return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_AUTO_CREATE_ACCOUNTS,
                 false);
     }
-    
+
+    @Override
+    public boolean trackExternalConnectionHistory() throws GuacamoleException {
+
+        // Track external connection history unless explicitly disabled
+        return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_TRACK_EXTERNAL_CONNECTION_HISTORY,
+                true);
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLGuacamoleProperties.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLGuacamoleProperties.java
index 271d9c0..dfa00b6 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLGuacamoleProperties.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLGuacamoleProperties.java
@@ -111,7 +111,7 @@
      * The number of seconds to wait for socket read operations.
      * If reading from the server takes longer than this value, the
      * connection will be closed. This can be used to handle network problems
-     * such as a dropped connection to the database. Similar to 
+     * such as a dropped connection to the database. Similar to
      * postgresql-default-statement-timeout, it will have the effect of
      * aborting queries that take too long.
      * A value of 0 (the default) means the timeout is disabled.
@@ -202,7 +202,7 @@
         public String getName() { return "postgresql-default-max-group-connections-per-user"; }
 
     };
-    
+
     /**
      * The SSL mode that should be used by the JDBC driver when making
      * connections to the remote server.  By default SSL will be attempted but
@@ -210,60 +210,60 @@
      */
     public static final EnumGuacamoleProperty<PostgreSQLSSLMode> POSTGRESQL_SSL_MODE =
             new EnumGuacamoleProperty<PostgreSQLSSLMode>(PostgreSQLSSLMode.class) {
-        
+
         @Override
         public String getName() { return "postgresql-ssl-mode"; }
-        
+
     };
-    
+
     /**
      * The client SSL certificate file used by the JDBC driver to make the
      * SSL connection.
      */
     public static final FileGuacamoleProperty POSTGRESQL_SSL_CERT_FILE =
             new FileGuacamoleProperty() {
-             
+
         @Override
         public String getName() { return "postgresql-ssl-cert-file"; }
-                
+
     };
-    
+
     /**
      * The client SSL private key file used by the JDBC driver to make the
      * SSL connection.
      */
     public static final FileGuacamoleProperty POSTGRESQL_SSL_KEY_FILE =
             new FileGuacamoleProperty() {
-    
+
         @Override
         public String getName() { return "postgresql-ssl-key-file"; }
-        
+
     };
-    
+
     /**
      * The client SSL root certificate file used by the JDBC driver to validate
      * certificates when making the SSL connection.
      */
     public static final FileGuacamoleProperty POSTGRESQL_SSL_ROOT_CERT_FILE =
             new FileGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "postgresql-ssl-root-cert-file"; }
-        
+
     };
-    
+
     /**
      * The password of the SSL private key used by the JDBC driver to make
      * the SSL connection to the PostgreSQL server.
      */
     public static final StringGuacamoleProperty POSTGRESQL_SSL_KEY_PASSWORD =
             new StringGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "postgresql-ssl-key-password"; }
-        
+
     };
-    
+
     /**
      * Whether or not to automatically create accounts in the PostgreSQL
      * database for users who successfully authenticate through another
@@ -271,10 +271,23 @@
      */
     public static final BooleanGuacamoleProperty POSTGRESQL_AUTO_CREATE_ACCOUNTS =
             new BooleanGuacamoleProperty() {
-                
+
         @Override
         public String getName() { return "postgresql-auto-create-accounts"; }
-                
+
     };
-    
+
+    /**
+     * Whether or not to track connection history for connections that do not originate
+     * from within the Postgres database. By default, external connection history will be
+     * tracked.
+     */
+    public static final BooleanGuacamoleProperty POSTGRESQL_TRACK_EXTERNAL_CONNECTION_HISTORY =
+            new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-track-external-connection-history"; }
+
+    };
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerEnvironment.java
index 0e15543..0b69836 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerEnvironment.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerEnvironment.java
@@ -91,8 +91,8 @@
     /**
      * Constructs a new SQLServerEnvironment, providing access to SQLServer-specific
      * configuration options.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs while setting up the underlying JDBCEnvironment
      *     or while parsing legacy SQLServer configuration options.
      */
@@ -158,11 +158,11 @@
     /**
      * Returns the hostname of the SQLServer server hosting the Guacamole
      * authentication tables. If unspecified, this will be "localhost".
-     * 
+     *
      * @return
      *     The URL of the SQLServer server.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public String getSQLServerHostname() throws GuacamoleException {
@@ -171,15 +171,15 @@
             DEFAULT_HOSTNAME
         );
     }
-    
+
     /**
      * Returns the instance name of the SQL Server installation hosting the
      * Guacamole database, if any.  If unspecified it will be null.
-     * 
+     *
      * @return
      *     The instance name of the SQL Server install hosting the Guacamole
      *     database, or null if undefined.
-     * 
+     *
      * @throws GuacamoleException
      *     If an error occurs reading guacamole.properties.
      */
@@ -188,16 +188,16 @@
             SQLServerGuacamoleProperties.SQLSERVER_INSTANCE
         );
     }
-    
+
     /**
      * Returns the port number of the SQLServer server hosting the Guacamole
      * authentication tables. If unspecified, this will be the default
      * SQLServer port of 5432.
-     * 
+     *
      * @return
      *     The port number of the SQLServer server.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public int getSQLServerPort() throws GuacamoleException {
@@ -206,15 +206,15 @@
             DEFAULT_PORT
         );
     }
-    
+
     /**
      * Returns the name of the SQLServer database containing the Guacamole
      * authentication tables.
-     * 
+     *
      * @return
      *     The name of the SQLServer database.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value, or if the
      *     value was not set, as this property is required.
      */
@@ -226,7 +226,7 @@
     public String getUsername() throws GuacamoleException {
         return getRequiredProperty(SQLServerGuacamoleProperties.SQLSERVER_USERNAME);
     }
-    
+
     @Override
     public String getPassword() throws GuacamoleException {
         return getRequiredProperty(SQLServerGuacamoleProperties.SQLSERVER_PASSWORD);
@@ -253,11 +253,19 @@
     public boolean isRecursiveQuerySupported(SqlSession session) {
         return true; // All versions of SQL Server support recursive queries through CTEs
     }
-    
+
     @Override
     public boolean autoCreateAbsentAccounts() throws GuacamoleException {
         return getProperty(SQLServerGuacamoleProperties.SQLSERVER_AUTO_CREATE_ACCOUNTS,
                 false);
     }
 
+    @Override
+    public boolean trackExternalConnectionHistory() throws GuacamoleException {
+
+        // Track external connection history unless explicitly disabled
+        return getProperty(SQLServerGuacamoleProperties.SQLSERVER_TRACK_EXTERNAL_CONNECTION_HISTORY,
+                true);
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerGuacamoleProperties.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerGuacamoleProperties.java
index df63c53..432454e 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerGuacamoleProperties.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerGuacamoleProperties.java
@@ -44,7 +44,7 @@
         public String getName() { return "sqlserver-hostname"; }
 
     };
-    
+
     /**
      * The instance name of the SQL Server where the Guacamole database is running.
      */
@@ -53,7 +53,7 @@
 
         @Override
         public String getName() { return "sqlserver-instance"; }
-                
+
     };
 
     /**
@@ -193,7 +193,7 @@
         public String getName() { return "sqlserver-driver"; }
 
     };
-    
+
     /**
      * Whether or not to automatically create accounts in the SQL Server
      * database for users who successfully authenticate through another
@@ -201,10 +201,23 @@
      */
     public static final BooleanGuacamoleProperty SQLSERVER_AUTO_CREATE_ACCOUNTS =
             new BooleanGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "sqlserver-auto-create-accounts"; }
-        
+
+    };
+
+    /**
+     * Whether or not to track connection history for connections that do not originate
+     * from within the SQL Server database. By default, external connection history will be
+     * tracked.
+     */
+    public static final BooleanGuacamoleProperty SQLSERVER_TRACK_EXTERNAL_CONNECTION_HISTORY =
+            new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "sqlserver-track-external-connection-history"; }
+
     };
 
 }
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/conf/ConfigurationService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/conf/ConfigurationService.java
index 0487737..95862bd 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/conf/ConfigurationService.java
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/conf/ConfigurationService.java
@@ -24,7 +24,12 @@
 import com.onelogin.saml2.settings.Saml2Settings;
 import com.onelogin.saml2.settings.SettingsBuilder;
 import com.onelogin.saml2.util.Constants;
+
+import java.io.File;
+import java.io.IOException;
 import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.util.HashMap;
 import java.util.Map;
 import javax.ws.rs.core.UriBuilder;
@@ -32,6 +37,7 @@
 import org.apache.guacamole.GuacamoleServerException;
 import org.apache.guacamole.environment.Environment;
 import org.apache.guacamole.properties.BooleanGuacamoleProperty;
+import org.apache.guacamole.properties.FileGuacamoleProperty;
 import org.apache.guacamole.properties.IntegerGuacamoleProperty;
 import org.apache.guacamole.properties.StringGuacamoleProperty;
 import org.apache.guacamole.properties.URIGuacamoleProperty;
@@ -162,6 +168,30 @@
     };
 
     /**
+     * The file containing the X.509 cert to use when signing or encrypting
+     * requests to the SAML IdP.
+     */
+    private static final FileGuacamoleProperty SAML_X509_CERT_PATH =
+            new FileGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "saml-x509-cert-path"; }
+
+    };
+
+    /**
+     * The file containing the private key to use when signing or encrypting
+     * requests to the SAML IdP.
+     */
+    private static final FileGuacamoleProperty SAML_PRIVATE_KEY_PATH =
+            new FileGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "saml-private-key-path"; }
+
+    };
+
+    /**
      * The Guacamole server environment.
      */
     @Inject
@@ -330,6 +360,70 @@
     }
 
     /**
+     * Returns the file containing the X.509 certificate to use when signing
+     * requests to the SAML IdP. If the property is not set, null will be
+     * returned.
+     *
+     * @return
+     *     The file containing the X.509 certificate to use when signing
+     *     requests to the SAML IdP, or null if not defined.
+     *
+     * @throws GuacamoleException
+     *     If the X.509 certificate cannot be parsed.
+     */
+    public File getCertificateFile() throws GuacamoleException {
+        return environment.getProperty(SAML_X509_CERT_PATH);
+    }
+
+    /**
+     * Returns the file containing the private key to use when signing
+     * requests to the SAML IdP. If the property is not set, null will be
+     * returned.
+     *
+     * @return
+     *     The file containing the private key to use when signing
+     *     requests to the SAML IdP, or null if not defined.
+     *
+     * @throws GuacamoleException
+     *     If the private key file cannot be parsed.
+     */
+    public File getPrivateKeyFile() throws GuacamoleException {
+        return environment.getProperty(SAML_PRIVATE_KEY_PATH);
+    }
+
+    /**
+     * Returns the contents of a small file, such as a private key or certificate into
+     * a String. If the file does not exist, or cannot be read for any reason, an exception
+     * will be thrown with the details of the failure.
+     *
+     * @param file
+     *     The file to read into a string.
+     *
+     * @param name
+     *     A human-readable name for the file, to be used when formatting log messages.
+     *
+     * @return
+     *     The contents of the file having the given path.
+     *
+     * @throws GuacamoleException
+     *     If the provided file does not exist, or cannot be read for any reason.
+     */
+    private String readFileContentsIntoString(File file, String name) throws GuacamoleException {
+
+        // Attempt to read the file directly into a String
+        try {
+            return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
+        }
+
+        // If the file cannot be read, log a warning and treat it as if it does not exist
+        catch (IOException e) {
+            throw new GuacamoleServerException(
+                    name + " at \"" + file.getAbsolutePath() + "\" could not be read.", e);
+        }
+
+    }
+
+    /**
      * Returns the collection of SAML settings used to initialize the client.
      *
      * @return
@@ -380,6 +474,18 @@
                     UriBuilder.fromUri(getCallbackUrl()).path("api/ext/saml/callback").build().toString());
         }
 
+        // If a private key file is set, load the value into the builder now
+        File privateKeyFile = getPrivateKeyFile();
+        if (privateKeyFile != null)
+            samlMap.put(SettingsBuilder.SP_PRIVATEKEY_PROPERTY_KEY,
+                    readFileContentsIntoString(privateKeyFile, "Private Key"));
+
+        // If a certificate file is set, load the value into the builder now
+        File certificateFile = getCertificateFile();
+        if (certificateFile != null)
+            samlMap.put(SettingsBuilder.SP_X509CERT_PROPERTY_KEY,
+                    readFileContentsIntoString(certificateFile, "X.509 Certificate"));
+
         SettingsBuilder samlBuilder = new SettingsBuilder();
         Saml2Settings samlSettings = samlBuilder.fromValues(samlMap).build();
         samlSettings.setStrict(getStrict());
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUser.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUser.java
index d50e3a6..0c6f567 100644
--- a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUser.java
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUser.java
@@ -45,17 +45,26 @@
     public static final String TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME = "guac-totp-key-confirmed";
     
     /**
-     * The name of the field used to trigger a reset of the TOTP data.
+     * The name of the user attribute defines whether the TOTP key has been
+     * generated for the user, regardless of whether that key has been
+     * confirmed. This attribute is not stored, but is instead exposed
+     * dynamically in lieu of exposing the actual TOTP key.
      */
-    public static final String TOTP_KEY_SECRET_RESET_FIELD = "guac-totp-reset";
+    public static final String TOTP_KEY_SECRET_GENERATED_ATTRIBUTE_NAME = "guac-totp-key-generated";
+
+    /**
+     * The string value used by TOTP user attributes to represent the boolean
+     * value "true".
+     */
+    private static final String TRUTH_VALUE = "true";
 
     /**
      * The form which contains all configurable properties for this user.
      */
-    public static final Form TOTP_CONFIG_FORM = new Form("totp-config-form",
+    public static final Form TOTP_ENROLLMENT_STATUS = new Form("totp-enrollment-status",
             Arrays.asList(
-                    new BooleanField(TOTP_KEY_SECRET_RESET_FIELD, "true"),
-                    new BooleanField(TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME, "true")
+                    new BooleanField(TOTP_KEY_SECRET_GENERATED_ATTRIBUTE_NAME, TRUTH_VALUE),
+                    new BooleanField(TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME, TRUTH_VALUE)
             )
     );
     
@@ -86,17 +95,12 @@
 
         // Create independent, mutable copy of attributes
         Map<String, String> attributes = new HashMap<>(super.getAttributes());
-        
-        // Protect the secret value by removing it
+
+        // Replace secret key with simple boolean attribute representing
+        // whether a key has been generated at all
         String secret = attributes.remove(TOTP_KEY_SECRET_ATTRIBUTE_NAME);
-        
-        // If secret is null or empty, mark the reset as true.
-        if (secret == null || secret.isEmpty())
-            attributes.put(TOTP_KEY_SECRET_RESET_FIELD, "true");
-            
-        // If secret has a value, mark the reset as false.
-        else
-            attributes.put(TOTP_KEY_SECRET_RESET_FIELD, "false");
+        if (secret != null && !secret.isEmpty())
+            attributes.put(TOTP_KEY_SECRET_GENERATED_ATTRIBUTE_NAME, TRUTH_VALUE);
 
         return attributes;
 
@@ -107,15 +111,13 @@
 
         // Create independent, mutable copy of attributes
         attributes = new HashMap<>(attributes);
-        
-        // Do not expose any TOTP secret attribute outside this extension
+
+        // Do not allow TOTP secret to be directly manipulated
         attributes.remove(TOTP_KEY_SECRET_ATTRIBUTE_NAME);
-        
-        // Pull off the boolean reset field
-        String reset = attributes.remove(TOTP_KEY_SECRET_RESET_FIELD);
-        
-        // If reset has been set to true, clear the secret.
-        if (reset != null && reset.equals("true")) {
+
+        // Reset TOTP status entirely if requested
+        String generated = attributes.remove(TOTP_KEY_SECRET_GENERATED_ATTRIBUTE_NAME);
+        if (generated != null && !generated.equals(TRUTH_VALUE)) {
             attributes.put(TOTP_KEY_SECRET_ATTRIBUTE_NAME, null);
             attributes.put(TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME, null);
         }
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUserContext.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUserContext.java
index f478519..a7a97b0 100644
--- a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUserContext.java
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUserContext.java
@@ -68,7 +68,7 @@
     @Override
     public Collection<Form> getUserAttributes() {
         Collection<Form> userAttrs = new HashSet<>(super.getUserAttributes());
-        userAttrs.add(TOTPUser.TOTP_CONFIG_FORM);
+        userAttrs.add(TOTPUser.TOTP_ENROLLMENT_STATUS);
         return Collections.unmodifiableCollection(userAttrs);
     }
 
diff --git a/extensions/guacamole-auth-totp/src/main/resources/translations/en.json b/extensions/guacamole-auth-totp/src/main/resources/translations/en.json
index 55bd69a..80bdaf0 100644
--- a/extensions/guacamole-auth-totp/src/main/resources/translations/en.json
+++ b/extensions/guacamole-auth-totp/src/main/resources/translations/en.json
@@ -33,10 +33,10 @@
     
     "USER_ATTRIBUTES" : {
         
-        "FIELD_HEADER_GUAC_TOTP_RESET"         : "Clear TOTP secret:",
-        "FIELD_HEADER_GUAC_TOTP_KEY_CONFIRMED" : "TOTP key confirmed:",
+        "FIELD_HEADER_GUAC_TOTP_KEY_GENERATED" : "Secret key generated:",
+        "FIELD_HEADER_GUAC_TOTP_KEY_CONFIRMED" : "Authentication device confirmed:",
         
-        "SECTION_HEADER_TOTP_CONFIG_FORM" : "Configure TOTP"
+        "SECTION_HEADER_TOTP_ENROLLMENT_STATUS" : "TOTP Enrollment Status"
         
     }
 
diff --git a/extensions/guacamole-display-statistics/.gitignore b/extensions/guacamole-display-statistics/.gitignore
new file mode 100644
index 0000000..e55f47f
--- /dev/null
+++ b/extensions/guacamole-display-statistics/.gitignore
@@ -0,0 +1 @@
+src/main/resources/generated/
diff --git a/extensions/guacamole-display-statistics/.ratignore b/extensions/guacamole-display-statistics/.ratignore
new file mode 100644
index 0000000..da318d1
--- /dev/null
+++ b/extensions/guacamole-display-statistics/.ratignore
@@ -0,0 +1 @@
+src/main/resources/html/*.html
diff --git a/extensions/guacamole-display-statistics/pom.xml b/extensions/guacamole-display-statistics/pom.xml
new file mode 100644
index 0000000..403d974
--- /dev/null
+++ b/extensions/guacamole-display-statistics/pom.xml
@@ -0,0 +1,124 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
+                        http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>org.apache.guacamole</groupId>
+    <artifactId>guacamole-display-statistics</artifactId>
+    <packaging>jar</packaging>
+    <version>1.4.0</version>
+    <name>guacamole-display-statistics</name>
+    <url>http://guacamole.apache.org/</url>
+
+    <parent>
+        <groupId>org.apache.guacamole</groupId>
+        <artifactId>extensions</artifactId>
+        <version>1.4.0</version>
+        <relativePath>../</relativePath>
+    </parent>
+
+    <build>
+        <plugins>
+
+            <!-- Pre-cache Angular templates with maven-angular-plugin -->
+            <plugin>
+                <groupId>com.keithbranton.mojo</groupId>
+                <artifactId>angular-maven-plugin</artifactId>
+                <version>0.3.4</version>
+                <executions>
+                    <execution>
+                        <phase>generate-resources</phase>
+                        <goals>
+                            <goal>html2js</goal>
+                        </goals>
+                    </execution>
+                </executions>
+                <configuration>
+                    <sourceDir>${basedir}/src/main/resources</sourceDir>
+                    <include>**/*.html</include>
+                    <target>${basedir}/src/main/resources/generated/templates-main/templates.js</target>
+                    <prefix>app/ext/display-stats</prefix>
+                </configuration>
+            </plugin>
+
+            <!-- JS/CSS Minification Plugin -->
+            <plugin>
+                <groupId>com.github.buckelieg</groupId>
+                <artifactId>minify-maven-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>default-cli</id>
+                        <configuration>
+                            <charset>UTF-8</charset>
+
+                            <webappSourceDir>${basedir}/src/main/resources</webappSourceDir>
+                            <webappTargetDir>${project.build.directory}/classes</webappTargetDir>
+
+                            <cssSourceDir>/</cssSourceDir>
+                            <cssTargetDir>/</cssTargetDir>
+                            <cssFinalFile>display-stats.css</cssFinalFile>
+
+                            <cssSourceFiles>
+                                <cssSourceFile>license.txt</cssSourceFile>
+                            </cssSourceFiles>
+
+                            <cssSourceIncludes>
+                                <cssSourceInclude>**/*.css</cssSourceInclude>
+                            </cssSourceIncludes>
+
+                            <jsSourceDir>/</jsSourceDir>
+                            <jsTargetDir>/</jsTargetDir>
+                            <jsFinalFile>display-stats.js</jsFinalFile>
+
+                            <jsSourceFiles>
+                                <jsSourceFile>license.txt</jsSourceFile>
+                            </jsSourceFiles>
+
+                            <jsSourceIncludes>
+                                <jsSourceInclude>**/*.js</jsSourceInclude>
+                            </jsSourceIncludes>
+
+                            <!-- Do not minify and include tests -->
+                            <jsSourceExcludes>
+                                <jsSourceExclude>**/*.test.js</jsSourceExclude>
+                            </jsSourceExcludes>
+                            <jsEngine>CLOSURE</jsEngine>
+
+                            <!-- Disable warnings for JSDoc annotations -->
+                            <closureWarningLevels>
+                                <misplacedTypeAnnotation>OFF</misplacedTypeAnnotation>
+                                <nonStandardJsDocs>OFF</nonStandardJsDocs>
+                            </closureWarningLevels>
+
+                        </configuration>
+                        <goals>
+                            <goal>minify</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
+        </plugins>
+    </build>
+
+</project>
diff --git a/extensions/guacamole-display-statistics/src/main/assembly/dist.xml b/extensions/guacamole-display-statistics/src/main/assembly/dist.xml
new file mode 100644
index 0000000..0b16a71
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/assembly/dist.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    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.
+-->
+<assembly
+    xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0 http://maven.apache.org/xsd/assembly-1.1.0.xsd">
+    
+    <id>dist</id>
+    <baseDirectory>${project.artifactId}-${project.version}</baseDirectory>
+
+    <!-- Output tar.gz -->
+    <formats>
+        <format>tar.gz</format>
+    </formats>
+
+    <!-- Include licenses and extension .jar -->
+    <fileSets>
+
+        <!-- Include licenses -->
+        <fileSet>
+            <outputDirectory></outputDirectory>
+            <directory>target/licenses</directory>
+        </fileSet>
+
+        <!-- Include extension .jar -->
+        <fileSet>
+            <directory>target</directory>
+            <outputDirectory></outputDirectory>
+            <includes>
+                <include>*.jar</include>
+            </includes>
+        </fileSet>
+
+    </fileSets>
+
+</assembly>
diff --git a/extensions/guacamole-display-statistics/src/main/resources/directives/guacClientStatistics.js b/extensions/guacamole-display-statistics/src/main/resources/directives/guacClientStatistics.js
new file mode 100644
index 0000000..4dc6b8a
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/directives/guacClientStatistics.js
@@ -0,0 +1,108 @@
+/*
+ * 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.
+ */
+
+/**
+ * A directive which displays frame rendering performance statistics for a
+ * Guacamole client.
+ */
+angular.module('client').directive('guacClientStatistics', [function guacClientStatistics() {
+
+    const directive = {
+        restrict: 'E',
+        templateUrl: 'app/ext/display-stats/templates/guacClientStatistics.html',
+    };
+
+    directive.scope = {
+
+        /**
+         * The Guacamole client to display frame rendering statistics for.
+         *
+         * @type ManagedClient
+         */
+        client : '='
+
+    };
+
+    directive.controller = ['$scope', function guacClientStatisticsController($scope) {
+
+        /**
+         * Updates the displayed frame rendering statistics to the values
+         * within the given statistics object.
+         *
+         * @param {!Guacamole.Display.Statistics} stats
+         *     An object containing general rendering performance statistics for
+         *     the remote desktop, Guacamole server, and Guacamole client.
+         */
+        var updateStatistics = function updateStatistics(stats) {
+            $scope.$apply(function statisticsChanged() {
+                $scope.statistics = stats;
+            });
+        };
+
+        /**
+         * Returns whether the given value is a defined value that should be
+         * rendered within the statistics toolbar.
+         *
+         * @param {number} value
+         *     The value to test.
+         *
+         * @returns {!boolean}
+         *     true if the given value should be rendered within the statistics
+         *     toolbar, false otherwise.
+         */
+        $scope.hasValue = function hasValue(value) {
+            return value || value === 0;
+        };
+
+        /**
+         * Rounds the given numeric value to the nearest hundredth (two decimal places).
+         *
+         * @param {!number} value
+         *     The value to round.
+         *
+         * @param {!number}
+         *     The given value, rounded to the nearest hundredth.
+         */
+        $scope.round = function round(value) {
+            return Math.round(value * 100) / 100;
+        };
+
+        // Assign/remove onstatistics handlers to track the statistics of the
+        // current client
+        $scope.$watch('client', function clientChanged(client, oldClient) {
+
+            if (oldClient)
+                oldClient.managedDisplay.display.onstatistics = null;
+
+            client.managedDisplay.display.statisticWindow = 1000;
+            client.managedDisplay.display.onstatistics = updateStatistics;
+
+        });
+
+        // Clear onstatistics handler when directive is being unloaded
+        $scope.$on('$destroy', function scopeDestroyed() {
+            if ($scope.client)
+                $scope.client.managedDisplay.display.onstatistics = null;
+        });
+
+    }];
+
+    return directive;
+
+}]);
diff --git a/extensions/guacamole-display-statistics/src/main/resources/guac-manifest.json b/extensions/guacamole-display-statistics/src/main/resources/guac-manifest.json
new file mode 100644
index 0000000..71c84b3
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/guac-manifest.json
@@ -0,0 +1,28 @@
+{
+
+    "guacamoleVersion" : "1.4.0",
+
+    "name"      : "Display Statistic Toolbar",
+    "namespace" : "display-stats",
+
+    "translations" : [
+        "translations/en.json"
+    ],
+
+    "js" : [
+        "display-stats.min.js"
+    ],
+
+    "css" : [
+        "display-stats.min.css"
+    ],
+
+    "html" : [
+        "html/add-statistics.html"
+    ],
+
+    "resources" : {
+        "templates/guacClientStatistics.html" : "text/html"
+    }
+
+}
diff --git a/extensions/guacamole-display-statistics/src/main/resources/html/add-statistics.html b/extensions/guacamole-display-statistics/src/main/resources/html/add-statistics.html
new file mode 100644
index 0000000..5978f7f
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/html/add-statistics.html
@@ -0,0 +1,4 @@
+<meta name="after" content=".client-tile guac-client">
+
+<!-- Guacamole display statistics -->
+<guac-client-statistics client="client"></guac-client-statistics>
diff --git a/extensions/guacamole-display-statistics/src/main/resources/license.txt b/extensions/guacamole-display-statistics/src/main/resources/license.txt
new file mode 100644
index 0000000..042f3ce
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/license.txt
@@ -0,0 +1,18 @@
+/*
+ * 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.
+ */
diff --git a/extensions/guacamole-display-statistics/src/main/resources/styles/clientStatistics.css b/extensions/guacamole-display-statistics/src/main/resources/styles/clientStatistics.css
new file mode 100644
index 0000000..ba46614
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/styles/clientStatistics.css
@@ -0,0 +1,57 @@
+/*
+ * 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.
+ */
+
+guac-client-statistics {
+    font-size: 13px;
+    color: white;
+    background: #111;
+}
+
+guac-client-statistics dl.client-statistics {
+    display: table;
+    margin: 0;
+    padding: 0.25em;
+}
+
+guac-client-statistics dl.client-statistics dt,
+guac-client-statistics dl.client-statistics dd {
+    display: table-cell;
+    padding: 0.25em;
+}
+
+guac-client-statistics dl.client-statistics dt {
+    padding-right: 0.5em;
+    padding-left: 1em;
+}
+
+guac-client-statistics dl.client-statistics dt:first-child {
+    padding-left: 0.5em;
+}
+
+guac-client-statistics dl.client-statistics dd {
+    min-width: 6em;
+    border: 1px solid rgba(255, 255, 255, 0.125);
+    border-radius: 3px;
+    background: black;
+}
+
+guac-client-statistics dl.client-statistics dd.no-value::before {
+    color: #888;
+    content: '-';
+}
diff --git a/extensions/guacamole-display-statistics/src/main/resources/templates/guacClientStatistics.html b/extensions/guacamole-display-statistics/src/main/resources/templates/guacClientStatistics.html
new file mode 100644
index 0000000..ae7537c
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/templates/guacClientStatistics.html
@@ -0,0 +1,39 @@
+<dl class="client-statistics">
+
+    <dt class="client-statistic desktop-fps">
+        {{ 'CLIENT.FIELD_HEADER_DESKTOP_FRAMERATE' | translate }}
+    </dt>
+    <dd ng-class="{ 'no-value' : !hasValue(statistics.desktopFps) }">
+        <span ng-show="hasValue(statistics.desktopFps)"
+            translate="CLIENT.INFO_FRAMERATE"
+            translate-values="{ VALUE : round(statistics.desktopFps) }"></span>
+    </dd>
+
+    <dt class="client-statistic server-fps">
+        {{ 'CLIENT.FIELD_HEADER_SERVER_FRAMERATE' | translate }}
+    </dt>
+    <dd ng-class="{ 'no-value' : !hasValue(statistics.serverFps) }">
+        <span ng-show="hasValue(statistics.serverFps)"
+            translate="CLIENT.INFO_FRAMERATE"
+            translate-values="{ VALUE : round(statistics.serverFps) }"></span>
+    </dd>
+
+    <dt class="client-statistic client-fps">
+        {{ 'CLIENT.FIELD_HEADER_CLIENT_FRAMERATE' | translate }}
+    </dt>
+    <dd ng-class="{ 'no-value' : !hasValue(statistics.clientFps) }">
+        <span ng-show="hasValue(statistics.clientFps)"
+            translate="CLIENT.INFO_FRAMERATE"
+            translate-values="{ VALUE : round(statistics.clientFps) }"></span>
+    </dd>
+
+    <dt class="client-statistic drop-rate">
+        {{ 'CLIENT.FIELD_HEADER_DROP_FRAMERATE' | translate }}
+    </dt>
+    <dd ng-class="{ 'no-value' : !hasValue(statistics.dropRate) }">
+        <span ng-show="hasValue(statistics.dropRate)"
+            translate="CLIENT.INFO_FRAMERATE"
+            translate-values="{ VALUE : round(statistics.dropRate) }"></span>
+    </dd>
+
+</dl>
\ No newline at end of file
diff --git a/extensions/guacamole-display-statistics/src/main/resources/translations/en.json b/extensions/guacamole-display-statistics/src/main/resources/translations/en.json
new file mode 100644
index 0000000..ef42908
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/translations/en.json
@@ -0,0 +1,12 @@
+{
+    "CLIENT" : {
+
+        "FIELD_HEADER_CLIENT_FRAMERATE"  : "Guacamole (Client):",
+        "FIELD_HEADER_DESKTOP_FRAMERATE" : "Remote Desktop:",
+        "FIELD_HEADER_DROP_FRAMERATE"    : "Drop:",
+        "FIELD_HEADER_SERVER_FRAMERATE"  : "Guacamole (Server):",
+
+        "INFO_FRAMERATE" : "{VALUE} fps"
+
+    }
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-base/pom.xml
index 96fe118..a8137b5 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-base/pom.xml
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/pom.xml
@@ -59,6 +59,13 @@
             <artifactId>jackson-dataformat-yaml</artifactId>
         </dependency>
 
+        <!-- JUnit -->
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <scope>test</scope>
+        </dependency>
+
         <!-- Guice -->
         <dependency>
             <groupId>com.google.inject</groupId>
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultAttributeService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultAttributeService.java
new file mode 100644
index 0000000..2bd08cd
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultAttributeService.java
@@ -0,0 +1,42 @@
+/*
+ * 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.vault.conf;
+
+import java.util.Collection;
+
+import org.apache.guacamole.form.Form;
+
+/**
+ * A service that exposes attributes for the admin UI, specific to the vault
+ * implementation. Any vault implementation will need to expose the attributes
+ * necessary for that implementation.
+ */
+public interface VaultAttributeService {
+
+    /**
+     * Return all custom connection group attributes to be exposed through the
+     * admin UI for the current vault implementation.
+     *
+     * @return
+     *     All custom connection group attributes to be exposed through the
+     *     admin UI for the current vault implementation.
+     */
+    public Collection<Form> getConnectionGroupAttributes();
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultConfigurationService.java
index a666a7b..490da68 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultConfigurationService.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultConfigurationService.java
@@ -55,7 +55,7 @@
 
     @Inject
     private VaultSecretService secretService;
-    
+
     /**
      * ObjectMapper for deserializing YAML.
      */
@@ -127,7 +127,7 @@
                 return Collections.emptyMap();
 
             return mapping;
-            
+
         }
 
         // Fail if YAML is invalid/unreadable
@@ -169,7 +169,7 @@
                     String secretName = super.getProperty(name);
                     if (secretName == null)
                         return null;
-                    
+
                     return secretService.getValue(secretName).get();
 
                 }
@@ -177,7 +177,7 @@
 
                     if (e.getCause() instanceof GuacamoleException)
                         throw (GuacamoleException) e;
-                    
+
                     throw new GuacamoleServerException(String.format("Property "
                             + "\"%s\" could not be retrieved from the vault.", name), e);
                 }
@@ -187,4 +187,23 @@
 
     }
 
+    /**
+     * Return whether Windows domains should be split out from usernames when
+     * fetched from the vault.
+     *
+     * For example: "DOMAIN\\user" or "user@DOMAIN" should both
+     * be split into seperate username and domain tokens if this configuration
+     * is true. If false, no domain token should be created and the above values
+     * should be stored directly in the username token.
+     *
+     * @return
+     *     true if windows domains should be split out from usernames, false
+     *     otherwise.
+     *
+     * @throws GuacamoleException
+     *     If the value specified within guacamole.properties cannot be
+     *     parsed.
+     */
+    public abstract boolean getSplitWindowsUsernames() throws GuacamoleException;
+
 }
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/VaultSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/VaultSecretService.java
index 76349ba..81204be 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/VaultSecretService.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/VaultSecretService.java
@@ -22,6 +22,8 @@
 import java.util.Map;
 import java.util.concurrent.Future;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.Connectable;
+import org.apache.guacamole.net.auth.UserContext;
 import org.apache.guacamole.protocol.GuacamoleConfiguration;
 import org.apache.guacamole.token.TokenFilter;
 
@@ -55,7 +57,9 @@
     /**
      * Returns a Future which eventually completes with the value of the secret
      * having the given name. If no such secret exists, the Future will be
-     * completed with null.
+     * completed with null. The secrets retrieved from this method are independent
+     * of the context of the particular connection being established, or any
+     * associated user context.
      *
      * @param name
      *     The name of the secret to retrieve.
@@ -73,6 +77,35 @@
     Future<String> getValue(String name) throws GuacamoleException;
 
     /**
+     * Returns a Future which eventually completes with the value of the secret
+     * having the given name. If no such secret exists, the Future will be
+     * completed with null. The connection or connection group, as well as the
+     * user context associated with the request are provided for additional context.
+     *
+     * @param userContext
+     *     The user context associated with the connection or connection group for
+     *     which the secret is being retrieved.
+     *
+     * @param connectable
+     *     The connection or connection group for which the secret is being retrieved.
+     *
+     * @param name
+     *     The name of the secret to retrieve.
+     *
+     * @return
+     *     A Future which completes with value of the secret having the given
+     *     name. If no such secret exists, the Future will be completed with
+     *     null. If an error occurs asynchronously which prevents retrieval of
+     *     the secret, that error will be exposed through an ExecutionException
+     *     when an attempt is made to retrieve the value from the Future.
+     *
+     * @throws GuacamoleException
+     *     If the secret cannot be retrieved due to an error.
+     */
+    Future<String> getValue(UserContext userContext, Connectable connectable,
+            String name) throws GuacamoleException;
+
+    /**
      * Returns a map of token names to corresponding Futures which eventually
      * complete with the value of that token, where each token is dynamically
      * defined based on connection parameters. If a vault implementation allows
@@ -80,6 +113,12 @@
      * function should be implemented to provide automatic tokens for those
      * secrets and remove the need for manual mapping via YAML.
      *
+     * @param userContext
+     *     The user context from which the connectable originated.
+     *
+     * @param connectable
+     *     The connection or connection group for which the tokens are being replaced.
+     *
      * @param config
      *     The configuration of the Guacamole connection for which tokens are
      *     being generated. This configuration may be empty or partial,
@@ -99,7 +138,7 @@
      *     If an error occurs producing the tokens and values required for the
      *     given configuration.
      */
-    Map<String, Future<String>> getTokens(GuacamoleConfiguration config,
-            TokenFilter filter) throws GuacamoleException;
+    Map<String, Future<String>> getTokens(UserContext userContext, Connectable connectable,
+            GuacamoleConfiguration config, TokenFilter filter) throws GuacamoleException;
 
 }
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/WindowsUsername.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/WindowsUsername.java
new file mode 100644
index 0000000..36d8a1b
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/WindowsUsername.java
@@ -0,0 +1,157 @@
+/*
+ * 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.vault.secret;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.annotation.Nonnull;
+
+/**
+ * A class representing a Windows username, which may optionally also include
+ * a domain. This class can be used to parse the username and domain out of a
+ * username from a vault.
+ */
+public class WindowsUsername {
+
+    /**
+     * A pattern for matching a down-level logon name containing a Windows
+     * domain and username - e.g. domain\\user. For more information, see
+     * https://docs.microsoft.com/en-us/windows/win32/secauthn/user-name-formats#down-level-logon-name
+     */
+    private static final Pattern DOWN_LEVEL_LOGON_NAME_PATTERN = Pattern.compile(
+            "(?<domain>[^@\\\\]+)\\\\(?<username>[^@\\\\]+)");
+
+    /**
+     * A pattern for matching a user principal name containing a Windows
+     * domain and username - e.g. user@domain. For more information, see
+     * https://docs.microsoft.com/en-us/windows/win32/secauthn/user-name-formats#user-principal-name
+     */
+    private static final  Pattern USER_PRINCIPAL_NAME_PATTERN = Pattern.compile(
+            "(?<username>[^@\\\\]+)@(?<domain>[^@\\\\]+)");
+
+    /**
+     * The username associated with the potential Windows domain/username
+     * value. If no domain is found, the username field will contain the
+     * entire value as read from the vault.
+     */
+    private final String username;
+
+    /**
+     * The dinaun associated with the potential Windows domain/username
+     * value. If no domain is found, this will be null.
+     */
+    private final String domain;
+
+    /**
+     * Create a WindowsUsername record with no associated domain.
+     *
+     * @param username
+     *     The username, which should be the entire value as extracted
+     *     from the vault.
+     */
+    private WindowsUsername(@Nonnull String username) {
+        this.username = username;
+        this.domain = null;
+    }
+
+    /**
+     * Create a WindowsUsername record with a username and a domain.
+     *
+     * @param username
+     *     The username portion of the field value from the vault.
+     *
+     * @param domain
+     *     The domain portion of the field value from the vault.
+     */
+    private WindowsUsername(
+            @Nonnull String username, @Nonnull String domain) {
+        this.username = username;
+        this.domain = domain;
+    }
+
+    /**
+     * Return the value of the username as extracted from the vault field.
+     * If the domain is null, this will be the entire field value.
+     *
+     * @return
+     *     The username value as extracted from the vault field.
+     */
+    public String getUsername() {
+        return username;
+    }
+
+    /**
+     * Return the value of the domain as extracted from the vault field.
+     * If this is null, it means that no domain was found in the vault field.
+     *
+     * @return
+     *     The domain value as extracted from the vault field.
+     */
+    public String getDomain() {
+        return domain;
+    }
+
+    /**
+     * Return true if a domain was found in the vault field, false otherwise.
+     *
+     * @return
+     *     true if a domain was found in the vault field, false otherwise.
+     */
+    public boolean hasDomain() {
+        return this.domain != null;
+    }
+
+    /**
+     * Strip off a Windows domain from the provided username, if one is
+     * present. For example: "DOMAIN\\user" or "user@DOMAIN" will both
+     * be stripped to just "user". Note: neither the '@' or '\\' characters
+     * are valid in Windows usernames.
+     *
+     * @param vaultField
+     *     The raw field value as retrieved from the vault. This might contain
+     *     a Windows domain.
+     *
+     * @return
+     *     The provided username with the Windows domain stripped off, if one
+     *     is present.
+     */
+    public static WindowsUsername splitWindowsUsernameFromDomain(String vaultField) {
+
+        // If it's the down-level logon format, return the extracted username and domain
+        Matcher downLevelLogonMatcher = DOWN_LEVEL_LOGON_NAME_PATTERN.matcher(vaultField);
+        if (downLevelLogonMatcher.matches())
+            return new WindowsUsername(
+                    downLevelLogonMatcher.group("username"),
+                    downLevelLogonMatcher.group("domain"));
+
+        // If it's the user principal format, return the extracted username and domain
+        Matcher userPrincipalMatcher = USER_PRINCIPAL_NAME_PATTERN.matcher(vaultField);
+        if (userPrincipalMatcher.matches())
+            return new WindowsUsername(
+                    userPrincipalMatcher.group("username"),
+                    userPrincipalMatcher.group("domain"));
+
+        // If none of the expected formats matched, return the username with do domain
+        return new WindowsUsername(vaultField);
+
+    }
+
+}
\ No newline at end of file
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultDirectoryService.java
new file mode 100644
index 0000000..700d9d3
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultDirectoryService.java
@@ -0,0 +1,140 @@
+/*
+ * 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.vault.user;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.ActiveConnection;
+import org.apache.guacamole.net.auth.Connection;
+import org.apache.guacamole.net.auth.ConnectionGroup;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.SharingProfile;
+import org.apache.guacamole.net.auth.User;
+import org.apache.guacamole.net.auth.UserGroup;
+
+/**
+ * A service that allows a vault implementation to override the directory
+ * for any entity that a user context may return.
+ */
+public abstract class VaultDirectoryService {
+
+    /**
+     * Given an existing User Directory, return a new Directory for
+     * this vault implementation.
+     *
+     * @return
+     *     A new User Directory based on the provided Directory.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    public Directory<User> getUserDirectory(
+            Directory<User> underlyingDirectory) throws GuacamoleException {
+
+        // By default, the provided directly will be returned unchanged
+        return underlyingDirectory;
+    }
+
+    /**
+     * Given an existing UserGroup Directory, return a new Directory for
+     * this vault implementation.
+     *
+     * @return
+     *     A new UserGroup Directory based on the provided Directory.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    public Directory<UserGroup> getUserGroupDirectory(
+            Directory<UserGroup> underlyingDirectory) throws GuacamoleException {
+
+        // Unless overriden in the vault implementation, the underlying directory
+        // will be returned directly
+        return underlyingDirectory;
+    }
+
+    /**
+     * Given an existing Connection Directory, return a new Directory for
+     * this vault implementation.
+     *
+     * @return
+     *     A new Connection Directory based on the provided Directory.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    public Directory<Connection> getConnectionDirectory(
+            Directory<Connection> underlyingDirectory) throws GuacamoleException {
+
+        // By default, the provided directly will be returned unchanged
+        return underlyingDirectory;
+    }
+
+    /**
+     * Given an existing ConnectionGroup Directory, return a new Directory for
+     * this vault implementation.
+     *
+     * @return
+     *     A new ConnectionGroup Directory based on the provided Directory.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    public Directory<ConnectionGroup> getConnectionGroupDirectory(
+            Directory<ConnectionGroup> underlyingDirectory) throws GuacamoleException {
+
+        // By default, the provided directly will be returned unchanged
+        return underlyingDirectory;
+    }
+
+    /**
+     * Given an existing ActiveConnection Directory, return a new Directory for
+     * this vault implementation.
+     *
+     * @return
+     *     A new ActiveConnection Directory based on the provided Directory.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    public Directory<ActiveConnection> getActiveConnectionDirectory(
+            Directory<ActiveConnection> underlyingDirectory) throws GuacamoleException {
+
+        // By default, the provided directly will be returned unchanged
+        return underlyingDirectory;
+    }
+
+    /**
+     * Given an existing SharingProfile Directory, return a new Directory for
+     * this vault implementation.
+     *
+     * @return
+     *     A new SharingProfile Directory based on the provided Directory.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    public Directory<SharingProfile> getSharingProfileDirectory(
+            Directory<SharingProfile> underlyingDirectory) throws GuacamoleException {
+
+        // By default, the provided directly will be returned unchanged
+        return underlyingDirectory;
+    }
+
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java
index 5390148..8e8f668 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java
@@ -22,19 +22,33 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
+
+import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleServerException;
+import org.apache.guacamole.form.Form;
+import org.apache.guacamole.net.auth.ActiveConnection;
+import org.apache.guacamole.net.auth.Connectable;
 import org.apache.guacamole.net.auth.Connection;
 import org.apache.guacamole.net.auth.ConnectionGroup;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.SharingProfile;
 import org.apache.guacamole.net.auth.TokenInjectingUserContext;
+import org.apache.guacamole.net.auth.User;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.auth.UserGroup;
 import org.apache.guacamole.protocol.GuacamoleConfiguration;
 import org.apache.guacamole.token.GuacamoleTokenUndefinedException;
 import org.apache.guacamole.token.TokenFilter;
+import org.apache.guacamole.vault.conf.VaultAttributeService;
 import org.apache.guacamole.vault.conf.VaultConfigurationService;
 import org.apache.guacamole.vault.secret.VaultSecretService;
 import org.slf4j.Logger;
@@ -122,6 +136,20 @@
     private VaultSecretService secretService;
 
     /**
+     * Service for retrieving any custom attributes defined for the
+     * current vault implementation.
+     */
+    @Inject
+    private VaultAttributeService attributeService;
+
+    /**
+     * Service for modifying any underlying directories for the current
+     * vault implementation.
+     */
+    @Inject
+    private VaultDirectoryService directoryService;
+
+    /**
      * Creates a new VaultUserContext which automatically injects tokens
      * containing values of secrets retrieved from a vault. The given
      * UserContext is decorated such that connections and connection groups
@@ -182,6 +210,10 @@
      * corresponding values from the vault, using the given TokenFilter to
      * filter tokens within the secret names prior to retrieving those secrets.
      *
+     * @param connectable
+     *     The connection or connection group to which the connection is being
+     *     established.
+     *
      * @param tokenMapping
      *     The mapping dictating the name of the secret which maps to each
      *     parameter token, where the key is the name of the parameter token
@@ -211,7 +243,8 @@
      *     If the value for any applicable secret cannot be retrieved from the
      *     vault due to an error.
      */
-    private Map<String, Future<String>> getTokens(Map<String, String> tokenMapping,
+    private Map<String, Future<String>> getTokens(
+            Connectable connectable, Map<String, String> tokenMapping,
             TokenFilter secretNameFilter, GuacamoleConfiguration config,
             TokenFilter configFilter) throws GuacamoleException {
 
@@ -236,14 +269,16 @@
 
             // Initiate asynchronous retrieval of the token value
             String tokenName = entry.getKey();
-            Future<String> secret = secretService.getValue(secretName);
+            Future<String> secret = secretService.getValue(
+                    this, connectable, secretName);
             pendingTokens.put(tokenName, secret);
 
         }
 
         // Additionally include any dynamic, parameter-based tokens
-        pendingTokens.putAll(secretService.getTokens(config, configFilter));
-        
+        pendingTokens.putAll(secretService.getTokens(
+                this, connectable, config, configFilter));
+
         return pendingTokens;
 
     }
@@ -318,7 +353,8 @@
 
         // Substitute tokens producing secret names, retrieving and storing
         // those secrets as parameter tokens
-        tokens.putAll(resolve(getTokens(confService.getTokenMapping(), filter,
+        tokens.putAll(resolve(getTokens(
+                connectionGroup, confService.getTokenMapping(), filter,
                 null, new TokenFilter(tokens))));
 
     }
@@ -398,9 +434,62 @@
 
         // Substitute tokens producing secret names, retrieving and storing
         // those secrets as parameter tokens
-        tokens.putAll(resolve(getTokens(confService.getTokenMapping(), filter,
-                config, new TokenFilter(tokens))));
+        tokens.putAll(resolve(getTokens(connection, confService.getTokenMapping(),
+                filter, config, new TokenFilter(tokens))));
 
     }
 
+    @Override
+    public Collection<Form> getConnectionGroupAttributes() {
+
+        // Add any custom attributes to any previously defined attributes
+        return Collections.unmodifiableCollection(Stream.concat(
+                super.getConnectionGroupAttributes().stream(),
+                attributeService.getConnectionGroupAttributes().stream()
+        ).collect(Collectors.toList()));
+
+    }
+
+    @Override
+    public Directory<User> getUserDirectory() throws GuacamoleException {
+
+        // Defer to the vault-specific directory service
+        return directoryService.getUserDirectory(super.getUserDirectory());
+    }
+
+    @Override
+    public Directory<UserGroup> getUserGroupDirectory() throws GuacamoleException {
+
+        // Defer to the vault-specific directory service
+        return directoryService.getUserGroupDirectory(super.getUserGroupDirectory());
+    }
+
+    @Override
+    public Directory<Connection> getConnectionDirectory() throws GuacamoleException {
+
+        // Defer to the vault-specific directory service
+        return directoryService.getConnectionDirectory(super.getConnectionDirectory());
+    }
+
+    @Override
+    public Directory<ConnectionGroup> getConnectionGroupDirectory() throws GuacamoleException {
+
+        // Defer to the vault-specific directory service
+        return directoryService.getConnectionGroupDirectory(super.getConnectionGroupDirectory());
+    }
+
+    @Override
+    public Directory<ActiveConnection> getActiveConnectionDirectory() throws GuacamoleException {
+
+        // Defer to the vault-specific directory service
+        return directoryService.getActiveConnectionDirectory(super.getActiveConnectionDirectory());
+    }
+
+    @Override
+    public Directory<SharingProfile> getSharingProfileDirectory() throws GuacamoleException {
+
+        // Defer to the vault-specific directory service
+        return directoryService.getSharingProfileDirectory(super.getSharingProfileDirectory());
+    }
+
 }
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/test/java/org/apache/guacamole/vault/secret/WindowsUsernameTest.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/test/java/org/apache/guacamole/vault/secret/WindowsUsernameTest.java
new file mode 100644
index 0000000..61f4546
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/test/java/org/apache/guacamole/vault/secret/WindowsUsernameTest.java
@@ -0,0 +1,82 @@
+/*
+ * 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.vault.secret;
+
+import org.junit.Test;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Class to test the parsing functionality of the WindowsUsername class.
+ */
+public class WindowsUsernameTest {
+
+    /**
+     * Verify that the splitWindowsUsernameFromDomain() method correctly strips Windows
+     * domains from provided usernames that include them, and does not modify
+     * usernames that do not have Windows domains.
+     */
+    @Test
+    public void testSplitWindowsUsernameFromDomain() {
+
+        WindowsUsername usernameAndDomain;
+
+        // If no Windows domain is present in the provided field, the username should
+        // contain the entire field, and no domain should be returned
+        usernameAndDomain = WindowsUsername.splitWindowsUsernameFromDomain("bob");
+        assertEquals(usernameAndDomain.getUsername(), "bob");
+        assertFalse(usernameAndDomain.hasDomain());
+
+        // It should parse down-level logon name style domains
+        usernameAndDomain = WindowsUsername.splitWindowsUsernameFromDomain("localhost\\bob");
+        assertEquals("bob", usernameAndDomain.getUsername(), "bob");
+        assertTrue(usernameAndDomain.hasDomain());
+        assertEquals("localhost", usernameAndDomain.getDomain());
+
+        // It should parse user principal name style domains
+        usernameAndDomain = WindowsUsername.splitWindowsUsernameFromDomain("bob@localhost");
+        assertEquals("bob", usernameAndDomain.getUsername(), "bob");
+        assertTrue(usernameAndDomain.hasDomain());
+        assertEquals("localhost", usernameAndDomain.getDomain());
+
+        // It should not match if there are an invalid number of separators
+        List<String> invalidSeparators = Arrays.asList(
+                "bob@local@host", "local\\host\\bob",
+                "bob\\local@host", "local@host\\bob");
+        invalidSeparators.stream().forEach(
+            invalidSeparator -> {
+
+                // An invalid number of separators means that the parse failed -
+                // there should be no detected domain, and the entire field value
+                // should be returned as the username
+                WindowsUsername parseOutput =
+                        WindowsUsername.splitWindowsUsernameFromDomain(invalidSeparator);
+                assertFalse(parseOutput.hasDomain());
+                assertEquals(invalidSeparator, parseOutput.getUsername());
+
+            });
+
+    }
+
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java
index bcc5a78..3c28553 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java
@@ -21,12 +21,19 @@
 
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.vault.VaultAuthenticationProviderModule;
+import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
 import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
 import org.apache.guacamole.vault.ksm.secret.KsmSecretService;
+import org.apache.guacamole.vault.ksm.user.KsmDirectoryService;
+import org.apache.guacamole.vault.conf.VaultAttributeService;
 import org.apache.guacamole.vault.conf.VaultConfigurationService;
 import org.apache.guacamole.vault.ksm.secret.KsmClient;
+import org.apache.guacamole.vault.ksm.secret.KsmClientFactory;
 import org.apache.guacamole.vault.ksm.secret.KsmRecordService;
 import org.apache.guacamole.vault.secret.VaultSecretService;
+import org.apache.guacamole.vault.user.VaultDirectoryService;
+
+import com.google.inject.assistedinject.FactoryModuleBuilder;
 
 /**
  * Guice module which configures injections specific to Keeper Secrets
@@ -49,10 +56,16 @@
     protected void configureVault() {
 
         // Bind services specific to Keeper Secrets Manager
-        bind(KsmClient.class);
         bind(KsmRecordService.class);
+        bind(VaultAttributeService.class).to(KsmAttributeService.class);
         bind(VaultConfigurationService.class).to(KsmConfigurationService.class);
         bind(VaultSecretService.class).to(KsmSecretService.class);
+        bind(VaultDirectoryService.class).to(KsmDirectoryService.class);
+
+        // Bind factory for creating KSM Clients
+        install(new FactoryModuleBuilder()
+                .implement(KsmClient.class, KsmClient.class)
+                .build(KsmClientFactory.class));
     }
 
 }
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmAttributeService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmAttributeService.java
new file mode 100644
index 0000000..83ab9c4
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmAttributeService.java
@@ -0,0 +1,63 @@
+/*
+ * 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.vault.ksm.conf;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+
+import org.apache.guacamole.form.Form;
+import org.apache.guacamole.form.TextField;
+import org.apache.guacamole.vault.conf.VaultAttributeService;
+
+import com.google.inject.Singleton;
+
+/**
+ * A service that exposes KSM-specific attributes, allowing setting KSM
+ * configuration through the admin interface.
+ */
+@Singleton
+public class KsmAttributeService implements VaultAttributeService {
+
+    /**
+     * The name of the attribute which can contain a KSM configuration blob
+     * associated with a connection group.
+     */
+    public static final String KSM_CONFIGURATION_ATTRIBUTE = "ksm-config";
+
+    /**
+     * All attributes related to configuring the KSM vault on a
+     * per-connection-group basis.
+     */
+    public static final Form KSM_CONFIGURATION_FORM = new Form("ksm-config",
+            Arrays.asList(new TextField(KSM_CONFIGURATION_ATTRIBUTE)));
+
+    /**
+     * All KSM-specific connection group attributes, organized by form.
+     */
+    public static final Collection<Form> KSM_CONNECTION_GROUP_ATTRIBUTES =
+            Collections.unmodifiableCollection(Arrays.asList(KSM_CONFIGURATION_FORM));
+
+    @Override
+    public Collection<Form> getConnectionGroupAttributes() {
+        return KSM_CONNECTION_GROUP_ATTRIBUTES;
+    }
+
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigProperty.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfig.java
similarity index 65%
rename from extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigProperty.java
rename to extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfig.java
index aaddb0d..54aaec7 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigProperty.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfig.java
@@ -23,21 +23,28 @@
 import com.keepersecurity.secretsManager.core.KeyValueStorage;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleServerException;
-import org.apache.guacamole.properties.GuacamoleProperty;
 
 /**
- * A GuacamoleProperty whose value is Keeper Secrets Manager {@link KeyValueStorage}
- * object. The value of this property must be base64-encoded JSON, as output by
- * the Keeper Commander CLI tool via the "sm client add" command.
+ * A utility for parsing base64-encoded JSON, as output by the Keeper Commander
+ * CLI tool via the "sm client add" command into a Keeper Secrets Manager
+ * {@link KeyValueStorage} object.
  */
-public abstract class KsmConfigProperty implements GuacamoleProperty<KeyValueStorage> {
+public class KsmConfig {
 
-    @Override
-    public KeyValueStorage parseValue(String value) throws GuacamoleException {
-
-        // If no property provided, return null.
-        if (value == null)
-            return null;
+    /**
+     * Given a base64-encoded JSON KSM configuration, parse and return a
+     * KeyValueStorage object.
+     *
+     * @param value
+     *     The base64-encoded JSON KSM configuration to parse.
+     *
+     * @return
+     *     The KeyValueStorage that is a result of the parsing operation
+     *
+     * @throws GuacamoleException
+     *     If the provided value is not valid base-64 encoded JSON KSM configuration.
+     */
+    public static KeyValueStorage parseKsmConfig(String value) throws GuacamoleException {
 
         // Parse base64 value as KSM config storage
         try {
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java
index 38bcaae..3ef02b8 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java
@@ -21,10 +21,18 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+
+import javax.annotation.Nonnull;
+
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleServerException;
 import org.apache.guacamole.environment.Environment;
 import org.apache.guacamole.properties.BooleanGuacamoleProperty;
+import org.apache.guacamole.properties.StringGuacamoleProperty;
 import org.apache.guacamole.vault.conf.VaultConfigurationService;
+
+import com.keepersecurity.secretsManager.core.InMemoryStorage;
+import com.keepersecurity.secretsManager.core.KeyValueStorage;
 import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
 
 /**
@@ -57,7 +65,7 @@
      * The base64-encoded configuration information generated by the Keeper
      * Commander CLI tool.
      */
-    private static final KsmConfigProperty KSM_CONFIG = new KsmConfigProperty() {
+    private static final StringGuacamoleProperty KSM_CONFIG = new StringGuacamoleProperty() {
 
         @Override
         public String getName() {
@@ -77,6 +85,18 @@
     };
 
     /**
+     * Whether windows domains should be stripped off from usernames that are
+     * read from the KSM vault.
+     */
+    private static final BooleanGuacamoleProperty STRIP_WINDOWS_DOMAINS = new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() {
+            return "ksm-strip-windows-domains";
+        }
+    };
+
+    /**
      * Creates a new KsmConfigurationService which reads the configuration
      * from "ksm-token-mapping.yml" and properties from
      * "guacamole.properties.ksm". The token mapping is a YAML file which lists
@@ -105,22 +125,73 @@
         return environment.getProperty(ALLOW_UNVERIFIED_CERT, false);
     }
 
+    @Override
+    public boolean getSplitWindowsUsernames() throws GuacamoleException {
+        return environment.getProperty(STRIP_WINDOWS_DOMAINS, false);
+    }
+
+
+    /**
+     * Return the globally-defined base-64-encoded JSON KSM configuration blob
+     * as a string.
+     *
+     * @return
+     *     The globally-defined base-64-encoded JSON KSM configuration blob
+     *     as a string.
+     *
+     * @throws GuacamoleException
+     *     If the value specified within guacamole.properties cannot be
+     *     parsed or does not exist.
+     */
+    public String getKsmConfig() throws GuacamoleException {
+        return environment.getRequiredProperty(KSM_CONFIG);
+    }
+
+    /**
+     * Given a base64-encoded JSON KSM configuration, parse and return a
+     * KeyValueStorage object.
+     *
+     * @param value
+     *     The base64-encoded JSON KSM configuration to parse.
+     *
+     * @return
+     *     The KeyValueStorage that is a result of the parsing operation
+     *
+     * @throws GuacamoleException
+     *     If the provided value is not valid base-64 encoded JSON KSM configuration.
+     */
+    private static KeyValueStorage parseKsmConfig(String value) throws GuacamoleException {
+
+        // Parse base64 value as KSM config storage
+        try {
+            return new InMemoryStorage(value);
+        }
+        catch (IllegalArgumentException e) {
+            throw new GuacamoleServerException("Invalid base64 configuration "
+                    + "for Keeper Secrets Manager.", e);
+        }
+
+    }
+
     /**
      * Returns the options required to authenticate with Keeper Secrets Manager
      * when retrieving secrets. These options are read from the contents of
      * base64-encoded JSON configuration data generated by the Keeper Commander
-     * CLI tool.
+     * CLI tool. This configuration data must be passed directly as an argument.
+     *
+     * @param ksmConfig
+     *     The KSM configuration blob to parse.
      *
      * @return
      *     The options that should be used when connecting to Keeper Secrets
      *     Manager when retrieving secrets.
      *
      * @throws GuacamoleException
-     *     If required properties are not specified within
-     *     guacamole.properties or cannot be parsed.
+     *     If an invalid ksmConfig parameter is provided.
      */
-    public SecretsManagerOptions getSecretsManagerOptions() throws GuacamoleException {
-        return new SecretsManagerOptions(environment.getRequiredProperty(KSM_CONFIG), null,
-                getAllowUnverifiedCertificate());
+    public SecretsManagerOptions getSecretsManagerOptions(@Nonnull String ksmConfig) throws GuacamoleException {
+
+        return new SecretsManagerOptions(
+                parseKsmConfig(ksmConfig), null, getAllowUnverifiedCertificate());
     }
 }
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClient.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClient.java
index 2372dcb..5572ef7 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClient.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClient.java
@@ -20,14 +20,16 @@
 package org.apache.guacamole.vault.ksm.secret;
 
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import com.keepersecurity.secretsManager.core.Hosts;
-import com.keepersecurity.secretsManager.core.KeeperFile;
 import com.keepersecurity.secretsManager.core.KeeperRecord;
 import com.keepersecurity.secretsManager.core.KeeperSecrets;
 import com.keepersecurity.secretsManager.core.Login;
 import com.keepersecurity.secretsManager.core.Notation;
 import com.keepersecurity.secretsManager.core.SecretsManager;
+import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
+
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
@@ -41,8 +43,8 @@
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+
 import org.apache.guacamole.GuacamoleException;
-import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -54,7 +56,6 @@
  * information), it's not possible for the server to perform a search of
  * content on the client's behalf. The client has to perform its own search.
  */
-@Singleton
 public class KsmClient {
 
     /**
@@ -63,12 +64,6 @@
     private static final Logger logger = LoggerFactory.getLogger(KsmClient.class);
 
     /**
-     * Service for retrieving configuration information.
-     */
-    @Inject
-    private KsmConfigurationService confService;
-
-    /**
      * Service for retrieving data from records.
      */
     @Inject
@@ -96,6 +91,11 @@
     private static final long CACHE_INTERVAL = 5000;
 
     /**
+     * The KSM configuration associated with this client instance.
+     */
+    private final SecretsManagerOptions ksmConfig;
+
+    /**
      * Read/write lock which guards access to all cached data, including the
      * timestamp recording the last time the cache was refreshed. Readers of
      * the cache must first acquire (and eventually release) the read lock, and
@@ -119,7 +119,7 @@
      * {@link #cacheLock} acquired appropriately.
      */
     private KeeperSecrets cachedSecrets = null;
-    
+
     /**
      * All records retrieved from Keeper Secrets Manager, where each key is the
      * UID of the corresponding record. The contents of this Map are
@@ -180,6 +180,17 @@
     private final Set<String> cachedAmbiguousUsernames = new HashSet<>();
 
     /**
+     * Create a new KSM client based around the provided KSM configuration.
+     *
+     * @param ksmConfig
+     *     The KSM configuration to use when retrieving properties from KSM.
+     */
+    @AssistedInject
+    public KsmClient(@Assisted SecretsManagerOptions ksmConfig) {
+        this.ksmConfig = ksmConfig;
+    }
+
+    /**
      * Validates that all cached data is current with respect to
      * {@link #CACHE_INTERVAL}, refreshing data from the server as needed.
      *
@@ -211,12 +222,12 @@
 
             // Attempt to pull all records first, allowing that operation to
             // succeed/fail BEFORE we clear out the last cached success
-            KeeperSecrets secrets = SecretsManager.getSecrets(confService.getSecretsManagerOptions());
+            KeeperSecrets secrets = SecretsManager.getSecrets(ksmConfig);
             List<KeeperRecord> records = secrets.getRecords();
 
             // Store all secrets within cache
             cachedSecrets = secrets;
-            
+
             // Clear unambiguous cache of all records by UID
             cachedRecordsByUid.clear();
 
@@ -398,7 +409,7 @@
      *     The record associated with the given username, or null if there is
      *     no such record or multiple such records.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs that prevents the record from being retrieved.
      */
     public KeeperRecord getRecordByLogin(String username) throws GuacamoleException {
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClientFactory.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClientFactory.java
new file mode 100644
index 0000000..f8220c1
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClientFactory.java
@@ -0,0 +1,45 @@
+/*
+ * 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.vault.ksm.secret;
+
+import javax.annotation.Nonnull;
+
+import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
+
+/**
+ * Factory for creating KsmClient instances.
+ */
+public interface KsmClientFactory {
+
+    /**
+     * Returns a new instance of a KsmClient instance associated with
+     * the provided KSM configuration options.
+     *
+     * @param ksmConfigOptions
+     *     The KSM config options to use when constructing the KsmClient
+     *     object.
+     *
+     * @return
+     *     A new KsmClient instance associated with the provided KSM config
+     *     options.
+     */
+    KsmClient create(@Nonnull SecretsManagerOptions ksmConfigOptions);
+
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmRecordService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmRecordService.java
index d67b581..a3d79b4 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmRecordService.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmRecordService.java
@@ -41,6 +41,7 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+
 /**
  * Service for automatically parsing out secrets and data from Keeper records.
  */
@@ -49,6 +50,13 @@
 
     /**
      * Regular expression which matches the labels of custom fields containing
+     * domains.
+     */
+    private static final Pattern DOMAIN_LABEL_PATTERN =
+            Pattern.compile("domain", Pattern.CASE_INSENSITIVE);
+
+    /**
+     * Regular expression which matches the labels of custom fields containing
      * hostnames/addresses.
      */
     private static final Pattern HOSTNAME_LABEL_PATTERN =
@@ -143,13 +151,13 @@
      * empty or contains multiple values, null is returned. Note that null will
      * also be returned if the mapping transformation returns null for the
      * single value stored in the list.
-     * 
+     *
      * @param <T>
      *     The type of object stored in the list.
-     *     
+     *
      * @param <R>
      *     The type of object to return.
-     *     
+     *
      * @param values
      *     The list to retrieve a single value from.
      *
@@ -168,7 +176,7 @@
             return null;
 
         return mapper.apply(value);
-        
+
     }
 
     /**
@@ -271,7 +279,7 @@
      * multiple such fields, null is returned. Both standard and custom fields
      * are searched. As standard fields do not have labels, any given label
      * pattern is ignored for standard fields.
-     * 
+     *
      * @param <T>
      *     The type of field to return.
      *
@@ -339,7 +347,7 @@
                 return null;
 
             foundFile = file;
-            
+
         }
 
         return foundFile;
@@ -362,7 +370,7 @@
 
         if (file == null)
             return CompletableFuture.completedFuture(null);
-        
+
         return CompletableFuture.supplyAsync(() -> {
             return new String(SecretsManager.downloadFile(file), StandardCharsets.UTF_8);
         });
@@ -446,6 +454,38 @@
     }
 
     /**
+     * Returns the single domain associated with the given record. If the
+     * record has no associated domain, or multiple domains, null is
+     * returned. Domains are retrieved from "Text" and "Hidden" fields
+     * that have the label "domain" (case-insensitive).
+     *
+     * @param record
+     *     The record to retrieve the domain from.
+     *
+     * @return
+     *     The domain associated with the given record, or null if the record
+     *     has no associated domain or multiple domains.
+     */
+    public String getDomain(KeeperRecord record) {
+
+        KeeperRecordData data = record.getData();
+        List<KeeperRecordField> custom = data.getCustom();
+
+        // First check text "domain" custom field ...
+        Text textField = getField(custom, Text.class, DOMAIN_LABEL_PATTERN);
+        if (textField != null)
+            return getSingleStringValue(textField.getValue());
+
+        // ... or hidden "domain" custom field if that's not found
+        HiddenField hiddenField = getField(custom, HiddenField.class, DOMAIN_LABEL_PATTERN);
+        if (hiddenField != null)
+            return getSingleStringValue(hiddenField.getValue());
+
+        return null;
+
+    }
+
+    /**
      * Returns the password associated with the given record. Both standard and
      * custom fields are searched. Only "Password" and "Hidden" field types are
      * considered. Custom fields must additionally have the label "password"
@@ -555,7 +595,7 @@
         // a pair of custom hidden fields for the private key and passphrase:
         // the standard password field of the "Login" record refers to the
         // user's own password, if any, not the passphrase of their key)
-        
+
         // Use password "private key" custom field as fallback ...
         Password passwordField = getField(custom, Password.class, PASSPHRASE_LABEL_PATTERN);
         if (passwordField != null)
@@ -567,7 +607,7 @@
             return getSingleStringValue(hiddenField.getValue());
 
         return null;
-        
+
     }
 
 }
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java
index 824f9e5..d8168dc 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java
@@ -22,29 +22,49 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.keepersecurity.secretsManager.core.KeeperRecord;
+import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
+
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.Future;
 
+import javax.annotation.Nonnull;
+
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.Connectable;
+import org.apache.guacamole.net.auth.Connection;
+import org.apache.guacamole.net.auth.ConnectionGroup;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.UserContext;
 import org.apache.guacamole.protocol.GuacamoleConfiguration;
 import org.apache.guacamole.token.TokenFilter;
+import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
+import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
 import org.apache.guacamole.vault.secret.VaultSecretService;
+import org.apache.guacamole.vault.secret.WindowsUsername;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Service which retrieves secrets from Keeper Secrets Manager.
+ * The configuration used to connect to KSM can be set at a global
+ * level using guacamole.properties, or using a connection group
+ * attribute.
  */
 @Singleton
 public class KsmSecretService implements VaultSecretService {
 
     /**
-     * Client for retrieving records and secrets from Keeper Secrets Manager.
+     * Logger for this class.
      */
-    @Inject
-    private KsmClient ksm;
+    private static final Logger logger = LoggerFactory.getLogger(VaultSecretService.class);
 
     /**
      * Service for retrieving data from records.
@@ -52,6 +72,57 @@
     @Inject
     private KsmRecordService recordService;
 
+    /**
+     * Service for retrieving configuration information.
+     */
+    @Inject
+    private KsmConfigurationService confService;
+
+    /**
+     * Factory for creating KSM client instances.
+     */
+    @Inject
+    private KsmClientFactory ksmClientFactory;
+
+    /**
+     * A map of base-64 encoded JSON KSM config blobs to associated KSM client instances.
+     * A distinct KSM client will exist for every KSM config.
+     */
+    private final ConcurrentMap<String, KsmClient> ksmClientMap = new ConcurrentHashMap<>();
+
+    /**
+     * Create and return a KSM client for the provided KSM config if not already
+     * present in the client map, otherwise return the existing client entry.
+     *
+     * @param ksmConfig
+     *     The base-64 encoded JSON KSM config blob associated with the client entry.
+     *     If an associated entry does not already exist, it will be created using
+     *     this configuration.
+     *
+     * @return
+     *     A KSM client for the provided KSM config if not already present in the
+     *     client map, otherwise the existing client entry.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the KSM client.
+     */
+    private KsmClient getClient(@Nonnull String ksmConfig)
+            throws GuacamoleException {
+
+        // If a client already exists for the provided config, use it
+        KsmClient ksmClient = ksmClientMap.get(ksmConfig);
+        if (ksmClient != null)
+            return ksmClient;
+
+        // Create and store a new KSM client instance for the provided KSM config blob
+        SecretsManagerOptions options = confService.getSecretsManagerOptions(ksmConfig);
+        ksmClient = ksmClientFactory.create(options);
+        KsmClient prevClient = ksmClientMap.putIfAbsent(ksmConfig, ksmClient);
+
+        // If the client was already set before this thread got there, use the existing one
+        return prevClient != null ? prevClient : ksmClient;
+    }
+
     @Override
     public String canonicalize(String nameComponent) {
         try {
@@ -67,8 +138,20 @@
     }
 
     @Override
+    public Future<String> getValue(UserContext userContext, Connectable connectable,
+            String name) throws GuacamoleException {
+
+        // Attempt to find a KSM config for this connection or group
+        String ksmConfig = getConnectionGroupKsmConfig(userContext, connectable);
+
+        return getClient(ksmConfig).getSecret(name);
+    }
+
+    @Override
     public Future<String> getValue(String name) throws GuacamoleException {
-        return ksm.getSecret(name);
+
+        // Use the default KSM configuration from guacamole.properties
+        return getClient(confService.getKsmConfig()).getSecret(name);
     }
 
     /**
@@ -86,17 +169,48 @@
      * @param record
      *     The record to retrieve secrets from when generating tokens. This may
      *     be null.
+     *
+     * @throws GuacamoleException
+     *     If configuration details in guacamole.properties cannot be parsed.
      */
     private void addRecordTokens(Map<String, Future<String>> tokens, String prefix,
-            KeeperRecord record) {
+            KeeperRecord record) throws GuacamoleException {
 
         if (record == null)
             return;
 
+        // Domain of server-related record
+        String domain = recordService.getDomain(record);
+        if (domain != null)
+            tokens.put(prefix + "DOMAIN", CompletableFuture.completedFuture(domain));
+
         // Username of server-related record
         String username = recordService.getUsername(record);
-        if (username != null)
-            tokens.put(prefix + "USERNAME", CompletableFuture.completedFuture(username));
+        if (username != null) {
+
+            // If the record had no directly defined domain, but there is a
+            // username, and the configuration is enabled to split Windows
+            // domains out of usernames, attempt to split the domain out now
+            if (domain == null && confService.getSplitWindowsUsernames()) {
+                WindowsUsername usernameAndDomain =
+                        WindowsUsername.splitWindowsUsernameFromDomain(username);
+
+                // Always store the username token
+                tokens.put(prefix + "USERNAME", CompletableFuture.completedFuture(
+                        usernameAndDomain.getUsername()));
+
+                // Only store the domain if one is detected
+                if (usernameAndDomain.hasDomain())
+                    tokens.put(prefix + "DOMAIN", CompletableFuture.completedFuture(
+                        usernameAndDomain.getDomain()));
+
+            }
+
+            // If splitting is not enabled, store the whole value in the USERNAME token
+            else {
+                tokens.put(prefix + "USERNAME", CompletableFuture.completedFuture(username));
+            }
+        }
 
         // Password of server-related record
         String password = recordService.getPassword(record);
@@ -113,14 +227,96 @@
         tokens.put(prefix + "KEY", privateKey);
 
     }
-    
+
+    /**
+     * Search for a KSM configuration attribute, recursing up the connection group tree
+     * until a connection group with the appropriate attribute is found. If the KSM config
+     * is found, it will be returned. If not, the default value from the config file will
+     * be returned.
+     *
+     * @param userContext
+     *     The userContext associated with the connection or connection group.
+     *
+     * @param connectable
+     *     A connection or connection group for which the tokens are being replaced.
+     *
+     * @return
+     *     The value of the KSM configuration attribute if found in the tree, the default
+     *     KSM config blob defined in guacamole.properties otherwise.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while attempting to retrieve the KSM config attribute, or if
+     *     no KSM config is found in the connection group tree, and the value is also not
+     *     defined in the config file.
+     */
+    private String getConnectionGroupKsmConfig(
+            UserContext userContext, Connectable connectable) throws GuacamoleException {
+
+        // Check to make sure it's a usable type before proceeding
+        if (
+                !(connectable instanceof Connection)
+                && !(connectable instanceof ConnectionGroup)) {
+            logger.warn(
+                    "Unsupported Connectable type: {}; skipping KSM config lookup.",
+                    connectable.getClass());
+
+            // Use the default value if searching is impossible
+            return confService.getKsmConfig();
+        }
+
+        // For connections, start searching the parent group for the KSM config
+        // For connection groups, start searching the group directly
+        String parentIdentifier = (connectable instanceof Connection)
+                ? ((Connection) connectable).getParentIdentifier()
+                : ((ConnectionGroup) connectable).getIdentifier();
+
+        // Keep track of all group identifiers seen while recursing up the tree
+        // in case there's a cycle - if the same identifier is ever seen twice,
+        // the search is over.
+        Set<String> observedIdentifiers = new HashSet<>();
+        observedIdentifiers.add(parentIdentifier);
+
+        Directory<ConnectionGroup> connectionGroupDirectory = userContext.getConnectionGroupDirectory();
+        while (true) {
+
+            // Fetch the parent group, if one exists
+            ConnectionGroup group = connectionGroupDirectory.get(parentIdentifier);
+            if (group == null)
+                break;
+
+            // If the current connection group has the KSM configuration attribute
+            // set to a non-empty value, return immediately
+            String ksmConfig = group.getAttributes().get(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE);
+            if (ksmConfig != null && !ksmConfig.trim().isEmpty())
+                return ksmConfig;
+
+            // Otherwise, keep searching up the tree until an appropriate configuration is found
+            parentIdentifier = group.getParentIdentifier();
+
+            // If the parent is a group that's already been seen, this is a cycle, so there's no
+            // need to search any further
+            if (!observedIdentifiers.add(parentIdentifier))
+                break;
+        }
+
+        // If no KSM configuration was ever found, use the default value
+        return confService.getKsmConfig();
+
+    }
+
     @Override
-    public Map<String, Future<String>> getTokens(GuacamoleConfiguration config,
-            TokenFilter filter) throws GuacamoleException {
+    public Map<String, Future<String>> getTokens(UserContext userContext, Connectable connectable,
+            GuacamoleConfiguration config, TokenFilter filter) throws GuacamoleException {
 
         Map<String, Future<String>> tokens = new HashMap<>();
         Map<String, String> parameters = config.getParameters();
 
+        // Attempt to find a KSM config for this connection or group
+        String ksmConfig = getConnectionGroupKsmConfig(userContext, connectable);
+
+        // Get a client instance for this KSM config
+        KsmClient ksm = getClient(ksmConfig);
+
         // Retrieve and define server-specific tokens, if any
         String hostname = parameters.get("hostname");
         if (hostname != null && !hostname.isEmpty())
@@ -135,7 +331,7 @@
 
         // Tokens specific to RDP
         if ("rdp".equals(config.getProtocol())) {
-        
+
             // Retrieve and define gateway server-specific tokens, if any
             String gatewayHostname = parameters.get("gateway-hostname");
             if (gatewayHostname != null && !gatewayHostname.isEmpty())
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnectionGroup.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnectionGroup.java
new file mode 100644
index 0000000..397c42f
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnectionGroup.java
@@ -0,0 +1,78 @@
+/*
+ * 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.vault.ksm.user;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.guacamole.net.auth.ConnectionGroup;
+import org.apache.guacamole.net.auth.DelegatingConnectionGroup;
+import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
+
+ /**
+  * A KSM-specific connection group implementation that always exposes
+  * the KSM_CONFIGURATION_ATTRIBUTE attribute, even when no value is set.
+  * This ensures that the attribute will always show up in the UI, even
+  * for connection groups that don't already have it set.
+  */
+ public class KsmConnectionGroup extends DelegatingConnectionGroup {
+
+    /**
+     * Create a new KsmConnectionGroup instance, wrapping the provided
+     * ConnectionGroup.
+     *
+     * @param connectionGroup
+     *     The ConnectionGroup instance to wrap.
+     */
+    public KsmConnectionGroup(ConnectionGroup connectionGroup) {
+
+        // Wrap the provided connection group
+        super(connectionGroup);
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+
+        // All attributes defined on the underlying connection group
+        Map<String, String> attributes = super.getAttributes();
+
+        // If the attribute is already present, there's no need to add it - return
+        // the existing attributes as they are
+        if (attributes.containsKey(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE))
+            return attributes;
+
+        // Make a copy of the existing attributes and add KSM_CONFIGURATION_ATTRIBUTE
+        attributes = new HashMap<>(attributes);
+        attributes.put(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE, null);
+        return attributes;
+
+    }
+
+    /**
+     * Return the underlying ConnectionGroup that's wrapped by this KsmConnectionGroup.
+     *
+     * @return
+     *     The underlying ConnectionGroup that's wrapped by this KsmConnectionGroup.
+     */
+    ConnectionGroup getUnderlyConnectionGroup() {
+        return getDelegateConnectionGroup();
+    }
+
+ }
\ No newline at end of file
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java
new file mode 100644
index 0000000..b924bc5
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java
@@ -0,0 +1,272 @@
+/*
+ * 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.vault.ksm.user;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.language.TranslatableGuacamoleClientException;
+import org.apache.guacamole.net.auth.Attributes;
+import org.apache.guacamole.net.auth.ConnectionGroup;
+import org.apache.guacamole.net.auth.DecoratingDirectory;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
+import org.apache.guacamole.vault.ksm.conf.KsmConfig;
+import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
+import org.apache.guacamole.vault.user.VaultDirectoryService;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.inject.Inject;
+import com.keepersecurity.secretsManager.core.InMemoryStorage;
+import com.keepersecurity.secretsManager.core.SecretsManager;
+import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
+
+/**
+ * A KSM-specific vault directory service that wraps the connection group directory
+ * to enable automatic translation of KSM one-time tokens into base64-encoded JSON
+ * config bundles.
+ */
+public class KsmDirectoryService extends VaultDirectoryService {
+
+    /**
+     * Service for retrieving KSM configuration details.
+     */
+    @Inject
+    private KsmConfigurationService configurationService;
+
+    /**
+     * A singleton ObjectMapper for converting a Map to a JSON string when
+     * generating a base64-encoded JSON KSM config blob.
+     */
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * All expected fields in the KSM configuration JSON blob.
+     */
+    private static final List<String> EXPECTED_KSM_FIELDS = (
+            Collections.unmodifiableList(Arrays.asList(
+                    SecretsManager.KEY_HOSTNAME,
+                    SecretsManager.KEY_CLIENT_ID,
+                    SecretsManager.KEY_PRIVATE_KEY,
+                    SecretsManager.KEY_CLIENT_KEY,
+                    SecretsManager.KEY_APP_KEY,
+                    SecretsManager.KEY_OWNER_PUBLIC_KEY,
+                    SecretsManager.KEY_SERVER_PUBIC_KEY_ID
+    )));
+
+    /**
+     * Return true if the provided input is a valid base64-encoded string,
+     * false otherwise.
+     *
+     * @param input
+     *     The string to check if base-64 encoded.
+     *
+     * @return
+     *     true if the provided input is a valid base64-encoded string,
+     *     false otherwise.
+     */
+    private static boolean isBase64(String input) {
+
+        try {
+            Base64.getDecoder().decode(input);
+            return true;
+        } catch (IllegalArgumentException e) {
+            return false;
+        }
+    }
+
+    /**
+     * Given an attributes-enabled entity, check for the presence of the
+     * KSM_CONFIGURATION_ATTRIBUTE attribute. If it's set, check if it's a valid
+     * KSM one-time token. If so, attempt to translate it to a base-64-encoded
+     * json KSM config blob, and set it back to the provided entity.
+     * If it's already a KSM config blob, validate it as config blob. If either
+     * validation fails, a GuacamoleException will be thrown.
+     *
+     * @param entity
+     *     The attributes-enabled entity for which the KSM configuration
+     *     attribute parsing/validation should be performed.
+     *
+     * @throws GuacamoleException
+     *     If the KSM_CONFIGURATION_ATTRIBUTE is set, but fails to validate as
+     *     either a KSM one-time-token, or a KSM base64-encoded JSON config blob.
+     */
+    public void processAttributes(Attributes entity) throws GuacamoleException {
+
+        // By default, if the KSM config attribute isn't being set, pass the
+        // provided attributes through without any changes
+        Map<String, String> attributes = entity.getAttributes();
+
+        // Get the value of the KSM config attribute in the provided map
+        String ksmConfigValue = attributes.get(
+                KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE);
+
+        // Check if the attribute is set to a non-empty value
+        if (ksmConfigValue != null && !ksmConfigValue.trim().isEmpty()) {
+
+            // If it's already base64-encoded, it's a KSM configuration blob,
+            // so validate it immediately
+            if (isBase64(ksmConfigValue)) {
+
+                // Attempt to validate the config as a base64-econded KSM config blob
+                try {
+                    KsmConfig.parseKsmConfig(ksmConfigValue);
+
+                    // If it validates, the entity can be left alone - it's already valid
+                    return;
+                }
+
+                catch (GuacamoleException exception) {
+
+                    // If the parsing attempt fails, throw a translatable error for display
+                    // on the frontend
+                    throw new TranslatableGuacamoleClientException(
+                            "Invalid base64-encoded JSON KSM config provided for "
+                            + KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE + " attribute",
+                            "CONNECTION_GROUP_ATTRIBUTES.ERROR_INVALID_KSM_CONFIG_BLOB",
+                            exception);
+                }
+            }
+
+            // It wasn't a valid base64-encoded string, it should be a one-time token, so
+            // attempt to validat it as such, and if valid, update the attribute to the
+            // base64 config blob generated by the token
+            try {
+
+                // Create an initially empty storage to be populated using the one-time token
+                InMemoryStorage storage = new InMemoryStorage();
+
+                // Populate the in-memory storage using the one-time-token
+                SecretsManager.initializeStorage(storage, ksmConfigValue, null);
+
+                // Create an options object using the values we extracted from the one-time token
+                SecretsManagerOptions options = new SecretsManagerOptions(
+                    storage, null,
+                    configurationService.getAllowUnverifiedCertificate());
+
+                // Attempt to fetch secrets using the options we created. This will both validate
+                // that the configuration works, and potentially populate missing fields that the
+                // initializeStorage() call did not set.
+                SecretsManager.getSecrets(options);
+
+                // Create a map to store the extracted values from the KSM storage
+                Map<String, String> configMap = new HashMap<>();
+
+                // Go through all the expected fields, extract from the KSM storage,
+                // and write to the newly created map
+                EXPECTED_KSM_FIELDS.forEach(configKey -> {
+
+                    // Only write the value into the new map if non-null
+                    String value = storage.getString(configKey);
+                    if (value != null)
+                        configMap.put(configKey, value);
+
+                });
+
+                // JSON-encode the value, and then base64 encode that to get the format
+                // that KSM would expect
+                String jsonString = objectMapper.writeValueAsString(configMap);
+                String base64EncodedJson = Base64.getEncoder().encodeToString(
+                        jsonString.getBytes(StandardCharsets.UTF_8));
+
+                // Finally, try to parse the newly generated token as a KSM config. If this
+                // works, the config should be fully functional
+                KsmConfig.parseKsmConfig(base64EncodedJson);
+
+                // Make a copy of the existing attributes, modifying just the value for
+                // KSM_CONFIGURATION_ATTRIBUTE
+                attributes = new HashMap<>(attributes);
+                attributes.put(
+                        KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE, base64EncodedJson);
+
+                // Set the newly updated attributes back to the original object
+                entity.setAttributes(attributes);
+
+            }
+
+            // The KSM SDK only throws raw Exceptions, so we can't be more specific
+            catch (Exception exception) {
+
+                // If the parsing attempt fails, throw a translatable error for display
+                // on the frontend
+                throw new TranslatableGuacamoleClientException(
+                        "Invalid one-time KSM token provided for "
+                        + KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE + " attribute",
+                        "CONNECTION_GROUP_ATTRIBUTES.ERROR_INVALID_KSM_ONE_TIME_TOKEN",
+                        exception);
+            }
+        }
+
+    }
+
+    @Override
+    public Directory<ConnectionGroup> getConnectionGroupDirectory(
+            Directory<ConnectionGroup> underlyingDirectory) throws GuacamoleException {
+
+        // A ConnectionGroup directory that will intercept add and update calls to
+        // validate KSM configurations, and translate one-time-tokens, if possible,
+        // as well as ensuring that all ConnectionGroups returned include the
+        // KSM_CONFIGURATION_ATTRIBUTE attribute, so it will be available in the UI.
+        return new DecoratingDirectory<ConnectionGroup>(underlyingDirectory) {
+
+            @Override
+            public void add(ConnectionGroup connectionGroup) throws GuacamoleException {
+
+                // Check for the KSM config attribute and translate the one-time token
+                // if possible before adding
+                processAttributes(connectionGroup);
+                super.add(connectionGroup);
+            }
+
+            @Override
+            public void update(ConnectionGroup connectionGroup) throws GuacamoleException {
+
+                // Check for the KSM config attribute and translate the one-time token
+                // if possible before updating
+                processAttributes(connectionGroup);
+                super.update(connectionGroup);
+            }
+
+            @Override
+            protected ConnectionGroup decorate(ConnectionGroup connectionGroup) throws GuacamoleException {
+
+                // Wrap the existing connection group in a KsmConnection to ensure the presence of the
+                // KSM_CONFIGURATION_ATTRIBUTE attribute
+                return new KsmConnectionGroup(connectionGroup);
+
+            }
+
+            @Override
+            protected ConnectionGroup undecorate(ConnectionGroup connectionGroup) throws GuacamoleException {
+
+                // Return the underlying connection group that the KsmConnectionGroup wraps
+                return ((KsmConnectionGroup) connectionGroup).getUnderlyConnectionGroup();
+
+            }
+
+        };
+    }
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json
new file mode 100644
index 0000000..4601a13
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json
@@ -0,0 +1,15 @@
+{
+
+    "DATA_SOURCE_KEEPER_SECRETS_MANAGER" : {
+        "NAME" : "Keeper Secrets Manager"
+    },
+
+    "CONNECTION_GROUP_ATTRIBUTES" : {
+        "SECTION_HEADER_KSM_CONFIG" : "Keeper Secrets Manager",
+        "FIELD_HEADER_KSM_CONFIG"   : "KSM Service Configuration ",
+
+        "ERROR_INVALID_KSM_CONFIG_BLOB"    : "The provided base64-encoded KSM configuration blob is not valid. Please ensure that you have copied the entire blob.",
+        "ERROR_INVALID_KSM_ONE_TIME_TOKEN" : "The provided configuration is not a valid KSM one-time token or base64-encoded configuration blob. Please ensure that you have copied the entire token value."
+    }
+
+}
diff --git a/extensions/pom.xml b/extensions/pom.xml
index de2b245..3bab332 100644
--- a/extensions/pom.xml
+++ b/extensions/pom.xml
@@ -53,6 +53,9 @@
         <module>guacamole-history-recording-storage</module>
         <module>guacamole-vault</module>
 
+        <!-- Utility extensions -->
+        <module>guacamole-display-statistics</module>
+
     </modules>
 
     <build>
diff --git a/guacamole-common-js/pom.xml b/guacamole-common-js/pom.xml
index 5d7585b..41cc790 100644
--- a/guacamole-common-js/pom.xml
+++ b/guacamole-common-js/pom.xml
@@ -118,7 +118,7 @@
             <plugin>
                 <groupId>com.github.searls</groupId>
                 <artifactId>jasmine-maven-plugin</artifactId>
-                <version>2.2</version>
+                <version>3.0-beta-02</version>
                 <executions>
                     <execution>
                         <goals>
diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js
index 83d89ab..2cc9c28 100644
--- a/guacamole-common-js/src/main/webapp/modules/Client.js
+++ b/guacamole-common-js/src/main/webapp/modules/Client.js
@@ -840,6 +840,12 @@
      * @event
      * @param {!number} timestamp
      *     The timestamp associated with the sync instruction.
+     *
+     * @param {!number} frames
+     *     The number of frames that were considered or combined to produce the
+     *     frame associated with this sync instruction, or zero if this value
+     *     is not known or the remote desktop server provides no concept of
+     *     frames.
      */
     this.onsync = null;
 
@@ -1530,6 +1536,7 @@
         "sync": function(parameters) {
 
             var timestamp = parseInt(parameters[0]);
+            var frames = parameters[1] ? parseInt(parameters[1]) : 0;
 
             // Flush display, send sync when done
             display.flush(function displaySyncComplete() {
@@ -1547,7 +1554,7 @@
                     currentTimestamp = timestamp;
                 }
 
-            });
+            }, timestamp, frames);
 
             // If received first update, no longer waiting.
             if (currentState === STATE_WAITING)
@@ -1555,7 +1562,7 @@
 
             // Call sync handler if defined
             if (guac_client.onsync)
-                guac_client.onsync(timestamp);
+                guac_client.onsync(timestamp, frames);
 
         },
 
diff --git a/guacamole-common-js/src/main/webapp/modules/Display.js b/guacamole-common-js/src/main/webapp/modules/Display.js
index 8baa6b7..374a762 100644
--- a/guacamole-common-js/src/main/webapp/modules/Display.js
+++ b/guacamole-common-js/src/main/webapp/modules/Display.js
@@ -113,6 +113,17 @@
     this.cursorY = 0;
 
     /**
+     * The number of milliseconds over which display rendering statistics
+     * should be gathered, dispatching {@link #onstatistics} events as those
+     * statistics are available. If set to zero, no statistics will be
+     * gathered.
+     *
+     * @default 0
+     * @type {!number}
+     */
+    this.statisticWindow = 0;
+
+    /**
      * Fired when the default layer (and thus the entire Guacamole display)
      * is resized.
      * 
@@ -143,6 +154,18 @@
     this.oncursor = null;
 
     /**
+     * Fired whenever performance statistics are available for recently-
+     * rendered frames. This event will fire only if {@link #statisticWindow}
+     * is non-zero.
+     *
+     * @event
+     * @param {!Guacamole.Display.Statistics} stats
+     *     An object containing general rendering performance statistics for
+     *     the remote desktop, Guacamole server, and Guacamole client.
+     */
+    this.onstatistics = null;
+
+    /**
      * The queue of all pending Tasks. Tasks will be run in order, with new
      * tasks added at the end of the queue and old tasks removed from the
      * front of the queue (FIFO). These tasks will eventually be grouped
@@ -163,11 +186,33 @@
     var frames = [];
 
     /**
-     * Flushes all pending frames.
+     * The ID of the animation frame request returned by the last call to
+     * requestAnimationFrame(). This value will only be set if the browser
+     * supports requestAnimationFrame(), if a frame render is currently
+     * pending, and if the current browser tab is currently focused (likely to
+     * handle requests for animation frames). In all other cases, this will be
+     * null.
+     *
+     * @private
+     * @type {number}
+     */
+    var inProgressFrame = null;
+
+    /**
+     * Flushes all pending frames synchronously. This function will block until
+     * all pending frames have rendered. If a frame is currently blocked by an
+     * asynchronous operation like an image load, this function will return
+     * after reaching that operation and the flush operation will
+     * automamtically resume after that operation completes.
+     *
      * @private
      */
-    function __flush_frames() {
+    var syncFlush = function syncFlush() {
 
+        var localTimestamp = 0;
+        var remoteTimestamp = 0;
+
+        var renderedLogicalFrames = 0;
         var rendered_frames = 0;
 
         // Draw all pending frames, if ready
@@ -178,6 +223,10 @@
                 break;
 
             frame.flush();
+
+            localTimestamp = frame.localTimestamp;
+            remoteTimestamp = frame.remoteTimestamp;
+            renderedLogicalFrames += frame.logicalFrames;
             rendered_frames++;
 
         } 
@@ -185,6 +234,172 @@
         // Remove rendered frames from array
         frames.splice(0, rendered_frames);
 
+        if (rendered_frames)
+            notifyFlushed(localTimestamp, remoteTimestamp, renderedLogicalFrames);
+
+    };
+
+    /**
+     * Flushes all pending frames asynchronously. This function returns
+     * immediately, relying on requestAnimationFrame() to dictate when each
+     * frame should be flushed.
+     *
+     * @private
+     */
+    var asyncFlush = function asyncFlush() {
+
+        var continueFlush = function continueFlush() {
+
+            // We're no longer waiting to render a frame
+            inProgressFrame = null;
+
+            // Nothing to do if there are no frames remaining
+            if (!frames.length)
+                return;
+
+            // Flush the next frame only if it is ready (not awaiting
+            // completion of some asynchronous operation like an image load)
+            if (frames[0].isReady()) {
+                var frame = frames.shift();
+                frame.flush();
+                notifyFlushed(frame.localTimestamp, frame.remoteTimestamp, frame.logicalFrames);
+            }
+
+            // Request yet another animation frame if frames remain to be
+            // flushed
+            if (frames.length)
+                inProgressFrame = window.requestAnimationFrame(continueFlush);
+
+        };
+
+        // Begin flushing frames if not already waiting to render a frame
+        if (!inProgressFrame)
+            inProgressFrame = window.requestAnimationFrame(continueFlush);
+
+    };
+
+    /**
+     * Recently-gathered display render statistics, as made available by calls
+     * to notifyFlushed(). The contents of this array will be trimmed to
+     * contain only up to {@link #statisticWindow} milliseconds of statistics.
+     *
+     * @private
+     * @type {Guacamole.Display.Statistics[]}
+     */
+    var statistics = [];
+
+    /**
+     * Notifies that one or more frames have been successfully rendered
+     * (flushed) to the display.
+     *
+     * @private
+     * @param {!number} localTimestamp
+     *     The local timestamp of the point in time at which the most recent,
+     *     flushed frame was received by the display, in milliseconds since the
+     *     Unix Epoch.
+     *
+     * @param {!number} remoteTimestamp
+     *     The remote timestamp of sync instruction associated with the most
+     *     recent, flushed frame received by the display. This timestamp is in
+     *     milliseconds, but is arbitrary, having meaning only relative to
+     *     other timestamps in the same connection.
+     *
+     * @param {!number} logicalFrames
+     *     The number of remote desktop frames that were flushed.
+     */
+    var notifyFlushed = function notifyFlushed(localTimestamp, remoteTimestamp, logicalFrames) {
+
+        // Ignore if statistics are not being gathered
+        if (!guac_display.statisticWindow)
+            return;
+
+        var current = new Date().getTime();
+
+        // Find the first statistic that is still within the configured time
+        // window
+        for (var first = 0; first < statistics.length; first++) {
+            if (current - statistics[first].timestamp <= guac_display.statisticWindow)
+                break;
+        }
+
+        // Remove all statistics except those within the time window
+        statistics.splice(0, first - 1);
+
+        // Record statistics for latest frame
+        statistics.push({
+            localTimestamp : localTimestamp,
+            remoteTimestamp : remoteTimestamp,
+            timestamp : current,
+            frames : logicalFrames
+        });
+
+        // Determine the actual time interval of the available statistics (this
+        // will not perfectly match the configured interval, which is an upper
+        // bound)
+        var statDuration = (statistics[statistics.length - 1].timestamp - statistics[0].timestamp) / 1000;
+
+        // Determine the amount of time that elapsed remotely (within the
+        // remote desktop)
+        var remoteDuration = (statistics[statistics.length - 1].remoteTimestamp - statistics[0].remoteTimestamp) / 1000;
+
+        // Calculate the number of frames that have been rendered locally
+        // within the configured time interval
+        var localFrames = statistics.length;
+
+        // Calculate the number of frames actually received from the remote
+        // desktop by the Guacamole server
+        var remoteFrames = statistics.reduce(function sumFrames(prev, stat) {
+            return prev + stat.frames;
+        }, 0);
+
+        // Calculate the number of frames that the Guacamole server had to
+        // drop or combine with other frames
+        var drops = statistics.reduce(function sumDrops(prev, stat) {
+            return prev + Math.max(0, stat.frames - 1);
+        }, 0);
+
+        // Produce lag and FPS statistics from above raw measurements
+        var stats = new Guacamole.Display.Statistics({
+            processingLag : current - localTimestamp,
+            desktopFps : (remoteDuration && remoteFrames) ? remoteFrames / remoteDuration : null,
+            clientFps : statDuration ? localFrames / statDuration : null,
+            serverFps : remoteDuration ? localFrames / remoteDuration : null,
+            dropRate : remoteDuration ? drops / remoteDuration : null
+        });
+
+        // Notify of availability of new statistics
+        if (guac_display.onstatistics)
+            guac_display.onstatistics(stats);
+
+    };
+
+    // Switch from asynchronous frame handling to synchronous frame handling if
+    // requestAnimationFrame() is unlikely to be usable (browsers may not
+    // invoke the animation frame callback if the relevant tab is not focused)
+    window.addEventListener('blur', function switchToSyncFlush() {
+        if (inProgressFrame && !document.hasFocus()) {
+
+            // Cancel pending asynchronous processing of frame ...
+            window.cancelAnimationFrame(inProgressFrame);
+            inProgressFrame = null;
+
+            // ... and instead process it synchronously
+            syncFlush();
+
+        }
+    }, true);
+
+    /**
+     * Flushes all pending frames.
+     * @private
+     */
+    function __flush_frames() {
+
+        if (window.requestAnimationFrame && document.hasFocus())
+            asyncFlush();
+        else
+            syncFlush();
+
     }
 
     /**
@@ -198,8 +413,43 @@
      *
      * @param {!Task[]} tasks
      *     The set of tasks which must be executed to render this frame.
+     *
+     * @param {number} [timestamp]
+     *     The remote timestamp of sync instruction associated with this frame.
+     *     This timestamp is in milliseconds, but is arbitrary, having meaning
+     *     only relative to other remote timestamps in the same connection. If
+     *     omitted, a compatible but local timestamp will be used instead.
+     *
+     * @param {number} [logicalFrames=0]
+     *     The number of remote desktop frames that were combined to produce
+     *     this frame, or zero if this value is unknown or inapplicable.
      */
-    function Frame(callback, tasks) {
+    var Frame = function Frame(callback, tasks, timestamp, logicalFrames) {
+
+        /**
+         * The local timestamp of the point in time at which this frame was
+         * received by the display, in milliseconds since the Unix Epoch.
+         *
+         * @type {!number}
+         */
+        this.localTimestamp = new Date().getTime();
+
+        /**
+         * The remote timestamp of sync instruction associated with this frame.
+         * This timestamp is in milliseconds, but is arbitrary, having meaning
+         * only relative to other remote timestamps in the same connection.
+         *
+         * @type {!number}
+         */
+        this.remoteTimestamp = timestamp || this.localTimestamp;
+
+        /**
+         * The number of remote desktop frames that were combined to produce
+         * this frame. If unknown or not applicable, this will be zero.
+         *
+         * @type {!number}
+         */
+        this.logicalFrames = logicalFrames || 0;
 
         /**
          * Cancels rendering of this frame and all associated tasks. The
@@ -254,7 +504,7 @@
 
         };
 
-    }
+    };
 
     /**
      * A container for an task handler. Each operation which must be ordered
@@ -431,11 +681,20 @@
      * @param {function} [callback]
      *     The function to call when this frame is flushed. This may happen
      *     immediately, or later when blocked tasks become unblocked.
+     *
+     * @param {number} timestamp
+     *     The remote timestamp of sync instruction associated with this frame.
+     *     This timestamp is in milliseconds, but is arbitrary, having meaning
+     *     only relative to other remote timestamps in the same connection.
+     *
+     * @param {number} logicalFrames
+     *     The number of remote desktop frames that were combined to produce
+     *     this frame.
      */
-    this.flush = function(callback) {
+    this.flush = function(callback, timestamp, logicalFrames) {
 
         // Add frame, reset tasks
-        frames.push(new Frame(callback, tasks));
+        frames.push(new Frame(callback, tasks, timestamp, logicalFrames));
         tasks = [];
 
         // Attempt flush
@@ -1855,3 +2114,79 @@
  * @type {!number}
  */
 Guacamole.Display.VisibleLayer.__next_id = 0;
+
+/**
+ * A set of Guacamole display performance statistics, describing the speed at
+ * which the remote desktop, Guacamole server, and Guacamole client are
+ * rendering frames.
+ *
+ * @constructor
+ * @param {Guacamole.Display.Statistics|Object} [template={}]
+ *     The object whose properties should be copied within the new
+ *     Guacamole.Display.Statistics.
+ */
+Guacamole.Display.Statistics = function Statistics(template) {
+
+    template = template || {};
+
+    /**
+     * The amount of time that the Guacamole client is taking to render
+     * individual frames, in milliseconds, if known. If this value is unknown,
+     * such as if the there are insufficient frame statistics recorded to
+     * calculate this value, this will be null.
+     *
+     * @type {?number}
+     */
+    this.processingLag = template.processingLag;
+
+    /**
+     * The framerate of the remote desktop currently being viewed within the
+     * relevant Gucamole.Display, independent of Guacamole, in frames per
+     * second. This represents the speed at which the remote desktop is
+     * producing frame data for the Guacamole server to consume. If this
+     * value is unknown, such as if the remote desktop server does not actually
+     * define frame boundaries, this will be null.
+     *
+     * @type {?number}
+     */
+    this.desktopFps = template.desktopFps;
+
+    /**
+     * The rate at which the Guacamole server is generating frames for the
+     * Guacamole client to consume, in frames per second. If the Guacamole
+     * server is correctly adjusting for variance in client/browser processing
+     * power, this rate should closely match the client rate, and should remain
+     * independent of any network latency. If this value is unknown, such as if
+     * the there are insufficient frame statistics recorded to calculate this
+     * value, this will be null.
+     *
+     * @type {?number}
+     */
+    this.serverFps = template.serverFps;
+
+    /**
+     * The rate at which the Guacamole client is consuming frames generated by
+     * the Guacamole server, in frames per second. If the Guacamole server is
+     * correctly adjusting for variance in client/browser processing power,
+     * this rate should closely match the server rate, regardless of any
+     * latency on the network between the server and client. If this value is
+     * unknown, such as if the there are insufficient frame statistics recorded
+     * to calculate this value, this will be null.
+     *
+     * @type {?number}
+     */
+    this.clientFps = template.clientFps;
+
+    /**
+     * The rate at which the Guacamole server is dropping or combining frames
+     * received from the remote desktop server to compensate for variance in
+     * client/browser processing power, in frames per second. This value may
+     * also be non-zero if the server is compensating for variances in its own
+     * processing power, or relative slowness in image compression vs. the rate
+     * that inbound frames are received. If this value is unknown, such as if
+     * the remote desktop server does not actually define frame boundaries,
+     * this will be null.
+     */
+    this.dropRate = template.dropRate;
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/Tunnel.js b/guacamole-common-js/src/main/webapp/modules/Tunnel.js
index 23657cd..fa886f5 100644
--- a/guacamole-common-js/src/main/webapp/modules/Tunnel.js
+++ b/guacamole-common-js/src/main/webapp/modules/Tunnel.js
@@ -1094,7 +1094,7 @@
                     if (tunnel.uuid === null) {
 
                         // Associate tunnel UUID if received
-                        if (opcode === Guacamole.Tunnel.INTERNAL_DATA_OPCODE)
+                        if (opcode === Guacamole.Tunnel.INTERNAL_DATA_OPCODE && elements.length === 1)
                             tunnel.setUUID(elements[0]);
 
                         // Tunnel is now open and UUID is available
@@ -1236,12 +1236,17 @@
             tunnel.onstatechange = chained_tunnel.onstatechange;
             tunnel.oninstruction = chained_tunnel.oninstruction;
             tunnel.onerror = chained_tunnel.onerror;
-            tunnel.onuuid = chained_tunnel.onuuid;
 
             // Assign UUID if already known
             if (tunnel.uuid)
                 chained_tunnel.setUUID(tunnel.uuid);
 
+            // Assign any future received UUIDs such that they are
+            // accessible from the main uuid property of the chained tunnel
+            tunnel.onuuid = function uuidReceived(uuid) {
+                chained_tunnel.setUUID(uuid);
+            };
+
             committedTunnel = tunnel;
 
         }
diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json
index badefb1..c51f762 100644
--- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json
+++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json
@@ -100,6 +100,7 @@
                         "failsafe",
                         "fr-be-azerty",
                         "fr-fr-azerty",
+                        "fr-ca-qwerty",
                         "fr-ch-qwertz",
                         "hu-hu-qwertz",                        
                         "it-it-qwerty",
@@ -297,6 +298,11 @@
                     "name"    : "disable-glyph-caching",
                     "type"    : "BOOLEAN",
                     "options" : [ "true" ]
+                },
+                {
+                    "name"    : "disable-gfx",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
                 }
             ]
         },
diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js
index e1a08eb..03344b6 100644
--- a/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js
+++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js
@@ -34,16 +34,16 @@
 
         /**
          * The client whose status should be displayed.
-         * 
+         *
          * @type ManagedClient
          */
         client : '='
-        
+
     };
 
     directive.controller = ['$scope', '$injector', '$element',
         function guacClientNotificationController($scope, $injector, $element) {
-   
+
         // Required types
         const ManagedClient      = $injector.get('ManagedClient');
         const ManagedClientState = $injector.get('ManagedClientState');
@@ -53,6 +53,7 @@
         const $location              = $injector.get('$location');
         const authenticationService  = $injector.get('authenticationService');
         const guacClientManager      = $injector.get('guacClientManager');
+        const guacTranslate          = $injector.get('guacTranslate');
         const requestService         = $injector.get('requestService');
         const userPageService        = $injector.get('userPageService');
 
@@ -66,26 +67,6 @@
         $scope.status = false;
 
         /**
-         * All client error codes handled and passed off for translation. Any error
-         * code not present in this list will be represented by the "DEFAULT"
-         * translation.
-         */
-        const CLIENT_ERRORS = {
-            0x0201: true,
-            0x0202: true,
-            0x0203: true,
-            0x0207: true,
-            0x0208: true,
-            0x0209: true,
-            0x020A: true,
-            0x020B: true,
-            0x0301: true,
-            0x0303: true,
-            0x0308: true,
-            0x031D: true
-        };
-
-        /**
          * All error codes for which automatic reconnection is appropriate when a
          * client error occurs.
          */
@@ -98,26 +79,7 @@
             0x0301: true,
             0x0308: true
         };
-     
-        /**
-         * All tunnel error codes handled and passed off for translation. Any error
-         * code not present in this list will be represented by the "DEFAULT"
-         * translation.
-         */
-        const TUNNEL_ERRORS = {
-            0x0201: true,
-            0x0202: true,
-            0x0203: true,
-            0x0204: true,
-            0x0205: true,
-            0x0207: true,
-            0x0208: true,
-            0x0301: true,
-            0x0303: true,
-            0x0308: true,
-            0x031D: true
-        };
-     
+
         /**
          * All error codes for which automatic reconnection is appropriate when a
          * tunnel error occurs.
@@ -239,7 +201,7 @@
             // Get any associated status code
             const status = $scope.client.clientState.statusCode;
 
-            // Connecting 
+            // Connecting
             if (connectionState === ManagedClientState.ConnectionState.CONNECTING
              || connectionState === ManagedClientState.ConnectionState.WAITING) {
                 $scope.status = {
@@ -254,44 +216,58 @@
             // Client error
             else if (connectionState === ManagedClientState.ConnectionState.CLIENT_ERROR) {
 
-                // Determine translation name of error
-                const errorName = (status in CLIENT_ERRORS) ? status.toString(16).toUpperCase() : "DEFAULT";
+                // Translation IDs for this error code
+                const errorPrefix = "CLIENT.ERROR_CLIENT_";
+                const errorId = errorPrefix + status.toString(16).toUpperCase();
+                const defaultErrorId = errorPrefix + "DEFAULT";
 
                 // Determine whether the reconnect countdown applies
                 const countdown = (status in CLIENT_AUTO_RECONNECT) ? RECONNECT_COUNTDOWN : null;
 
-                // Show error status
-                notifyConnectionClosed({
-                    className : "error",
-                    title     : "CLIENT.DIALOG_HEADER_CONNECTION_ERROR",
-                    text      : {
-                        key : "CLIENT.ERROR_CLIENT_" + errorName
-                    },
-                    countdown : countdown,
-                    actions   : actions
-                });
+                // Use the guacTranslate service to determine if there is a translation for
+                // this error code; if not, use the default
+                guacTranslate(errorId, defaultErrorId).then(
+
+                    // Show error status
+                    translationResult => notifyConnectionClosed({
+                        className : "error",
+                        title     : "CLIENT.DIALOG_HEADER_CONNECTION_ERROR",
+                        text      : {
+                            key : translationResult.id
+                        },
+                        countdown : countdown,
+                        actions   : actions
+                    })
+                );
 
             }
 
             // Tunnel error
             else if (connectionState === ManagedClientState.ConnectionState.TUNNEL_ERROR) {
 
-                // Determine translation name of error
-                const errorName = (status in TUNNEL_ERRORS) ? status.toString(16).toUpperCase() : "DEFAULT";
+                // Translation IDs for this error code
+                const errorPrefix = "CLIENT.ERROR_TUNNEL_";
+                const errorId = errorPrefix + status.toString(16).toUpperCase();
+                const defaultErrorId = errorPrefix + "DEFAULT";
 
                 // Determine whether the reconnect countdown applies
                 const countdown = (status in TUNNEL_AUTO_RECONNECT) ? RECONNECT_COUNTDOWN : null;
 
-                // Show error status
-                notifyConnectionClosed({
-                    className : "error",
-                    title     : "CLIENT.DIALOG_HEADER_CONNECTION_ERROR",
-                    text      : {
-                        key : "CLIENT.ERROR_TUNNEL_" + errorName
-                    },
-                    countdown : countdown,
-                    actions   : actions
-                });
+                // Use the guacTranslate service to determine if there is a translation for
+                // this error code; if not, use the default
+                guacTranslate(errorId, defaultErrorId).then(
+
+                    // Show error status
+                    translationResult => notifyConnectionClosed({
+                        className : "error",
+                        title     : "CLIENT.DIALOG_HEADER_CONNECTION_ERROR",
+                        text      : {
+                            key : translationResult.id
+                        },
+                        countdown : countdown,
+                        actions   : actions
+                    })
+                );
 
             }
 
diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacFileTransfer.js b/guacamole/src/main/frontend/src/app/client/directives/guacFileTransfer.js
index a9c09bc..d016a72 100644
--- a/guacamole/src/main/frontend/src/app/client/directives/guacFileTransfer.js
+++ b/guacamole/src/main/frontend/src/app/client/directives/guacFileTransfer.js
@@ -30,7 +30,7 @@
 
             /**
              * The file transfer to display.
-             * 
+             *
              * @type ManagedFileUpload|ManagedFileDownload
              */
             transfer : '='
@@ -40,28 +40,13 @@
         templateUrl: 'app/client/templates/guacFileTransfer.html',
         controller: ['$scope', '$injector', function guacFileTransferController($scope, $injector) {
 
+            // Required services
+            const guacTranslate = $injector.get('guacTranslate');
+
             // Required types
             var ManagedFileTransferState = $injector.get('ManagedFileTransferState');
 
             /**
-             * All upload error codes handled and passed off for translation.
-             * Any error code not present in this list will be represented by
-             * the "DEFAULT" translation.
-             */
-            var UPLOAD_ERRORS = {
-                0x0100: true,
-                0x0201: true,
-                0x0202: true,
-                0x0203: true,
-                0x0204: true,
-                0x0205: true,
-                0x0301: true,
-                0x0303: true,
-                0x0308: true,
-                0x031D: true
-            };
-
-            /**
              * Returns the unit string that is most appropriate for the
              * number of bytes transferred thus far - either 'gb', 'mb', 'kb',
              * or 'b'.
@@ -193,7 +178,7 @@
                     return;
 
                 // Save file
-                saveAs($scope.transfer.blob, $scope.transfer.filename); 
+                saveAs($scope.transfer.blob, $scope.transfer.filename);
 
             };
 
@@ -210,23 +195,20 @@
                 return $scope.transfer.transferState.streamState === ManagedFileTransferState.StreamState.ERROR;
             };
 
-            /**
-             * Returns the text of the current error as a translation string.
-             *
-             * @returns {String}
-             *     The name of the translation string containing the text
-             *     associated with the current error.
-             */
-            $scope.getErrorText = function getErrorText() {
+            // The translated error message for the current status code
+            $scope.translatedErrorMessage = '';
+
+            $scope.$watch('transfer.transferState.statusCode', function statusCodeChanged(statusCode) {
 
                 // Determine translation name of error
-                var status = $scope.transfer.transferState.statusCode;
-                var errorName = (status in UPLOAD_ERRORS) ? status.toString(16).toUpperCase() : "DEFAULT";
+                const errorName = 'CLIENT.ERROR_UPLOAD_' + statusCode.toString(16).toUpperCase();
 
-                // Return translation string
-                return 'CLIENT.ERROR_UPLOAD_' + errorName;
+                // Use translation string, or the default if no translation is found for this error code
+                guacTranslate(errorName, 'CLIENT.ERROR_UPLOAD_DEFAULT').then(
+                    translationResult => $scope.translatedErrorMessage = translationResult.message
+                );
 
-            };
+            });
 
         }] // end file transfer controller
 
diff --git a/guacamole/src/main/frontend/src/app/client/services/guacTranslate.js b/guacamole/src/main/frontend/src/app/client/services/guacTranslate.js
new file mode 100644
index 0000000..c7fe8e9
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/client/services/guacTranslate.js
@@ -0,0 +1,82 @@
+/*
+ * 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.
+ */
+
+/**
+ * A wrapper around the angular-translate $translate service that offers a
+ * convenient way to fall back to a default translation if the requested
+ * translation is not available.
+ */
+ angular.module('client').factory('guacTranslate', ['$injector', function guacTranslate($injector) {
+
+    // Required services
+    const $q = $injector.get('$q');
+    const $translate = $injector.get('$translate');
+
+    // Required types
+    const TranslationResult = $injector.get('TranslationResult');
+
+    /**
+     * Returns a promise that will be resolved with a TranslationResult containg either the
+     * requested ID and message (if translated), or the default ID and message if translated,
+     * or the literal value of `defaultTranslationId` for both the ID and message if neither
+     * is translated.
+     *
+     * @param {String} translationId
+     *     The requested translation ID, which may or may not be translated.
+     *
+     * @param {Sting} defaultTranslationId
+     *     The translation ID that will be used if no translation is found for `translationId`.
+     *
+     * @returns {Promise.<TranslationResult>}
+     *     A promise which resolves with a TranslationResult containing the results from
+     *     the translation attempt.
+     */
+    var translateWithFallback = function translateWithFallback(translationId, defaultTranslationId) {
+        const deferredTranslation = $q.defer();
+
+        // Attempt to translate the requested translation ID
+        $translate(translationId).then(
+
+            // If the requested translation is available, use that
+            translation => deferredTranslation.resolve(new TranslationResult({
+                id: translationId, message: translation
+            })),
+
+            // Otherwise, try the default translation ID
+            () => $translate(defaultTranslationId).then(
+
+                // Default translation worked, so use that
+                defaultTranslation =>
+                    deferredTranslation.resolve(new TranslationResult({
+                        id: defaultTranslationId, message: defaultTranslation
+                    })),
+
+                // Neither translation is available; as a fallback, return default ID for both
+                () => deferredTranslation.resolve(new TranslationResult({
+                    id: defaultTranslationId, message: defaultTranslationId
+                })),
+            )
+        );
+
+        return deferredTranslation.promise;
+    };
+
+    return translateWithFallback;
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/client/templates/guacFileTransfer.html b/guacamole/src/main/frontend/src/app/client/templates/guacFileTransfer.html
index dd96baa..32ead84 100644
--- a/guacamole/src/main/frontend/src/app/client/templates/guacFileTransfer.html
+++ b/guacamole/src/main/frontend/src/app/client/templates/guacFileTransfer.html
@@ -10,7 +10,7 @@
         </div>
 
         <!-- Error text -->
-        <p class="error-text">{{getErrorText() | translate}}</p>
+        <p class="error-text">{{translatedErrorMessage}}</p>
 
     </div>
 
diff --git a/guacamole/src/main/frontend/src/app/client/types/TranslationResult.js b/guacamole/src/main/frontend/src/app/client/types/TranslationResult.js
new file mode 100644
index 0000000..0a81511
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/client/types/TranslationResult.js
@@ -0,0 +1,59 @@
+/*
+ * 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.
+ */
+
+/**
+ * Provides the TranslationResult class used by the guacTranslate service. This class contains
+ * both the translated message and the translation ID that generated the message, in the case
+ * where it's unknown whether a translation is defined or not.
+ */
+ angular.module('client').factory('TranslationResult', [function defineTranslationResult() {
+
+    /**
+     * Object which represents the result of a translation as returned from
+     * the guacTranslate service.
+     *
+     * @constructor
+     * @param {TranslationResult|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     TranslationResult.
+     */
+    const TranslationResult = function TranslationResult(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The translation ID.
+         *
+         * @type {String}
+         */
+        this.id = template.id;
+
+        /**
+         * The translated message.
+         *
+         * @type {String}
+         */
+        this.message = template.message;
+
+    };
+
+    return TranslationResult;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/frontend/src/app/home/templates/home.html b/guacamole/src/main/frontend/src/app/home/templates/home.html
index b2b3db5..06c0f22 100644
--- a/guacamole/src/main/frontend/src/app/home/templates/home.html
+++ b/guacamole/src/main/frontend/src/app/home/templates/home.html
@@ -4,14 +4,14 @@
 
         <!-- The recent connections for this user -->
         <div class="header">
-            <h2>{{'HOME.SECTION_HEADER_RECENT_CONNECTIONS' | translate}}</h2>
+            <h2 id="section-header-recent-connections">{{'HOME.SECTION_HEADER_RECENT_CONNECTIONS' | translate}}</h2>
             <guac-user-menu></guac-user-menu>
         </div>
         <guac-recent-connections root-groups="rootConnectionGroups"></guac-recent-connections>
 
         <!-- All connections for this user -->
         <div class="header">
-            <h2>{{'HOME.SECTION_HEADER_ALL_CONNECTIONS' | translate}}</h2>
+            <h2 id="section-header-all-connections">{{'HOME.SECTION_HEADER_ALL_CONNECTIONS' | translate}}</h2>
             <guac-group-list-filter connection-groups="rootConnectionGroups"
                 filtered-connection-groups="filteredRootConnectionGroups"
                 placeholder="'HOME.FIELD_PLACEHOLDER_FILTER' | translate"
diff --git a/guacamole/src/main/frontend/src/app/rest/services/patchService.js b/guacamole/src/main/frontend/src/app/rest/services/patchService.js
index 991a54a..ea06e5b 100644
--- a/guacamole/src/main/frontend/src/app/rest/services/patchService.js
+++ b/guacamole/src/main/frontend/src/app/rest/services/patchService.js
@@ -25,17 +25,16 @@
 
     // Required services
     var requestService        = $injector.get('requestService');
-    var authenticationService = $injector.get('authenticationService');
     var cacheService          = $injector.get('cacheService');
 
     var service = {};
-    
+
     /**
      * Makes a request to the REST API to get the list of patches, returning
      * a promise that provides the array of all applicable patches if
      * successful. Each patch is a string of raw HTML with meta information
      * describing the patch operation stored within meta tags.
-     *                          
+     *
      * @returns {Promise.<String[]>}
      *     A promise which will resolve with an array of HTML patches upon
      *     success.
@@ -43,14 +42,14 @@
     service.getPatches = function getPatches() {
 
         // Retrieve all applicable HTML patches
-        return authenticationService.request({
+        return requestService({
             cache   : cacheService.patches,
             method  : 'GET',
             url     : 'api/patches'
         });
 
     };
-    
+
     return service;
 
 }]);
diff --git a/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js b/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js
index 6a637ff..5f9a21a 100644
--- a/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js
+++ b/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js
@@ -323,7 +323,7 @@
 
             // Parse and reject with resulting JSON error
             else if (xhr.getResponseHeader('Content-Type') === 'application/json')
-                deferred.reject(angular.fromJson(xhr.responseText));
+                deferred.reject(new Error(angular.fromJson(xhr.responseText)));
 
             // Warn of lack of permission of a proxy rejects the upload
             else if (xhr.status >= 400 && xhr.status < 500)
diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json
index 6896d0a..05a9fa2 100644
--- a/guacamole/src/main/frontend/src/translations/en.json
+++ b/guacamole/src/main/frontend/src/translations/en.json
@@ -494,6 +494,7 @@
         "FIELD_HEADER_DISABLE_BITMAP_CACHING"     : "Disable bitmap caching:",
         "FIELD_HEADER_DISABLE_OFFSCREEN_CACHING"  : "Disable off-screen caching:",
         "FIELD_HEADER_DISABLE_GLYPH_CACHING"      : "Disable glyph caching:",
+        "FIELD_HEADER_DISABLE_GFX"                : "Disable Graphics Pipeline Extension:",
         "FIELD_HEADER_ENABLE_PRINTING"            : "Enable printing:",
         "FIELD_HEADER_ENABLE_SFTP"     : "Enable SFTP:",
         "FIELD_HEADER_ENABLE_THEMING"             : "Enable theming:",
@@ -582,6 +583,7 @@
         "FIELD_OPTION_SERVER_LAYOUT_ES_LATAM_QWERTY" : "Latin American (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
         "FIELD_OPTION_SERVER_LAYOUT_FR_BE_AZERTY" : "Belgian French (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_CA_QWERTY" : "Canadian French (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ" : "Swiss French (Qwertz)",
         "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "French (Azerty)",
         "FIELD_OPTION_SERVER_LAYOUT_HU_HU_QWERTZ" : "Hungarian (Qwertz)",
diff --git a/guacamole/src/main/frontend/src/translations/fr.json b/guacamole/src/main/frontend/src/translations/fr.json
index 110bbe0..3c8dc27 100644
--- a/guacamole/src/main/frontend/src/translations/fr.json
+++ b/guacamole/src/main/frontend/src/translations/fr.json
@@ -550,6 +550,7 @@
         "FIELD_OPTION_SERVER_LAYOUT_ES_LATAM_QWERTY" : "Latino-Américain (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
         "FIELD_OPTION_SERVER_LAYOUT_FR_BE_AZERTY" : "Français Belge (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_CA_QWERTY" : "Français Canada (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ" : "Français Suisse (Qwertz)",
         "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "Français (Azerty)",
         "FIELD_OPTION_SERVER_LAYOUT_HU_HU_QWERTZ" : "Hongrois (Qwertz)",
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupObjectTranslator.java b/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupObjectTranslator.java
index 721156a..88000b0 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupObjectTranslator.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupObjectTranslator.java
@@ -57,7 +57,7 @@
             throws GuacamoleException {
 
         // Filter object attributes by defined schema
-        object.setAttributes(filterAttributes(userContext.getUserAttributes(),
+        object.setAttributes(filterAttributes(userContext.getUserGroupAttributes(),
                 object.getAttributes()));
 
     }