Skip to content
This repository was archived by the owner on Mar 6, 2026. It is now read-only.

Commit 1dee698

Browse files
feat: add mTLS ADC support for HTTP
1 parent b2dd77f commit 1dee698

11 files changed

Lines changed: 922 additions & 6 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Copyright 2020 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Helper functions for getting mTLS cert and key, for internal use only."""
16+
17+
import json
18+
import logging
19+
from os import path
20+
import re
21+
import subprocess
22+
23+
CONTEXT_AWARE_METADATA_PATH = "~/.secureConnect/context_aware_metadata.json"
24+
_CERT_PROVIDER_COMMAND = "cert_provider_command"
25+
_CERT_REGEX = re.compile(
26+
b"-----BEGIN CERTIFICATE-----.+-----END CERTIFICATE-----\r?\n?", re.DOTALL
27+
)
28+
29+
# support various format of key files, e.g.
30+
# "-----BEGIN PRIVATE KEY-----...",
31+
# "-----BEGIN EC PRIVATE KEY-----...",
32+
# "-----BEGIN RSA PRIVATE KEY-----..."
33+
_KEY_REGEX = re.compile(
34+
b"-----BEGIN [A-Z ]*PRIVATE KEY-----.+-----END [A-Z ]*PRIVATE KEY-----\r?\n?",
35+
re.DOTALL,
36+
)
37+
38+
_LOGGER = logging.getLogger(__name__)
39+
40+
41+
def _check_dca_metadata_path(metadata_path):
42+
"""Checks for context aware metadata. If it exists, returns the absolute path;
43+
otherwise returns None.
44+
45+
Args:
46+
metadata_path (str): context aware metadata path.
47+
48+
Returns:
49+
str: absolute path if exists and None otherwise.
50+
"""
51+
metadata_path = path.expanduser(metadata_path)
52+
if not path.exists(metadata_path):
53+
_LOGGER.debug("%s is not found, skip client SSL authentication.", metadata_path)
54+
return None
55+
return metadata_path
56+
57+
58+
def _read_dca_metadata_file(metadata_path):
59+
"""Loads context aware metadata from the given path.
60+
61+
Args:
62+
metadata_path (str): context aware metadata path.
63+
64+
Returns:
65+
Dict[str, str]: The metadata.
66+
67+
Raises:
68+
ValueError: If failed to parse metadata as JSON.
69+
"""
70+
with open(metadata_path) as f:
71+
metadata = json.load(f)
72+
73+
return metadata
74+
75+
76+
def get_client_ssl_credentials(metadata_json):
77+
"""Returns the client side mTLS cert and key.
78+
79+
Args:
80+
metadata_json (Dict[str, str]): metadata JSON file which contains the cert
81+
provider command.
82+
83+
Returns:
84+
Tuple[bytes, bytes]: client certificate and key, both in PEM format.
85+
86+
Raises:
87+
OSError: If the cert provider command failed to run.
88+
RuntimeError: If the cert provider command has a runtime error.
89+
ValueError: If the metadata json file doesn't contain the cert provider
90+
command or if the command doesn't produce both the client certificate
91+
and client key.
92+
"""
93+
# TODO: implement an in-memory cache of cert and key so we don't have to
94+
# run cert provider command every time.
95+
96+
# Check the cert provider command existence in the metadata json file.
97+
if _CERT_PROVIDER_COMMAND not in metadata_json:
98+
raise ValueError("Cert provider command is not found")
99+
100+
# Execute the command. It throws OsError in case of system failure.
101+
command = metadata_json[_CERT_PROVIDER_COMMAND]
102+
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
103+
stdout, stderr = process.communicate()
104+
105+
# Check cert provider command execution error.
106+
if process.returncode != 0:
107+
raise RuntimeError(
108+
"Cert provider command returns non-zero status code %s" % process.returncode
109+
)
110+
111+
# Extract certificate (chain) and key.
112+
cert_match = re.findall(_CERT_REGEX, stdout)
113+
if len(cert_match) != 1:
114+
raise ValueError("Client SSL certificate is missing or invalid")
115+
key_match = re.findall(_KEY_REGEX, stdout)
116+
if len(key_match) != 1:
117+
raise ValueError("Client SSL key is missing or invalid")
118+
return cert_match[0], key_match[0]
119+
120+
121+
def get_client_cert_and_key(client_cert_callback=None):
122+
"""Returns the client side certificate and private key. The function first
123+
tries to get certificate and key from client_cert_callback; if the callback
124+
is None or doesn't provide certificate and key, the function tries application
125+
default SSL credentials.
126+
127+
Args:
128+
client_cert_callback (Optional[Callable[[], (bool, bytes, bytes)]]): A
129+
callback which returns a bool indicating if the call is successful,
130+
and client certificate bytes and private key bytes both in PEM format.
131+
132+
Returns:
133+
Tuple[bool, bytes, bytes]:
134+
A boolean indicating if cert and key are obtained, the cert bytes
135+
and key bytes both in PEM format.
136+
137+
Raises:
138+
OSError: If the cert provider command failed to run.
139+
RuntimeError: If the cert provider command has a runtime error.
140+
ValueError: If the metadata json file doesn't contain the cert provider
141+
command or if the command doesn't produce both the client certificate
142+
and client key.
143+
"""
144+
if client_cert_callback:
145+
return client_cert_callback()
146+
147+
metadata_path = _check_dca_metadata_path(CONTEXT_AWARE_METADATA_PATH)
148+
if metadata_path:
149+
metadata = _read_dca_metadata_file(metadata_path)
150+
cert, key = get_client_ssl_credentials(metadata)
151+
return True, cert, key
152+
153+
return False, None, None

google/auth/transport/requests.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,14 @@
3535
)
3636
import requests.adapters # pylint: disable=ungrouped-imports
3737
import requests.exceptions # pylint: disable=ungrouped-imports
38+
from requests.packages.urllib3.util.ssl_ import (
39+
create_urllib3_context,
40+
) # pylint: disable=ungrouped-imports
3841
import six # pylint: disable=ungrouped-imports
3942

4043
from google.auth import exceptions
4144
from google.auth import transport
45+
import google.auth.transport._mtls_helper
4246

4347
_LOGGER = logging.getLogger(__name__)
4448

@@ -182,6 +186,52 @@ def __call__(
182186
six.raise_from(new_exc, caught_exc)
183187

184188

189+
class _MutualTlsAdapter(requests.adapters.HTTPAdapter):
190+
"""
191+
A TransportAdapter that enables mutual TLS.
192+
193+
Args:
194+
cert (bytes): client certificate in PEM format
195+
key (bytes): client private key in PEM format
196+
197+
Raises:
198+
ImportError: if certifi or pyOpenSSL is not installed
199+
OpenSSL.crypto.Error: if client cert or key is invalid
200+
"""
201+
202+
def __init__(self, cert, key):
203+
import certifi
204+
from OpenSSL import crypto
205+
import urllib3.contrib.pyopenssl
206+
207+
urllib3.contrib.pyopenssl.inject_into_urllib3()
208+
209+
pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
210+
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
211+
212+
ctx_poolmanager = create_urllib3_context()
213+
ctx_poolmanager.load_verify_locations(cafile=certifi.where())
214+
ctx_poolmanager._ctx.use_certificate(x509)
215+
ctx_poolmanager._ctx.use_privatekey(pkey)
216+
self._ctx_poolmanager = ctx_poolmanager
217+
218+
ctx_proxymanager = create_urllib3_context()
219+
ctx_proxymanager.load_verify_locations(cafile=certifi.where())
220+
ctx_proxymanager._ctx.use_certificate(x509)
221+
ctx_proxymanager._ctx.use_privatekey(pkey)
222+
self._ctx_proxymanager = ctx_proxymanager
223+
224+
super(_MutualTlsAdapter, self).__init__()
225+
226+
def init_poolmanager(self, *args, **kwargs):
227+
kwargs["ssl_context"] = self._ctx_poolmanager
228+
super(_MutualTlsAdapter, self).init_poolmanager(*args, **kwargs)
229+
230+
def proxy_manager_for(self, *args, **kwargs):
231+
kwargs["ssl_context"] = self._ctx_proxymanager
232+
return super(_MutualTlsAdapter, self).proxy_manager_for(*args, **kwargs)
233+
234+
185235
class AuthorizedSession(requests.Session):
186236
"""A Requests Session class with credentials.
187237
@@ -198,6 +248,49 @@ class AuthorizedSession(requests.Session):
198248
The underlying :meth:`request` implementation handles adding the
199249
credentials' headers to the request and refreshing credentials as needed.
200250
251+
This class also supports mutual TLS via :meth:`configure_mtls_channel`
252+
method. This method first tries to load client certificate and private key
253+
using the given client_cert_callabck; if callback is None or fails, it tries
254+
to load application default SSL credentials. Exceptions are raised if there
255+
are problems with the certificate, private key, or the loading process, so
256+
it should be called within a try/except block.
257+
258+
First we create an :class:`AuthorizedSession` instance and specify the endpoints::
259+
260+
regular_endpoint = 'https://pubsub.googleapis.com/v1/projects/{my_project_id}/topics'
261+
mtls_endpoint = 'https://pubsub.mtls.googleapis.com/v1/projects/{my_project_id}/topics'
262+
263+
authed_session = AuthorizedSession(credentials)
264+
265+
Now we can pass a callback to :meth:`configure_mtls_channel`::
266+
267+
def my_cert_callback():
268+
# some code to load client cert bytes and private key bytes, both in
269+
# PEM format.
270+
some_code_to_load_client_cert_and_key()
271+
if loaded:
272+
return True, cert, key
273+
else:
274+
return False, None, None
275+
276+
# Always call configure_mtls_channel within a try/except block.
277+
try:
278+
authed_session.configure_mtls_channel(my_cert_callback)
279+
except:
280+
# handle exceptions.
281+
282+
if authed_session.is_mtls:
283+
response = authed_session.request('GET', mtls_endpoint)
284+
else:
285+
response = authed_session.request('GET', regular_endpoint)
286+
287+
You can alternatively use application default SSL credentials like this::
288+
289+
try:
290+
authed_session.configure_mtls_channel()
291+
except:
292+
# handle exceptions.
293+
201294
Args:
202295
credentials (google.auth.credentials.Credentials): The credentials to
203296
add to the request.
@@ -229,6 +322,7 @@ def __init__(
229322
self._refresh_status_codes = refresh_status_codes
230323
self._max_refresh_attempts = max_refresh_attempts
231324
self._refresh_timeout = refresh_timeout
325+
self._is_mtls = False
232326

233327
if auth_request is None:
234328
auth_request_session = requests.Session()
@@ -247,6 +341,40 @@ def __init__(
247341
# credentials.refresh).
248342
self._auth_request = auth_request
249343

344+
def configure_mtls_channel(self, client_cert_callback=None):
345+
"""Configure the client certificate and key for SSL connection.
346+
347+
If client certificate and key are successfully obtained (from the given
348+
client_cert_callabck or from application default SSL credentials), a
349+
:class:`_MutualTlsAdapter` instance will be mounted to "https://" prefix.
350+
351+
Args:
352+
client_cert_callabck (Optional[Callable[[], (bool, bytes, bytes)]]):
353+
The optional callback returns a boolean indicating if the call
354+
is successful, and the client certificate and private key bytes
355+
both in PEM format.
356+
If the call is not succesful, application default SSL credentials
357+
will be used.
358+
359+
Raises:
360+
ImportError: If certifi or pyOpenSSL is not installed.
361+
OpenSSL.crypto.Error: If client cert or key is invalid.
362+
OSError: If the cert provider command launch fails during the
363+
application default SSL credentials loading process.
364+
RuntimeError: If the cert provider command has a runtime error during
365+
the application default SSL credentials loading process.
366+
ValueError: If the context aware metadata file is malformed or the
367+
cert provider command doesn't produce both client certicate and
368+
key during the application default SSL credentials loading process.
369+
"""
370+
self._is_mtls, cert, key = google.auth.transport._mtls_helper.get_client_cert_and_key(
371+
client_cert_callback
372+
)
373+
374+
if self._is_mtls:
375+
mtls_adapter = _MutualTlsAdapter(cert, key)
376+
self.mount("https://", mtls_adapter)
377+
250378
def request(
251379
self,
252380
method,
@@ -361,3 +489,8 @@ def request(
361489
)
362490

363491
return response
492+
493+
@property
494+
def is_mtls(self):
495+
"""Indicates if the created SSL channel is mutual TLS."""
496+
return self._is_mtls

0 commit comments

Comments
 (0)