WebDriverHandler.java

/*
** Module   : WebDriverHandler.java
** Abstract : Base class for Virtual Desktop and Embedded web handlers (web gui/chui launch).
**
** Copyright (c) 2013-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------Description---------------------------------
** 001 MAG 20131113 Created initial version
** 002 MAG 20131129 Add authentication page. Handle GET and POST requests.
** 003 MAG 20140206 Send URL as referrer address via configuration file.
** 004 CA  20140206 Changes related to refactoring the spawner infrastructure (allow P2J process
**                  clients to be started, beside web chui clients).
** 005 MAG 20140303 Add authorization token.
** 006 MAG 20140723 Send authorization token as a query parameter instead a cookie.
** 007 OM  20150108 Passed null map as additional environment to WebClientSpawner.spawn().
** 008 GES 20150127 Cleanup code formatting.
** 009 GES 20150311 Modified this to support both chui and gui web clients.
** 010 SBI 20160205 Changed the redirect HTTP response to the "text/html" response containing
**                  the redirect path in its body and changed the error page response to
**                  the error message to the ajax client.
**     GES 20160121 Added static implementation of WebClientLauncher interface for remote web
**                  client spawning. Reworked spawning implementation for embedded mode.
**                  Eliminated use of PlatformHelper, since that was testing the server OS and
**                  had no relation to the platform on which spawning was occurring.
** 011 CA  20160428 Added command-line configuration options for web-clients.
** 012 SBI 20170628 Changed to manage web clients.
** 013 SBI 20171023 Added getClientAddress and CLIENT_IP client address parameter key.
** 014 SBI 20181016 Added UI theme setup in the virtual desktop mode and set login form styles.
** 015 SBI 20210514 Added OPTIONS handler.
** 016 SBI 20230306 Added new exception that all network resource are used up, followed new implemented logic
**                  to spawn client process only after the network port is allocated successfully.
** 017 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 018 SBI 20230615 Fixed incorrect codes that hide the spawner error codes.
** 019 GBB 20230825 Complete rework. Renamed from WebHandler to WebDriverHandler. Now a base class for both 
**                  virtual desktop and embedded concrete implementations.
** 020 GBB 20231027 Adding storageId to the data injected in the html template element.
** 021 GBB 20231110 Fix for empty password leading to a missing argument in passwordless spawn.
** 022 GBB 20231113 Adding handleSessionFork.
**                  Cleanup for web:referrer:url.
**                  Adding session description from SsoAuthenticator to CLIENT_UUID_SESS_DESCR_PAIRS.
** 023 GBB 20231127 Changes to the theme options: an empty option added, no default selected.
** 024 GBB 20240111 Moves isValidTheme() to LogicalTerminal.isValidThemeClass().
**                  Default theme option to be selected.
** 025 GBB 20240214 Resolving deviceId and adding it to the spawn args to be available in the session 
**                  post-init. One browser policy implemented.
** 026 GBB 20240229 Adding log for attempt to start forked session with invalid token.
** 027 HC  20240513 Moved SPREADSHEET backend (Keikai web application and the related FWD
**                  integration) from client to server.
** 028 GBB 20240515 Adding new method filterOutValidLoginParams() and resolving webClient configs for 
**                  login params. Passing in the login url query params to the logout url.
** 029 GBB 20240517 Adding proxy context path to web page urls.
** 030 GBB 20240530 loadPartial() reworked to allow custom placeholders.
**                  REMOVE_PROXY_PATH_IN_WEB_PAGES made protected.
** 031 GBB 20240709 Hard-coded config names replaced by ConfigItem constants. Store web client builder 
**                  options for each web context in driverOptionsMap and reset the overrides with each new 
**                  spawn request. Split the runtime web options in multiple fields for proper merge after 
**                  the project token is resolved.
** 032 GBB 20240730 Resolve the OS user once for all requests.
** 033 CA  20250221 If the SSO plugin specifies OS credentials (username/password), use those.
*/
/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
** 
** Additional terms under GNU Affero GPL version 3 section 7:
** 
**   Under Section 7 of the GNU Affero GPL version 3, the following additional
**   terms apply to the works covered under the License.  These additional terms
**   are non-permissive additional terms allowed under Section 7 of the GNU
**   Affero GPL version 3 and may not be removed by you.
** 
**   0. Attribution Requirement.
** 
**     You must preserve all legal notices or author attributions in the covered
**     work or Appropriate Legal Notices displayed by works containing the covered
**     work.  You may not remove from the covered work any author or developer
**     credit already included within the covered work.
** 
**   1. No License To Use Trademarks.
** 
**     This license does not grant any license or rights to use the trademarks
**     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
**     of Golden Code Development Corporation. You are not authorized to use the
**     name Golden Code, FWD, or the names of any author or contributor, for
**     publicity purposes without written authorization.
** 
**   2. No Misrepresentation of Affiliation.
** 
**     You may not represent yourself as Golden Code Development Corporation or FWD.
** 
**     You may not represent yourself for publicity purposes as associated with
**     Golden Code Development Corporation, FWD, or any author or contributor to
**     the covered work, without written authorization.
** 
**   3. No Misrepresentation of Source or Origin.
** 
**     You may not represent the covered work as solely your work.  All modified
**     versions of the covered work must be marked in a reasonable way to make it
**     clear that the modified work is not originating from Golden Code Development
**     Corporation or FWD.  All modified versions must contain the notices of
**     attribution required in this license.
*/

package com.goldencode.p2j.main;

import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.*;
import com.goldencode.p2j.net.*;
import com.goldencode.p2j.security.*;
import com.goldencode.p2j.security.SecurityManager;
import com.goldencode.p2j.translation.*;
import com.goldencode.p2j.ui.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;
import com.goldencode.util.*;
import org.eclipse.jetty.http.*;
import org.eclipse.jetty.server.*;
import org.eclipse.jetty.server.handler.*;
import org.eclipse.jetty.util.*;

import javax.servlet.http.*;
import java.io.*;
import java.lang.ref.*;
import java.net.*;
import java.nio.charset.*;
import java.util.*;
import java.util.Scanner;
import java.util.concurrent.*;
import java.util.logging.*;
import java.util.regex.*;

/** Base class for Virtual Desktop and Embedded web handlers. */
public abstract class WebDriverHandler
extends HandlerCollection
{
   /** Map of client uuid : fwd user for sso. */
   public static final ConcurrentMap<String, String> CLIENT_UUID_FWD_USERS = new ConcurrentHashMap<>();

   /** Flag to indicate if the One Browser Policy is enabled. */
   public static final boolean ONE_BROWSER_POLICY = ConfigItem.ONE_BROWSER_POLICY.read(false);
   
   /** The logout page context path. */
   private static final String CONTEXT_PATH_LOGOUT = "/logout";

   /** The root portion of the GUI client request name (part of the URL). */
   static final String TARGET_GUI_ROOT = "/" + ConfigItem.GUI_IDENTIFIER.read("gui");

   /** The root portion of the ChUI client request name (part of the URL). */
   static final String TARGET_CHUI_ROOT = "/" + ConfigItem.CHUI_IDENTIFIER.read("chui");
   
   /** The portion of the GUI client request name (part of the URL). */
   static final String TARGET_GUI_LOGOUT = TARGET_GUI_ROOT + CONTEXT_PATH_LOGOUT;
   
   /** The portion of the CHUI client request name (part of the URL). */
   static final String TARGET_CHUI_LOGOUT = TARGET_CHUI_ROOT + CONTEXT_PATH_LOGOUT;

   /** The base path for this handler. */
   static final String TARGET_EMBEDDED = "/" + ConfigItem.EMBEDDED_IDENTIFIER.read("embedded");

   /** Map of client uuid : authentication blob for sso. */
   static final ConcurrentMap<String, String> CLIENT_UUID_AUTHBLOB_PAIRS = new ConcurrentHashMap<>();
   
   /** Map of client uuid : session description. */
   static final ConcurrentMap<String, String> CLIENT_UUID_SESS_DESCR_PAIRS = new ConcurrentHashMap<>();
   
   /** Map of client uuid : related session ID. */
   static final ConcurrentMap<String, String> CLIENT_UUID_RELATED_SESSION_ID_PAIRS = new ConcurrentHashMap<>();

   /** Map of client uuid : device ID. */
   static final ConcurrentMap<String, String> CLIENT_UUID_DEVICEID_PAIRS = new ConcurrentHashMap<>();
   
   /**
    * Map of client uuid : ALL login url param name : values (to be validated and filtered out before 
    * accessed by 4GL).
    */
   static final ConcurrentMap<String, Map<String, String[]>> CLIENT_UUID_LOGIN_PARAMS = new ConcurrentHashMap<>();

   /** Replaces the former WebClientBuilderParameters.cbo  */
   static final Map<WebDriverType, WebClientBuilderOptions> driverOptionsMap = new ConcurrentHashMap<>();
   
   /** Template placeholder : current value pairs. Should not be updated with request specific values. */
   protected static final Map<String, String> PLACEHOLDER_VALUE_PAIRS = new HashMap<>();

   /** Template placeholder for page title. */
   protected static final String PLACEHOLDER_TITLE = "${title}";

   /** Template placeholder for the newly spawned client url. */
   protected static final String AUTO_LOGIN_DATA = "${autoLoginData}";
   
   /** Template placeholder for the html template. */
   protected static final String PLACEHOLDER_HTML_TEMPLATE = "${html}";
   
   /** Template placeholder for a reverse proxy custom context path. */
   protected static final String PLACEHOLDER_PROXY_PATH = "${proxyPath}";

   /** The application's package root, in folder form. */
   protected static final String PKG_ROOT = Utils.getLegacyPkgRootAsJarPath();

   /** The login page title. */
   protected static final String PAGE_TITLE_LOGIN = ConfigItem.LOGIN_TITLE.read(null);

   /** The logout page title. */
   protected static final String PAGE_TITLE_LOGOUT = ConfigItem.LOGOUT_TITLE.read(null);
   
   /**
    * Flag to remove the proxy path from login/logout page urls to allow reverse proxy mapping of
    * {@link WebDriverHandler#TARGET_GUI_ROOT} and
    * {@link WebDriverHandler#TARGET_CHUI_ROOT} directly to the proxy path. In this case 
    * directory webClient configs 'serverProxyPath' and 'guiIdentifier' / 'chuiIdentifier' should be the same.
    */
   protected static final boolean REMOVE_PROXY_PATH_IN_WEB_PAGES =
      ConfigItem.PROXY_REMOVE_SERVER_PATH.read(false);
   
   /** Translations manager. */
   protected static final SessionlessTranslationManager TRANSLATION_MANAGER =
      new SessionlessTranslationManager(WebDriverHandler.class.getSimpleName());

   /** The dir name in the custom package where the relevant res reside. */
   static final String PKG_RES_DIR = "webres/";
   
   /** The reverse proxy custom context path. */
   private static final String PROXY_PATH = ConfigItem.PROXY_SERVER_PATH.read(null);
   
   /** The context for the base web resources (css, favicon, logo) */
   private static final String TARGET_BASE_RES = "/webres";
   
   /** Map of the supported file ext and their corresponding mime types. */
   private static final Map<String, String> FILE_EXT_MIME_TYPE_PAIRS = new HashMap<String, String>()
   {{
      put("png", "image/png");
      put("ico", "image/x-icon");
      put("svg", "image/svg+xml");
      put("css", "text/css");
      put("js", "text/javascript");
   }};
   
   /** Directory config for the host:port when reverse proxy enabled. */
   private static final String FORWARDED_HOST_CONFIG = ConfigItem.PROXY_HOST.read(null);

   /** Directory config for the protocol when reverse proxy enabled. */
   private static final String FORWARDED_PROTO_CONFIG = 
      ConfigItem.PROXY_PROTOCOL.read(HttpScheme.HTTPS.asString());

   /** The name of the FWD device ID cookie. */
   private static final String DEVICE_ID_COOKIE_NAME = "FWD-Device-ID";
   
   /** The divider between the device ID and the signature in the FWD device ID cookie. */
   private static final String DEVICE_ID_COOKIE_DIVIDER = "|";
   
   /** Session initialization / termination listener. Kept as static field to prevent GC. */
   private static final InitTermListener SESSION_LISTENER;

   /** Collections of all live sessions sorted by the corresponding device ID. */
   private static final Map<String, List<String>> deviceIdSessionIdPairs = new ConcurrentHashMap<>();
   
   /** Pairs of session id (accessible from session handle) : session net id (from security manager). */
   private static final Map<String, Object> sessionIdNetIdPairs = new ConcurrentHashMap<>();

   /** Pairs of session id : ClientExports network interface. */
   private static final Map<String, WeakReference<ClientExports>> sessionIdClientPairs =
      new ConcurrentHashMap<>();

   /** Map of name and validation regex for login request params configured in directory's webClient. */
   private static final Map<String, Pattern> expectedLoginParamNameRegex = new ConcurrentHashMap<>();
   
   /** Logger. */
   private static final CentralLogger LOG = CentralLogger.get(WebDriverHandler.class);
   
   /** The server's secure port or -1 if this is a remote launch. */
   private static final int SECURE_PORT;
   
   /**
    * The alias of the server certificate in the certificate key store which will be used to authenticate 
    * that this server indeed is on the other side of the secure connection.
    */
   private static final String SERVER_ALIAS;

   /** Indicates the OS user for the SSO login should be overwritten by {@link #DEFAULT_OS_USER}. */
   private static final boolean OS_USER_OVERRIDE = ConfigItem.OVERRIDE_OS_USER.read(false);

   /** The default OS user with SSO login. */
   private static final String DEFAULT_OS_USER = ConfigItem.DEFAULT_OS_USER.read(null);

   /** The signature instance to sign and verify device id cookies. */
   private static DigitalSignature digitalSignature;

   /** The bootstrap config overrides to be applied first. Lowest priority. */
   private final List<String> firstWebOverrides;
   
   /** The bootstrap config overrides coming from directory. */
   private final List<String> baseDirWebOverrides;
   
   /** The bootstrap config overrides to be applied last. Highest priority. */
   private final List<String> lastWebOverrides;
   
   static
   {
      // One Browser Policy
      SESSION_LISTENER = !ONE_BROWSER_POLICY ? null : new WebSessionListener();
      
      if (SESSION_LISTENER != null)
      {
         StandardServer.registerSessionListener(SESSION_LISTENER);
      }
      
      // Default placeholders
      PLACEHOLDER_VALUE_PAIRS.put(AUTO_LOGIN_DATA, "");
      
      // UI Themes
      List<ThemeOption> themes = LogicalTerminal.getThemes();

      ObjectMapper mapper = new ObjectMapper();
      ArrayNode array = mapper.createArrayNode();

      if (LogicalTerminal.getRequiredTheme() == null && themes.size() > 1)
      {
         for (ThemeOption theme : themes)
         {
            ObjectNode themeNode = mapper.createObjectNode();
            themeNode.put("presentationName", theme.getPresentationName());
            themeNode.put("className", theme.getClassName());
            themeNode.put("isDefault", theme.isDefault());
            array.add(themeNode);
         }
      }
      PLACEHOLDER_VALUE_PAIRS.put("${themes}", array.toString());
      
      // Device ID signature
      try
      {
         digitalSignature = DigitalSignature.fromServerKeyStore();
      }
      catch (Exception e)
      {
         LOG.severe("Couldn't instantiate DigitalSignature. Device ID will not be applicable to requests.",
                    e);
      }
      
      // Login page url params
      List<String> expectedParams = ConfigItem.LOGIN_PARAMS.read(null);
      for (String expectedParam : expectedParams)
      {
         String regex = ConfigItem.LOGIN_PARAMS_REGEX.read(null, expectedParam);
         if (regex == null)
         {
            LOG.log(Level.WARNING,
                    "No regex found for expected login param %s. Please, check webClient configurations.",
                    expectedParam);
            continue;
         }
         try
         {
            Pattern pattern = Pattern.compile(regex);
            expectedLoginParamNameRegex.put(expectedParam, pattern);
         }
         catch (Throwable t)
         {
            String msg =
               "The regex provided for login param %s is invalid. Please, check webClient configurations.";
            LOG.log(Level.WARNING, t, msg, expectedParam);
         }
      }

      // in trusted mode, we must authenticate by using the NativeSecureConnection
      // to access the P2J server and get our temporary account; this requires some
      // additional secure connection configuration to be available
      SERVER_ALIAS = SecurityManager.getInstance().getServerAlias();
      SECURE_PORT = SessionManager.get().config().getInt(ConfigItem.SERVER_SECURE_PORT, -1);
      
      // Directory-derived options
      if (ConfigItem.VIRTUAL_DESKTOP.read(false))
      {
         driverOptionsMap.put(WebDriverType.CHUI, new WebClientBuilderOptions(WebDriverType.CHUI));
         driverOptionsMap.put(WebDriverType.GUI, new WebClientBuilderOptions(WebDriverType.GUI));
      }
      
      if (ConfigItem.EMBEDDED.read(false))
      {
         driverOptionsMap.put(WebDriverType.EMBEDDED, new WebClientBuilderOptions(WebDriverType.EMBEDDED));
      }
   }

   /**
    * Package-private constructor. Reads web client config overrides and creates an options list.
    *
    * @param    firstOption
    *           An additional option to be added at the start of the options. Can be overwritten.
    * @param    lastOption
    *           An additional option to be added at the end of the options. Should not be overwritten.
    */
   WebDriverHandler(String firstOption, String lastOption)
   {
      firstWebOverrides = firstOption != null ?
         Collections.singletonList(firstOption) :
         Collections.emptyList();
      
      baseDirWebOverrides = Arrays.asList(ConfigItem.CFG_OVERRIDES_WEB.read("").split(" "));
      
      lastWebOverrides = lastOption != null ? 
         Collections.singletonList(lastOption) :
         Collections.emptyList();
   }

   /**
    * Returns a list of session ids for the specified device id.
    * 
    * @param    deviceId
    *           The device id.
    * 
    * @return   List of session ids for the specified device id.
    */
   public static List<String> getSessionsByDeviceId(String deviceId)
   {
      return deviceIdSessionIdPairs.get(deviceId);
   }

   /**
    * Returns the session net id for the specified session id.
    *
    * @param    sessionId
    *           The session id (as in the session handle).
    *
    * @return   The session net id.
    */
   public static Object getSessionNetId(String sessionId)
   {
      return sessionIdNetIdPairs.get(sessionId);
   }

   /**
    * Returns the network interface of the client.
    *
    * @param    sessionId
    *           The session id (as in the session handle).
    *
    * @return   The network interface of the client.
    */
   public static ClientExports getSessionClientInterface(String sessionId)
   {
      return sessionIdClientPairs.get(sessionId).get();
   }
   
   /**
    * Returns the proxy path as absolute path if configured in directory, otherwise an empty string.
    *
    * @param    request
    *           The HTTP request.
    *
    * @return   See above.
    */
   public static String getServerProxyPath(HttpServletRequest request)
   {
      return getServerProxyPath(resolveForwardedHost(request));
   }
   
   /**
    * Returns the proxy path as absolute path if configured in directory, otherwise an empty string.
    *
    * @param    forwardedHost
    *           The proxy address.
    *           
    * @return   See above.
    */
   public static String getServerProxyPath(String forwardedHost)
   {
      if (!StringHelper.hasContent(forwardedHost))
      {
         // no proxy
         return "";
      }
      if (!StringHelper.hasContent(PROXY_PATH))
      {
         // no config
         return "";
      }
      return "/" + PROXY_PATH;
   }

   /**
    * Returns a map of all valid login params according to directory configurations.
    * 
    * @param    loginParams
    *           The map of name : values for the params sent by the login request.
    *           
    * @return   See above.
    */
   public static Map<String, String[]> filterOutValidLoginParams(Map<String, String[]> loginParams)
   {
      Map<String, String[]> validatedParams = new HashMap<>();
      
      for (String expectedParamName : expectedLoginParamNameRegex.keySet())
      {
         String[] actualValues = loginParams.get(expectedParamName);
         if (actualValues == null)
         {
            continue;
         }
         
         Pattern expectedParamPattern = expectedLoginParamNameRegex.get(expectedParamName);
         List<String> validatedValues = new ArrayList<>();
         for (int valueIndex = 0; valueIndex < actualValues.length; valueIndex++)
         {
            String actualValue = actualValues[valueIndex];
            if (!expectedParamPattern.matcher(actualValue).matches())
            {
               if (LOG.isLoggable(Level.FINER))
               {
                  LOG.log(Level.FINER,
                          "A successful login request for FWD user %s has invalid value '%s' for param '%s'.",
                          SecurityManager.getInstance().getUserId(),
                          actualValue,
                          expectedParamName);
               }
               continue;
            }
            validatedValues.add(actualValue);
         }

         if (!validatedValues.isEmpty())
         {
            validatedParams.put(expectedParamName, validatedValues.toArray(new String[0]));
         }
      }
      
      return validatedParams;
   }

   /**
    * Handle HTTP requests for common resources (css style, favicon and logo image).
    *
    * @param    target
    *           The target of the request - either a URI or a name.
    * @param    base
    *           The HTTP request, Jetty style.
    * @param    request
    *           The HTTP request.
    * @param    response
    *           The HTTP response.
    */
   @Override
   public void handle(String target, Request base, HttpServletRequest request, HttpServletResponse response)
   {
      try
      {
         super.handle(target, base, request, response);
      
         if (base.isHandled())
         {
            return;
         }
   
         target = target.toLowerCase();
         if (target.startsWith(TARGET_BASE_RES) && request.getMethod().toUpperCase().equals("GET"))
         {
            base.setHandled(true);
            
            String fileName = target.replaceFirst("/" + PKG_RES_DIR, "");
            String fileExt = fileName.substring(fileName.lastIndexOf(".") + 1);
            String mimeType = FILE_EXT_MIME_TYPE_PAIRS.getOrDefault(fileExt, "text/plain");

            Map<String, String> placeholderValuePairs = fileExt.equals("js") ? 
               new HashMap<String, String>(PLACEHOLDER_VALUE_PAIRS) {{
                  put(UiTextKey.MSG_SDK_DEFAULT_REQUEST_ERROR.name(),
                      TRANSLATION_MANAGER.translate(UiTextKey.MSG_SDK_DEFAULT_REQUEST_ERROR.name()));
               }} :
               PLACEHOLDER_VALUE_PAIRS;
            
            serveStatic(response,
                        mimeType,
                        placeholderValuePairs,
                        PKG_ROOT + PKG_RES_DIR + fileName,
                        getResPathCurrentDir(fileName));
         }
      }
      catch (Throwable t)
      {
         LOG.warning("Unexpected exception:", t);
         sendError(response, SupportedHttpCode.UNEXPECTED_ERROR, null);
      }
   }

   /**
    * Handles HTTP requests for spawning a new client.
    *
    * @param    base
    *           The HTTP request, Jetty style.
    * @param    request
    *           The HTTP request.
    * @param    response
    *           The HTTP response.
    * @param    isSso
    *           Flag indicating if SSO is enabled.
    * @param    driverType
    *           The type of web client - GUI / CHUI / embedded.
    */
   protected void handleStartClientRequest(Request base,
                                           HttpServletRequest request,
                                           HttpServletResponse response,
                                           boolean isSso,
                                           WebDriverType driverType)
   {
      WebDriverRequestParameters requestParameters = getRequestParameters(base, request);
      
      String deviceId = setDeviceId(requestParameters, response);
      
      List<String> options = addThemeToOptions(requestParameters.getTheme());
      options = addUrlsToOptions(options, requestParameters, driverType);
      WebDriverSpawnParameters spawnParams = new WebDriverSpawnParameters(driverType,
                                                                          isSso,
                                                                          options,
                                                                          deviceId);

      SpawnerResult spawnerResult = spawnWorker(requestParameters, spawnParams);
      if (spawnerResult.uri == null)
      {
         sendError(response, spawnerResult.rc, spawnerResult.spawnError);
         return;
      }

      if (LOG.isLoggable(Level.INFO))
      {
         LOG.log(Level.INFO,
                 "Returns the redirect url %s as response to %s client.",
                 spawnerResult.uri,
                 driverType);
      }
      writeUrlInResponse(spawnerResult.uri,
                         spawnerResult.storageId,
                         response,
                         spawnerResult.webClientUuid,
                         spawnerResult.cookie);
   }
   
   /**
    * Retrieves the FWD device ID cookie and resets the expiration date. If not present, generates a new 
    * device ID and adds the cookie to the response. Returns the device ID.
    *
    * @param    requestParameters
    *           Holds HTTP derived parameters - headers, cookies, form params.
    * @param    response
    *           The HTTP response.
    *
    * @return   The device ID.
    */
   protected String setDeviceId(WebDriverRequestParameters requestParameters, HttpServletResponse response)
   {
      Cookie[] cookies = requestParameters.getCookies();
      
      Tuple<String, Cookie> deviceIdCookiePair = getValidDeviceId(cookies, requestParameters.getUser());
      deviceIdCookiePair = deviceIdCookiePair == null ? generateDeviceId() : deviceIdCookiePair;
      if (deviceIdCookiePair == null)
      {
         return null;
      }
      
      Cookie deviceIdCookie = deviceIdCookiePair.getValue();
      setDeviceIdCookieAttributes(deviceIdCookie);
      response.addCookie(deviceIdCookie);
      
      return deviceIdCookiePair.getKey();
   }

   /**
    * Spawn a client with the given account credentials and UI mode.
    *
    * @param    requestParameters
    *           Holds HTTP derived parameters - headers, cookies, form params.
    * @param    spawnParameters
    *           Holds additional parameters required for the spawn process.
    *
    * @return   The redirect URI will be populated if successful, otherwise the URI will be
    *           <code>null</code> and the return code will describe the error. 
    */
   protected SpawnerResult spawnWorker(WebDriverRequestParameters requestParameters,
                                       WebDriverSpawnParameters spawnParameters)
   {
      SecurityManager sm = SecurityManager.getInstance();
      SpawnerResult result = new SpawnerResult();
      result.rc = SupportedHttpCode.UNEXPECTED_ERROR;

      String user = requestParameters.getUser();
      String pw = requestParameters.getPassword();
      String authBlob = spawnParameters.isSessionFork() ?
         spawnParameters.getSessionForkParams().getAuthBlob() : 
         null;
      String fwdUser = spawnParameters.isSessionFork() ?
         spawnParameters.getSessionForkParams().getFwdUser() : 
         null;
      String storageId = spawnParameters.isSessionFork() ?
         spawnParameters.getSessionForkParams().getStorageId() :
         null;
      String sessionDescription = spawnParameters.isSessionFork() ?
         "Forked session" :
         null;
      
      if (!spawnParameters.isSessionFork() && spawnParameters.isSso())
      {
         if (spawnParameters.getSsoAuthResult() != null)
         {
            // auto login has already completed
            authBlob = spawnParameters.getSsoAuthResult().getAuthBlob();
            fwdUser = spawnParameters.getSsoAuthResult().getFwdUser();
            storageId = spawnParameters.getSsoAuthResult().getPersistentUserStorageIdentifier();
            sessionDescription = spawnParameters.getSsoAuthResult().getSessionDescription();
         }
         else
         {
            SsoAuthenticator ssoAuthenticator = sm.ssoSm.getSsoAuthenticator();
            if (ssoAuthenticator == null)
            {
               result.spawnError = TRANSLATION_MANAGER.translate(UiTextKey.MSG_AUTHENTICATOR_ISSUE.name());
               LOG.warning(result.spawnError);
               return result;
            }

            SsoAuthenticator.Result ssoAuthResult =
               ssoAuthenticator.authenticate(requestParameters.getParamMap(),
                                             requestParameters.getCookies(),
                                             new SsoAuthenticator.LicensingData(
                                                requestParameters.getExternalUserIp(),
                                                requestParameters.getInternalUserIp(),
                                                spawnParameters.getDeviceId()));

            if (!ssoAuthResult.isSuccess())
            {
               result.rc = ssoAuthResult.getFailureHttpCode() != null ? 
                  ssoAuthResult.getFailureHttpCode() :
                  SupportedHttpCode.UNAUTHORIZED;
               
               result.spawnError = ssoAuthResult.getTranslatedErrorMessage() != null ?
                  ssoAuthResult.getTranslatedErrorMessage() :
                  TRANSLATION_MANAGER.translate(UiTextKey.MSG_INVALID_CREDENTIALS.name());
               
               LOG.fine("SSO authentication failed. " + result.spawnError);
               return result;
            }

            SsoUsers ssoUsers = processSsoResult(ssoAuthResult, sm, result);
            if (ssoUsers == null)
            {
               LOG.warning(result.spawnError);
               result.osUser = "";
               return result;
            }
            
            LOG.log(Level.FINE, "Attempting spawn for SSO authenticated app user %s.", user);

            authBlob = ssoAuthResult.getAuthBlob();
            storageId = ssoAuthResult.getPersistentUserStorageIdentifier();
            // replace in-app user & pass with os user & pass
            user = ssoUsers.osUser;
            pw = ssoUsers.osPass;
            fwdUser = ssoUsers.fwdUser;
            sessionDescription = ssoAuthResult.getSessionDescription();
         }

         // the override flag allows the server to force the OS user to a specific value
         // honor default OS user if user was not specified
         if (OS_USER_OVERRIDE || user == null)
         {
            if (DEFAULT_OS_USER == null)
            {
               result.spawnError =
                  String.format(TRANSLATION_MANAGER.translate(UiTextKey.MSG_DEFAULT_OS_USER_MISSING.name()), OS_USER_OVERRIDE);
               LOG.warning(result.spawnError);
               return result;
            }

            String[] osUserCred = sm.getOsUserCredByName(DEFAULT_OS_USER);
            if (osUserCred == null)
            {
               result.spawnError = String.format(TRANSLATION_MANAGER.translate(UiTextKey.MSG_OSUSER_NOT_LISTED.name()),
                                                 DEFAULT_OS_USER);
               LOG.warning(result.spawnError);
               return result;
            }
            user = osUserCred[0];
            pw = osUserCred[1];
         }
      }
      result.osUser = user;

      LOG.log(Level.FINE, "Attempting spawn under OS user %s.", user);

      pw = StringHelper.hasContent(pw) ? pw : null;
      boolean trusted = pw == null;
         
      WebClientBuilderParameters builderParams =
         new WebClientBuilderParameters(user,
                                        pw,
                                        driverOptionsMap.get(spawnParameters.getDriverType()),
                                        SECURE_PORT,
                                        SERVER_ALIAS,
                                        trusted,
                                        fwdUser,
                                        storageId,
                                        firstWebOverrides,
                                        baseDirWebOverrides,
                                        spawnParameters.getRuntimeWebOptions(),
                                        lastWebOverrides);
                               
      WebClientsManager webClientsManager = WebClientsManager.getInstance();
      WebClientSpawner spawner = new WebClientSpawner(builderParams, webClientsManager);

      String clientUuid = builderParams.getUuid();
      if (authBlob != null)
         CLIENT_UUID_AUTHBLOB_PAIRS.put(clientUuid, authBlob);
      if (fwdUser != null)
         CLIENT_UUID_FWD_USERS.put(clientUuid, fwdUser);
      if (spawnParameters.isSessionFork())
         CLIENT_UUID_RELATED_SESSION_ID_PAIRS.put(clientUuid,
                                                  spawnParameters.getSessionForkParams().getRelatedSessionId());

      if (sessionDescription != null)
         CLIENT_UUID_SESS_DESCR_PAIRS.put(clientUuid, sessionDescription);

      CLIENT_UUID_DEVICEID_PAIRS.put(clientUuid, spawnParameters.getDeviceId());
      CLIENT_UUID_LOGIN_PARAMS.put(clientUuid, requestParameters.getParamMap());

      int exitCode = spawner.spawn(webClientsManager,
                                   new String[] {
                                      requestParameters.getForwardedHost(),
                                      requestParameters.getForwardedProto(),
                                      requestParameters.getExternalUserIp()});
      
      if (exitCode == 0)
      {
         try
         {
            result.uri = spawner.getRemoteUri();
            result.storageId = storageId;
            result.rc = SupportedHttpCode.SUCCESS;
         }
         catch (URISyntaxException e)
         {
            LOG.log(Level.SEVERE,
                    e,
                    "Rewrite failed: X_FORWARDED_HOST=%s X_FORWARDED_PROTO=%s",
                    requestParameters.getForwardedHost(),
                    requestParameters.getForwardedProto());
         }
      }
      
      if (result.uri == null) 
      {
         CLIENT_UUID_AUTHBLOB_PAIRS.remove(clientUuid);
         CLIENT_UUID_FWD_USERS.remove(clientUuid);
         CLIENT_UUID_SESS_DESCR_PAIRS.remove(clientUuid);
         CLIENT_UUID_RELATED_SESSION_ID_PAIRS.remove(clientUuid);
         CLIENT_UUID_DEVICEID_PAIRS.remove(clientUuid);
         CLIENT_UUID_LOGIN_PARAMS.remove(clientUuid);
         
         SpawnError spawnError = SpawnError.get(exitCode).orElse(SpawnError.DEFAULT_SPAWN_ERR);
         result.spawnError = String.format(TRANSLATION_MANAGER.translate(spawnError.name()), exitCode);
         if (result.rc == SupportedHttpCode.SUCCESS || result.rc == null)
         {
            result.rc = !spawnParameters.isSso() && spawnError.isInvalidOsCredentials() ?
               SupportedHttpCode.UNAUTHORIZED :
               SupportedHttpCode.UNEXPECTED_ERROR;
         }
      }
      
      result.webClientUuid = clientUuid;
      return result;
   }

   /**
    * Handles HTTP requests that allow SSO auto-login and direct spawn of a new client.
    *
    * @param    requestParameters
    *           Holds HTTP derived parameters - headers, cookies, form params.
    * @param    deviceId
    *           The device ID.
    * @param    driverType
    *           The type of web client - GUI / CHUI / embedded.
    *
    * @return   The result of the spawn attempt.
    */
   protected SpawnerResult handleSsoAutoLogin(WebDriverRequestParameters requestParameters,
                                              String deviceId,
                                              WebDriverType driverType)
   {
      Cookie[] cookies = requestParameters.getCookies();
      if (cookies == null || cookies.length <= 0)
      {
         return null;
      }

      SecurityManager sm = SecurityManager.getInstance();
      
      SsoAuthenticator ssoAuthenticator = sm.ssoSm.getSsoAuthenticator();
      if (ssoAuthenticator == null)
      {
         return null;
      }

      String authCookieName = ssoAuthenticator.getAuthCookieName();
      if (authCookieName == null)
      {
         return null;
      }
      boolean hasAuthCookie = false;
      for (int i = 0; i < cookies.length; i++)
      {
         Cookie cookie = cookies[i];
         if (cookie.getName().equals(authCookieName))
         {
            hasAuthCookie = true;
            break;
         }
      }

      if (!hasAuthCookie)
      {
         return null;
      }
      
      SsoAuthenticator.Result ssoAuthResult =
         ssoAuthenticator.authenticate(cookies,
                                       new SsoAuthenticator.LicensingData(requestParameters.getExternalUserIp(),
                                                                          requestParameters.getInternalUserIp(),
                                                                          deviceId));
      if (ssoAuthResult == null || !ssoAuthResult.isSuccess())
      {
         return null;
      }

      SpawnerResult result = new SpawnerResult();
      SsoUsers ssoUsers = processSsoResult(ssoAuthResult, sm, result);
      if (ssoUsers == null)
      {
         return null;
      }

      requestParameters.setUser(ssoUsers.osUser);
      requestParameters.setPassword(ssoUsers.osPass);
      
      List<String> options = addThemeToOptions(requestParameters.getTheme());
      options = addUrlsToOptions(options, requestParameters, driverType);
      
      WebDriverSpawnParameters spawnParams = new WebDriverSpawnParameters(driverType,
                                                                          true,
                                                                          options,
                                                                          deviceId);
      spawnParams.setSsoAuthResult(ssoAuthResult);
      
      SpawnerResult spawnerResult = spawnWorker(requestParameters, spawnParams);

      if (spawnerResult.rc != SupportedHttpCode.SUCCESS)
      {
         return null;
      }
      if (spawnerResult.uri == null)
      {
         LOG.fine("No URL returned by the spawnWorker method.");
         return null;
      }

      if (LOG.isLoggable(Level.INFO))
      {
         LOG.log(Level.INFO, "Client to be redirected by auto-login to %s.", spawnerResult.uri);
      }

      return spawnerResult;
   }
   
   /**
    * Handles HTTP requests that allow forking session and direct spawn of a new client.
    *
    * @param    requestParameters
    *           Holds HTTP derived parameters - headers, cookies, form params.
    * @param    sm
    *           SecurityManager
    * @param    driver
    *           The UI driver type
    * @param    isSso
    *           Flag indicating if SSO is enabled
    * @param    deviceId
    *           The device ID
    * @param    placeholderValuePairs
    *           Map with template placeholder : value pairs
    *
    * @return   The result of the spawn attempt.
    */
   protected boolean handleSessionFork(WebDriverRequestParameters requestParameters,
                                       SecurityManager sm,
                                       WebDriverType driver,
                                       boolean isSso,
                                       String deviceId,
                                       Map<String, String> placeholderValuePairs)
   {
      String oneTimeToken = requestParameters.getParameter(SessionForkManager.PARAM_NAME_TOKEN);
      if (!StringHelper.hasContent(oneTimeToken))
      {
         return false;
      }
      
      SessionForkManager.ForkedSessionConfigs sessionForkConfigs =
         SessionForkManager.getInstance().getConfigsForToken(oneTimeToken);
      if (sessionForkConfigs == null)
      {
         LOG.info("Attempt to start a new forked session with invalid or expired token from end-user with " +
                     "IP: " + requestParameters.getExternalUserIp());
         return false;
      }

      String osUser = sessionForkConfigs.getOsUser();
      String[] osUserCred = sm.getOsUserCredByName(osUser);
      if (osUserCred != null && osUserCred.length == 2)
      {
         requestParameters.setUser(osUserCred[0]);
         requestParameters.setPassword(osUserCred[1]);
      }
      else
      {
         requestParameters.setUser(osUser);
      }

      List<String> options = new ArrayList<>();
      options = addUrlsToOptions(options, requestParameters, driver);
      if (sessionForkConfigs.getCfgOverrides() != null)
      {
         options.addAll(sessionForkConfigs.getCfgOverrides());
      }

      WebDriverSpawnParameters spawnParams = new WebDriverSpawnParameters(driver,
                                                                          isSso,
                                                                          options,
                                                                          deviceId);
      
      spawnParams.setSessionFork(sessionForkConfigs);
      
      SpawnerResult spawnerResult = spawnWorker(requestParameters, spawnParams);
      if (spawnerResult.uri == null)
      {
         return false;
      }
      if (LOG.isLoggable(Level.INFO))
      {
         LOG.log(Level.INFO,
                 "Forked session. Returns the redirect url %s as response to %s client.",
                 spawnerResult.uri,
                 driver);
      }

      // the new client url will be injected in the landing page as template content
      placeholderValuePairs.put(AUTO_LOGIN_DATA,
                                new ObjectMapper().createObjectNode()
                                                  .put("url", spawnerResult.uri)
                                                  .put("storageId", spawnerResult.storageId)
                                                  .toString());
      return true;
   }

   /**
    * Loads static resources, inflates templates and serves the content. If the resource is not found in 
    * any of the alternative paths provided, or if IOException occurs, the status is set to 404, otherwise 200.
    *
    * @param    response
    *           The HTTP response.
    * @param    requestSpecificTemplateValues
    *           The template placeholder values.
    * @param    altPaths
    *           The target resource paths within the classpath.
    */
   protected void serveHtml(HttpServletResponse response,
                            Map<String, String> requestSpecificTemplateValues,
                            String... altPaths)
   {
      Map<String, String> allPlaceholderValues = new HashMap<>();
      allPlaceholderValues.putAll(PLACEHOLDER_VALUE_PAIRS);
      if (requestSpecificTemplateValues != null)
      {
         allPlaceholderValues.putAll(requestSpecificTemplateValues);
      }

      serveStatic(response, MimeTypes.Type.TEXT_HTML_UTF_8.toString(), allPlaceholderValues, altPaths);
   }
   
   /**
    * Loads static resource, inflates templates and serves the content. If the resource is not found in any
    * of the alternative paths provided, or if IOException occurs, the status is set to 404, otherwise 200.
    *
    * @param    response
    *           The HTTP response.
    * @param    mimeType
    *           The mime type.
    * @param    placeholderValues
    *           The template placeholder values.
    * @param    altPaths
    *           The target resource paths within the classpath.
    */
   protected void serveStatic(HttpServletResponse response,
                              String mimeType,
                              Map<String, String> placeholderValues,
                              String... altPaths)
   {
      boolean resFound = false;
      for (String path : altPaths)
      {
         InputStream inputStream = getClass().getResourceAsStream(path);
         if (inputStream == null)
         {
            continue;
         }
         resFound = true;
         response.setContentType(mimeType);
         
         if (mimeType.startsWith("image"))
         {
            writeBinaryResponse(response, inputStream, path);
         }
         else
         {
            writeTextResponse(response, inputStream, path, placeholderValues);
         }
         break;
      }
      if (resFound)
      {
         setResponseSuccess(response);
      }
      else
      {
         response.setStatus(HttpServletResponse.SC_NOT_FOUND);
      }
   }
   
   /**
    * Reads the input stream line by line, replaces placeholders and serves the content. If IOException 
    * occurs, the status is set to 404, otherwise 200.
    *
    * @param    response
    *           The HTTP response.
    * @param    inputStream
    *           The text file input stream.
    * @param    path
    *           The text resource file path.
    * @param    placeholderValues
    *           The template placeholder values.
    */
   private void writeTextResponse(HttpServletResponse response,
                                  InputStream inputStream,
                                  String path,
                                  Map<String, String> placeholderValues)
   {
      try (Scanner scanner = new Scanner(inputStream))
      {
         while (scanner.hasNext())
         {
            String line = scanner.nextLine();
            if (placeholderValues != null)
            {
               line = inflateTemplate(line, placeholderValues);
            }
            response.getWriter().println(line);
         }
      }
      catch (IOException e)
      {
         LOG.log(Level.FINE, e, "Couldn't serve static resource %s.", path);
         response.setStatus(HttpServletResponse.SC_NOT_FOUND);
      }
   }
   
   /**
    * Reads the input stream a few bytes at a time and serves the content. If IOException occurs, the 
    * status is set to 404, otherwise 200.
    *
    * @param    response
    *           The HTTP response.
    * @param    inputStream
    *           The text file input stream.
    * @param    path
    *           The text resource file path.
    */
   private void writeBinaryResponse(HttpServletResponse response, InputStream inputStream, String path)
   {
      try
      {
         byte[] buffer = new byte[1024];
         int bytesRead;
         OutputStream outputStream = response.getOutputStream();
         while ((bytesRead = inputStream.read(buffer)) != -1)
         {
            outputStream.write(buffer, 0, bytesRead);
         }
      }
      catch (IOException e)
      {
         LOG.log(Level.FINE, e, "Couldn't serve static resource %s.", path);
         response.setStatus(HttpServletResponse.SC_NOT_FOUND);
      }
   }
   
   /**
    * Returns the HTML partial resource as string. First tries to find the resource under the custom 
    * package dir, then looks for it under the current package.
    *
    * @param    partialFilename
    *           The name of the file
    * @param    customPackageDir
    *           The dir in the custom package
    *
    * @return   String representation of the HTML partial resource
    */
   protected String loadPartial(String partialFilename,
                                String customPackageDir,
                                Map<String, String> placeholderValuePairs)
   {
      InputStream customResInputStream = getClass()
         .getResourceAsStream(PKG_ROOT + customPackageDir + partialFilename);

      InputStream resInputStream = customResInputStream != null ?
         customResInputStream :
         getClass().getResourceAsStream(getResPathCurrentDir(partialFilename));

      if (resInputStream == null)
      {
         return null;
      }

      StringBuilder partialBuilder = new StringBuilder();
      try (Scanner scanner = new Scanner(resInputStream))
      {
         HashMap<String, String> placeholderValues = new HashMap<>(PLACEHOLDER_VALUE_PAIRS);
         if (placeholderValuePairs != null)
         {
            placeholderValues.putAll(placeholderValuePairs);
         }
         while (scanner.hasNext())
         {
            partialBuilder.append(inflateTemplate(scanner.nextLine(), placeholderValues))
                          .append("\n");
         }
      }
      return partialBuilder.toString();
   }

   /**
    * Returns the web context path to the target resource file, transforming the class package into segments
    * of the path.
    *
    * @param    fileName
    *           The name of the file
    *
    * @return   String representation of the client's IP address
    */
   protected String getResPathCurrentDir(String fileName)
   {
      return "/" + getClass().getPackage().getName().replace('.', '/') + "/" + fileName;
   }

   /**
    * Replaces placeholders in the text with the provided values.
    *
    * @param    line
    *           The text to be updated.
    * @param    placeholderValues
    *           The map with placeholder name : value pairs.
    *
    * @return   The text with replaced placeholders.
    */
   private String inflateTemplate(String line, Map<String, String> placeholderValues)
   {
      for (String placeholder : placeholderValues.keySet())
      {
         if (line.contains(placeholder))
         {
            line = line.replace(placeholder, placeholderValues.get(placeholder));
         }
      }
      return line;
   }

   /**
    * Processes the successful SSO login result. Finds the OS user associated with the FWD account. Sets 
    * the cookies to the spawner result.
    *
    * @param    ssoAuthResult
    *           The result of the SSO authentication.
    * @param    sm
    *           The SecurityManager.
    * @param    result
    *           The result from the new client spawn.
    *
    * @return   The container for accounts associated with the sso login.
    */
   private static SsoUsers processSsoResult(SsoAuthenticator.Result ssoAuthResult,
                                            SecurityManager sm,
                                            SpawnerResult result)
   {
      String fwdUser = ssoAuthResult.getFwdUser();
      if (!StringHelper.hasContent(fwdUser))
      {
         result.spawnError =
            String.format(TRANSLATION_MANAGER.translate(UiTextKey.MSG_INVALID_FWD_ACCOUNT.name()), fwdUser);
         LOG.warning(result.spawnError);
         return null;
      }
      
      result.cookie = ssoAuthResult.getCookie();

      String osUser = null;
      String osPass = null;
      
      if (ssoAuthResult.getOsUser() != null)
      {
         osUser = ssoAuthResult.getOsUser();
         osPass = ssoAuthResult.getOsPassword();
      }
      else
      {
         String[] osUserCred = sm.getOsUserCredByFwdAcc(fwdUser);
         // these can be overridden by configs
         osUser = osUserCred == null ? null : osUserCred[0];
         osPass = osUserCred == null || osUserCred.length < 2 ? null : osUserCred[1];
      }
      
      return new SsoUsers(fwdUser, osUser, osPass);
   }

   /**
    * Retrieves these parameters: a forwarded host, a forwarded proto and a client IP address if
    * they are provided by http request.
    *
    * @param    base
    *           The HTTP request. Jetty style.
    * @param    request
    *           The HTTP servlet request
    *
    * @return   The parameters array is filled that the first parameters[0] holds the forwarded
    *           host of "X-Forwarded-Host" HTTP header, the second parameters[1] holds the used
    *           forwarded protocol that is provided by "X-Forwarded-Proto" value and the third
    *           parameters[2] holds the client IP address.
    */
   static WebDriverRequestParameters getRequestParameters(Request base, HttpServletRequest request)
   {
      String externalUserIp;
      String internalUserIp;
      String forwardedIps = request.getHeader(HttpHeader.X_FORWARDED_FOR.asString());
      if (StringHelper.hasContent(forwardedIps))
      {
         String[] forwardedIpArray = forwardedIps.split(",");
         externalUserIp = forwardedIpArray[0];
         // same as HttpHeader.X-Real-IP
         internalUserIp = forwardedIpArray[forwardedIpArray.length - 1];
      }
      else
      {
         externalUserIp = request.getRemoteAddr();
         internalUserIp = request.getRemoteAddr();
      }
      
      return new WebDriverRequestParameters()
         .setForwardedHost(resolveForwardedHost(request))
         .setForwardedProto(FORWARDED_HOST_CONFIG != null ?
                               FORWARDED_PROTO_CONFIG :
                               request.getHeader(HttpHeader.X_FORWARDED_PROTO.asString()))
         .setExternalUserIp(externalUserIp)
         .setInternalUserIp(internalUserIp)
         .setServerName(request.getServerName())
         .setServerPort(request.getServerPort())
         .setCookies(request.getCookies())
         .setParamMap(request.getParameterMap())
         .setQueryParams(base.getQueryParameters())
         .setUser(base.getParameter("usr"))
         .setPassword(base.getParameter("psw"))
         .setTheme(base.getParameter("theme"));
   }
   
   /**
    * Returns the resolved forward host.
    *
    * @param    request
    *           The HTTP servlet request
    *
    * @return   See above.
    */
   static String resolveForwardedHost(HttpServletRequest request)
   {
      return FORWARDED_HOST_CONFIG != null ? 
         FORWARDED_HOST_CONFIG :
         request.getHeader(HttpHeader.X_FORWARDED_HOST.asString());
   }

   /**
    * Sets status, content type and headers for a successful response.
    *
    * @param    response
    *           The HTTP response.
    */
   private void setResponseSuccess(HttpServletResponse response)
   {
      response.setStatus(HttpServletResponse.SC_OK);
      response.setHeader(HttpHeader.CACHE_CONTROL.asString(), "no-cache, no-store, must-revalidate");
      response.setDateHeader(HttpHeader.EXPIRES.asString(), 0);
   }
   
   /**
    * Sends the error message to the client.
    * 
    * @param    httpCode
    *           The http code to return to the client.
    * @param    response
    *           The http response.
    */
   void sendError(HttpServletResponse response, SupportedHttpCode httpCode, String spawnError)
   {
      response.setContentType(MimeTypes.Type.TEXT_HTML.asString());
      response.setStatus(httpCode.getStatus());
      response.setHeader(HttpHeader.CACHE_CONTROL.asString(), "no-cache, no-store, must-revalidate");
      response.setDateHeader(HttpHeader.EXPIRES.asString(), 0);
      
      try
      {
         PrintWriter writer = response.getWriter();
         writer.println(spawnError != null ? spawnError : SpawnError.GENERIC.getMsg());
         writer.flush();
      }
      catch (IOException ioe)
      {
         LOG.severe("Couldn't send an error as response!", ioe);
      }
   }
   
   /**
    * Returns the url to the main application site (the new web session) as response.
    * 
    * @param    remoteUri
    *           The redirect url provided with the authorization parameter.
    * @param    storageId
    *           The storage ID.
    * @param    response
    *           The HTTP response.
    * @param    webClientUuid
    *           The just spawned web client uuid.
    * @param    cookie
    *           The cookie to be attached to the response.
    */
   private void writeUrlInResponse(String remoteUri,
                                   String storageId,
                                   HttpServletResponse response,
                                   String webClientUuid,
                                   Cookie cookie)
   {
      setResponseSuccess(response);

      response.setContentType(MimeTypes.Type.TEXT_HTML.asString());
      
      if (cookie != null)
      {
         response.addCookie(cookie);
      }
      
      ObjectMapper mapper = new ObjectMapper();
      ObjectNode responseJson = mapper.createObjectNode();
      responseJson.put("url", remoteUri);
      responseJson.put("storageId", storageId);
      
      try
      {
         PrintWriter writer = response.getWriter();
         writer.println(responseJson.toString());
         writer.flush();
      }
      catch (IOException e)
      {
         WebClientsManager.getInstance().freeWebClientResources(webClientUuid);
         LOG.log(Level.WARNING,  "A new client has been spawned with auto login, but the url couldn't " +
            "get returned to the browser.", e);
      }
   }

   /**
    * Adds the chosen theme from the HTTP request parameters to the spawn options. If a required theme is 
    * configured in directory, the theme selected in the UI (sent via HTTP request) is discarded.
    *
    * @param    selectedTheme
    *           The theme from the HTTP request parameters.
    *
    * @return   A modifiable list of all base options + the selected theme.
    */
   private static List<String> addThemeToOptions(String selectedTheme)
   {
      List<String> options = new ArrayList<>();
      
      if (LogicalTerminal.getRequiredTheme() != null)
      {
         return options;
      }
      
      if (StringHelper.hasContent(selectedTheme) && LogicalTerminal.isValidThemeClass(selectedTheme))
      {
         options.add("client:driver:theme=" + selectedTheme);
      }
      return options;
   }
   
   /**
    * Adds the urls for login and logout page to list of spawn options.
    *
    * @param    options
    *           The current client options.
    * @param    requestParameters
    *           The web request details fetched from the servlet request.
    * @param    driverType
    *           The type of web client - GUI / CHUI / embedded.
    *
    * @return   A modifiable list of all base options + the login and logout urls.
    */
   private static List<String> addUrlsToOptions(List<String> options,
                                                WebDriverRequestParameters requestParameters,
                                                WebDriverType driverType)
   {
      List<String> modifiableOptions = new ArrayList<>(options);

      boolean hasProxy = StringHelper.hasContent(requestParameters.getForwardedHost());
      String proxyPath = hasProxy ? getServerProxyPath(requestParameters.getForwardedHost()) : "";
      
      String baseUrl = hasProxy ?
         requestParameters.getForwardedProto() + "://" + requestParameters.getForwardedHost() :
         HttpScheme.HTTPS.asString() + "://" + requestParameters.getServerName() + ":" + requestParameters.getServerPort();

      String loginPage =
         baseUrl + (REMOVE_PROXY_PATH_IN_WEB_PAGES ? "" : proxyPath) + driverType.getLoginTarget();
      
      String logoutPage = 
         baseUrl + (REMOVE_PROXY_PATH_IN_WEB_PAGES ? "" : proxyPath) + 
            (isSsoEnabled() ? driverType.getLogoutTarget() : driverType.getLoginTarget());

      MultiMap<String> queryParams = requestParameters.getQueryParams();
      queryParams.remove(SessionForkManager.PARAM_NAME_TOKEN);
      if (!queryParams.isEmpty())
      {
         String encodedQueryString = UrlEncoded.encode(queryParams, StandardCharsets.UTF_8, false);
         logoutPage += "?" + encodedQueryString;
      }

      modifiableOptions.add("web:url:loginPage=" + loginPage);
      modifiableOptions.add("web:url:logoutPage=" + logoutPage);
      modifiableOptions.add("web:url:serverBaseUrl=" + baseUrl + proxyPath);
      return modifiableOptions;
   }

   /**
    * Returns a flag indicating if SSO is enabled. 
    *
    * @return   <code>true</code> if SSO enabled, <code>false</code> otherwise.
    */
   protected static boolean isSsoEnabled()
   {
      return SecurityManager.getInstance().ssoSm.isSsoEnabled();
   }

   /**
    * Searches through all cookies in the request and returns boolean to indicate if a valid device ID 
    * cookie is found.
    *
    * @param    requestCookies
    *           Array of all cookies in the request.
    * @param    user
    *           The name of the user trying to log in.
    *
    * @return   Tuple with the device ID and the FWD device ID cookie.
    */
   private Tuple<String, Cookie> getValidDeviceId(Cookie[] requestCookies, String user)
   {
      Tuple<String, Cookie> validDeviceIdCookiePair = null;

      if (requestCookies == null)
      {
         return null;
      }

      for (int i = 0; i < requestCookies.length; i++)
      {
         Cookie cookie = requestCookies[i];

         // ideally we should add checks for HttpOnly & Secure, but the fields are not set by the container
         if (cookie.getName().equals(DEVICE_ID_COOKIE_NAME))
         {
            String value = cookie.getValue();
            int dividerIndex = value.indexOf(DEVICE_ID_COOKIE_DIVIDER);
            if (dividerIndex == -1)
            {
               continue;
            }
            String deviceId = value.substring(0, dividerIndex);
            String signatureHex = value.substring(dividerIndex + 1);
            if (!StringHelper.hasContent(deviceId) || !StringHelper.hasContent(signatureHex))
            {
               continue;
            }

            byte[] signatureBytes = DigitalSignature.fromHex(signatureHex);
            try
            {
               if (digitalSignature.verify(deviceId, signatureBytes))
               {
                  validDeviceIdCookiePair = new Tuple<>(deviceId, cookie);
                  break;
               }
            }
            catch (Exception e)
            {
               LOG.info(DEVICE_ID_COOKIE_NAME + " cookie found with invalid signature for user " + user, e);
            }
         }
      }

      return validDeviceIdCookiePair;
   }

   /**
    * Generates a new UUID for a new device ID, signs it with the server certificate and sets it in a cookie.
    *
    * @return   Tuple with the device ID and the FWD device ID cookie.
    */
   private Tuple<String, Cookie> generateDeviceId()
   {
      String deviceId = UUID.randomUUID().toString();

      try
      {
         byte[] signatureBytes = digitalSignature.sign(deviceId);
         String signature = DigitalSignature.toHex(signatureBytes);
         Cookie cookie = new Cookie(DEVICE_ID_COOKIE_NAME, deviceId + DEVICE_ID_COOKIE_DIVIDER + signature);
         setDeviceIdCookieAttributes(cookie);
         return new Tuple<>(deviceId, cookie);
      }
      catch (Throwable e)
      {
         LOG.info("Unable to generate device id.", e);
      }

      return null;
   }

   /**
    * Sets attributes for the device ID cookie
    *
    * @param    deviceIdCookie
    *           The device id cookie.
    */
   private void setDeviceIdCookieAttributes(Cookie deviceIdCookie)
   {
      deviceIdCookie.setHttpOnly(true);
      deviceIdCookie.setSecure(true);
      deviceIdCookie.setMaxAge(Integer.MAX_VALUE); // 400 days is the max age supported by Chrome 
   }

   /** Enum for result codes available to the end-user. */
   public enum SupportedHttpCode
   {
      /** 200 Success. */
      SUCCESS(HttpServletResponse.SC_OK),

      /** 401 Unauthorized. */
      UNAUTHORIZED(HttpServletResponse.SC_UNAUTHORIZED),

      /** 403 Forbidden. */
      FORBIDDEN(HttpServletResponse.SC_FORBIDDEN),

      /** 500 Internal Server Error. */
      UNEXPECTED_ERROR(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);

      /** The HTTP code to be returned to the end-user. */
      private final int status;

      /**
       * Contructor to set the message for that specific enum value.
       *
       * @param    status
       *           The HTTP code.
       */
      SupportedHttpCode(int status)
      {
         this.status = status;
      }

      /**
       * Getter for {@link #status}.
       *
       * @return   See above.
       */
      public int getStatus()
      {
         return status;
      }
   }

   /** Simple container for accounts associated with the sso login. */
   protected static final class SsoUsers
   {
      /** FWD account. */
      protected final String fwdUser;
      
      /** OS user ID. */
      protected final String osUser;
      
      /** OS user password. */
      protected final String osPass;

      /**
       * Private constructor.
       * 
       * @param    fwdUser
       *           FWD account
       * @param    osUser
       *           OS user ID
       * @param    osPass
       *           OS user password
       */
      private SsoUsers(String fwdUser, String osUser, String osPass)
      {
         this.fwdUser = fwdUser;
         this.osUser = osUser;
         this.osPass = osPass;
      }
   }

   /** Simple container for the spawn result. */
   static class SpawnerResult
   {
      /** Redirect URI in case of a successful spawn. <code>null</code> on failure. */
      public String uri;
      
      /** Unique identifier for the local storage used by the user. */
      public String storageId;

      /** The uuid of the spawned client. */
      public String webClientUuid;
      
      /** Cookie. */
      public Cookie cookie;

      /** Result code. */
      public SupportedHttpCode rc;

      /** Spawn error. */
      public String spawnError;

      /** OS username. */
      public String osUser;
   }

   /** Keys for translation resources. */
   private enum UiTextKey
   {
      /** Error message format for invalid FWD account returned by the authenticator. */
      MSG_INVALID_FWD_ACCOUNT,
      
      /** Error message format for user not listed in osusers */
      MSG_OSUSER_NOT_LISTED,
      
      /** Error message format for missing defaultOsUser */
      MSG_DEFAULT_OS_USER_MISSING,
      
      /** Error message format for invalid in-app credentials */
      MSG_INVALID_CREDENTIALS,
      
      /** Error message format for issue with SSO authenticator instantiating */
      MSG_AUTHENTICATOR_ISSUE,

      /** Error message format for not enabled remote launch config. */
      MSG_REMOTE_LAUNCH_NOT_ENABLED,

      /** Error message format for unauthorized user using config. */
      MSG_UNAUTH_KEY_USE,

      /** Error message format for no connection to the server default UI message. */
      MSG_SDK_DEFAULT_REQUEST_ERROR
   }
   
   /**
    * Listener for client sessions. Registers / removes from the in-memory collection all web sessions on 
    * their initialization / termination. Used by the One Browser Policy.
    */
   private static class WebSessionListener
   implements InitTermListener
   {
      /**
       * This method is invoked when business logic main entry point is about to be invoked.
       */
      @Override
      public void initialize()
      {
         if (!StandardServer.getClientParameters().web)
         {
            return;
         }
         String deviceId = SessionUtils.getDeviceId().toStringMessage();
         String sessionId = SessionUtils.getSessionId().toStringMessage();
         List<String> sessionIds;
         if (deviceIdSessionIdPairs.containsKey(deviceId))
         {
            sessionIds = deviceIdSessionIdPairs.get(deviceId);
         }
         else
         {
            sessionIds = new ArrayList<>();
            deviceIdSessionIdPairs.put(deviceId, sessionIds);
         }
         sessionIds.add(sessionId);
         
         Object sessionNetId = SecurityManager.getInstance().sessionSm.getSessionId();
         sessionIdNetIdPairs.put(sessionId, sessionNetId);

         sessionIdClientPairs.put(sessionId, new WeakReference<>(LogicalTerminal.getClient()));
      }

      /**
       * This method is invoked when business logic entry point finished its work.
       *
       * @param    t
       *           Error or exception which caused exit from business logic. Can be <code>null</code>.
       */
      @Override
      public void terminate(Throwable t)
      {
         if (!StandardServer.getClientParameters().web)
         {
            return;
         }
         String deviceId = SessionUtils.getDeviceId().toStringMessage();
         String sessionId = SessionUtils.getSessionId().toStringMessage();
         if (deviceIdSessionIdPairs.containsKey(deviceId))
         {
            deviceIdSessionIdPairs.get(deviceId).remove(sessionId);
         }
         sessionIdNetIdPairs.remove(sessionId);
         sessionIdClientPairs.remove(sessionId);
      }
   }
}