Skip to content

Commit

Permalink
feat: add mTLS ADC support for HTTP (#457)
Browse files Browse the repository at this point in the history
feat: add mTLS ADC support for HTTP
  • Loading branch information
arithmetic1728 committed Mar 20, 2020
1 parent b526473 commit bb9215a
Show file tree
Hide file tree
Showing 10 changed files with 642 additions and 37 deletions.
40 changes: 39 additions & 1 deletion google/auth/transport/_mtls_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ def get_client_ssl_credentials(metadata_json):
Raises:
OSError: If the cert provider command failed to run.
RuntimeError: If the cert provider command has a runtime error.
ValueError: If the metadata json file doesn't contain the cert provider command or if the command doesn't produce both the client certificate and client key.
ValueError: If the metadata json file doesn't contain the cert provider
command or if the command doesn't produce both the client certificate
and client key.
"""
# TODO: implement an in-memory cache of cert and key so we don't have to
# run cert provider command every time.
Expand Down Expand Up @@ -114,3 +116,39 @@ def get_client_ssl_credentials(metadata_json):
if len(key_match) != 1:
raise ValueError("Client SSL key is missing or invalid")
return cert_match[0], key_match[0]


def get_client_cert_and_key(client_cert_callback=None):
"""Returns the client side certificate and private key. The function first
tries to get certificate and key from client_cert_callback; if the callback
is None or doesn't provide certificate and key, the function tries application
default SSL credentials.
Args:
client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): An
optional callback which returns client certificate bytes and private
key bytes both in PEM format.
Returns:
Tuple[bool, bytes, bytes]:
A boolean indicating if cert and key are obtained, the cert bytes
and key bytes both in PEM format.
Raises:
OSError: If the cert provider command failed to run.
RuntimeError: If the cert provider command has a runtime error.
ValueError: If the metadata json file doesn't contain the cert provider
command or if the command doesn't produce both the client certificate
and client key.
"""
if client_cert_callback:
cert, key = client_cert_callback()
return True, cert, key

metadata_path = _check_dca_metadata_path(CONTEXT_AWARE_METADATA_PATH)
if metadata_path:
metadata = _read_dca_metadata_file(metadata_path)
cert, key = get_client_ssl_credentials(metadata)
return True, cert, key

return False, None, None
131 changes: 131 additions & 0 deletions google/auth/transport/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@
)
import requests.adapters # pylint: disable=ungrouped-imports
import requests.exceptions # pylint: disable=ungrouped-imports
from requests.packages.urllib3.util.ssl_ import (
create_urllib3_context,
) # pylint: disable=ungrouped-imports
import six # pylint: disable=ungrouped-imports

from google.auth import exceptions
from google.auth import transport
import google.auth.transport._mtls_helper

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -182,6 +186,52 @@ def __call__(
six.raise_from(new_exc, caught_exc)


class _MutualTlsAdapter(requests.adapters.HTTPAdapter):
"""
A TransportAdapter that enables mutual TLS.
Args:
cert (bytes): client certificate in PEM format
key (bytes): client private key in PEM format
Raises:
ImportError: if certifi or pyOpenSSL is not installed
OpenSSL.crypto.Error: if client cert or key is invalid
"""

def __init__(self, cert, key):
import certifi
from OpenSSL import crypto
import urllib3.contrib.pyopenssl

urllib3.contrib.pyopenssl.inject_into_urllib3()

pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)

ctx_poolmanager = create_urllib3_context()
ctx_poolmanager.load_verify_locations(cafile=certifi.where())
ctx_poolmanager._ctx.use_certificate(x509)
ctx_poolmanager._ctx.use_privatekey(pkey)
self._ctx_poolmanager = ctx_poolmanager

ctx_proxymanager = create_urllib3_context()
ctx_proxymanager.load_verify_locations(cafile=certifi.where())
ctx_proxymanager._ctx.use_certificate(x509)
ctx_proxymanager._ctx.use_privatekey(pkey)
self._ctx_proxymanager = ctx_proxymanager

super(_MutualTlsAdapter, self).__init__()

def init_poolmanager(self, *args, **kwargs):
kwargs["ssl_context"] = self._ctx_poolmanager
super(_MutualTlsAdapter, self).init_poolmanager(*args, **kwargs)

def proxy_manager_for(self, *args, **kwargs):
kwargs["ssl_context"] = self._ctx_proxymanager
return super(_MutualTlsAdapter, self).proxy_manager_for(*args, **kwargs)


class AuthorizedSession(requests.Session):
"""A Requests Session class with credentials.
Expand All @@ -198,6 +248,48 @@ class AuthorizedSession(requests.Session):
The underlying :meth:`request` implementation handles adding the
credentials' headers to the request and refreshing credentials as needed.
This class also supports mutual TLS via :meth:`configure_mtls_channel`
method. If client_cert_callabck is provided, client certificate and private
key are loaded using the callback; if client_cert_callabck is None,
application default SSL credentials will be used. Exceptions are raised if
there are problems with the certificate, private key, or the loading process,
so it should be called within a try/except block.
First we create an :class:`AuthorizedSession` instance and specify the endpoints::
regular_endpoint = 'https://pubsub.googleapis.com/v1/projects/{my_project_id}/topics'
mtls_endpoint = 'https://pubsub.mtls.googleapis.com/v1/projects/{my_project_id}/topics'
authed_session = AuthorizedSession(credentials)
Now we can pass a callback to :meth:`configure_mtls_channel`::
def my_cert_callback():
# some code to load client cert bytes and private key bytes, both in
# PEM format.
some_code_to_load_client_cert_and_key()
if loaded:
return cert, key
raise MyClientCertFailureException()
# Always call configure_mtls_channel within a try/except block.
try:
authed_session.configure_mtls_channel(my_cert_callback)
except:
# handle exceptions.
if authed_session.is_mtls:
response = authed_session.request('GET', mtls_endpoint)
else:
response = authed_session.request('GET', regular_endpoint)
You can alternatively use application default SSL credentials like this::
try:
authed_session.configure_mtls_channel()
except:
# handle exceptions.
Args:
credentials (google.auth.credentials.Credentials): The credentials to
add to the request.
Expand Down Expand Up @@ -229,6 +321,7 @@ def __init__(
self._refresh_status_codes = refresh_status_codes
self._max_refresh_attempts = max_refresh_attempts
self._refresh_timeout = refresh_timeout
self._is_mtls = False

if auth_request is None:
auth_request_session = requests.Session()
Expand All @@ -247,6 +340,39 @@ def __init__(
# credentials.refresh).
self._auth_request = auth_request

def configure_mtls_channel(self, client_cert_callback=None):
"""Configure the client certificate and key for SSL connection.
If client certificate and key are successfully obtained (from the given
client_cert_callabck or from application default SSL credentials), a
:class:`_MutualTlsAdapter` instance will be mounted to "https://" prefix.
Args:
client_cert_callabck (Optional[Callable[[], (bytes, bytes)]]):
The optional callback returns the client certificate and private
key bytes both in PEM format.
If the callback is None, application default SSL credentials
will be used.
Raises:
ImportError: If certifi or pyOpenSSL is not installed.
OpenSSL.crypto.Error: If client cert or key is invalid.
OSError: If the cert provider command launch fails during the
application default SSL credentials loading process.
RuntimeError: If the cert provider command has a runtime error during
the application default SSL credentials loading process.
ValueError: If the context aware metadata file is malformed or the
cert provider command doesn't produce both client certicate and
key during the application default SSL credentials loading process.
"""
self._is_mtls, cert, key = google.auth.transport._mtls_helper.get_client_cert_and_key(
client_cert_callback
)

if self._is_mtls:
mtls_adapter = _MutualTlsAdapter(cert, key)
self.mount("https://", mtls_adapter)

def request(
self,
method,
Expand Down Expand Up @@ -361,3 +487,8 @@ def request(
)

return response

@property
def is_mtls(self):
"""Indicates if the created SSL channel is mutual TLS."""
return self._is_mtls
129 changes: 125 additions & 4 deletions google/auth/transport/urllib3.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from __future__ import absolute_import

import logging

import warnings

# Certifi is Mozilla's certificate bundle. Urllib3 needs a certificate bundle
# to verify HTTPS requests, and certifi is the recommended and most reliable
Expand Down Expand Up @@ -149,6 +149,39 @@ def _make_default_http():
return urllib3.PoolManager()


def _make_mutual_tls_http(cert, key):
"""Create a mutual TLS HTTP connection with the given client cert and key.
See https://github.com/urllib3/urllib3/issues/474#issuecomment-253168415
Args:
cert (bytes): client certificate in PEM format
key (bytes): client private key in PEM format
Returns:
urllib3.PoolManager: Mutual TLS HTTP connection.
Raises:
ImportError: If certifi or pyOpenSSL is not installed.
OpenSSL.crypto.Error: If the cert or key is invalid.
"""
import certifi
from OpenSSL import crypto
import urllib3.contrib.pyopenssl

urllib3.contrib.pyopenssl.inject_into_urllib3()
ctx = urllib3.util.ssl_.create_urllib3_context()
ctx.load_verify_locations(cafile=certifi.where())

pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)

ctx._ctx.use_certificate(x509)
ctx._ctx.use_privatekey(pkey)

http = urllib3.PoolManager(ssl_context=ctx)
return http


class AuthorizedHttp(urllib3.request.RequestMethods):
"""A urllib3 HTTP class with credentials.
Expand All @@ -168,6 +201,48 @@ class AuthorizedHttp(urllib3.request.RequestMethods):
The underlying :meth:`urlopen` implementation handles adding the
credentials' headers to the request and refreshing credentials as needed.
This class also supports mutual TLS via :meth:`configure_mtls_channel`
method. If client_cert_callabck is provided, client certificate and private
key are loaded using the callback; if client_cert_callabck is None,
application default SSL credentials will be used. Exceptions are raised if
there are problems with the certificate, private key, or the loading process,
so it should be called within a try/except block.
First we create an :class:`AuthorizedHttp` instance and specify the endpoints::
regular_endpoint = 'https://pubsub.googleapis.com/v1/projects/{my_project_id}/topics'
mtls_endpoint = 'https://pubsub.mtls.googleapis.com/v1/projects/{my_project_id}/topics'
authed_http = AuthorizedHttp(credentials)
Now we can pass a callback to :meth:`configure_mtls_channel`::
def my_cert_callback():
# some code to load client cert bytes and private key bytes, both in
# PEM format.
some_code_to_load_client_cert_and_key()
if loaded:
return cert, key
raise MyClientCertFailureException()
# Always call configure_mtls_channel within a try/except block.
try:
is_mtls = authed_http.configure_mtls_channel(my_cert_callback)
except:
# handle exceptions.
if is_mtls:
response = authed_http.request('GET', mtls_endpoint)
else:
response = authed_http.request('GET', regular_endpoint)
You can alternatively use application default SSL credentials like this::
try:
is_mtls = authed_http.configure_mtls_channel()
except:
# handle exceptions.
Args:
credentials (google.auth.credentials.Credentials): The credentials to
add to the request.
Expand All @@ -189,12 +264,14 @@ def __init__(
refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES,
max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS,
):

if http is None:
http = _make_default_http()
self.http = _make_default_http()
self._has_user_provided_http = False
else:
self.http = http
self._has_user_provided_http = True

self.credentials = credentials
self.http = http
self._refresh_status_codes = refresh_status_codes
self._max_refresh_attempts = max_refresh_attempts
# Request instance used by internal methods (for example,
Expand All @@ -203,6 +280,50 @@ def __init__(

super(AuthorizedHttp, self).__init__()

def configure_mtls_channel(self, client_cert_callabck=None):
"""Configures mutual TLS channel using the given client_cert_callabck or
application default SSL credentials. Returns True if the channel is
mutual TLS and False otherwise. Note that the `http` provided in the
constructor will be overwritten.
Args:
client_cert_callabck (Optional[Callable[[], (bytes, bytes)]]):
The optional callback returns the client certificate and private
key bytes both in PEM format.
If the callback is None, application default SSL credentials
will be used.
Returns:
True if the channel is mutual TLS and False otherwise.
Raises:
ImportError: If certifi or pyOpenSSL is not installed.
OpenSSL.crypto.Error: If client cert or key is invalid.
OSError: If the cert provider command launch fails during the
application default SSL credentials loading process.
RuntimeError: If the cert provider command has a runtime error during
the application default SSL credentials loading process.
ValueError: If the context aware metadata file is malformed or the
cert provider command doesn't produce both client certicate and
key during the application default SSL credentials loading process.
"""
found_cert_key, cert, key = transport._mtls_helper.get_client_cert_and_key(
client_cert_callabck
)

if found_cert_key:
self.http = _make_mutual_tls_http(cert, key)
else:
self.http = _make_default_http()

if self._has_user_provided_http:
self._has_user_provided_http = False
warnings.warn(
"`http` provided in the constructor is overwritten", UserWarning
)

return found_cert_key

def urlopen(self, method, url, body=None, headers=None, **kwargs):
"""Implementation of urllib3's urlopen."""
# pylint: disable=arguments-differ
Expand Down
1 change: 1 addition & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"freezegun",
"mock",
"oauth2client",
"pyopenssl",
"pytest",
"pytest-cov",
"pytest-localserver",
Expand Down
Loading

0 comments on commit bb9215a

Please sign in to comment.