diff --git a/packages/google-auth/google/auth/_agent_identity_utils.py b/packages/google-auth/google/auth/_agent_identity_utils.py index b57c7bc82b52..847d7dfa7749 100644 --- a/packages/google-auth/google/auth/_agent_identity_utils.py +++ b/packages/google-auth/google/auth/_agent_identity_utils.py @@ -201,6 +201,13 @@ def get_and_parse_agent_identity_certificate(): if is_opted_out: return None + # Respect explicit opt-out of mTLS / client certs + from google.auth.transport import _mtls_helper + + env_override = _mtls_helper._check_use_client_cert_env() + if env_override is False: + return None + cert_path = get_agent_identity_certificate_path() if not cert_path: return None @@ -312,7 +319,17 @@ def should_request_bound_token(cert): ).lower() == "true" ) - return is_agent_cert and is_opted_in + if not (is_agent_cert and is_opted_in): + return False + + # Respect explicit opt-out of mTLS / client certs + from google.auth.transport import _mtls_helper + + env_override = _mtls_helper._check_use_client_cert_env() + if env_override is False: + return False + + return True def get_cached_cert_fingerprint(cached_cert): diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 027cb09c15a9..bb7873b02aef 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -17,6 +17,7 @@ import functools import time from typing import Mapping, Optional, TYPE_CHECKING, Union +import warnings from google.auth import _exponential_backoff, exceptions from google.auth.aio import transport @@ -36,6 +37,7 @@ except (ImportError, AttributeError): ClientTimeout = None + # Tracks the internal aiohttp installation and usage try: from google.auth.aio.transport.aiohttp import Request as AiohttpRequest @@ -150,11 +152,11 @@ def __init__( async def configure_mtls_channel(self, client_cert_callback=None): """Configure the client certificate and key for SSL connection. - The function does nothing unless `GOOGLE_API_USE_CLIENT_CERTIFICATE` is - explicitly set to `true`. In this case if client certificate and key are - successfully obtained (from the given client_cert_callback or from application - default SSL credentials), the underlying transport will be reconfigured - to use mTLS. + This method configures mTLS if client certificates are explicitly enabled + (via GOOGLE_API_USE_CLIENT_CERTIFICATE=true) or auto-enabled (when the env + variable is unset and workload certificates are discovered). In these cases, + the underlying transport will be reconfigured to use mTLS. + Note: This function does nothing if the `aiohttp` library is not installed. Important: Calling this method will close any ongoing API requests associated @@ -207,12 +209,23 @@ async def _do_configure(): self._auth_request = AiohttpRequest(session=new_session) await old_auth_request.close() + else: + self._is_mtls = False + warnings.warn( + "Attempted to establish mTLS, but a custom async transport was provided. " + "google-auth cannot automatically configure custom transports for mTLS. " + "Falling back to standard TLS. If your custom transport is not manually " + "configured for mTLS, you may encounter 401 Unauthorized errors when " + "using Certificate-Bound Tokens.", + UserWarning, + ) except ( exceptions.ClientCertError, ImportError, OSError, ) as caught_exc: + self._is_mtls = False new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc diff --git a/packages/google-auth/google/auth/transport/_mtls_helper.py b/packages/google-auth/google/auth/transport/_mtls_helper.py index d6450291c7f2..803dd71207f2 100644 --- a/packages/google-auth/google/auth/transport/_mtls_helper.py +++ b/packages/google-auth/google/auth/transport/_mtls_helper.py @@ -46,6 +46,12 @@ _LOGGER = logging.getLogger(__name__) +# A flag to track if we have already logged a warning about mTLS auto-enablement failures. +# This prevents log spam when client libraries create transports or session instances +# frequently within a single process. +_has_logged_mtls_warning = False + + _PASSPHRASE_REGEX = re.compile( b"-----BEGIN PASSPHRASE-----(.+)-----END PASSPHRASE-----", re.DOTALL ) @@ -200,12 +206,13 @@ def _get_workload_cert_and_key_paths(config_path, include_context_aware=True): return None, None workload = cert_configs["workload"] - if "cert_path" not in workload: - return None, None + if "cert_path" not in workload or "key_path" not in workload: + raise exceptions.ClientCertError( + 'Workload certificate configuration is missing "cert_path" or "key_path" in {}'.format( + absolute_path + ) + ) cert_path = workload["cert_path"] - - if "key_path" not in workload: - return None, None key_path = workload["key_path"] # == BEGIN Temporary Cloud Run PATCH == @@ -448,6 +455,16 @@ def client_cert_callback(): return crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey) +def _check_use_client_cert_env(): + use_client_cert = getenv( + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE + ) or getenv(environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE) + + if use_client_cert: + return use_client_cert.lower() == "true" + return None + + def check_use_client_cert(): """Returns boolean for whether the client certificate should be used for mTLS. @@ -455,46 +472,53 @@ def check_use_client_cert(): bool value will be returned. If the value is set to an unexpected string, it will default to False. If GOOGLE_API_USE_CLIENT_CERTIFICATE is unset, the value will be inferred - by reading a file pointed at by GOOGLE_API_CERTIFICATE_CONFIG, and verifying - it contains a "workload" section. If so, the function will return True, - otherwise False. + as True (auto-enabled) if a workload config file exists (pointed at by + GOOGLE_API_CERTIFICATE_CONFIG) containing a "workload" section. + Otherwise, it returns False. Returns: bool: Whether the client certificate should be used for mTLS connection. """ - use_client_cert = getenv(environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE) - if use_client_cert is None or use_client_cert == "": - use_client_cert = getenv( - environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE - ) + global _has_logged_mtls_warning + env_override = _check_use_client_cert_env() + if env_override is not None: + return env_override - # Check if the value of GOOGLE_API_USE_CLIENT_CERTIFICATE is set. - if use_client_cert: - return use_client_cert.lower() == "true" - else: - # Check if the value of GOOGLE_API_CERTIFICATE_CONFIG is set. - cert_path = getenv(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG) - if cert_path is None: - cert_path = getenv( - environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH - ) + # Auto-enablement checks (when GOOGLE_API_USE_CLIENT_CERTIFICATE is not set) + + # Check if the value of GOOGLE_API_CERTIFICATE_CONFIG is set. + cert_path = getenv(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG) or getenv( + environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH + ) - if cert_path: - try: - with open(cert_path, "r") as f: - content = json.load(f) - # verify json has workload key - content["cert_configs"]["workload"] - return True - except ( - FileNotFoundError, - OSError, - KeyError, - TypeError, - json.JSONDecodeError, - ) as e: - _LOGGER.debug("error decoding certificate: %s", e) - return False + if cert_path: + try: + with open(cert_path, "r") as f: + content = json.load(f) + except (FileNotFoundError, OSError, json.JSONDecodeError) as e: + if not _has_logged_mtls_warning: + _LOGGER.warning( + "mTLS auto-enablement failed: Could not read/parse certificate file at %s. Error: %s", + cert_path, + e, + ) + _has_logged_mtls_warning = True + return False + + # Structural validation + if isinstance(content, dict): + cert_configs = content.get("cert_configs") + if isinstance(cert_configs, dict) and "workload" in cert_configs: + return True + + # If we got here, the file exists but the expected structure is missing + if not _has_logged_mtls_warning: + _LOGGER.warning( + "mTLS auto-enablement failed: Certificate configuration file at %s is missing the required ['cert_configs']['workload'] section.", + cert_path, + ) + _has_logged_mtls_warning = True + return False def check_parameters_for_unauthorized_response(cached_cert): diff --git a/packages/google-auth/google/auth/transport/requests.py b/packages/google-auth/google/auth/transport/requests.py index 9735762c4414..a36d85f84661 100644 --- a/packages/google-auth/google/auth/transport/requests.py +++ b/packages/google-auth/google/auth/transport/requests.py @@ -428,11 +428,11 @@ def __init__( def configure_mtls_channel(self, client_cert_callback=None): """Configure the client certificate and key for SSL connection. - The function does nothing unless `GOOGLE_API_USE_CLIENT_CERTIFICATE` is - explicitly set to `true`. In this case if client certificate and key are - successfully obtained (from the given client_cert_callback or from application - default SSL credentials), a :class:`_MutualTlsAdapter` instance will be mounted - to "https://" prefix. + This method configures mTLS if client certificates are explicitly enabled + (via GOOGLE_API_USE_CLIENT_CERTIFICATE=true) or auto-enabled (when the env + variable is unset and workload certificates are discovered). In these cases, + if the client certificate and key are successfully obtained, a + :class:`_MutualTlsAdapter` instance will be mounted to the "https://" prefix. Args: client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): @@ -452,6 +452,7 @@ def configure_mtls_channel(self, client_cert_callback=None): try: import OpenSSL except ImportError as caught_exc: + self._is_mtls = False new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc @@ -471,8 +472,10 @@ def configure_mtls_channel(self, client_cert_callback=None): except ( exceptions.ClientCertError, ImportError, + OSError, OpenSSL.crypto.Error, ) as caught_exc: + self._is_mtls = False new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc diff --git a/packages/google-auth/google/auth/transport/urllib3.py b/packages/google-auth/google/auth/transport/urllib3.py index de07007a946c..ace693773aa1 100644 --- a/packages/google-auth/google/auth/transport/urllib3.py +++ b/packages/google-auth/google/auth/transport/urllib3.py @@ -313,13 +313,12 @@ def __init__( def configure_mtls_channel(self, client_cert_callback=None): """Configures mutual TLS channel using the given client_cert_callback or - application default SSL credentials. The behavior is controlled by - `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable. - (1) If the environment variable value is `true`, the function returns True - if the channel is mutual TLS and False otherwise. The `http` provided - in the constructor will be overwritten. - (2) If the environment variable is not set or `false`, the function does - nothing and it always return False. + application default SSL credentials. + + The channel is configured if GOOGLE_API_USE_CLIENT_CERTIFICATE is "true", + or if it is unset and workload certificates are detected in the environment. + If client_cert_callback is None, default SSL credentials (workload or SecureConnect) + are loaded. Args: client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): @@ -344,6 +343,7 @@ def configure_mtls_channel(self, client_cert_callback=None): try: import OpenSSL except ImportError as caught_exc: + self._is_mtls = False new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc @@ -357,11 +357,14 @@ def configure_mtls_channel(self, client_cert_callback=None): self._cached_cert = cert else: self.http = _make_default_http() + self._is_mtls = False except ( exceptions.ClientCertError, ImportError, + OSError, OpenSSL.crypto.Error, ) as caught_exc: + self._is_mtls = False new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc diff --git a/packages/google-auth/tests/test_agent_identity_utils.py b/packages/google-auth/tests/test_agent_identity_utils.py index 50a47367b9d7..917a57d2b6b1 100644 --- a/packages/google-auth/tests/test_agent_identity_utils.py +++ b/packages/google-auth/tests/test_agent_identity_utils.py @@ -48,6 +48,17 @@ class TestAgentIdentityUtils: + @pytest.fixture(autouse=True) + def clean_env(self, monkeypatch): + monkeypatch.delenv( + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, + raising=False, + ) + monkeypatch.delenv( + environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE, + raising=False, + ) + @mock.patch("cryptography.x509.load_pem_x509_certificate") def test_parse_certificate(self, mock_load_cert): result = _agent_identity_utils.parse_certificate(b"cert_bytes") @@ -150,6 +161,33 @@ def test_should_request_bound_token(self, mock_is_agent, monkeypatch): ) assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) + @mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate") + def test_should_request_bound_token_explicit_use_client_cert_false( + self, mock_is_agent, monkeypatch + ): + mock_is_agent.return_value = True + monkeypatch.setenv( + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, + "false", + ) + assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) + + @mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate") + def test_should_request_bound_token_explicit_use_client_cert_invalid( + self, mock_is_agent, monkeypatch + ): + mock_is_agent.return_value = True + monkeypatch.setenv( + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, + "foo", + ) + assert not _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) + + @mock.patch("google.auth._agent_identity_utils._is_agent_identity_certificate") + def test_should_request_bound_token_auto_enablement(self, mock_is_agent): + mock_is_agent.return_value = True + assert _agent_identity_utils.should_request_bound_token(mock.sentinel.cert) + def test_get_agent_identity_certificate_path_success(self, tmpdir, monkeypatch): cert_path = tmpdir.join("cert.pem") cert_path.write("cert_content") @@ -439,6 +477,30 @@ def test_get_and_parse_agent_identity_certificate_success( mock_parse_certificate.assert_called_once_with(b"cert_bytes") assert result == mock_parse_certificate.return_value + @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") + def test_get_and_parse_agent_identity_certificate_use_client_cert_false( + self, mock_get_path, monkeypatch + ): + monkeypatch.setenv( + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, + "false", + ) + result = _agent_identity_utils.get_and_parse_agent_identity_certificate() + assert result is None + mock_get_path.assert_not_called() + + @mock.patch("google.auth._agent_identity_utils.get_agent_identity_certificate_path") + def test_get_and_parse_agent_identity_certificate_use_client_cert_invalid( + self, mock_get_path, monkeypatch + ): + monkeypatch.setenv( + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, + "foo", + ) + result = _agent_identity_utils.get_and_parse_agent_identity_certificate() + assert result is None + mock_get_path.assert_not_called() + def test_get_cached_cert_fingerprint_no_cert(self): with pytest.raises(ValueError, match="mTLS connection is not configured."): _agent_identity_utils.get_cached_cert_fingerprint(None) diff --git a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py index 18fdb2e58cf1..cd9e72cd55c9 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -21,6 +21,7 @@ from google.auth import exceptions from google.auth.aio import credentials +from google.auth.aio import transport from google.auth.aio.transport import sessions # This is the valid "workload" format the library expects @@ -75,10 +76,7 @@ async def test_configure_mtls_channel_disabled(self): mock_exists.return_value = False mock_creds = mock.AsyncMock(spec=credentials.Credentials) session = sessions.AsyncAuthorizedSession(mock_creds) - await session.configure_mtls_channel() - - # If the file doesn't exist, it shouldn't error; it just won't use mTLS assert session._is_mtls is False @pytest.mark.asyncio @@ -111,10 +109,7 @@ async def test_configure_mtls_channel_invalud_fields(self): mock_exists.return_value = True mock_creds = mock.AsyncMock(spec=credentials.Credentials) session = sessions.AsyncAuthorizedSession(mock_creds) - await session.configure_mtls_channel() - - # If the file couldn't be parsed, it shouldn't error; it just won't use mTLS assert session._is_mtls is False @pytest.mark.asyncio @@ -140,3 +135,31 @@ def mock_callback(): await session.configure_mtls_channel(client_cert_callback=mock_callback) assert session._is_mtls is True + + @pytest.mark.asyncio + async def test_configure_mtls_channel_custom_request(self): + """ + Tests that if _auth_request is not an AiohttpRequest, it gracefully falls back to tLS. + """ + with mock.patch.dict( + os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "true"} + ), mock.patch("os.path.exists") as mock_exists, mock.patch( + "builtins.open", mock.mock_open(read_data=json.dumps(VALID_WORKLOAD_CONFIG)) + ), mock.patch( + "google.auth.aio.transport.mtls.get_client_cert_and_key" + ) as mock_helper, mock.patch( + "google.auth.aio.transport.mtls.make_client_cert_ssl_context" + ) as mock_make_context: + mock_exists.return_value = True + mock_helper.return_value = (True, b"fake_cert_data", b"fake_key_data") + + mock_context = mock.Mock(spec=ssl.SSLContext) + mock_make_context.return_value = mock_context + + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_auth_request = mock.AsyncMock(spec=transport.Request) + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_request + ) + await session.configure_mtls_channel() + assert session._is_mtls is False diff --git a/packages/google-auth/tests/transport/test__mtls_helper.py b/packages/google-auth/tests/transport/test__mtls_helper.py index 078df67470d2..0fe5067352a1 100644 --- a/packages/google-auth/tests/transport/test__mtls_helper.py +++ b/packages/google-auth/tests/transport/test__mtls_helper.py @@ -591,9 +591,8 @@ def test_no_cert_file(self, mock_get_cert_config_path, mock_load_json_file): "cert_configs": {"workload": {"key_path": "path/to/key"}} } - actual_cert, actual_key = _mtls_helper._get_workload_cert_and_key("") - assert actual_cert is None - assert actual_key is None + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._get_workload_cert_and_key("") @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) @mock.patch( @@ -605,9 +604,8 @@ def test_no_key_file(self, mock_get_cert_config_path, mock_load_json_file): "cert_configs": {"workload": {"cert_path": "path/to/key"}} } - actual_cert, actual_key = _mtls_helper._get_workload_cert_and_key("") - assert actual_cert is None - assert actual_key is None + with pytest.raises(exceptions.ClientCertError): + _mtls_helper._get_workload_cert_and_key("") class TestReadCertAndKeyFile(object): @@ -773,6 +771,38 @@ def test_crypto_error(self): ) +class TestCheckUseClientCertEnv(object): + @mock.patch.dict(os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "true"}) + def test_env_var_explicit_true(self): + assert _mtls_helper._check_use_client_cert_env() is True + + @mock.patch.dict(os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "True"}) + def test_env_var_explicit_true_capitalized(self): + assert _mtls_helper._check_use_client_cert_env() is True + + @mock.patch.dict(os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "false"}) + def test_env_var_explicit_false(self): + assert _mtls_helper._check_use_client_cert_env() is False + + @mock.patch.dict(os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "garbage"}) + def test_env_var_explicit_garbage(self): + assert _mtls_helper._check_use_client_cert_env() is False + + @mock.patch.dict(os.environ, {}, clear=True) + def test_env_var_unset(self): + assert _mtls_helper._check_use_client_cert_env() is None + + @mock.patch.dict( + os.environ, + { + "GOOGLE_API_USE_CLIENT_CERTIFICATE": "", + "CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE": "true", + }, + ) + def test_env_var_fallback_true(self): + assert _mtls_helper._check_use_client_cert_env() is True + + class TestCheckUseClientCert(object): @mock.patch.dict(os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "true"}) def test_env_var_explicit_true(self): diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py index c9fab036e17b..a7617d44992c 100644 --- a/packages/google-auth/tests/transport/test_requests.py +++ b/packages/google-auth/tests/transport/test_requests.py @@ -470,11 +470,7 @@ def test_configure_mtls_channel_non_mtls( os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} ): auth_session.configure_mtls_channel() - - assert not auth_session.is_mtls - - # Assert _MutualTlsAdapter constructor is not called. - mock_adapter_ctor.assert_not_called() + assert auth_session._is_mtls is False @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py index b29e4e950433..d9753b9e90cf 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -259,11 +259,9 @@ def test_configure_mtls_channel_non_mtls( with mock.patch.dict( os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} ): - is_mtls = authed_http.configure_mtls_channel() - - assert not is_mtls - mock_get_client_cert_and_key.assert_called_once() - mock_make_mutual_tls_http.assert_not_called() + res = authed_http.configure_mtls_channel() + assert res is False + assert authed_http._is_mtls is False @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True