# 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);
          }
       }
    }