Skip to content

Commit e7baed1

Browse files
authored
feat(auth): Implement python mtls helpers (#17495)
This PR provides helper methods to allow custom HTTP and WebSocket connection pools (such as those in google-genai and google-adk) to load default client certificates and resolve the GOOGLE_API_USE_MTLS_ENDPOINT env var. Changes include: - Introduced `GOOGLE_API_USE_MTLS_ENDPOINT` environment variable to control whether an mTLS endpoint should be used (`always`, `never`, or `auto`). - Added several new helper functions in `google.auth.transport.mtls` to facilitate SSL context creation and client certificate loading: - `_load_client_cert_into_context`: Loads a client certificate and key into a provided SSL context. - `load_default_client_cert`: Discovers and loads the default client certificate into a provided SSL context if mTLS is enabled. - `get_default_ssl_context`: Returns a default SSL context pre-loaded with the default client certificate, or `None` if unavailable. - `should_use_mtls_endpoint`: Determines if an mTLS endpoint should be used based on the new environment variable and certificate availability. - Fixed outdated docstrings for `default_client_cert_source` and `default_client_encrypted_cert_source` to correctly state they raise `MutualTLSChannelError` instead of `DefaultClientCertSourceError`. - Added comprehensive unit tests for the new mTLS helper methods.
1 parent 7caf6c9 commit e7baed1

8 files changed

Lines changed: 579 additions & 24 deletions

File tree

packages/google-auth/google/auth/aio/transport/mtls.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,7 @@ def make_client_cert_ssl_context(
5555
):
5656
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
5757
if cert_path:
58-
password = (
59-
passphrase_val.decode("utf-8")
60-
if isinstance(passphrase_val, bytes)
61-
else passphrase_val
62-
)
58+
password = passphrase_val
6359
context.load_cert_chain(
6460
certfile=cert_path,
6561
keyfile=key_path,

packages/google-auth/google/auth/environment_vars.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,6 @@
133133
"GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES"
134134
)
135135
"""Environment variable to prevent agent token sharing for GCP services."""
136+
137+
GOOGLE_API_USE_MTLS_ENDPOINT = "GOOGLE_API_USE_MTLS_ENDPOINT"
138+
"""Environment variable controlling whether to use mTLS endpoint or not."""

packages/google-auth/google/auth/transport/mtls.py

Lines changed: 166 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,26 @@
1414

1515
"""Utilites for mutual TLS."""
1616

17+
import enum
18+
import logging
1719
from os import getenv
20+
import ssl
21+
from typing import Optional
1822

23+
from google.auth import environment_vars
1924
from google.auth import exceptions
2025
from google.auth.transport import _mtls_helper
2126

2227

28+
_LOGGER = logging.getLogger(__name__)
29+
30+
31+
class UseMtlsEndpointMode(enum.Enum):
32+
ALWAYS = "always"
33+
NEVER = "never"
34+
AUTO = "auto"
35+
36+
2337
def has_default_client_cert_source(include_context_aware=True):
2438
"""Check if default client SSL credentials exists on the device.
2539
@@ -60,7 +74,7 @@ def default_client_cert_source():
6074
client certificate bytes and private key bytes, both in PEM format.
6175
6276
Raises:
63-
google.auth.exceptions.DefaultClientCertSourceError: If the default
77+
google.auth.exceptions.MutualTLSChannelError: If the default
6478
client SSL credentials don't exist or are malformed.
6579
"""
6680
if not has_default_client_cert_source(include_context_aware=True):
@@ -96,7 +110,7 @@ def default_client_encrypted_cert_source(cert_path, key_path):
96110
returns the cert_path, key_path and passphrase bytes.
97111
98112
Raises:
99-
google.auth.exceptions.DefaultClientCertSourceError: If any problem
113+
google.auth.exceptions.MutualTLSChannelError: If any problem
100114
occurs when loading or saving the client certificate and key.
101115
"""
102116
if not has_default_client_cert_source(include_context_aware=True):
@@ -140,3 +154,153 @@ def should_use_client_cert():
140154
bool: indicating whether the client certificate should be used for mTLS.
141155
"""
142156
return _mtls_helper.check_use_client_cert()
157+
158+
159+
def _load_client_cert_into_context(
160+
ctx: ssl.SSLContext,
161+
cert_bytes: bytes,
162+
key_bytes: bytes,
163+
passphrase: Optional[bytes] = None,
164+
) -> None:
165+
"""Load a client certificate and key into an SSL context.
166+
167+
Args:
168+
ctx (ssl.SSLContext): The SSL context to load the certificate and key into.
169+
cert_bytes (bytes): The client certificate bytes in PEM format.
170+
key_bytes (bytes): The client private key bytes in PEM format.
171+
passphrase (Optional[bytes]): The passphrase for the client private key.
172+
173+
Raises:
174+
google.auth.exceptions.MutualTLSChannelError: If the SSL context is invalid,
175+
or if loading the certificate and key fails.
176+
"""
177+
if not isinstance(ctx, ssl.SSLContext):
178+
raise exceptions.MutualTLSChannelError(
179+
"Failed to load client certificate and key for mTLS. The provided context "
180+
"object is invalid or does not support loading certificate chains."
181+
)
182+
183+
try:
184+
with _mtls_helper.secure_cert_key_paths(
185+
cert_bytes, key_bytes, passphrase=passphrase
186+
) as (
187+
cert_path,
188+
key_path,
189+
passphrase_val,
190+
):
191+
if cert_path is None or key_path is None:
192+
raise exceptions.MutualTLSChannelError(
193+
"Failed to generate temporary file paths for the client certificate and key."
194+
)
195+
ctx.load_cert_chain(
196+
certfile=cert_path, keyfile=key_path, password=passphrase_val
197+
)
198+
except (
199+
ssl.SSLError,
200+
OSError,
201+
ValueError,
202+
RuntimeError,
203+
TypeError,
204+
) as caught_exc:
205+
new_exc = exceptions.MutualTLSChannelError(caught_exc)
206+
raise new_exc from caught_exc
207+
208+
209+
def load_default_client_cert(ctx: ssl.SSLContext) -> bool:
210+
"""Load the default client certificate and key into an SSL context if configured.
211+
212+
If client certificates are enabled and a default client certificate source is
213+
found, the certificate and key are loaded into the SSL context.
214+
215+
Args:
216+
ctx (ssl.SSLContext): The SSL context to load the default client certificate
217+
and key into.
218+
219+
Returns:
220+
bool: True if client certificates are enabled and the default client
221+
certificate was successfully loaded. False if client certificates
222+
are disabled or if no default certificate source is configured.
223+
224+
Raises:
225+
google.auth.exceptions.MutualTLSChannelError: If the default client certificate
226+
or key is malformed.
227+
"""
228+
if not should_use_client_cert() or not has_default_client_cert_source():
229+
return False
230+
try:
231+
(
232+
has_cert,
233+
cert_bytes,
234+
key_bytes,
235+
passphrase,
236+
) = _mtls_helper.get_client_ssl_credentials()
237+
except (
238+
exceptions.ClientCertError,
239+
OSError,
240+
RuntimeError,
241+
ValueError,
242+
) as caught_exc:
243+
new_exc = exceptions.MutualTLSChannelError(caught_exc)
244+
raise new_exc from caught_exc
245+
else:
246+
if not has_cert:
247+
return False
248+
_load_client_cert_into_context(ctx, cert_bytes, key_bytes, passphrase)
249+
return True
250+
251+
252+
def get_default_ssl_context() -> Optional[ssl.SSLContext]:
253+
"""Get a default SSL context loaded with the default client certificate.
254+
255+
Returns:
256+
ssl.SSLContext: An SSL context loaded with the default client
257+
certificate, or None if client certificates are not configured
258+
or available.
259+
260+
Raises:
261+
google.auth.exceptions.MutualTLSChannelError: If the default client certificate
262+
or key is malformed.
263+
"""
264+
if not should_use_client_cert() or not has_default_client_cert_source():
265+
return None
266+
267+
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
268+
return ctx if load_default_client_cert(ctx) else None
269+
270+
271+
def should_use_mtls_endpoint(
272+
client_cert_available: Optional[bool] = None,
273+
) -> bool:
274+
"""Determine whether to use an mTLS endpoint.
275+
276+
This relies on the GOOGLE_API_USE_MTLS_ENDPOINT environment variable. If set to
277+
"always", returns True. If set to "never", returns False. If set to "auto"
278+
or unset, returns whether a client certificate is available.
279+
280+
Args:
281+
client_cert_available (Optional[bool]): indicating if a client certificate
282+
is available. If None, this is determined by checking if client
283+
certificates are enabled using :func:`should_use_client_cert`.
284+
285+
Returns:
286+
bool: indicating if an mTLS endpoint should be used.
287+
"""
288+
if client_cert_available is None:
289+
client_cert_available = should_use_client_cert()
290+
291+
use_mtls_endpoint = getenv(environment_vars.GOOGLE_API_USE_MTLS_ENDPOINT)
292+
use_mtls_endpoint = (use_mtls_endpoint or "auto").strip().lower()
293+
try:
294+
mode = UseMtlsEndpointMode(use_mtls_endpoint)
295+
except ValueError:
296+
raise exceptions.MutualTLSChannelError(
297+
f"Unsupported {environment_vars.GOOGLE_API_USE_MTLS_ENDPOINT} value "
298+
f"'{use_mtls_endpoint}'. Accepted values: never, auto, always."
299+
)
300+
301+
if mode == UseMtlsEndpointMode.ALWAYS:
302+
return True
303+
if mode == UseMtlsEndpointMode.NEVER:
304+
return False
305+
if mode == UseMtlsEndpointMode.AUTO:
306+
return client_cert_available

packages/google-auth/google/auth/transport/requests.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,7 @@ def __init__(self, cert, key):
224224
key_path,
225225
passphrase,
226226
):
227-
password = (
228-
passphrase.decode("utf-8")
229-
if isinstance(passphrase, bytes)
230-
else passphrase
231-
)
227+
password = passphrase
232228
ctx_poolmanager.load_cert_chain(
233229
certfile=cert_path,
234230
keyfile=key_path,

packages/google-auth/google/auth/transport/urllib3.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,7 @@ def _make_mutual_tls_http(cert, key):
188188
key_path,
189189
passphrase,
190190
):
191-
password = (
192-
passphrase.decode("utf-8")
193-
if isinstance(passphrase, bytes)
194-
else passphrase
195-
)
191+
password = passphrase
196192
ctx.load_cert_chain(
197193
certfile=cert_path,
198194
keyfile=key_path,

packages/google-auth/tests/transport/test__mtls_helper.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,6 +1204,31 @@ def test_falls_back_to_tempfile_when_memfd_fails(
12041204
assert key_path == "/tmp/key"
12051205
assert passphrase == b"new_pass"
12061206

1207+
@mock.patch.object(sys, "platform", "linux")
1208+
@mock.patch.object(os, "memfd_create", create=True)
1209+
@mock.patch.object(_mtls_helper, "_memfd_cert_key_paths", autospec=True)
1210+
@mock.patch.object(_mtls_helper, "_tempfile_cert_key_paths", autospec=True)
1211+
def test_tier3_fallback_failure_propagation(
1212+
self, mock_tempfile_cm, mock_memfd_cm, mock_memfd_create
1213+
):
1214+
mock_memfd_ctx = mock.MagicMock()
1215+
mock_memfd_ctx.__enter__.side_effect = _mtls_helper._MemfdCreationError(
1216+
"memfd failed"
1217+
)
1218+
mock_memfd_cm.return_value = mock_memfd_ctx
1219+
1220+
mock_tempfile_ctx = mock.MagicMock()
1221+
mock_tempfile_ctx.__enter__.side_effect = OSError("tempfile failed")
1222+
mock_tempfile_cm.return_value = mock_tempfile_ctx
1223+
1224+
with pytest.raises(OSError) as exc_info:
1225+
with _mtls_helper.secure_cert_key_paths(
1226+
pytest.public_cert_bytes, pytest.private_key_bytes, b"passphrase"
1227+
):
1228+
pass
1229+
1230+
assert "tempfile failed" in str(exc_info.value)
1231+
12071232
@mock.patch.object(sys, "platform", "darwin")
12081233
@mock.patch.object(_mtls_helper, "_tempfile_cert_key_paths", autospec=True)
12091234
def test_uses_tempfile_directly_on_unsupported_os(self, mock_tempfile_cm):
@@ -1378,6 +1403,43 @@ def _redirect_mkstemp(dir=None):
13781403
# Verify cert file is still cleaned up even if key cleanup raised KeyboardInterrupt
13791404
assert not os.path.exists(cert_path)
13801405

1406+
@mock.patch.object(os, "access", return_value=True)
1407+
@mock.patch.object(os.path, "isdir", return_value=True)
1408+
@mock.patch.object(_mtls_helper, "_encrypt_key_if_plaintext", autospec=True)
1409+
def test_cleanup_on_key_write_error(
1410+
self, mock_encrypt, mock_isdir, mock_access, tmpdir
1411+
):
1412+
original_mkstemp = tempfile.mkstemp
1413+
1414+
cert_path_ref = []
1415+
1416+
def _redirect_mkstemp(dir=None):
1417+
fd, path = original_mkstemp(dir=str(tmpdir))
1418+
cert_path_ref.append(path)
1419+
return fd, path
1420+
1421+
with mock.patch.object(tempfile, "mkstemp", side_effect=_redirect_mkstemp):
1422+
mock_encrypt.return_value = (b"encrypted_key", b"new_pass")
1423+
1424+
original_fdopen = os.fdopen
1425+
call_count = [0]
1426+
1427+
def _failing_fdopen(fd, *args, **kwargs):
1428+
call_count[0] += 1
1429+
if call_count[0] == 2:
1430+
raise OSError("key write failed")
1431+
return original_fdopen(fd, *args, **kwargs)
1432+
1433+
with pytest.raises(OSError):
1434+
with mock.patch("os.fdopen", side_effect=_failing_fdopen):
1435+
with _mtls_helper._tempfile_cert_key_paths(
1436+
b"cert", b"key", b"pass"
1437+
):
1438+
pass
1439+
1440+
assert len(cert_path_ref) > 0
1441+
assert not os.path.exists(cert_path_ref[0])
1442+
13811443
@mock.patch.object(os, "access", return_value=True)
13821444
@mock.patch.object(os.path, "isdir", return_value=True)
13831445
@mock.patch.object(_mtls_helper, "_encrypt_key_if_plaintext", autospec=True)
@@ -1472,6 +1534,21 @@ def test_encrypts_plaintext_key(self):
14721534
)
14731535
assert decrypted
14741536

1537+
original_key = serialization.load_pem_private_key(
1538+
pytest.private_key_bytes, password=None
1539+
)
1540+
decrypted_bytes = decrypted.private_bytes(
1541+
encoding=serialization.Encoding.PEM,
1542+
format=serialization.PrivateFormat.PKCS8,
1543+
encryption_algorithm=serialization.NoEncryption(),
1544+
)
1545+
original_bytes = original_key.private_bytes(
1546+
encoding=serialization.Encoding.PEM,
1547+
format=serialization.PrivateFormat.PKCS8,
1548+
encryption_algorithm=serialization.NoEncryption(),
1549+
)
1550+
assert decrypted_bytes == original_bytes
1551+
14751552
@mock.patch("secrets.token_hex", return_value="0123456789abcdef0123456789abcdef")
14761553
def test_default_passphrase_generation(self, mock_secrets):
14771554
encrypted_bytes, passphrase = _mtls_helper._encrypt_key_if_plaintext(
@@ -1495,6 +1572,24 @@ def test_encrypts_plaintext_key_string_passphrase(self):
14951572
assert encrypted_bytes != pytest.private_key_bytes
14961573
assert b"ENCRYPTED PRIVATE KEY" in encrypted_bytes
14971574

1575+
decrypted = serialization.load_pem_private_key(
1576+
encrypted_bytes, password=b"my_passphrase_str"
1577+
)
1578+
original_key = serialization.load_pem_private_key(
1579+
pytest.private_key_bytes, password=None
1580+
)
1581+
decrypted_bytes = decrypted.private_bytes(
1582+
encoding=serialization.Encoding.PEM,
1583+
format=serialization.PrivateFormat.PKCS8,
1584+
encryption_algorithm=serialization.NoEncryption(),
1585+
)
1586+
original_bytes = original_key.private_bytes(
1587+
encoding=serialization.Encoding.PEM,
1588+
format=serialization.PrivateFormat.PKCS8,
1589+
encryption_algorithm=serialization.NoEncryption(),
1590+
)
1591+
assert decrypted_bytes == original_bytes
1592+
14981593
def test_returns_unsupported_algorithm_asis(self):
14991594
import cryptography.exceptions
15001595

@@ -1537,6 +1632,7 @@ def test_success(
15371632

15381633
mock_open.assert_called_once_with("/path/to/secret", "r+b")
15391634
mock_fh.write.assert_called_once_with(b"\0" * 10)
1635+
mock_fh.flush.assert_called_once()
15401636
mock_fsync.assert_called_once()
15411637
mock_remove.assert_called_once_with("/path/to/secret")
15421638

@@ -1583,5 +1679,6 @@ def test_remove_oserror_ignored(
15831679
_mtls_helper._secure_wipe_and_remove("/path/to/secret")
15841680

15851681
mock_open.assert_called_once_with("/path/to/secret", "r+b")
1682+
mock_fh.flush.assert_called_once()
15861683
mock_fsync.assert_called_once()
15871684
mock_remove.assert_called_once_with("/path/to/secret")

packages/google-auth/tests/transport/test_aio_mtls_helper.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ async def test_make_client_cert_ssl_context_success(self):
5050
kwargs = mock_context.load_cert_chain.call_args.kwargs
5151
assert "certfile" in kwargs
5252
assert "keyfile" in kwargs
53-
assert kwargs["password"] == passphrase.decode("utf-8")
53+
assert kwargs["password"] == passphrase
5454

5555
assert not os.path.exists(kwargs["certfile"])
5656
assert not os.path.exists(kwargs["keyfile"])

0 commit comments

Comments
 (0)