Repository: softlayer/softlayer-python
Pull request #2139 (2024-04-25 01:29:27)

Adding support for internal styles of authentication
Allows employees and utility users to authenticate.


Patch:
diff --git a/.secrets.baseline b/.secrets.baseline
index d236a8ed..ea850e07 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -3,7 +3,7 @@
     "files": "^.secrets.baseline$",
     "lines": null
   },
-  "generated_at": "2024-04-18T01:09:09Z",
+  "generated_at": "2024-04-25T01:18:20Z",
   "plugins_used": [
     {
       "name": "AWSKeyDetector"
@@ -554,7 +554,7 @@
         "hashed_secret": "a4c805a62a0387010cd172cfed6f6772eb92a5d6",
         "is_secret": false,
         "is_verified": false,
-        "line_number": 76,
+        "line_number": 81,
         "type": "Secret Keyword",
         "verified_result": null
       }
diff --git a/README-internal.md b/README-internal.md
new file mode 100644
index 00000000..f519d702
--- /dev/null
+++ b/README-internal.md
@@ -0,0 +1,40 @@
+This document is for internal users wanting to use this library to interact with the internal API. It will not work for `api.softlayer.com`.
+
+
+## Certificate Example
+
+For use with a utility certificate. In your config file (usually `~/.softlayer`), you need to set the following:
+
+```
+[softlayer]
+endpoint_url = https://<internal api endpoint>/v3/internal/rest/
+timeout = 0
+theme = dark
+auth_cert = /etc/ssl/certs/my_utility_cert-dev.pem
+server_cert = /etc/ssl/certs/allCAbundle.pem
+```
+
+`auth_cert`: is your utility user certificate
+`server_cert`: is the CA certificate bundle to validate the internal API ssl chain. Otherwise you get self-signed ssl errors without this.
+
+
+```
+import SoftLayer
+import logging
+import click
+
+@click.command()
+def testAuthentication():
+	client = SoftLayer.CertificateClient()
+	result = client.call('SoftLayer_Account', 'getObject', id=12345, mask="mask[id,companyName]")
+	print(result)
+
+
+if __name__ == "__main__":
+	logger = logging.getLogger()
+	logger.addHandler(logging.StreamHandler())
+	logger.setLevel(logging.DEBUG)
+	testAuthentication()
+```
+
+## Employee Example
\ No newline at end of file
diff --git a/SoftLayer/API.py b/SoftLayer/API.py
index 4d291861..dc24914f 100644
--- a/SoftLayer/API.py
+++ b/SoftLayer/API.py
@@ -7,7 +7,6 @@
 """
 # pylint: disable=invalid-name
 import time
-import warnings
 
 import concurrent.futures as cf
 import json
@@ -28,11 +27,13 @@
 
 __all__ = [
     'create_client_from_env',
+    'employee_client',
     'Client',
     'BaseClient',
     'API_PUBLIC_ENDPOINT',
     'API_PRIVATE_ENDPOINT',
     'IAMClient',
+    'CertificateClient'
 ]
 
 VALID_CALL_ARGS = set((
@@ -143,13 +144,86 @@ def create_client_from_env(username=None,
     return BaseClient(auth=auth, transport=transport, config_file=config_file)
 
 
-def Client(**kwargs):
-    """Get a SoftLayer API Client using environmental settings.
+def employee_client(username=None,
+                    access_token=None,
+                    endpoint_url=None,
+                    timeout=None,
+                    auth=None,
+                    config_file=None,
+                    proxy=None,
+                    user_agent=None,
+                    transport=None,
+                    verify=True):
+    """Creates an INTERNAL SoftLayer API client using your environment.
+
+    Settings are loaded via keyword arguments, environemtal variables and config file.
 
-    Deprecated in favor of create_client_from_env()
+    :param username: your user ID
+    :param access_token: hash from SoftLayer_User_Employee::performExternalAuthentication(username, password, token)
+    :param password: password to use for employee authentication
+    :param endpoint_url: the API endpoint base URL you wish to connect to.
+        Set this to API_PRIVATE_ENDPOINT to connect via SoftLayer's private network.
+    :param proxy: proxy to be used to make API calls
+    :param integer timeout: timeout for API requests
+    :param auth: an object which responds to get_headers() to be inserted into the xml-rpc headers.
+        Example: `BasicAuthentication`
+    :param config_file: A path to a configuration file used to load settings
+    :param user_agent: an optional User Agent to report when making API
+        calls if you wish to bypass the packages built in User Agent string
+    :param transport: An object that's callable with this signature: transport(SoftLayer.transports.Request)
+    :param bool verify: decide to verify the server's SSL/TLS cert.
     """
-    warnings.warn("use SoftLayer.create_client_from_env() instead",
-                  DeprecationWarning)
+    settings = config.get_client_settings(username=username,
+                                          api_key=None,
+                                          endpoint_url=endpoint_url,
+                                          timeout=timeout,
+                                          proxy=proxy,
+                                          verify=None,
+                                          config_file=config_file)
+
+    url = settings.get('endpoint_url')
+    verify = settings.get('verify', True)
+
+    if 'internal' not in url:
+        raise exceptions.SoftLayerError(f"{url} does not look like an Internal Employee url.")
+
+    if transport is None:
+        if url is not None and '/rest' in url:
+            # If this looks like a rest endpoint, use the rest transport
+            transport = transports.RestTransport(
+                endpoint_url=settings.get('endpoint_url'),
+                proxy=settings.get('proxy'),
+                timeout=settings.get('timeout'),
+                user_agent=user_agent,
+                verify=verify,
+            )
+        else:
+            # Default the transport to use XMLRPC
+            transport = transports.XmlRpcTransport(
+                endpoint_url=settings.get('endpoint_url'),
+                proxy=settings.get('proxy'),
+                timeout=settings.get('timeout'),
+                user_agent=user_agent,
+                verify=verify,
+            )
+
+    if access_token is None:
+        access_token = settings.get('access_token')
+
+    user_id = settings.get('userid')
+
+    # Assume access_token is valid for now, user has logged in before at least.
+    if access_token and user_id:
+        auth = slauth.EmployeeAuthentication(user_id, access_token)
+        return EmployeeClient(auth=auth, transport=transport)
+    else:
+        # This is for logging in mostly.
+        LOGGER.info("No access_token or userid found in settings, creating a No Auth client for now.")
+        return EmployeeClient(auth=None, transport=transport)
+
+
+def Client(**kwargs):
+    """Get a SoftLayer API Client using environmental settings."""
     return create_client_from_env(**kwargs)
 
 
@@ -157,19 +231,25 @@ class BaseClient(object):
     """Base SoftLayer API client.
 
     :param auth: auth driver that looks like SoftLayer.auth.AuthenticationBase
-    :param transport: An object that's callable with this signature:
-                      transport(SoftLayer.transports.Request)
+    :param transport: An object that's callable with this signature: transport(SoftLayer.transports.Request)
     """
-
     _prefix = "SoftLayer_"
+    auth: slauth.AuthenticationBase
 
     def __init__(self, auth=None, transport=None, config_file=None):
         if config_file is None:
             config_file = CONFIG_FILE
-        self.auth = auth
         self.config_file = config_file
         self.settings = config.get_config(self.config_file)
+        self.__setAuth(auth)
+        self.__setTransport(transport)
 
+    def __setAuth(self, auth=None):
+        """Prepares the authentication property"""
+        self.auth = auth
+
+    def __setTransport(self, transport=None):
+        """Prepares the transport property"""
         if transport is None:
             url = self.settings['softlayer'].get('endpoint_url')
             if url is not None and '/rest' in url:
@@ -194,9 +274,7 @@ def __init__(self, auth=None, transport=None, config_file=None):
 
         self.transport = transport
 
-    def authenticate_with_password(self, username, password,
-                                   security_question_id=None,
-                                   security_question_answer=None):
+    def authenticate_with_password(self, username, password, security_question_id=None, security_question_answer=None):
         """Performs Username/Password Authentication
 
         :param string username: your SoftLayer username
@@ -259,8 +337,7 @@ def call(self, service, method, *args, **kwargs):
 
         invalid_kwargs = set(kwargs.keys()) - VALID_CALL_ARGS
         if invalid_kwargs:
-            raise TypeError(
-                'Invalid keyword arguments: %s' % ','.join(invalid_kwargs))
+            raise TypeError('Invalid keyword arguments: %s' % ','.join(invalid_kwargs))
 
         prefixes = (self._prefix, 'BluePages_Search', 'IntegratedOfferingTeam_Region')
         if self._prefix and not service.startswith(prefixes):
@@ -286,6 +363,7 @@ def call(self, service, method, *args, **kwargs):
         request.filter = kwargs.get('filter')
         request.limit = kwargs.get('limit')
         request.offset = kwargs.get('offset')
+        request.url = self.settings['softlayer'].get('endpoint_url')
         if kwargs.get('verify') is not None:
             request.verify = kwargs.get('verify')
 
@@ -391,6 +469,31 @@ def __len__(self):
         return 0
 
 
+class CertificateClient(BaseClient):
+    """Client that works with a X509 Certificate for authentication.
+
+    Will read the certificate file from the config file (~/.softlayer usually).
+    > auth_cert = /path/to/authentication/cert.pm
+    > server_cert = /path/to/CAcert.pem
+    Set auth to a SoftLayer.auth.Authentication class to manually set authentication
+    """
+
+    def __init__(self, auth=None, transport=None, config_file=None):
+        BaseClient.__init__(self, auth, transport, config_file)
+        self.__setAuth(auth)
+
+    def __setAuth(self, auth=None):
+        """Prepares the authentication property"""
+        if auth is None:
+            auth_cert = self.settings['softlayer'].get('auth_cert')
+            serv_cert = self.settings['softlayer'].get('server_cert', None)
+            auth = slauth.X509Authentication(auth_cert, serv_cert)
+        self.auth = auth
+
+    def __repr__(self):
+        return "CertificateClient(transport=%r, auth=%r)" % (self.transport, self.auth)
+
+
 class IAMClient(BaseClient):
     """IBM ID Client for using IAM authentication
 
@@ -575,6 +678,94 @@ def __repr__(self):
         return "IAMClient(transport=%r, auth=%r)" % (self.transport, self.auth)
 
 
+class EmployeeClient(BaseClient):
+    """Internal SoftLayer Client
+
+    :param auth: auth driver that looks like SoftLayer.auth.AuthenticationBase
+    :param transport: An object that's callable with this signature: transport(SoftLayer.transports.Request)
+    """
+
+    def __init__(self, auth=None, transport=None, config_file=None, account_id=None):
+        BaseClient.__init__(self, auth, transport, config_file)
+        self.account_id = account_id
+
+    def authenticate_with_internal(self, username, password, security_token=None):
+        """Performs internal authentication
+
+        :param string username: your softlayer username
+        :param string password: your softlayer password
+        :param int security_token: your 2FA token, prompt if None
+        """
+
+        self.auth = None
+        if security_token is None:
+            security_token = input("Enter your 2FA Token now: ")
+            if len(security_token) != 6:
+                raise exceptions.SoftLayerAPIError("Invalid security token: {}".format(security_token))
+
+        auth_result = self.call('SoftLayer_User_Employee', 'performExternalAuthentication',
+                                username, password, security_token)
+
+        self.settings['softlayer']['access_token'] = auth_result['hash']
+        self.settings['softlayer']['userid'] = str(auth_result['userId'])
+        # self.settings['softlayer']['refresh_token'] = tokens['refresh_token']
+
+        config.write_config(self.settings, self.config_file)
+        self.auth = slauth.EmployeeAuthentication(auth_result['userId'], auth_result['hash'])
+
+        return auth_result
+
+    def authenticate_with_hash(self, userId, access_token):
+        """Authenticates to the Internal SL API with an employee userid + token
+
+        :param string userId: Employee UserId
+        :param string access_token: Employee Hash Token
+        """
+        self.auth = slauth.EmployeeAuthentication(userId, access_token)
+
+    def refresh_token(self, userId, auth_token):
+        """Refreshes the login token"""
+
+        # Go directly to base client, to avoid infite loop if the token is super expired.
+        auth_result = BaseClient.call(self, 'SoftLayer_User_Employee', 'refreshEncryptedToken', auth_token, id=userId)
+        if len(auth_result) > 1:
+            for returned_data in auth_result:
+                # Access tokens should be 188 characters, but just incase its longer or something.
+                if len(returned_data) > 180:
+                    self.settings['softlayer']['access_token'] = returned_data
+        else:
+            message = "Excepted 2 properties from refreshEncryptedToken, got {}|".format(auth_result)
+            raise exceptions.SoftLayerAPIError(message)
+
+        config.write_config(self.settings, self.config_file)
+        self.auth = slauth.EmployeeAuthentication(userId, auth_result[0])
+        return auth_result
+
+    def call(self, service, method, *args, **kwargs):
+        """Handles refreshing Employee tokens in case of a HTTP 401 error"""
+        if (service == 'SoftLayer_Account' or service == 'Account') and not kwargs.get('id'):
+            if not self.account_id:
+                raise exceptions.SoftLayerError("SoftLayer_Account service requires an ID")
+            kwargs['id'] = self.account_id
+
+        try:
+            return BaseClient.call(self, service, method, *args, **kwargs)
+        except exceptions.SoftLayerAPIError as ex:
+            if ex.faultCode == "SoftLayer_Exception_EncryptedToken_Expired":
+                userId = self.settings['softlayer'].get('userid')
+                access_token = self.settings['softlayer'].get('access_token')
+                LOGGER.warning("Token has expired, trying to refresh. %s", ex.faultString)
+                self.refresh_token(userId, access_token)
+                # Try the Call again this time....
+                return BaseClient.call(self, service, method, *args, **kwargs)
+
+            else:
+                raise ex
+
+    def __repr__(self):
+        return "EmployeeClient(transport=%r, auth=%r)" % (self.transport, self.auth)
+
+
 class Service(object):
     """A SoftLayer Service.
 
diff --git a/SoftLayer/CLI/core.py b/SoftLayer/CLI/core.py
index df979324..870c47f0 100644
--- a/SoftLayer/CLI/core.py
+++ b/SoftLayer/CLI/core.py
@@ -74,34 +74,28 @@ def get_version_message(ctx, param, value):
 The easiest way to do that is to use: 'slcli setup'""",
              cls=CommandLoader,
              context_settings=CONTEXT_SETTINGS)
-@click.option('--format',
-              default=DEFAULT_FORMAT,
-              show_default=True,
+@click.option('--format', default=DEFAULT_FORMAT, show_default=True,
               help="Output format",
               type=click.Choice(VALID_FORMATS))
-@click.option('-C', '--config',
-              required=False,
+@click.option('-C', '--config', required=False, show_default=True,
               default=click.get_app_dir('softlayer', force_posix=True),
-              show_default=True,
               help="Config file location",
               type=click.Path(resolve_path=True))
 @click.option('--verbose', '-v',
               help="Sets the debug noise level, specify multiple times for more verbosity.",
               type=click.IntRange(0, 3, clamp=True),
               count=True)
-@click.option('--proxy',
-              required=False,
+@click.option('--proxy', required=False,
               help="HTTPS or HTTP proxy to be use to make API calls")
-@click.option('--really / --not-really', '-y',
-              is_flag=True,
-              required=False,
+@click.option('--really / --not-really', '-y', is_flag=True, required=False,
               help="Confirm all prompt actions")
-@click.option('--demo / --no-demo',
-              is_flag=True,
-              required=False,
+@click.option('--demo / --no-demo', is_flag=True, required=False,
               help="Use demo data instead of actually making API calls")
 @click.option('--version', is_flag=True, expose_value=False, is_eager=True, callback=get_version_message,
               help="Show version information.",  allow_from_autoenv=False,)
+@click.option('--account', '-a', help="Account Id, only needed for some API calls.")
+@click.option('--internal', '-i', is_flag=True, required=False,
+              help="Use the Employee Client instead of the Customer Client.")
 @environment.pass_env
 def cli(env,
         format='table',
@@ -110,6 +104,8 @@ def cli(env,
         proxy=None,
         really=False,
         demo=False,
+        account=None,
+        internal=False,
         **kwargs):
     """Main click CLI entry-point."""
 
@@ -118,7 +114,10 @@ def cli(env,
     env.config_file = config
     env.format = format
     env.set_env_theme(config_file=config)
-    env.ensure_client(config_file=config, is_demo=demo, proxy=proxy)
+    if internal:
+        env.ensure_emp_client(config_file=config, is_demo=demo, proxy=proxy)
+    else:
+        env.ensure_client(config_file=config, is_demo=demo, proxy=proxy)
     env.vars['_start'] = time.time()
     logger = logging.getLogger()
 
@@ -133,6 +132,7 @@ def cli(env,
     env.vars['_timings'] = SoftLayer.DebugTransport(env.client.transport)
     env.vars['verbose'] = verbose
     env.client.transport = env.vars['_timings']
+    env.client.account_id = account
 
 
 @cli.result_callback()
diff --git a/SoftLayer/CLI/environment.py b/SoftLayer/CLI/environment.py
index c17ee72a..e2fde6e3 100644
--- a/SoftLayer/CLI/environment.py
+++ b/SoftLayer/CLI/environment.py
@@ -183,16 +183,26 @@ def ensure_client(self, config_file=None, is_demo=False, proxy=None):
 
         # Environment can be passed in explicitly. This is used for testing
         if is_demo:
-            client = SoftLayer.BaseClient(
-                transport=SoftLayer.FixtureTransport(),
-                auth=None,
-            )
+            client = SoftLayer.BaseClient(transport=SoftLayer.FixtureTransport(), auth=None)
         else:
             # Create SL Client
-            client = SoftLayer.create_client_from_env(
-                proxy=proxy,
-                config_file=config_file,
-            )
+            client = SoftLayer.create_client_from_env(proxy=proxy, config_file=config_file)
+        self.client = client
+
+    def ensure_emp_client(self, config_file=None, is_demo=False, proxy=None):
+        """Create a new SLAPI client to the environment.
+
+        This will be a no-op if there is already a client in this environment.
+        """
+        if self.client is not None:
+            return
+
+        # Environment can be passed in explicitly. This is used for testing
+        if is_demo:
+            client = SoftLayer.BaseClient(transport=SoftLayer.FixtureTransport(), auth=None)
+        else:
+            # Create SL Client
+            client = SoftLayer.employee_client(proxy=proxy, config_file=config_file)
         self.client = client
 
     def set_env_theme(self, config_file=None):
diff --git a/SoftLayer/CLI/login.py b/SoftLayer/CLI/login.py
new file mode 100644
index 00000000..7f3e76e3
--- /dev/null
+++ b/SoftLayer/CLI/login.py
@@ -0,0 +1,68 @@
+"""Login with your employee username, password, 2fa token"""
+# :license: MIT, see LICENSE for more details.
+import os
+
+import click
+
+from SoftLayer.API import employee_client
+from SoftLayer.CLI.command import SLCommand as SLCommand
+from SoftLayer.CLI import environment
+from SoftLayer import config
+from SoftLayer import consts
+
+
+def censor_password(value):
+    """Replaces a password with *s"""
+    if value:
+        value = '*' * len(value)
+    return value
+
+
+@click.command(cls=SLCommand)
+@environment.pass_env
+def cli(env):
+    """Logs you into the internal SoftLayer Network.
+
+    username: Set this in either the softlayer config, or SL_USER ENV variable
+    password: Set this in SL_PASSWORD env variable. You will be prompted for them otherwise.
+    """
+    config_settings = config.get_config(config_file=env.config_file)
+    settings = config_settings['softlayer']
+    username = settings.get('username') or os.environ.get('SLCLI_USER', None)
+    password = os.environ.get('SLCLI_PASSWORD', '')
+    yubi = None
+    client = employee_client()
+
+    # Might already be logged in, try and refresh token
+    if settings.get('access_token') and settings.get('userid'):
+        client.authenticate_with_hash(settings.get('userid'), settings.get('access_token'))
+        try:
+            emp_id = settings.get('userid')
+            client.call('SoftLayer_User_Employee', 'getObject', id=emp_id, mask="mask[id,username]")
+            client.refresh_token(emp_id, settings.get('access_token'))
+            client.call('SoftLayer_User_Employee', 'refreshEncryptedToken', settings.get('access_token'), id=emp_id)
+
+            config_settings['softlayer'] = settings
+            config.write_config(config_settings, env.config_file)
+            return
+        # pylint: disable=broad-exception-caught
+        except Exception as ex:
+            print("Error with Hash Authentication, try with password: {}".format(ex))
+
+    url = settings.get('endpoint_url') or consts.API_EMPLOYEE_ENDPOINT
+    click.echo("URL: {}".format(url))
+    if username is None:
+        username = input("Username: ")
+    click.echo("Username: {}".format(username))
+    if not password:
+        password = env.getpass("Password: ")
+    click.echo("Password: {}".format(censor_password(password)))
+    yubi = input("Yubi: ")
+    try:
+        result = client.authenticate_with_internal(username, password, str(yubi))
+        print(result)
+    # pylint: disable=broad-exception-caught
+    except Exception as e:
+        click.echo("EXCEPTION: {}".format(e))
+
+    print("OK")
diff --git a/SoftLayer/CLI/routes.py b/SoftLayer/CLI/routes.py
index f6ba2a8c..705b2cac 100644
--- a/SoftLayer/CLI/routes.py
+++ b/SoftLayer/CLI/routes.py
@@ -8,6 +8,7 @@
 
 ALL_ROUTES = [
     ('shell', 'SoftLayer.shell.core:cli'),
+    ('emplogin', 'SoftLayer.CLI.login:cli'),
 
     ('call-api', 'SoftLayer.CLI.call_api:cli'),
 
diff --git a/SoftLayer/auth.py b/SoftLayer/auth.py
index 1c16ef51..8698249a 100644
--- a/SoftLayer/auth.py
+++ b/SoftLayer/auth.py
@@ -12,6 +12,8 @@
     'TokenAuthentication',
     'BasicHTTPAuthentication',
     'AuthenticationBase',
+    'X509Authentication',
+    'EmployeeAuthentication'
 ]
 
 
@@ -137,3 +139,50 @@ def get_request(self, request):
 
     def __repr__(self):
         return f"BearerAuthentication(username={self.username}, token={self.api_key})"
+
+
+class X509Authentication(AuthenticationBase):
+    """X509Authentication authentication class.
+
+        :param certificate str:  Path to a users SSL certificate for authentication
+        :param CA Cert str: Path to the Servers signed certificate.
+    """
+
+    def __init__(self, cert, ca_cert):
+        self.cert = cert
+        self.ca_cert = ca_cert
+
+    def get_request(self, request):
+        """Sets token-based auth headers."""
+        request.cert = self.cert
+        request.verify = self.ca_cert
+        return request
+
+    def __repr__(self):
+        return f"X509Authentication(cert={self.cert}, ca_cert={self.ca_cert})"
+
+
+class EmployeeAuthentication(AuthenticationBase):
+    """Token-based authentication class.
+
+        :param username str: a user's username
+        :param user_hash str: a user's Authentication hash
+    """
+    def __init__(self, user_id, user_hash):
+        self.user_id = user_id
+        self.hash = user_hash
+
+    def get_request(self, request):
+        """Sets token-based auth headers."""
+        if 'xml' in request.url:
+            request.headers['employeesession'] = {
+                'userId': self.user_id,
+                'authToken': self.hash,
+            }
+        else:
+            request.transport_user = self.user_id
+            request.transport_password = self.hash
+        return request
+
+    def __repr__(self):
+        return "EmployeeAuthentication(userId=%r,hash=%s)" % (self.user_id, self.hash)
diff --git a/SoftLayer/config.py b/SoftLayer/config.py
index 5ae8c713..c32c0b5e 100644
--- a/SoftLayer/config.py
+++ b/SoftLayer/config.py
@@ -59,6 +59,9 @@ def get_client_settings_config_file(**kwargs):  # pylint: disable=inconsistent-r
         'endpoint_url': '',
         'timeout': '0',
         'proxy': '',
+        'userid': '',
+        'access_token': '',
+        'verify': True
     })
     config.read(config_files)
 
@@ -69,6 +72,9 @@ def get_client_settings_config_file(**kwargs):  # pylint: disable=inconsistent-r
             'proxy': config.get('softlayer', 'proxy'),
             'username': config.get('softlayer', 'username'),
             'api_key': config.get('softlayer', 'api_key'),
+            'userid': config.get('softlayer', 'userid'),
+            'access_token': config.get('softlayer', 'access_token'),
+            'verify':   config.get('softlayer', 'verify')
         }
 
 
@@ -109,6 +115,8 @@ def get_config(config_file=None):
         config['softlayer']['endpoint_url'] = ''
         config['softlayer']['api_key'] = ''
         config['softlayer']['timeout'] = '0'
+        config['softlayer']['userid'] = ''
+        config['softlayer']['access_tokne'] = ''
 
     return config
 
diff --git a/SoftLayer/fixtures/xmlrpc/Employee_getObject.xml b/SoftLayer/fixtures/xmlrpc/Employee_getObject.xml
new file mode 100644
index 00000000..57ec3f14
--- /dev/null
+++ b/SoftLayer/fixtures/xmlrpc/Employee_getObject.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<params>
+    <param>
+        <value>
+            <struct>
+                <member>
+                    <name>id</name>
+                    <value>
+                        <int>5555</int>
+                    </value>
+                </member>
+                <member>
+                    <name>username</name>
+                    <value>
+                        <string>testUser</string>
+                    </value>
+                </member>
+            </struct>
+        </value>
+    </param>
+</params>
\ No newline at end of file
diff --git a/SoftLayer/fixtures/xmlrpc/expiredToken.xml b/SoftLayer/fixtures/xmlrpc/expiredToken.xml
new file mode 100644
index 00000000..43237bb3
--- /dev/null
+++ b/SoftLayer/fixtures/xmlrpc/expiredToken.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<methodResponse>
+    <fault>
+        <value>
+            <struct>
+                <member>
+                    <name>faultCode</name>
+                    <value>
+                        <string>SoftLayer_Exception_EncryptedToken_Expired</string>
+                    </value>
+                </member>
+                <member>
+                    <name>faultString</name>
+                    <value>
+                        <string>The token has expired.</string>
+                    </value>
+                </member>
+            </struct>
+        </value>
+    </fault>
+</methodResponse>
\ No newline at end of file
diff --git a/SoftLayer/fixtures/xmlrpc/invalidLogin.xml b/SoftLayer/fixtures/xmlrpc/invalidLogin.xml
new file mode 100644
index 00000000..1c993d2b
--- /dev/null
+++ b/SoftLayer/fixtures/xmlrpc/invalidLogin.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<methodResponse>
+    <fault>
+        <value>
+            <struct>
+                <member>
+                    <name>faultCode</name>
+                    <value>
+                        <string>SoftLayer_Exception_Public</string>
+                    </value>
+                </member>
+                <member>
+                    <name>faultString</name>
+                    <value>
+                        <string>Invalid username/password</string>
+                    </value>
+                </member>
+            </struct>
+        </value>
+    </fault>
+</methodResponse>
\ No newline at end of file
diff --git a/SoftLayer/fixtures/xmlrpc/refreshFailure.xml b/SoftLayer/fixtures/xmlrpc/refreshFailure.xml
new file mode 100644
index 00000000..e69de29b
diff --git a/SoftLayer/fixtures/xmlrpc/refreshSuccess.xml b/SoftLayer/fixtures/xmlrpc/refreshSuccess.xml
new file mode 100644
index 00000000..0b8003b3
--- /dev/null
+++ b/SoftLayer/fixtures/xmlrpc/refreshSuccess.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<params>
+    <param>
+        <value>
+            <array>
+                <data>
+                    <value>
+                        <string>REFRESHEDTOKENaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</string>
+                    </value>
+                    <value>
+                        <string>300</string>
+                    </value>
+                </data>
+            </array>
+        </value>
+    </param>
+</params>
\ No newline at end of file
diff --git a/SoftLayer/fixtures/xmlrpc/successLogin.xml b/SoftLayer/fixtures/xmlrpc/successLogin.xml
new file mode 100644
index 00000000..880d9497
--- /dev/null
+++ b/SoftLayer/fixtures/xmlrpc/successLogin.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<params>
+    <param>
+        <value>
+            <struct>
+                <member>
+                    <name>hash</name>
+                    <value>
+                        <string>xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</string>
+                    </value>
+                </member>
+                <member>
+                    <name>userId</name>
+                    <value>
+                        <int>1234</int>
+                    </value>
+                </member>
+            </struct>
+        </value>
+    </param>
+</params>
diff --git a/SoftLayer/transports/rest.py b/SoftLayer/transports/rest.py
index 7f8d2554..30ce11ba 100644
--- a/SoftLayer/transports/rest.py
+++ b/SoftLayer/transports/rest.py
@@ -108,7 +108,6 @@ def __call__(self, request):
         request.url = '%s.%s' % ('/'.join(url_parts), 'json')
 
         # Prefer the request setting, if it's not None
-
         if request.verify is None:
             request.verify = self.verify
 
diff --git a/SoftLayer/transports/transport.py b/SoftLayer/transports/transport.py
index 616e20cf..0454632b 100644
--- a/SoftLayer/transports/transport.py
+++ b/SoftLayer/transports/transport.py
@@ -56,10 +56,12 @@ def __init__(self):
         #: Transport headers.
         self.transport_headers = {}
 
-        #: Boolean specifying if the server certificate should be verified.
+        #: False -> Don't verify the SSL certificate
+        #: True -> Verify the SSL certificate
+        #: Path String -> Verify the SSL certificate with the .pem file at path
         self.verify = None
 
-        #: Client certificate file path.
+        #: Client certificate file path. (Used by X509Authentication)
         self.cert = None
 
         #: InitParameter/identifier of an object.
@@ -99,9 +101,12 @@ def __repr__(self):
         """Prints out what this call is all about"""
         pretty_mask = utils.clean_string(self.mask)
         pretty_filter = self.filter
-        param_string = "id={id}, mask='{mask}', filter='{filter}', args={args}, limit={limit}, offset={offset}".format(
-            id=self.identifier, mask=pretty_mask, filter=pretty_filter,
-            args=self.args, limit=self.limit, offset=self.offset)
+        clean_args = self.args
+        # Passwords can show up here, so censor them before logging.
+        if self.method in ["performExternalAuthentication", "refreshEncryptedToken", "getPortalLoginToken"]:
+            clean_args = "*************"
+        param_string = (f"id={self.identifier}, mask='{pretty_mask}', filter='{pretty_filter}', args={clean_args}, "
+                        f"limit={self.limit}, offset={self.offset}")
         return "{service}::{method}({params})".format(
             service=self.service, method=self.method, params=param_string)
 
diff --git a/SoftLayer/transports/xmlrpc.py b/SoftLayer/transports/xmlrpc.py
index 99fbe1dd..57ba4e9f 100644
--- a/SoftLayer/transports/xmlrpc.py
+++ b/SoftLayer/transports/xmlrpc.py
@@ -84,8 +84,7 @@ def __call__(self, request):
                                               encoding="iso-8859-1")
 
         # Prefer the request setting, if it's not None
-        verify = request.verify
-        if verify is None:
+        if request.verify is None:
             request.verify = self.verify
 
         try:
diff --git a/docs/cli/commands.rst b/docs/cli/commands.rst
index d99126b0..9efe5803 100644
--- a/docs/cli/commands.rst
+++ b/docs/cli/commands.rst
@@ -45,3 +45,13 @@ Can be called with an un-authenticated API call.
 .. click:: SoftLayer.CLI.search:cli
     :prog: search
     :show-nested:
+
+
+Employee Login
+==============
+
+Allows employees to use their login information to make API calls.
+
+.. click:: SoftLayer.CLI.login:cli
+    :prog: emplogin
+    :show-nested:
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 0f814817..5771eb8e 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,4 +1,4 @@
-sphinx==7.3.7
+sphinx==7.2.6
 sphinx_rtd_theme==2.0.0
 sphinx-click==5.1.0
 click
diff --git a/setup.py b/setup.py
index 28462d00..48f643d5 100644
--- a/setup.py
+++ b/setup.py
@@ -24,11 +24,10 @@
     packages=find_packages(exclude=['tests']),
     license='MIT',
     zip_safe=False,
-    url='http://github.com/softlayer/softlayer-python',
+    url='https://github.com/SoftLayer/softlayer-python',
     entry_points={
         'console_scripts': [
             'slcli = SoftLayer.CLI.core:main',
-            'sl = SoftLayer.CLI.deprecated:main',
         ],
     },
     python_requires='>=3.7',
@@ -41,7 +40,7 @@
         'urllib3 >= 1.24',
         'rich == 13.7.1'
     ],
-    keywords=['softlayer', 'cloud', 'slcli'],
+    keywords=['softlayer', 'cloud', 'slcli', 'ibmcloud'],
     classifiers=[
         'Environment :: Console',
         'Environment :: Web Environment',
@@ -50,7 +49,6 @@
         'License :: OSI Approved :: MIT License',
         'Operating System :: OS Independent',
         'Topic :: Software Development :: Libraries :: Python Modules',
-        'Programming Language :: Python :: 3',
         'Programming Language :: Python :: 3.7',
         'Programming Language :: Python :: 3.8',
         'Programming Language :: Python :: 3.9',


Test patch:
diff --git a/tests/api_tests.py b/tests/api_tests.py
index ea4726a6..438d7702 100644
--- a/tests/api_tests.py
+++ b/tests/api_tests.py
@@ -4,10 +4,15 @@
 
     :license: MIT, see LICENSE for more details.
 """
+import io
+import os
+import requests
 from unittest import mock as mock
 
 import SoftLayer
 import SoftLayer.API
+from SoftLayer import auth as slauth
+from SoftLayer import exceptions
 from SoftLayer import testing
 from SoftLayer import transports
 
@@ -310,3 +315,94 @@ def test_authenticate_with_password(self, _call):
         self.assertIsNotNone(self.client.auth)
         self.assertEqual(self.client.auth.user_id, 12345)
         self.assertEqual(self.client.auth.auth_token, 'TOKEN')
+
+
+class EmployeeClientTests(testing.TestCase):
+
+    @staticmethod
+    def setup_response(filename, status_code=200, total_items=1):
+        basepath = os.path.dirname(__file__)
+        body = b''
+        with open(f"{basepath}/../SoftLayer/fixtures/xmlrpc/{filename}.xml", 'rb') as fixture:
+            body = fixture.read()
+        response = requests.Response()
+        list_body = body
+        response.raw = io.BytesIO(list_body)
+        response.headers['SoftLayer-Total-Items'] = total_items
+        response.status_code = status_code
+        return response
+
+    def set_up(self):
+        self.client = SoftLayer.API.EmployeeClient(config_file='./tests/testconfig')
+
+    @mock.patch('SoftLayer.transports.xmlrpc.requests.Session.request')
+    def test_auth_with_pass_failure(self, api_response):
+        api_response.return_value = self.setup_response('invalidLogin')
+        exception = self.assertRaises(
+            exceptions.SoftLayerAPIError,
+            self.client.authenticate_with_password, 'testUser', 'testPassword', '123456')
+        self.assertEqual(exception.faultCode, "SoftLayer_Exception_Public")
+
+    @mock.patch('SoftLayer.transports.xmlrpc.requests.Session.request')
+    def test_auth_with_pass_success(self, api_response):
+        api_response.return_value = self.setup_response('successLogin')
+        result = self.client.authenticate_with_internal('testUser', 'testPassword', '123456')
+        print(result)
+        self.assertEqual(result['userId'], 1234)
+        self.assertEqual(self.client.settings['softlayer']['userid'], '1234')
+        self.assertIn('x'*200, self.client.settings['softlayer']['access_token'])
+
+    def test_auth_with_hash(self):
+        self.client.auth = None
+        self.client.authenticate_with_hash(5555, 'abcdefg')
+        self.assertEqual(self.client.auth.user_id, 5555)
+        self.assertEqual(self.client.auth.hash, 'abcdefg')
+
+    @mock.patch('SoftLayer.transports.xmlrpc.requests.Session.request')
+    def test_refresh_token(self, api_response):
+        api_response.return_value = self.setup_response('refreshSuccess')
+        self.client.refresh_token(9999, 'qweasdzxcqweasdzxcqweasdzxc')
+        self.assertEqual(self.client.auth.user_id, 9999)
+        self.assertIn('REFRESHEDTOKENaaaa', self.client.auth.hash)
+
+    @mock.patch('SoftLayer.transports.xmlrpc.requests.Session.request')
+    def test_expired_token_is_refreshed(self, api_response):
+        api_response.side_effect = [
+            self.setup_response('expiredToken'),
+            self.setup_response('refreshSuccess'),
+            self.setup_response('Employee_getObject')
+        ]
+        self.client.auth = slauth.EmployeeAuthentication(5555, 'aabbccee')
+        self.client.settings['softlayer']['userid'] = '5555'
+        result = self.client.call('SoftLayer_User_Employee', 'getObject', id=5555)
+        self.assertIn('REFRESHEDTOKENaaaa', self.client.auth.hash)
+        self.assertEqual('testUser', result['username'])
+
+    @mock.patch('SoftLayer.transports.xmlrpc.requests.Session.request')
+    def test_expired_token_is_really_expired(self, api_response):
+        api_response.side_effect = [
+            self.setup_response('expiredToken'),
+            self.setup_response('expiredToken')
+        ]
+        self.client.auth = slauth.EmployeeAuthentication(5555, 'aabbccee')
+        self.client.settings['softlayer']['userid'] = '5555'
+        exception = self.assertRaises(
+            exceptions.SoftLayerAPIError,
+            self.client.call, 'SoftLayer_User_Employee', 'getObject', id=5555)
+        self.assertEqual(exception.faultCode, "SoftLayer_Exception_EncryptedToken_Expired")
+
+    @mock.patch('SoftLayer.API.BaseClient.call')
+    def test_account_check(self, _call):
+        self.client.transport = self.mocks
+        exception = self.assertRaises(
+            exceptions.SoftLayerError,
+            self.client.call, "SoftLayer_Account", "getObject")
+        self.assertEqual(str(exception), "SoftLayer_Account service requires an ID")
+        self.client.account_id = 1234
+        self.client.call("SoftLayer_Account", "getObject")
+        self.client.call("SoftLayer_Account", "getObject1", id=9999)
+
+        _call.assert_has_calls([
+            mock.call(self.client, 'SoftLayer_Account', 'getObject', id=1234),
+            mock.call(self.client, 'SoftLayer_Account', 'getObject1', id=9999),
+        ])
diff --git a/tests/auth_tests.py b/tests/auth_tests.py
index 6bac999f..cd8ffaa6 100644
--- a/tests/auth_tests.py
+++ b/tests/auth_tests.py
@@ -83,3 +83,22 @@ def test_repr(self):
         s = repr(self.auth)
         self.assertIn('BasicHTTPAuthentication', s)
         self.assertIn('USERNAME', s)
+
+
+class TestX509AUthentication(testing.TestCase):
+    def set_up(self):
+        self.auth = auth.X509Authentication('authcert.pm', 'servercert.pm')
+
+    def test_attribs(self):
+        self.assertEqual(self.auth.cert, 'authcert.pm')
+        self.assertEqual(self.auth.ca_cert, 'servercert.pm')
+
+    def test_get_request(self):
+        req = transports.Request()
+        authed_req = self.auth.get_request(req)
+        self.assertEqual(authed_req.cert, 'authcert.pm')
+        self.assertEqual(authed_req.verify, 'servercert.pm')
+
+    def test_repr(self):
+        s = repr(self.auth)
+        self.assertEqual(s, "X509Authentication(cert=authcert.pm, ca_cert=servercert.pm)")