Repository: encode/httpx

Issue:
How to configure TLS beyond client certificate and CA root certs?
Typically in requests you can use a __HTTPAdapter__ to hijack and set the SSL context and define what ciphers to use, how does one go about doing the same in __httpx__?


Discussion:
I was thinking about this, maybe we need to either provide a better interface or a recipe for how to do this. There are plenty of valid use-cases for configuring beyond our defaults and we should expose that in the way that forces us to do the least work possible.
My proposed solution was the allow passing an `ssl.SSLContext` into `verify=...`. It would get transformed into an `SSLConfig` object and then still call `load_client_cert()` if `cert=...` is called with it? 
I think your biggest hurdle doing that is around here https://github.com/encode/httpx/blob/master/httpx/dispatch/connection.py#L64-L78 due to your logic and how the logic in config is constructed
Is there anyway to access HTTPConnection i mean you could populate __HTTPConnection.ssl.ssl_context__ with a context
But that creates issues in https://github.com/encode/httpx/blob/master/httpx/config.py#L68-L105 where it just blindly creates a new context via ___create_default_ssl_context__

Why bother checking __self.ssl_context__ if its populated in __load_ssl_context__ for verify and unverify when both functions being called just create another context anyway...
> My proposed solution was the allow passing an ssl.SSLContext into verify=.... It would get transformed into an SSLConfig object and then still call load_client_cert() if cert=... is called with it?

Yeah either overloading `verify=...`, or else adding a new `ssl=...` argument.

I think I probably quite like your suggestion here @sethmlarson.
I like the `ssl` argument suggestion personally, I am really excited for this project :) and thanks for taking the time to look into this

Resolving patch:
diff --git a/httpx/config.py b/httpx/config.py
--- a/httpx/config.py
+++ b/httpx/config.py
@@ -7,7 +7,7 @@
 from .__version__ import __version__
 
 CertTypes = typing.Union[str, typing.Tuple[str, str], typing.Tuple[str, str, str]]
-VerifyTypes = typing.Union[str, bool]
+VerifyTypes = typing.Union[str, bool, ssl.SSLContext]
 TimeoutTypes = typing.Union[float, typing.Tuple[float, float, float], "TimeoutConfig"]
 
 
@@ -40,9 +40,17 @@ class SSLConfig:
 
     def __init__(self, *, cert: CertTypes = None, verify: VerifyTypes = True):
         self.cert = cert
-        self.verify = verify
 
-        self.ssl_context: typing.Optional[ssl.SSLContext] = None
+        # Allow passing in our own SSLContext object that's pre-configured.
+        # If you do this we assume that you want verify=True as well.
+        ssl_context = None
+        if isinstance(verify, ssl.SSLContext):
+            ssl_context = verify
+            verify = True
+            self._load_client_certs(ssl_context)
+
+        self.ssl_context: typing.Optional[ssl.SSLContext] = ssl_context
+        self.verify: typing.Union[str, bool] = verify
 
     def __eq__(self, other: typing.Any) -> bool:
         return (
@@ -121,17 +129,7 @@ def load_ssl_context_verify(self) -> ssl.SSLContext:
         elif ca_bundle_path.is_dir():
             context.load_verify_locations(capath=str(ca_bundle_path))
 
-        if self.cert is not None:
-            if isinstance(self.cert, str):
-                context.load_cert_chain(certfile=self.cert)
-            elif isinstance(self.cert, tuple) and len(self.cert) == 2:
-                context.load_cert_chain(certfile=self.cert[0], keyfile=self.cert[1])
-            elif isinstance(self.cert, tuple) and len(self.cert) == 3:
-                context.load_cert_chain(
-                    certfile=self.cert[0],
-                    keyfile=self.cert[1],
-                    password=self.cert[2],  # type: ignore
-                )
+        self._load_client_certs(context)
 
         return context
 
@@ -155,6 +153,22 @@ def _create_default_ssl_context(self) -> ssl.SSLContext:
 
         return context
 
+    def _load_client_certs(self, ssl_context: ssl.SSLContext) -> None:
+        """
+        Loads client certificates into our SSLContext object
+        """
+        if self.cert is not None:
+            if isinstance(self.cert, str):
+                ssl_context.load_cert_chain(certfile=self.cert)
+            elif isinstance(self.cert, tuple) and len(self.cert) == 2:
+                ssl_context.load_cert_chain(certfile=self.cert[0], keyfile=self.cert[1])
+            elif isinstance(self.cert, tuple) and len(self.cert) == 3:
+                ssl_context.load_cert_chain(
+                    certfile=self.cert[0],
+                    keyfile=self.cert[1],
+                    password=self.cert[2],  # type: ignore
+                )
+
 
 class TimeoutConfig:
     """


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -76,6 +76,15 @@ def test_load_ssl_config_no_verify():
     assert context.check_hostname is False
 
 
+def test_load_ssl_context():
+    ssl_context = ssl.create_default_context()
+    ssl_config = httpx.SSLConfig(verify=ssl_context)
+
+    assert ssl_config.verify is True
+    assert ssl_config.ssl_context is ssl_context
+    assert repr(ssl_config) == "SSLConfig(cert=None, verify=True)"
+
+
 def test_ssl_repr():
     ssl = httpx.SSLConfig(verify=False)
     assert repr(ssl) == "SSLConfig(cert=None, verify=False)"