Skip to content

Commit f492d3d

Browse files
authored
fix(auth): align mTLS discovery and enforce fail-fast transport configuration. (#17470)
This PR resolves two mTLS issues: (1) it synchronizes the transport and token discovery paths to check for workload certs and library support, preventing mismatched bound tokens, and (2) it enforces fail-fast error behavior in all transports if mTLS configuration fails, avoiding silent fallback to insecure TLS. These align token and transport states securely.
1 parent ad02121 commit f492d3d

10 files changed

Lines changed: 248 additions & 79 deletions

File tree

packages/google-auth/google/auth/_agent_identity_utils.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,13 @@ def get_and_parse_agent_identity_certificate():
201201
if is_opted_out:
202202
return None
203203

204+
# Respect explicit opt-out of mTLS / client certs
205+
from google.auth.transport import _mtls_helper
206+
207+
env_override = _mtls_helper._check_use_client_cert_env()
208+
if env_override is False:
209+
return None
210+
204211
cert_path = get_agent_identity_certificate_path()
205212
if not cert_path:
206213
return None
@@ -312,7 +319,17 @@ def should_request_bound_token(cert):
312319
).lower()
313320
== "true"
314321
)
315-
return is_agent_cert and is_opted_in
322+
if not (is_agent_cert and is_opted_in):
323+
return False
324+
325+
# Respect explicit opt-out of mTLS / client certs
326+
from google.auth.transport import _mtls_helper
327+
328+
env_override = _mtls_helper._check_use_client_cert_env()
329+
if env_override is False:
330+
return False
331+
332+
return True
316333

317334

318335
def get_cached_cert_fingerprint(cached_cert):

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import functools
1818
import time
1919
from typing import Mapping, Optional, TYPE_CHECKING, Union
20+
import warnings
2021

2122
from google.auth import _exponential_backoff, exceptions
2223
from google.auth.aio import transport
@@ -36,6 +37,7 @@
3637
except (ImportError, AttributeError):
3738
ClientTimeout = None
3839

40+
3941
# Tracks the internal aiohttp installation and usage
4042
try:
4143
from google.auth.aio.transport.aiohttp import Request as AiohttpRequest
@@ -150,11 +152,11 @@ def __init__(
150152
async def configure_mtls_channel(self, client_cert_callback=None):
151153
"""Configure the client certificate and key for SSL connection.
152154
153-
The function does nothing unless `GOOGLE_API_USE_CLIENT_CERTIFICATE` is
154-
explicitly set to `true`. In this case if client certificate and key are
155-
successfully obtained (from the given client_cert_callback or from application
156-
default SSL credentials), the underlying transport will be reconfigured
157-
to use mTLS.
155+
This method configures mTLS if client certificates are explicitly enabled
156+
(via GOOGLE_API_USE_CLIENT_CERTIFICATE=true) or auto-enabled (when the env
157+
variable is unset and workload certificates are discovered). In these cases,
158+
the underlying transport will be reconfigured to use mTLS.
159+
158160
Note: This function does nothing if the `aiohttp` library is not
159161
installed.
160162
Important: Calling this method will close any ongoing API requests associated
@@ -207,12 +209,23 @@ async def _do_configure():
207209
self._auth_request = AiohttpRequest(session=new_session)
208210

209211
await old_auth_request.close()
212+
else:
213+
self._is_mtls = False
214+
warnings.warn(
215+
"Attempted to establish mTLS, but a custom async transport was provided. "
216+
"google-auth cannot automatically configure custom transports for mTLS. "
217+
"Falling back to standard TLS. If your custom transport is not manually "
218+
"configured for mTLS, you may encounter 401 Unauthorized errors when "
219+
"using Certificate-Bound Tokens.",
220+
UserWarning,
221+
)
210222

211223
except (
212224
exceptions.ClientCertError,
213225
ImportError,
214226
OSError,
215227
) as caught_exc:
228+
self._is_mtls = False
216229
new_exc = exceptions.MutualTLSChannelError(caught_exc)
217230
raise new_exc from caught_exc
218231

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

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646
_LOGGER = logging.getLogger(__name__)
4747

4848

49+
# A flag to track if we have already logged a warning about mTLS auto-enablement failures.
50+
# This prevents log spam when client libraries create transports or session instances
51+
# frequently within a single process.
52+
_has_logged_mtls_warning = False
53+
54+
4955
_PASSPHRASE_REGEX = re.compile(
5056
b"-----BEGIN PASSPHRASE-----(.+)-----END PASSPHRASE-----", re.DOTALL
5157
)
@@ -200,12 +206,13 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True):
200206
return None, None
201207
workload = cert_configs["workload"]
202208

203-
if "cert_path" not in workload:
204-
return None, None
209+
if "cert_path" not in workload or "key_path" not in workload:
210+
raise exceptions.ClientCertError(
211+
'Workload certificate configuration is missing "cert_path" or "key_path" in {}'.format(
212+
absolute_path
213+
)
214+
)
205215
cert_path = workload["cert_path"]
206-
207-
if "key_path" not in workload:
208-
return None, None
209216
key_path = workload["key_path"]
210217

211218
# == BEGIN Temporary Cloud Run PATCH ==
@@ -448,53 +455,70 @@ def client_cert_callback():
448455
return crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey)
449456

450457

458+
def _check_use_client_cert_env():
459+
use_client_cert = getenv(
460+
environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE
461+
) or getenv(environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE)
462+
463+
if use_client_cert:
464+
return use_client_cert.lower() == "true"
465+
return None
466+
467+
451468
def check_use_client_cert():
452469
"""Returns boolean for whether the client certificate should be used for mTLS.
453470
454471
If GOOGLE_API_USE_CLIENT_CERTIFICATE is set to true or false, a corresponding
455472
bool value will be returned. If the value is set to an unexpected string, it
456473
will default to False.
457474
If GOOGLE_API_USE_CLIENT_CERTIFICATE is unset, the value will be inferred
458-
by reading a file pointed at by GOOGLE_API_CERTIFICATE_CONFIG, and verifying
459-
it contains a "workload" section. If so, the function will return True,
460-
otherwise False.
475+
as True (auto-enabled) if a workload config file exists (pointed at by
476+
GOOGLE_API_CERTIFICATE_CONFIG) containing a "workload" section.
477+
Otherwise, it returns False.
461478
462479
Returns:
463480
bool: Whether the client certificate should be used for mTLS connection.
464481
"""
465-
use_client_cert = getenv(environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE)
466-
if use_client_cert is None or use_client_cert == "":
467-
use_client_cert = getenv(
468-
environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE
469-
)
482+
global _has_logged_mtls_warning
483+
env_override = _check_use_client_cert_env()
484+
if env_override is not None:
485+
return env_override
470486

471-
# Check if the value of GOOGLE_API_USE_CLIENT_CERTIFICATE is set.
472-
if use_client_cert:
473-
return use_client_cert.lower() == "true"
474-
else:
475-
# Check if the value of GOOGLE_API_CERTIFICATE_CONFIG is set.
476-
cert_path = getenv(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG)
477-
if cert_path is None:
478-
cert_path = getenv(
479-
environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH
480-
)
487+
# Auto-enablement checks (when GOOGLE_API_USE_CLIENT_CERTIFICATE is not set)
488+
489+
# Check if the value of GOOGLE_API_CERTIFICATE_CONFIG is set.
490+
cert_path = getenv(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG) or getenv(
491+
environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH
492+
)
481493

482-
if cert_path:
483-
try:
484-
with open(cert_path, "r") as f:
485-
content = json.load(f)
486-
# verify json has workload key
487-
content["cert_configs"]["workload"]
488-
return True
489-
except (
490-
FileNotFoundError,
491-
OSError,
492-
KeyError,
493-
TypeError,
494-
json.JSONDecodeError,
495-
) as e:
496-
_LOGGER.debug("error decoding certificate: %s", e)
497-
return False
494+
if cert_path:
495+
try:
496+
with open(cert_path, "r") as f:
497+
content = json.load(f)
498+
except (FileNotFoundError, OSError, json.JSONDecodeError) as e:
499+
if not _has_logged_mtls_warning:
500+
_LOGGER.warning(
501+
"mTLS auto-enablement failed: Could not read/parse certificate file at %s. Error: %s",
502+
cert_path,
503+
e,
504+
)
505+
_has_logged_mtls_warning = True
506+
return False
507+
508+
# Structural validation
509+
if isinstance(content, dict):
510+
cert_configs = content.get("cert_configs")
511+
if isinstance(cert_configs, dict) and "workload" in cert_configs:
512+
return True
513+
514+
# If we got here, the file exists but the expected structure is missing
515+
if not _has_logged_mtls_warning:
516+
_LOGGER.warning(
517+
"mTLS auto-enablement failed: Certificate configuration file at %s is missing the required ['cert_configs']['workload'] section.",
518+
cert_path,
519+
)
520+
_has_logged_mtls_warning = True
521+
return False
498522

499523

500524
def check_parameters_for_unauthorized_response(cached_cert):

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -428,11 +428,11 @@ def __init__(
428428
def configure_mtls_channel(self, client_cert_callback=None):
429429
"""Configure the client certificate and key for SSL connection.
430430
431-
The function does nothing unless `GOOGLE_API_USE_CLIENT_CERTIFICATE` is
432-
explicitly set to `true`. In this case if client certificate and key are
433-
successfully obtained (from the given client_cert_callback or from application
434-
default SSL credentials), a :class:`_MutualTlsAdapter` instance will be mounted
435-
to "https://" prefix.
431+
This method configures mTLS if client certificates are explicitly enabled
432+
(via GOOGLE_API_USE_CLIENT_CERTIFICATE=true) or auto-enabled (when the env
433+
variable is unset and workload certificates are discovered). In these cases,
434+
if the client certificate and key are successfully obtained, a
435+
:class:`_MutualTlsAdapter` instance will be mounted to the "https://" prefix.
436436
437437
Args:
438438
client_cert_callback (Optional[Callable[[], (bytes, bytes)]]):
@@ -452,6 +452,7 @@ def configure_mtls_channel(self, client_cert_callback=None):
452452
try:
453453
import OpenSSL
454454
except ImportError as caught_exc:
455+
self._is_mtls = False
455456
new_exc = exceptions.MutualTLSChannelError(caught_exc)
456457
raise new_exc from caught_exc
457458

@@ -471,8 +472,10 @@ def configure_mtls_channel(self, client_cert_callback=None):
471472
except (
472473
exceptions.ClientCertError,
473474
ImportError,
475+
OSError,
474476
OpenSSL.crypto.Error,
475477
) as caught_exc:
478+
self._is_mtls = False
476479
new_exc = exceptions.MutualTLSChannelError(caught_exc)
477480
raise new_exc from caught_exc
478481

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -313,13 +313,12 @@ def __init__(
313313

314314
def configure_mtls_channel(self, client_cert_callback=None):
315315
"""Configures mutual TLS channel using the given client_cert_callback or
316-
application default SSL credentials. The behavior is controlled by
317-
`GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable.
318-
(1) If the environment variable value is `true`, the function returns True
319-
if the channel is mutual TLS and False otherwise. The `http` provided
320-
in the constructor will be overwritten.
321-
(2) If the environment variable is not set or `false`, the function does
322-
nothing and it always return False.
316+
application default SSL credentials.
317+
318+
The channel is configured if GOOGLE_API_USE_CLIENT_CERTIFICATE is "true",
319+
or if it is unset and workload certificates are detected in the environment.
320+
If client_cert_callback is None, default SSL credentials (workload or SecureConnect)
321+
are loaded.
323322
324323
Args:
325324
client_cert_callback (Optional[Callable[[], (bytes, bytes)]]):
@@ -344,6 +343,7 @@ def configure_mtls_channel(self, client_cert_callback=None):
344343
try:
345344
import OpenSSL
346345
except ImportError as caught_exc:
346+
self._is_mtls = False
347347
new_exc = exceptions.MutualTLSChannelError(caught_exc)
348348
raise new_exc from caught_exc
349349

@@ -357,11 +357,14 @@ def configure_mtls_channel(self, client_cert_callback=None):
357357
self._cached_cert = cert
358358
else:
359359
self.http = _make_default_http()
360+
self._is_mtls = False
360361
except (
361362
exceptions.ClientCertError,
362363
ImportError,
364+
OSError,
363365
OpenSSL.crypto.Error,
364366
) as caught_exc:
367+
self._is_mtls = False
365368
new_exc = exceptions.MutualTLSChannelError(caught_exc)
366369
raise new_exc from caught_exc
367370

packages/google-auth/tests/test_agent_identity_utils.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@
4848

4949

5050
class TestAgentIdentityUtils:
51+
@pytest.fixture(autouse=True)
52+
def clean_env(self, monkeypatch):
53+
monkeypatch.delenv(
54+
environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE,
55+
raising=False,
56+
)
57+
monkeypatch.delenv(
58+
environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE,
59+
raising=False,
60+
)
61+
5162
@mock.patch("cryptography.x509.load_pem_x509_certificate")
5263
def test_parse_certificate(self, mock_load_cert):
5364
result = _agent_identity_utils.parse_certificate(b"cert_bytes")
@@ -150,6 +161,33 @@ def test_should_request_bound_token(self, mock_is_agent, monkeypatch):
150161
)
151162
assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert)
152163

164+
@mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate")
165+
def test_should_request_bound_token_explicit_use_client_cert_false(
166+
self, mock_is_agent, monkeypatch
167+
):
168+
mock_is_agent.return_value = True
169+
monkeypatch.setenv(
170+
environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE,
171+
"false",
172+
)
173+
assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert)
174+
175+
@mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate")
176+
def test_should_request_bound_token_explicit_use_client_cert_invalid(
177+
self, mock_is_agent, monkeypatch
178+
):
179+
mock_is_agent.return_value = True
180+
monkeypatch.setenv(
181+
environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE,
182+
"foo",
183+
)
184+
assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert)
185+
186+
@mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate")
187+
def test_should_request_bound_token_auto_enablement(self, mock_is_agent):
188+
mock_is_agent.return_value = True
189+
assert _agent_identity_utils.should_request_bound_token(mock.sentinel.cert)
190+
153191
def test_get_agent_identity_certificate_path_success(self, tmpdir, monkeypatch):
154192
cert_path = tmpdir.join("cert.pem")
155193
cert_path.write("cert_content")
@@ -439,6 +477,30 @@ def test_get_and_parse_agent_identity_certificate_success(
439477
mock_parse_certificate.assert_called_once_with(b"cert_bytes")
440478
assert result == mock_parse_certificate.return_value
441479

480+
@mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path")
481+
def test_get_and_parse_agent_identity_certificate_use_client_cert_false(
482+
self, mock_get_path, monkeypatch
483+
):
484+
monkeypatch.setenv(
485+
environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE,
486+
"false",
487+
)
488+
result = _agent_identity_utils.get_and_parse_agent_identity_certificate()
489+
assert result is None
490+
mock_get_path.assert_not_called()
491+
492+
@mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path")
493+
def test_get_and_parse_agent_identity_certificate_use_client_cert_invalid(
494+
self, mock_get_path, monkeypatch
495+
):
496+
monkeypatch.setenv(
497+
environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE,
498+
"foo",
499+
)
500+
result = _agent_identity_utils.get_and_parse_agent_identity_certificate()
501+
assert result is None
502+
mock_get_path.assert_not_called()
503+
442504
def test_get_cached_cert_fingerprint_no_cert(self):
443505
with pytest.raises(ValueError, match="mTLS connection is not configured."):
444506
_agent_identity_utils.get_cached_cert_fingerprint(None)

0 commit comments

Comments
 (0)