Repository: dwavesystems/dwave-cloud-client
Pull request #383 (2020-06-05 13:37:20)

Add support for client-side certificate auth
Config/kwarg options added to client:
- `client_cert`
- `client_cert_key`

They both accept a string path to a crt/key file.

If only `client_cert` is given, the file must contain both the certificate
and the key. Alternatively, cert and key can be split among the two
options.

Closes #383.


Patch:
diff --git a/dwave/cloud/client.py b/dwave/cloud/client.py
index 582813f..05bd128 100644
--- a/dwave/cloud/client.py
+++ b/dwave/cloud/client.py
@@ -379,13 +379,23 @@ class Client(object):
         if not token:
             raise ValueError("API token not defined")
 
+        # parse optional client certificate
+        client_cert = kwargs.pop('client_cert', None)
+        client_cert_key = kwargs.pop('client_cert_key', None)
+        if client_cert_key is not None:
+            if client_cert is not None:
+                client_cert = (client_cert, client_cert_key)
+            else:
+                raise ValueError(
+                    "Client certificate key given, but the cert is missing")
+
         logger.debug(
             "Creating a client for (endpoint=%r, token=%r, solver=%r, proxy=%r, "
             "permissive_ssl=%r, request_timeout=%r, polling_timeout=%r, "
-            "connection_close=%r, headers=%r, **kwargs=%r)",
+            "connection_close=%r, headers=%r, client_cert=%r, **kwargs=%r)",
             endpoint, token, solver, proxy,
             permissive_ssl, request_timeout, polling_timeout,
-            connection_close, headers, kwargs
+            connection_close, headers, client_cert, kwargs
         )
 
         # parse solver
@@ -432,6 +442,7 @@ class Client(object):
         self.default_solver = solver_def
 
         self.token = token
+        self.client_cert = client_cert
         self.request_timeout = parse_float(request_timeout)
         self.polling_timeout = parse_float(polling_timeout)
 
@@ -507,10 +518,15 @@ class Client(object):
         session = BaseUrlSession(base_url=endpoint)
         session.mount('http://', TimeoutingHTTPAdapter(timeout=self.request_timeout))
         session.mount('https://', TimeoutingHTTPAdapter(timeout=self.request_timeout))
+
+        session.headers.update({'User-Agent': user_agent(__packagename__, __version__)})
         if self.headers:
             session.headers.update(self.headers)
-        session.headers.update({'X-Auth-Token': self.token,
-                                'User-Agent': user_agent(__packagename__, __version__)})
+        if self.token:
+            session.headers.update({'X-Auth-Token': self.token})
+        if self.client_cert:
+            session.cert = self.client_cert
+
         session.proxies = {'http': self.proxy, 'https': self.proxy}
         if self.permissive_ssl:
             session.verify = False


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index 4286dfb..a4f5fc9 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -305,6 +305,64 @@ class ClientFactory(unittest.TestCase):
             with dwave.cloud.Client.from_config(headers=headers_str) as client:
                 self.assertDictEqual(client.headers, headers_dict)
 
+    def test_client_cert_from_config(self):
+        crt = '/path/to/crt'
+        key = '/path/to/key'
+
+        # single file with cert+key
+        client_cert = crt
+        conf = dict(token='token', client_cert=crt)
+
+        with mock.patch("dwave.cloud.client.load_config", lambda **kwargs: conf):
+            with dwave.cloud.Client.from_config() as client:
+                self.assertEqual(client.client_cert, client_cert)
+
+                session = client.create_session()
+                self.assertEqual(session.cert, client_cert)
+
+        # separate cert and key files
+        client_cert = (crt, key)
+        conf = dict(token='token', client_cert=crt, client_cert_key=key)
+
+        with mock.patch("dwave.cloud.client.load_config", lambda **kwargs: conf):
+            with dwave.cloud.Client.from_config() as client:
+                self.assertEqual(client.client_cert, client_cert)
+
+                session = client.create_session()
+                self.assertEqual(session.cert, client_cert)
+
+    def test_client_cert_from_kwargs(self):
+        crt = '/path/to/crt'
+        key = '/path/to/key'
+
+        def load_config(**kwargs):
+            file_conf = dict(token='token')
+            return merge(kwargs, file_conf, op=lambda a, b: a or b)
+
+        # single file with cert+key
+        client_cert = crt
+        conf = dict(client_cert=crt)
+
+        with mock.patch("dwave.cloud.client.load_config", load_config):
+            with dwave.cloud.Client.from_config(**conf) as client:
+                self.assertEqual(client.client_cert, client_cert)
+
+        # separate cert and key files
+        client_cert = (crt, key)
+        conf = dict(client_cert=crt, client_cert_key=key)
+
+        with mock.patch("dwave.cloud.client.load_config", load_config):
+            with dwave.cloud.Client.from_config(**conf) as client:
+                self.assertEqual(client.client_cert, client_cert)
+
+        # client_cert as tuple (direct `requests` format)
+        client_cert = (crt, key)
+        conf = dict(client_cert=client_cert)
+
+        with mock.patch("dwave.cloud.client.load_config", load_config):
+            with dwave.cloud.Client.from_config(**conf) as client:
+                self.assertEqual(client.client_cert, client_cert)
+
 
 class FeatureBasedSolverSelection(unittest.TestCase):
     """Test Client.get_solvers(**filters)."""