From 14deb84a81962dc7eaa7236851e498f90998bfd1 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Fri, 5 Jun 2026 20:45:59 -0700 Subject: [PATCH 1/7] fix(auth): resolve mTLS transport state and gRPC workload certificate issues. --- .../google-auth/google/auth/transport/grpc.py | 7 ++-- .../tests/transport/aio/test_sessions_mtls.py | 10 +++++- .../google-auth/tests/transport/test_grpc.py | 33 +++++++++++++++++++ .../tests/transport/test_urllib3.py | 9 +++-- 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/google-auth/google/auth/transport/grpc.py b/packages/google-auth/google/auth/transport/grpc.py index e541d20ca0a4..57c62006c8d0 100644 --- a/packages/google-auth/google/auth/transport/grpc.py +++ b/packages/google-auth/google/auth/transport/grpc.py @@ -20,6 +20,7 @@ from google.auth import exceptions from google.auth.transport import _mtls_helper +from google.auth.transport import mtls from google.oauth2 import service_account try: @@ -295,11 +296,7 @@ def __init__(self): if not use_client_cert: self._is_mtls = False else: - # Load client SSL credentials. - metadata_path = _mtls_helper._check_config_path( - _mtls_helper.CONTEXT_AWARE_METADATA_PATH - ) - self._is_mtls = metadata_path is not None + self._is_mtls = mtls.has_default_client_cert_source() @property def ssl_credentials(self): 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 cd9e72cd55c9..31c0ce01758b 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -139,7 +139,9 @@ def mock_callback(): @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. + """ + Tests that if _auth_request is not an AiohttpRequest, _is_mtls is set to False + because we can't configure the custom request with mTLS. """ with mock.patch.dict( os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "true"} @@ -162,4 +164,10 @@ async def test_configure_mtls_channel_custom_request(self): mock_creds, auth_request=mock_auth_request ) await session.configure_mtls_channel() + + # If the request handler is not an AiohttpRequest, the library cannot configure + # the connection to use mTLS, so _is_mtls must be False to reflect this unconfigured state. assert session._is_mtls is False + mock_make_context.assert_called_once_with( + b"fake_cert_data", b"fake_key_data" + ) diff --git a/packages/google-auth/tests/transport/test_grpc.py b/packages/google-auth/tests/transport/test_grpc.py index 7ebd14758e55..6cd6aa337458 100644 --- a/packages/google-auth/tests/transport/test_grpc.py +++ b/packages/google-auth/tests/transport/test_grpc.py @@ -468,6 +468,39 @@ def test_get_client_ssl_credentials_success( certificate_chain=PUBLIC_CERT_BYTES, private_key=PRIVATE_KEY_BYTES ) + @mock.patch("google.auth.transport.mtls.has_default_client_cert_source", autospec=True) + def test_get_client_ssl_credentials_workload_cert( + self, + mock_has_default_client_cert_source, + mock_check_config_path, + mock_load_json_file, + mock_get_client_ssl_credentials, + mock_ssl_channel_credentials, + ): + # Mock that context-aware metadata does not exist, but workload cert config does. + mock_check_config_path.return_value = None + mock_has_default_client_cert_source.return_value = True + mock_get_client_ssl_credentials.return_value = ( + True, + PUBLIC_CERT_BYTES, + PRIVATE_KEY_BYTES, + None, + ) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + ssl_credentials = google.auth.transport.grpc.SslCredentials() + + # If a workload certificate config exists on the device (and use_client_cert is true), + # is_mtls must be True and get_client_ssl_credentials should be invoked. + assert ssl_credentials.ssl_credentials is not None + assert ssl_credentials.is_mtls + mock_get_client_ssl_credentials.assert_called_once() + mock_ssl_channel_credentials.assert_called_once_with( + certificate_chain=PUBLIC_CERT_BYTES, private_key=PRIVATE_KEY_BYTES + ) + def test_get_client_ssl_credentials_without_client_cert_env( self, mock_check_config_path, diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py index d9753b9e90cf..5a9460565c43 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -259,9 +259,14 @@ def test_configure_mtls_channel_non_mtls( with mock.patch.dict( os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} ): - res = authed_http.configure_mtls_channel() - assert res is False + is_mtls = authed_http.configure_mtls_channel() + + assert is_mtls is False + # If client certificate and key are not found, the transport falls back to + # a standard connection. _is_mtls must be False to reflect this fallback state. assert authed_http._is_mtls is False + mock_get_client_cert_and_key.assert_called_once() + mock_make_mutual_tls_http.assert_not_called() @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True From c83ed69f61579c8e976cd4ed6c878dbbc74b78d1 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Fri, 5 Jun 2026 20:52:29 -0700 Subject: [PATCH 2/7] lint fixes. --- .../google-auth/tests/transport/aio/test_sessions_mtls.py | 2 ++ packages/google-auth/tests/transport/test_grpc.py | 6 ++++-- packages/google-auth/tests/transport/test_urllib3.py | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) 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 31c0ce01758b..045b9d993ac8 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -163,6 +163,8 @@ async def test_configure_mtls_channel_custom_request(self): session = sessions.AsyncAuthorizedSession( mock_creds, auth_request=mock_auth_request ) + + await session.configure_mtls_channel() # If the request handler is not an AiohttpRequest, the library cannot configure diff --git a/packages/google-auth/tests/transport/test_grpc.py b/packages/google-auth/tests/transport/test_grpc.py index 6cd6aa337458..d18497101de1 100644 --- a/packages/google-auth/tests/transport/test_grpc.py +++ b/packages/google-auth/tests/transport/test_grpc.py @@ -468,7 +468,9 @@ def test_get_client_ssl_credentials_success( certificate_chain=PUBLIC_CERT_BYTES, private_key=PRIVATE_KEY_BYTES ) - @mock.patch("google.auth.transport.mtls.has_default_client_cert_source", autospec=True) + @mock.patch( + "google.auth.transport.mtls.has_default_client_cert_source", autospec=True + ) def test_get_client_ssl_credentials_workload_cert( self, mock_has_default_client_cert_source, @@ -492,7 +494,7 @@ def test_get_client_ssl_credentials_workload_cert( ): ssl_credentials = google.auth.transport.grpc.SslCredentials() - # If a workload certificate config exists on the device (and use_client_cert is true), + # If a workload certificate config exists on the device (and use_client_cert is true), # is_mtls must be True and get_client_ssl_credentials should be invoked. assert ssl_credentials.ssl_credentials is not None assert ssl_credentials.is_mtls diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py index 5a9460565c43..4f4a68d099f2 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -261,8 +261,8 @@ def test_configure_mtls_channel_non_mtls( ): is_mtls = authed_http.configure_mtls_channel() - assert is_mtls is False - # If client certificate and key are not found, the transport falls back to + assert not is_mtls + # If client certificate and key are not found, the transport falls back to # a standard connection. _is_mtls must be False to reflect this fallback state. assert authed_http._is_mtls is False mock_get_client_cert_and_key.assert_called_once() From 6ad5a02b2f679adc77cfa541e0f66de4c0d4883d Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Mon, 22 Jun 2026 17:50:07 -0700 Subject: [PATCH 3/7] Addressed comments. --- .../google/auth/aio/transport/sessions.py | 1 - .../google-auth/google/auth/transport/grpc.py | 35 +++++---- .../tests/transport/aio/test_sessions_mtls.py | 27 +++++++ .../google-auth/tests/transport/test_grpc.py | 71 ++++++++++++++++--- .../tests/transport/test_requests.py | 10 +++ .../tests/transport/test_urllib3.py | 10 +++ 6 files changed, 132 insertions(+), 22 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index bb7873b02aef..f6e452338646 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -156,7 +156,6 @@ async def configure_mtls_channel(self, client_cert_callback=None): (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 diff --git a/packages/google-auth/google/auth/transport/grpc.py b/packages/google-auth/google/auth/transport/grpc.py index 57c62006c8d0..a0aa0c8dd055 100644 --- a/packages/google-auth/google/auth/transport/grpc.py +++ b/packages/google-auth/google/auth/transport/grpc.py @@ -280,14 +280,18 @@ def my_client_cert_callback(): class SslCredentials: """Class for application default SSL credentials. - The behavior is controlled by `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment - variable whose default value is `false`. Client certificate will not be used - unless the environment variable is explicitly set to `true`. See - https://google.aip.dev/auth/4114 - - If the environment variable is `true`, then for devices with endpoint verification - support, a device certificate will be automatically loaded and mutual TLS will - be established. + The client certificate usage (mutual TLS) is determined by the + `should_use_client_cert` helper. Client certificate will not be used + unless client certificate usage is enabled. This is true if the + `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable is explicitly + set to `"true"`, or if the environment variable is unset/empty but a client + certificate configuration is found (e.g. via the `GOOGLE_API_CERTIFICATE_CONFIG` + environment variable containing a `"workload"` certificate configuration). + See https://google.aip.dev/auth/4114 + + If client certificate usage is enabled, then for devices with endpoint + verification support, a device certificate will be automatically loaded and + mutual TLS will be established. See https://cloud.google.com/endpoint-verification/docs/overview. """ @@ -316,11 +320,16 @@ def ssl_credentials(self): """ if self._is_mtls: try: - _, cert, key, _ = _mtls_helper.get_client_ssl_credentials() - self._ssl_credentials = grpc.ssl_channel_credentials( - certificate_chain=cert, private_key=key - ) - except exceptions.ClientCertError as caught_exc: + has_cert, cert, key, _ = _mtls_helper.get_client_ssl_credentials() + if has_cert: + self._ssl_credentials = grpc.ssl_channel_credentials( + certificate_chain=cert, private_key=key + ) + else: + self._ssl_credentials = grpc.ssl_channel_credentials() + self._is_mtls = False + except (exceptions.ClientCertError, OSError) as caught_exc: + self._is_mtls = False new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc else: 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 045b9d993ac8..301d7668ba38 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -173,3 +173,30 @@ async def test_configure_mtls_channel_custom_request(self): mock_make_context.assert_called_once_with( b"fake_cert_data", b"fake_key_data" ) + + @pytest.mark.asyncio + async def test_configure_mtls_channel_exception_resets_flag(self): + """ + Tests that self._is_mtls is reset to False if an exception is raised + during configuration. + """ + 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_make_context.side_effect = exceptions.ClientCertError("Mock error") + + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + session = sessions.AsyncAuthorizedSession(mock_creds) + + with pytest.raises(exceptions.MutualTLSChannelError): + await session.configure_mtls_channel() + + assert session._is_mtls is False diff --git a/packages/google-auth/tests/transport/test_grpc.py b/packages/google-auth/tests/transport/test_grpc.py index d18497101de1..8f52276b280d 100644 --- a/packages/google-auth/tests/transport/test_grpc.py +++ b/packages/google-auth/tests/transport/test_grpc.py @@ -216,9 +216,12 @@ def test_secure_authorized_channel_adc_without_client_cert_env( request = mock.create_autospec(transport.Request) target = "example.com:80" - channel = google.auth.transport.grpc.secure_authorized_channel( - credentials, request, target, options=mock.sentinel.options - ) + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "false"} + ): + channel = google.auth.transport.grpc.secure_authorized_channel( + credentials, request, target, options=mock.sentinel.options + ) # Check the auth plugin construction. auth_plugin = metadata_call_credentials.call_args[0][0] @@ -375,9 +378,12 @@ def test_secure_authorized_channel_cert_callback_without_client_cert_env( target = "example.com:80" client_cert_callback = mock.Mock() - google.auth.transport.grpc.secure_authorized_channel( - credentials, request, target, client_cert_callback=client_cert_callback - ) + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "false"} + ): + google.auth.transport.grpc.secure_authorized_channel( + credentials, request, target, client_cert_callback=client_cert_callback + ) # Check client_cert_callback is not called because GOOGLE_API_USE_CLIENT_CERTIFICATE # is not set. @@ -510,8 +516,10 @@ def test_get_client_ssl_credentials_without_client_cert_env( mock_get_client_ssl_credentials, mock_ssl_channel_credentials, ): - # Test client cert won't be used if GOOGLE_API_USE_CLIENT_CERTIFICATE is not set. - ssl_credentials = google.auth.transport.grpc.SslCredentials() + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "false"} + ): + ssl_credentials = google.auth.transport.grpc.SslCredentials() assert ssl_credentials.ssl_credentials is not None assert not ssl_credentials.is_mtls @@ -519,3 +527,50 @@ def test_get_client_ssl_credentials_without_client_cert_env( mock_load_json_file.assert_not_called() mock_get_client_ssl_credentials.assert_not_called() mock_ssl_channel_credentials.assert_called_once() + + def test_get_client_ssl_credentials_no_workload_cert( + self, + mock_check_config_path, + mock_load_json_file, + mock_get_client_ssl_credentials, + mock_ssl_channel_credentials, + ): + mock_check_config_path.return_value = METADATA_PATH + mock_load_json_file.return_value = {"cert_provider_command": ["some command"]} + mock_get_client_ssl_credentials.return_value = ( + False, + None, + None, + None, + ) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + ssl_credentials = google.auth.transport.grpc.SslCredentials() + + assert ssl_credentials.ssl_credentials is not None + assert not ssl_credentials.is_mtls + mock_get_client_ssl_credentials.assert_called_once() + mock_ssl_channel_credentials.assert_called_once_with() + + def test_get_client_ssl_credentials_os_error( + self, + mock_check_config_path, + mock_load_json_file, + mock_get_client_ssl_credentials, + mock_ssl_channel_credentials, + ): + mock_check_config_path.return_value = METADATA_PATH + mock_load_json_file.return_value = {"cert_provider_command": ["some command"]} + mock_get_client_ssl_credentials.side_effect = OSError("Mock file read error") + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + ssl_credentials = google.auth.transport.grpc.SslCredentials() + + with pytest.raises(exceptions.MutualTLSChannelError): + _ = ssl_credentials.ssl_credentials + + assert not ssl_credentials.is_mtls diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py index a7617d44992c..22f543333e69 100644 --- a/packages/google-auth/tests/transport/test_requests.py +++ b/packages/google-auth/tests/transport/test_requests.py @@ -486,6 +486,15 @@ def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} ): auth_session.configure_mtls_channel() + assert auth_session._is_mtls is False + + mock_get_client_cert_and_key.side_effect = OSError("Mock file read error") + with pytest.raises(exceptions.MutualTLSChannelError): + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel() + assert auth_session._is_mtls is False mock_get_client_cert_and_key.return_value = (False, None, None) with mock.patch.dict("sys.modules"): @@ -496,6 +505,7 @@ def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"}, ): auth_session.configure_mtls_channel() + 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 4f4a68d099f2..2441248fe80b 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -282,6 +282,15 @@ def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} ): authed_http.configure_mtls_channel() + assert authed_http._is_mtls is False + + mock_get_client_cert_and_key.side_effect = OSError("Mock file read error") + with pytest.raises(exceptions.MutualTLSChannelError): + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + authed_http.configure_mtls_channel() + assert authed_http._is_mtls is False mock_get_client_cert_and_key.return_value = (False, None, None) with mock.patch.dict("sys.modules"): @@ -292,6 +301,7 @@ def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"}, ): authed_http.configure_mtls_channel() + assert authed_http._is_mtls is False @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True From d74ff322a4ec76262b68922eeb65706443cc8439 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Thu, 25 Jun 2026 21:14:09 -0700 Subject: [PATCH 4/7] Addressed comments. --- .../google/auth/aio/transport/sessions.py | 12 ++- .../google/auth/transport/requests.py | 6 +- .../google/auth/transport/urllib3.py | 13 +-- .../tests/transport/aio/test_sessions_mtls.py | 36 +++++++ .../tests/transport/test_requests.py | 83 +++++++++++++++- .../tests/transport/test_urllib3.py | 94 +++++++++++++++++-- 6 files changed, 220 insertions(+), 24 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index f6e452338646..824d11bfa96f 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -219,11 +219,7 @@ async def _do_configure(): UserWarning, ) - except ( - exceptions.ClientCertError, - ImportError, - OSError, - ) as caught_exc: + except Exception as caught_exc: self._is_mtls = False new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc @@ -585,4 +581,10 @@ async def close(self) -> None: """ Close the underlying auth request session. """ + if self._mtls_init_task and not self._mtls_init_task.done(): + self._mtls_init_task.cancel() + try: + await self._mtls_init_task + except asyncio.CancelledError: + pass await self._auth_request.close() diff --git a/packages/google-auth/google/auth/transport/requests.py b/packages/google-auth/google/auth/transport/requests.py index a36d85f84661..c1b36a656332 100644 --- a/packages/google-auth/google/auth/transport/requests.py +++ b/packages/google-auth/google/auth/transport/requests.py @@ -445,14 +445,15 @@ def configure_mtls_channel(self, client_cert_callback=None): google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel creation failed for any reason. """ + self._is_mtls = False + self.mount("https://", requests.adapters.HTTPAdapter()) + use_client_cert = google.auth.transport._mtls_helper.check_use_client_cert() if not use_client_cert: - self._is_mtls = False return try: import OpenSSL except ImportError as caught_exc: - self._is_mtls = False new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc @@ -475,7 +476,6 @@ def configure_mtls_channel(self, client_cert_callback=None): 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 ace693773aa1..e887c00225cf 100644 --- a/packages/google-auth/google/auth/transport/urllib3.py +++ b/packages/google-auth/google/auth/transport/urllib3.py @@ -334,16 +334,16 @@ def configure_mtls_channel(self, client_cert_callback=None): google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel creation failed for any reason. """ + self._is_mtls = False + self.http = _make_default_http() + use_client_cert = transport._mtls_helper.check_use_client_cert() if not use_client_cert: - self._is_mtls = False return False - else: - self._is_mtls = True + try: import OpenSSL except ImportError as caught_exc: - self._is_mtls = False new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc @@ -355,16 +355,13 @@ def configure_mtls_channel(self, client_cert_callback=None): if found_cert_key: self.http = _make_mutual_tls_http(cert, key) self._cached_cert = cert - else: - self.http = _make_default_http() - self._is_mtls = False + self._is_mtls = True 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/transport/aio/test_sessions_mtls.py b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py index 301d7668ba38..7ad785dc4bc9 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -64,6 +64,7 @@ async def test_configure_mtls_channel(self): mock_make_context.assert_called_once_with( b"fake_cert_data", b"fake_key_data" ) + await session.close() @pytest.mark.asyncio async def test_configure_mtls_channel_disabled(self): @@ -78,6 +79,7 @@ async def test_configure_mtls_channel_disabled(self): session = sessions.AsyncAuthorizedSession(mock_creds) await session.configure_mtls_channel() assert session._is_mtls is False + await session.close() @pytest.mark.asyncio async def test_configure_mtls_channel_invalid_format(self): @@ -95,6 +97,7 @@ async def test_configure_mtls_channel_invalid_format(self): with pytest.raises(exceptions.MutualTLSChannelError): await session.configure_mtls_channel() + await session.close() @pytest.mark.asyncio async def test_configure_mtls_channel_invalud_fields(self): @@ -111,6 +114,7 @@ async def test_configure_mtls_channel_invalud_fields(self): session = sessions.AsyncAuthorizedSession(mock_creds) await session.configure_mtls_channel() assert session._is_mtls is False + await session.close() @pytest.mark.asyncio async def test_configure_mtls_channel_mock_callback(self): @@ -135,6 +139,7 @@ def mock_callback(): await session.configure_mtls_channel(client_cert_callback=mock_callback) assert session._is_mtls is True + await session.close() @pytest.mark.asyncio async def test_configure_mtls_channel_custom_request(self): @@ -173,6 +178,7 @@ async def test_configure_mtls_channel_custom_request(self): mock_make_context.assert_called_once_with( b"fake_cert_data", b"fake_key_data" ) + await session.close() @pytest.mark.asyncio async def test_configure_mtls_channel_exception_resets_flag(self): @@ -200,3 +206,33 @@ async def test_configure_mtls_channel_exception_resets_flag(self): await session.configure_mtls_channel() assert session._is_mtls is False + await session.close() + + @pytest.mark.asyncio + async def test_configure_mtls_channel_transport_error_resets_flag(self): + """ + Tests that self._is_mtls is reset to False if a TransportError is raised + during configuration. + """ + 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_make_context.side_effect = exceptions.TransportError("Mock error") + + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + session = sessions.AsyncAuthorizedSession(mock_creds) + + with pytest.raises(exceptions.MutualTLSChannelError): + await session.configure_mtls_channel() + + assert session._is_mtls is False + await session.close() + diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py index 22f543333e69..be3e7582df54 100644 --- a/packages/google-auth/tests/transport/test_requests.py +++ b/packages/google-auth/tests/transport/test_requests.py @@ -563,8 +563,8 @@ def test_cert_rotation_when_cert_mismatch_and_mtls_enabled(self): old_cert = b"-----BEGIN CERTIFICATE-----\nMIIBdTCCARqgAwIBAgIJAOYVvu/axMxvMAoGCCqGSM49BAMCMCcxJTAjBgNVBAMM\nHEdvb2dsZSBFbmRwb2ludCBWZXJpZmljYXRpb24wHhcNMjUwNzMwMjMwNjA4WhcN\nMjYwNzMxMjMwNjA4WjAnMSUwIwYDVQQDDBxHb29nbGUgRW5kcG9pbnQgVmVyaWZp\nY2F0aW9uMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbtr18gkEtwPow2oqyZsU\n4KLwFaLFlRlYv55UATS3QTDykDnIufC42TJCnqFRYhwicwpE2jnUV+l9g3Voias8\nraMvMC0wCQYDVR0TBAIwADALBgNVHQ8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUH\nAwIwCgYIKoZIzj0EAwIDSQAwRgIhAKcjW6dmF1YCksXPgDPlPu/nSnOjb3qCcivz\n/Jxq2zoeAiEA7/aNxcEoCGS3hwMIXoaaD/vPcZOOopKSyqXCvxRooKQ=\n-----END CERTIFICATE-----\n" # New certificate and key to simulate rotation. - new_cert = CERT_MOCK_VAL - new_key = KEY_MOCK_VAL + new_cert = pytest.public_cert_bytes + new_key = pytest.private_key_bytes # Set _cached_cert to a callable that returns the old certificate. authed_session._cached_cert = old_cert @@ -767,6 +767,85 @@ def test_cert_rotation_logic_skipped_on_other_refresh_status_codes(self): # Assert mTLS check logic was SKIPPED (Inner Check was False) assert not mock_helper.check_parameters_for_unauthorized_response.called + def test_configure_mtls_channel_subsequent_failure(self): + # 1. Setup successful mTLS configuration + mock_callback = mock.Mock() + mock_callback.return_value = ( + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel(mock_callback) + + assert auth_session.is_mtls + assert isinstance( + auth_session.adapters["https://"], + google.auth.transport.requests._MutualTlsAdapter, + ) + + # 2. Trigger a failure on a subsequent configuration call + with mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) as mock_get_client_cert_and_key: + mock_get_client_cert_and_key.side_effect = exceptions.ClientCertError() + + with pytest.raises(exceptions.MutualTLSChannelError): + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel() + + # 3. Verify it falls back to standard HTTPAdapter and is_mtls becomes False + assert not auth_session.is_mtls + assert not isinstance( + auth_session.adapters["https://"], + google.auth.transport.requests._MutualTlsAdapter, + ) + assert isinstance( + auth_session.adapters["https://"], + requests.adapters.HTTPAdapter, + ) + + def test_configure_mtls_channel_subsequent_disabled(self): + # 1. Setup successful mTLS configuration + mock_callback = mock.Mock() + mock_callback.return_value = ( + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel(mock_callback) + + assert auth_session.is_mtls + + # 2. Subsequent call returns no client certificate (disabled) + with mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) as mock_get_client_cert_and_key: + mock_get_client_cert_and_key.return_value = (False, None, None) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + auth_session.configure_mtls_channel() + + # 3. Verify mTLS is disabled and standard HTTPAdapter is restored + assert not auth_session.is_mtls + assert isinstance( + auth_session.adapters["https://"], + requests.adapters.HTTPAdapter, + ) + class TestMutualTlsOffloadAdapter(object): @mock.patch.object(requests.adapters.HTTPAdapter, "init_poolmanager") diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py index 2441248fe80b..e4dec4a57cda 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -337,10 +337,19 @@ def test_clear_pool_on_del(self): authed_http.__del__() # Expect it to not crash - def test_cert_rotation_when_cert_mismatch_and_mtls_endpoint_used(self): + @mock.patch("google.auth.transport.urllib3._make_mutual_tls_http", autospec=True) + @mock.patch("google.auth.transport.urllib3._make_default_http", autospec=True) + def test_cert_rotation_when_cert_mismatch_and_mtls_endpoint_used( + self, mock_make_default_http, mock_make_mutual_tls_http + ): credentials = mock.Mock(wraps=CredentialsStub()) final_response = ResponseStub(status=http_client.OK) - http = HttpStub([ResponseStub(status=http_client.UNAUTHORIZED), final_response]) + + # We simulate the HTTP stub rotation. When mTLS http is created, we return rotated_http. + rotated_http = HttpStub([final_response]) + mock_make_mutual_tls_http.return_value = rotated_http + + http = HttpStub([ResponseStub(status=http_client.UNAUTHORIZED)]) authed_http = google.auth.transport.urllib3.AuthorizedHttp( credentials, http=http @@ -349,8 +358,8 @@ def test_cert_rotation_when_cert_mismatch_and_mtls_endpoint_used(self): old_cert = b"-----BEGIN CERTIFICATE-----\nMIIBdTCCARqgAwIBAgIJAOYVvu/axMxvMAoGCCqGSM49BAMCMCcxJTAjBgNVBAMM\nHEdvb2dsZSBFbmRwb2ludCBWZXJpZmljYXRpb24wHhcNMjUwNzMwMjMwNjA4WhcN\nMjYwNzMxMjMwNjA4WjAnMSUwIwYDVQQDDBxHb29nbGUgRW5kcG9pbnQgVmVyaWZp\nY2F0aW9uMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbtr18gkEtwPow2oqyZsU\n4KLwFaLFlRlYv55UATS3QTDykDnIufC42TJCnqFRYhwicwpE2jnUV+l9g3Voias8\nraMvMC0wCQYDVR0TBAIwADALBgNVHQ8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUH\nAwIwCgYIKoZIzj0EAwIDSQAwRgIhAKcjW6dmF1YCksXPgDPlPu/nSnOjb3qCcivz\n/Jxq2zoeAiEA7/aNxcEoCGS3hwMIXoaaD/vPcZOOopKSyqXCvxRooKQ=\n-----END CERTIFICATE-----\n" # New certificate and key to simulate rotation. - new_cert = CERT_MOCK_VAL - new_key = KEY_MOCK_VAL + new_cert = pytest.public_cert_bytes + new_key = pytest.private_key_bytes # Set _cached_cert to a callable that returns the old certificate. authed_http._cached_cert = old_cert authed_http._is_mtls = True @@ -360,14 +369,20 @@ def test_cert_rotation_when_cert_mismatch_and_mtls_endpoint_used(self): "call_client_cert_callback", return_value=(new_cert, new_key), ) as mock_callback: - # mTLS endpoint is used - result = authed_http.urlopen("GET", "http://example.mtls.googleapis.com") + # mTLS endpoint is used, and client cert env var is true + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + result = authed_http.urlopen("GET", "http://example.mtls.googleapis.com") # Asserts to verify the behavior. assert result == final_response assert credentials.refresh.called assert credentials.refresh.call_count == 1 assert mock_callback.called + mock_make_mutual_tls_http.assert_called_once_with( + cert=new_cert, key=new_key + ) def test_no_cert_rotation_when_cert_match_and_mtls_endpoint_used(self): credentials = mock.Mock(wraps=CredentialsStub()) @@ -541,3 +556,70 @@ def test_cert_rotation_logic_skipped_on_other_refresh_status_codes(self): # Assert mTLS check logic was SKIPPED (Inner Check was False) assert not mock_helper.check_parameters_for_unauthorized_response.called + + @mock.patch("google.auth.transport.urllib3._make_mutual_tls_http", autospec=True) + def test_configure_mtls_channel_subsequent_failure(self, mock_make_mutual_tls_http): + callback = mock.Mock() + callback.return_value = (pytest.public_cert_bytes, pytest.private_key_bytes) + + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=mock.Mock() + ) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + is_mtls = authed_http.configure_mtls_channel(callback) + + assert is_mtls + assert authed_http._is_mtls + + # Subsequent call fails + with mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) as mock_get_client_cert_and_key: + mock_get_client_cert_and_key.side_effect = exceptions.ClientCertError() + + with pytest.raises(exceptions.MutualTLSChannelError): + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + authed_http.configure_mtls_channel() + + # Verify it was reset to default HTTP connection pool and _is_mtls is False + assert not authed_http._is_mtls + assert not isinstance(authed_http.http, mock.Mock) + assert isinstance(authed_http.http, urllib3.PoolManager) + + @mock.patch("google.auth.transport.urllib3._make_mutual_tls_http", autospec=True) + def test_configure_mtls_channel_subsequent_disabled(self, mock_make_mutual_tls_http): + callback = mock.Mock() + callback.return_value = (pytest.public_cert_bytes, pytest.private_key_bytes) + + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=mock.Mock() + ) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + is_mtls = authed_http.configure_mtls_channel(callback) + + assert is_mtls + assert authed_http._is_mtls + + # Subsequent call returns no client certificate (disabled) + with mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) as mock_get_client_cert_and_key: + mock_get_client_cert_and_key.return_value = (False, None, None) + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + is_mtls = authed_http.configure_mtls_channel() + + # Verify mTLS is disabled and standard PoolManager is restored + assert not is_mtls + assert not authed_http._is_mtls + assert isinstance(authed_http.http, urllib3.PoolManager) From 1f92e9c32761214457268bdbf1059c47098303e5 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Thu, 25 Jun 2026 21:14:26 -0700 Subject: [PATCH 5/7] lint changes. --- .../tests/transport/aio/test_sessions_mtls.py | 1 - .../tests/transport/test_requests.py | 9 ++++--- .../tests/transport/test_urllib3.py | 25 +++++++++++-------- 3 files changed, 19 insertions(+), 16 deletions(-) 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 7ad785dc4bc9..d6756dd9112c 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -235,4 +235,3 @@ async def test_configure_mtls_channel_transport_error_resets_flag(self): assert session._is_mtls is False await session.close() - diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py index be3e7582df54..2ad030e5e70e 100644 --- a/packages/google-auth/tests/transport/test_requests.py +++ b/packages/google-auth/tests/transport/test_requests.py @@ -793,13 +793,14 @@ def test_configure_mtls_channel_subsequent_failure(self): "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True ) as mock_get_client_cert_and_key: mock_get_client_cert_and_key.side_effect = exceptions.ClientCertError() - + with pytest.raises(exceptions.MutualTLSChannelError): with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + os.environ, + {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"}, ): auth_session.configure_mtls_channel() - + # 3. Verify it falls back to standard HTTPAdapter and is_mtls becomes False assert not auth_session.is_mtls assert not isinstance( @@ -833,7 +834,7 @@ def test_configure_mtls_channel_subsequent_disabled(self): "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True ) as mock_get_client_cert_and_key: mock_get_client_cert_and_key.return_value = (False, None, None) - + with mock.patch.dict( os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} ): diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py index e4dec4a57cda..854d087119f4 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -344,7 +344,7 @@ def test_cert_rotation_when_cert_mismatch_and_mtls_endpoint_used( ): credentials = mock.Mock(wraps=CredentialsStub()) final_response = ResponseStub(status=http_client.OK) - + # We simulate the HTTP stub rotation. When mTLS http is created, we return rotated_http. rotated_http = HttpStub([final_response]) mock_make_mutual_tls_http.return_value = rotated_http @@ -373,16 +373,16 @@ def test_cert_rotation_when_cert_mismatch_and_mtls_endpoint_used( with mock.patch.dict( os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} ): - result = authed_http.urlopen("GET", "http://example.mtls.googleapis.com") + result = authed_http.urlopen( + "GET", "http://example.mtls.googleapis.com" + ) # Asserts to verify the behavior. assert result == final_response assert credentials.refresh.called assert credentials.refresh.call_count == 1 assert mock_callback.called - mock_make_mutual_tls_http.assert_called_once_with( - cert=new_cert, key=new_key - ) + mock_make_mutual_tls_http.assert_called_once_with(cert=new_cert, key=new_key) def test_no_cert_rotation_when_cert_match_and_mtls_endpoint_used(self): credentials = mock.Mock(wraps=CredentialsStub()) @@ -579,20 +579,23 @@ def test_configure_mtls_channel_subsequent_failure(self, mock_make_mutual_tls_ht "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True ) as mock_get_client_cert_and_key: mock_get_client_cert_and_key.side_effect = exceptions.ClientCertError() - + with pytest.raises(exceptions.MutualTLSChannelError): with mock.patch.dict( - os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + os.environ, + {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"}, ): authed_http.configure_mtls_channel() - + # Verify it was reset to default HTTP connection pool and _is_mtls is False assert not authed_http._is_mtls assert not isinstance(authed_http.http, mock.Mock) assert isinstance(authed_http.http, urllib3.PoolManager) @mock.patch("google.auth.transport.urllib3._make_mutual_tls_http", autospec=True) - def test_configure_mtls_channel_subsequent_disabled(self, mock_make_mutual_tls_http): + def test_configure_mtls_channel_subsequent_disabled( + self, mock_make_mutual_tls_http + ): callback = mock.Mock() callback.return_value = (pytest.public_cert_bytes, pytest.private_key_bytes) @@ -613,12 +616,12 @@ def test_configure_mtls_channel_subsequent_disabled(self, mock_make_mutual_tls_h "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True ) as mock_get_client_cert_and_key: mock_get_client_cert_and_key.return_value = (False, None, None) - + with mock.patch.dict( os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} ): is_mtls = authed_http.configure_mtls_channel() - + # Verify mTLS is disabled and standard PoolManager is restored assert not is_mtls assert not authed_http._is_mtls From 19b812095b0f5dbca60dabec022d951eba872e34 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Fri, 26 Jun 2026 17:19:12 -0700 Subject: [PATCH 6/7] Addressed comments. --- .../google-auth/google/auth/transport/grpc.py | 1 - .../google/auth/transport/requests.py | 25 +++++++----- .../google/auth/transport/urllib3.py | 23 +++++++---- .../tests/transport/aio/test_sessions_mtls.py | 5 +-- .../google-auth/tests/transport/test_grpc.py | 35 +++++++++++++++- .../tests/transport/test_requests.py | 40 +++++++++---------- .../tests/transport/test_urllib3.py | 30 ++++++++------ 7 files changed, 105 insertions(+), 54 deletions(-) diff --git a/packages/google-auth/google/auth/transport/grpc.py b/packages/google-auth/google/auth/transport/grpc.py index a0aa0c8dd055..edf91a8d3c4b 100644 --- a/packages/google-auth/google/auth/transport/grpc.py +++ b/packages/google-auth/google/auth/transport/grpc.py @@ -329,7 +329,6 @@ def ssl_credentials(self): self._ssl_credentials = grpc.ssl_channel_credentials() self._is_mtls = False except (exceptions.ClientCertError, OSError) as caught_exc: - self._is_mtls = False new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc else: diff --git a/packages/google-auth/google/auth/transport/requests.py b/packages/google-auth/google/auth/transport/requests.py index c1b36a656332..b9c308903213 100644 --- a/packages/google-auth/google/auth/transport/requests.py +++ b/packages/google-auth/google/auth/transport/requests.py @@ -443,14 +443,13 @@ def configure_mtls_channel(self, client_cert_callback=None): Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel - creation failed for any reason. + creation failed for any reason. The existing session state (such + as adapter mounts) remains unmodified if this error is raised. """ - self._is_mtls = False - self.mount("https://", requests.adapters.HTTPAdapter()) - use_client_cert = google.auth.transport._mtls_helper.check_use_client_cert() if not use_client_cert: return + try: import OpenSSL except ImportError as caught_exc: @@ -459,17 +458,17 @@ def configure_mtls_channel(self, client_cert_callback=None): try: ( - self._is_mtls, + 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._cached_cert = cert - self.mount("https://", mtls_adapter) + if is_mtls: + new_adapter = _MutualTlsAdapter(cert, key) + else: + new_adapter = requests.adapters.HTTPAdapter() except ( exceptions.ClientCertError, ImportError, @@ -479,6 +478,14 @@ def configure_mtls_channel(self, client_cert_callback=None): new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc + self.mount("https://", new_adapter) + self._is_mtls = is_mtls + if is_mtls: + self._cached_cert = cert + else: + if hasattr(self, "_cached_cert"): + del self._cached_cert + def request( self, method, diff --git a/packages/google-auth/google/auth/transport/urllib3.py b/packages/google-auth/google/auth/transport/urllib3.py index e887c00225cf..0a1de5f86f7c 100644 --- a/packages/google-auth/google/auth/transport/urllib3.py +++ b/packages/google-auth/google/auth/transport/urllib3.py @@ -332,11 +332,9 @@ def configure_mtls_channel(self, client_cert_callback=None): Raises: google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel - creation failed for any reason. + creation failed for any reason. The existing channel state (the + HTTP client) remains unmodified if this error is raised. """ - self._is_mtls = False - self.http = _make_default_http() - use_client_cert = transport._mtls_helper.check_use_client_cert() if not use_client_cert: return False @@ -353,9 +351,11 @@ def configure_mtls_channel(self, client_cert_callback=None): ) if found_cert_key: - self.http = _make_mutual_tls_http(cert, key) - self._cached_cert = cert - self._is_mtls = True + new_http = _make_mutual_tls_http(cert, key) + new_is_mtls = True + else: + new_http = _make_default_http() + new_is_mtls = False except ( exceptions.ClientCertError, ImportError, @@ -365,6 +365,15 @@ def configure_mtls_channel(self, client_cert_callback=None): new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc + self.http = new_http + self._is_mtls = new_is_mtls + self._request.http = new_http + if new_is_mtls: + self._cached_cert = cert + else: + if hasattr(self, "_cached_cert"): + del self._cached_cert + if self._has_user_provided_http: self._has_user_provided_http = False warnings.warn( 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 d6756dd9112c..3d9cda2efc87 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -143,9 +143,7 @@ def mock_callback(): @pytest.mark.asyncio async def test_configure_mtls_channel_custom_request(self): - """ - """ - Tests that if _auth_request is not an AiohttpRequest, _is_mtls is set to False + """Tests that if _auth_request is not an AiohttpRequest, _is_mtls is set to False because we can't configure the custom request with mTLS. """ with mock.patch.dict( @@ -169,7 +167,6 @@ async def test_configure_mtls_channel_custom_request(self): mock_creds, auth_request=mock_auth_request ) - await session.configure_mtls_channel() # If the request handler is not an AiohttpRequest, the library cannot configure diff --git a/packages/google-auth/tests/transport/test_grpc.py b/packages/google-auth/tests/transport/test_grpc.py index 8f52276b280d..9aa84bf85e90 100644 --- a/packages/google-auth/tests/transport/test_grpc.py +++ b/packages/google-auth/tests/transport/test_grpc.py @@ -573,4 +573,37 @@ def test_get_client_ssl_credentials_os_error( with pytest.raises(exceptions.MutualTLSChannelError): _ = ssl_credentials.ssl_credentials - assert not ssl_credentials.is_mtls + assert ssl_credentials.is_mtls + + def test_get_client_ssl_credentials_transient_error_retry( + self, + mock_check_config_path, + mock_load_json_file, + mock_get_client_ssl_credentials, + mock_ssl_channel_credentials, + ): + mock_check_config_path.return_value = METADATA_PATH + mock_load_json_file.return_value = {"cert_provider_command": ["some command"]} + # First call fails with OSError, second succeeds + mock_get_client_ssl_credentials.side_effect = [ + OSError("Mock transient error"), + (True, b"cert", b"key", None), + ] + + with mock.patch.dict( + os.environ, {environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "true"} + ): + ssl_credentials = google.auth.transport.grpc.SslCredentials() + + # First call raises error + with pytest.raises(exceptions.MutualTLSChannelError): + _ = ssl_credentials.ssl_credentials + + assert ssl_credentials.is_mtls # Should remain True + + # Second call succeeds + assert ssl_credentials.ssl_credentials is not None + assert ssl_credentials.is_mtls + mock_ssl_channel_credentials.assert_called_with( + certificate_chain=b"cert", private_key=b"key" + ) diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py index 2ad030e5e70e..d13f0fac2f20 100644 --- a/packages/google-auth/tests/transport/test_requests.py +++ b/packages/google-auth/tests/transport/test_requests.py @@ -513,20 +513,24 @@ def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): def test_configure_mtls_channel_without_client_cert_env( self, get_client_cert_and_key ): - # Test client cert won't be used if GOOGLE_API_USE_CLIENT_CERTIFICATE - # environment variable is not set. - auth_session = google.auth.transport.requests.AuthorizedSession( - credentials=mock.Mock() - ) - - auth_session.configure_mtls_channel() - assert not auth_session.is_mtls - get_client_cert_and_key.assert_not_called() + env_to_patch = { + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "", + environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE: "", + environment_vars.GOOGLE_API_CERTIFICATE_CONFIG: "", + environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH: "", + } + with mock.patch.dict(os.environ, env_to_patch): + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) + auth_session.configure_mtls_channel() + assert not auth_session.is_mtls + get_client_cert_and_key.assert_not_called() - mock_callback = mock.Mock() - auth_session.configure_mtls_channel(mock_callback) - assert not auth_session.is_mtls - mock_callback.assert_not_called() + mock_callback = mock.Mock() + auth_session.configure_mtls_channel(mock_callback) + assert not auth_session.is_mtls + mock_callback.assert_not_called() def test_close_wo_passed_in_auth_request(self): authed_session = google.auth.transport.requests.AuthorizedSession( @@ -801,15 +805,11 @@ def test_configure_mtls_channel_subsequent_failure(self): ): auth_session.configure_mtls_channel() - # 3. Verify it falls back to standard HTTPAdapter and is_mtls becomes False - assert not auth_session.is_mtls - assert not isinstance( - auth_session.adapters["https://"], - google.auth.transport.requests._MutualTlsAdapter, - ) + # 3. Verify it retains its previous mTLS state and MutualTlsAdapter + assert auth_session.is_mtls assert isinstance( auth_session.adapters["https://"], - requests.adapters.HTTPAdapter, + google.auth.transport.requests._MutualTlsAdapter, ) def test_configure_mtls_channel_subsequent_disabled(self): diff --git a/packages/google-auth/tests/transport/test_urllib3.py b/packages/google-auth/tests/transport/test_urllib3.py index 854d087119f4..04ace52a2350 100644 --- a/packages/google-auth/tests/transport/test_urllib3.py +++ b/packages/google-auth/tests/transport/test_urllib3.py @@ -315,15 +315,22 @@ def test_configure_mtls_channel_without_client_cert_env( credentials=mock.Mock(), http=mock.Mock() ) - # Test the callback is not called if GOOGLE_API_USE_CLIENT_CERTIFICATE is not set. - is_mtls = authed_http.configure_mtls_channel(callback) - assert not is_mtls - callback.assert_not_called() + env_to_patch = { + environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE: "", + environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE: "", + environment_vars.GOOGLE_API_CERTIFICATE_CONFIG: "", + environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH: "", + } + with mock.patch.dict(os.environ, env_to_patch): + # Test the callback is not called if GOOGLE_API_USE_CLIENT_CERTIFICATE is not set. + is_mtls = authed_http.configure_mtls_channel(callback) + assert not is_mtls + callback.assert_not_called() - # Test ADC client cert is not used if GOOGLE_API_USE_CLIENT_CERTIFICATE is not set. - is_mtls = authed_http.configure_mtls_channel(callback) - assert not is_mtls - get_client_cert_and_key.assert_not_called() + # Test ADC client cert is not used if GOOGLE_API_USE_CLIENT_CERTIFICATE is not set. + is_mtls = authed_http.configure_mtls_channel(callback) + assert not is_mtls + get_client_cert_and_key.assert_not_called() def test_clear_pool_on_del(self): http = mock.create_autospec(urllib3.PoolManager) @@ -587,10 +594,9 @@ def test_configure_mtls_channel_subsequent_failure(self, mock_make_mutual_tls_ht ): authed_http.configure_mtls_channel() - # Verify it was reset to default HTTP connection pool and _is_mtls is False - assert not authed_http._is_mtls - assert not isinstance(authed_http.http, mock.Mock) - assert isinstance(authed_http.http, urllib3.PoolManager) + # Verify it retains its previous mTLS state and connection pool + assert authed_http._is_mtls + assert isinstance(authed_http.http, mock.Mock) @mock.patch("google.auth.transport.urllib3._make_mutual_tls_http", autospec=True) def test_configure_mtls_channel_subsequent_disabled( From be5a7aa9da25a338e647933e871740b5c1fed99a Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Mon, 29 Jun 2026 17:40:51 -0700 Subject: [PATCH 7/7] Make async mTLS configuration atomic and improve mTLS unit test coverage. --- .../google/auth/aio/transport/sessions.py | 16 ++-- .../google-auth/google/auth/transport/grpc.py | 16 ++-- .../tests/transport/aio/test_sessions_mtls.py | 80 ++++++++++++++++++- .../google-auth/tests/transport/test_grpc.py | 37 +++++++++ 4 files changed, 131 insertions(+), 18 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 824d11bfa96f..1af41af9ebff 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -156,6 +156,7 @@ async def configure_mtls_channel(self, client_cert_callback=None): (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 @@ -181,18 +182,16 @@ async def _do_configure(): google.auth.transport._mtls_helper.check_use_client_cert ) if not use_client_cert: - self._is_mtls = False return try: ( - self._is_mtls, + is_mtls, cert, key, ) = await mtls.get_client_cert_and_key(client_cert_callback) - if self._is_mtls: - self._cached_cert = cert + if is_mtls: ssl_context = await mtls._run_in_executor( mtls.make_client_cert_ssl_context, cert, key ) @@ -209,7 +208,7 @@ async def _do_configure(): await old_auth_request.close() else: - self._is_mtls = False + 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. " @@ -219,8 +218,13 @@ async def _do_configure(): UserWarning, ) + self._is_mtls = is_mtls + if is_mtls: + self._cached_cert = cert + else: + self._cached_cert = None + except Exception 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/grpc.py b/packages/google-auth/google/auth/transport/grpc.py index edf91a8d3c4b..692f53c0e1de 100644 --- a/packages/google-auth/google/auth/transport/grpc.py +++ b/packages/google-auth/google/auth/transport/grpc.py @@ -280,14 +280,14 @@ def my_client_cert_callback(): class SslCredentials: """Class for application default SSL credentials. - The client certificate usage (mutual TLS) is determined by the - `should_use_client_cert` helper. Client certificate will not be used - unless client certificate usage is enabled. This is true if the - `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable is explicitly - set to `"true"`, or if the environment variable is unset/empty but a client - certificate configuration is found (e.g. via the `GOOGLE_API_CERTIFICATE_CONFIG` - environment variable containing a `"workload"` certificate configuration). - See https://google.aip.dev/auth/4114 + Mutual TLS (mTLS) is enabled if either: + 1. The `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable is explicitly + set to `"true"`. + 2. The `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable is unset or empty, + but a valid workload certificate configuration is found (e.g., via the + `GOOGLE_API_CERTIFICATE_CONFIG` environment variable or the default gcloud config path). + + See https://google.aip.dev/auth/4114 for client certificate discovery details. If client certificate usage is enabled, then for devices with endpoint verification support, a device certificate will be automatically loaded and 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 3d9cda2efc87..0efb1df74372 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -48,7 +48,12 @@ async def test_configure_mtls_channel(self): "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: + ) as mock_make_context, mock.patch( + "aiohttp.TCPConnector" + ) as mock_connector, mock.patch( + "aiohttp.ClientSession" + ) as mock_session: + mock_session.return_value.close = mock.AsyncMock() mock_exists.return_value = True mock_helper.return_value = (True, b"fake_cert_data", b"fake_key_data") @@ -64,6 +69,8 @@ async def test_configure_mtls_channel(self): mock_make_context.assert_called_once_with( b"fake_cert_data", b"fake_key_data" ) + mock_connector.assert_called_once_with(ssl=mock_context) + mock_session.assert_called_once_with(connector=mock_connector.return_value) await session.close() @pytest.mark.asyncio @@ -131,14 +138,27 @@ def mock_callback(): "google.auth.transport.mtls.has_default_client_cert_source", return_value=True, ), mock.patch( - "ssl.SSLContext.load_cert_chain" - ): + "google.auth.aio.transport.mtls.make_client_cert_ssl_context" + ) as mock_make_context, mock.patch( + "aiohttp.TCPConnector" + ) as mock_connector, mock.patch( + "aiohttp.ClientSession" + ) as mock_session: + mock_session.return_value.close = mock.AsyncMock() + mock_context = mock.Mock(spec=ssl.SSLContext) + mock_make_context.return_value = mock_context + mock_creds = mock.AsyncMock(spec=credentials.Credentials) session = sessions.AsyncAuthorizedSession(mock_creds) await session.configure_mtls_channel(client_cert_callback=mock_callback) assert session._is_mtls is True + mock_make_context.assert_called_once_with( + b"fake_cert_bytes", b"fake_key_bytes" + ) + mock_connector.assert_called_once_with(ssl=mock_context) + mock_session.assert_called_once_with(connector=mock_connector.return_value) await session.close() @pytest.mark.asyncio @@ -167,7 +187,8 @@ async def test_configure_mtls_channel_custom_request(self): mock_creds, auth_request=mock_auth_request ) - await session.configure_mtls_channel() + with pytest.warns(UserWarning, match="Attempted to establish mTLS"): + await session.configure_mtls_channel() # If the request handler is not an AiohttpRequest, the library cannot configure # the connection to use mTLS, so _is_mtls must be False to reflect this unconfigured state. @@ -232,3 +253,54 @@ async def test_configure_mtls_channel_transport_error_resets_flag(self): assert session._is_mtls is False await session.close() + + @pytest.mark.asyncio + async def test_configure_mtls_channel_atomic_on_exception(self): + """ + Tests that if configure_mtls_channel has already successfully configured mTLS, + a subsequent attempt that raises an exception will preserve the original mTLS state. + """ + # Step 1: Successful configuration + 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.patch( + "aiohttp.TCPConnector" + ) as mock_connector, mock.patch( + "aiohttp.ClientSession" + ) as mock_session: + mock_session.return_value.close = mock.AsyncMock() + mock_exists.return_value = True + mock_helper.return_value = (True, b"fake_cert_data_1", b"fake_key_data_1") + + mock_context = mock.Mock(spec=ssl.SSLContext) + mock_make_context.return_value = mock_context + + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + session = sessions.AsyncAuthorizedSession(mock_creds) + + await session.configure_mtls_channel() + assert session._is_mtls is True + assert session._cached_cert == b"fake_cert_data_1" + first_auth_request = session._auth_request + + # Step 2: Failed subsequent configuration attempt + # Reset task so we trigger a new configuration run + session._mtls_init_task = None + + # Patch context generator to fail this time + mock_make_context.side_effect = exceptions.ClientCertError("Mock error") + + with pytest.raises(exceptions.MutualTLSChannelError): + await session.configure_mtls_channel() + + # Verify that the state remains unchanged from the first successful configuration + assert session._is_mtls is True + assert session._cached_cert == b"fake_cert_data_1" + assert session._auth_request is first_auth_request + await session.close() diff --git a/packages/google-auth/tests/transport/test_grpc.py b/packages/google-auth/tests/transport/test_grpc.py index 9aa84bf85e90..9f3c117ed933 100644 --- a/packages/google-auth/tests/transport/test_grpc.py +++ b/packages/google-auth/tests/transport/test_grpc.py @@ -607,3 +607,40 @@ def test_get_client_ssl_credentials_transient_error_retry( mock_ssl_channel_credentials.assert_called_with( certificate_chain=b"cert", private_key=b"key" ) + + @mock.patch( + "google.auth.transport.mtls.has_default_client_cert_source", autospec=True + ) + def test_get_client_ssl_credentials_auto_enablement( + self, + mock_has_default_client_cert_source, + mock_check_config_path, + mock_load_json_file, + mock_get_client_ssl_credentials, + mock_ssl_channel_credentials, + ): + fake_config_content = '{"version": 1, "cert_configs": {"workload": {"cert_path": "/tmp/mock_cert.pem", "key_path": "/tmp/mock_key.pem"}}}' + mock_has_default_client_cert_source.return_value = True + mock_get_client_ssl_credentials.return_value = ( + True, + PUBLIC_CERT_BYTES, + PRIVATE_KEY_BYTES, + None, + ) + + with mock.patch.dict( + os.environ, + { + environment_vars.GOOGLE_API_CERTIFICATE_CONFIG: "fake_config_path.json", + }, + ), mock.patch("builtins.open", mock.mock_open(read_data=fake_config_content)): + # Ensure GOOGLE_API_USE_CLIENT_CERTIFICATE is not present in the environment + os.environ.pop(environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE, None) + ssl_credentials = google.auth.transport.grpc.SslCredentials() + + assert ssl_credentials.ssl_credentials is not None + assert ssl_credentials.is_mtls + mock_get_client_ssl_credentials.assert_called_once() + mock_ssl_channel_credentials.assert_called_once_with( + certificate_chain=PUBLIC_CERT_BYTES, private_key=PRIVATE_KEY_BYTES + )