GUACAMOLE-1643: Validate/translate KSM configs and one-time tokens on connection group save.
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 dbfbbb9..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
@@ -35,11 +35,16 @@
 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;
@@ -138,6 +143,13 @@
     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
@@ -438,4 +450,46 @@
 
     }
 
+    @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-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 6a7a70c..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
@@ -24,12 +24,14 @@
 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;
 
@@ -58,6 +60,7 @@
         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()
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..fc4a962
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java
@@ -0,0 +1,253 @@
+/*
+ * 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.DelegatingDirectory;
+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
+        return new DelegatingDirectory<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);
+            }
+
+        };
+    }
+}
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
index abda16c..4601a13 100644
--- 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
@@ -6,7 +6,10 @@
 
     "CONNECTION_GROUP_ATTRIBUTES" : {
         "SECTION_HEADER_KSM_CONFIG" : "Keeper Secrets Manager",
-        "FIELD_HEADER_KSM_CONFIG"   : "KSM Service Configuration "
+        "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."
     }
 
 }