diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index bb7873b02aef..cf7915a562dd 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -182,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 ) @@ -208,9 +206,13 @@ async def _do_configure(): old_auth_request = self._auth_request self._auth_request = AiohttpRequest(session=new_session) - await old_auth_request.close() + try: + await old_auth_request.close() + except Exception: + # Suppress so it doesn't abort the mTLS configuration + pass 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. " @@ -220,12 +222,13 @@ async def _do_configure(): UserWarning, ) - except ( - exceptions.ClientCertError, - ImportError, - OSError, - ) as caught_exc: - self._is_mtls = False + self._is_mtls = is_mtls + if is_mtls: + self._cached_cert = cert + else: + self._cached_cert = None + + except Exception as caught_exc: new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc @@ -586,4 +589,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/grpc.py b/packages/google-auth/google/auth/transport/grpc.py index e541d20ca0a4..7482038589a3 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: @@ -279,14 +280,19 @@ 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 + Mutual TLS (mTLS) is enabled if either: - 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. + 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 + mutual TLS will be established. See https://cloud.google.com/endpoint-verification/docs/overview. """ @@ -295,11 +301,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): @@ -319,11 +321,15 @@ 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: 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 a36d85f84661..b9c308903213 100644 --- a/packages/google-auth/google/auth/transport/requests.py +++ b/packages/google-auth/google/auth/transport/requests.py @@ -443,42 +443,49 @@ 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. """ 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 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, OSError, OpenSSL.crypto.Error, ) as caught_exc: - self._is_mtls = False 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 ace693773aa1..0a1de5f86f7c 100644 --- a/packages/google-auth/google/auth/transport/urllib3.py +++ b/packages/google-auth/google/auth/transport/urllib3.py @@ -332,18 +332,16 @@ 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. """ 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 @@ -353,21 +351,29 @@ 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 + new_http = _make_mutual_tls_http(cert, key) + new_is_mtls = True else: - self.http = _make_default_http() - self._is_mtls = False + new_http = _make_default_http() + new_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 + 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 cd9e72cd55c9..de9b056f27bb 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,9 @@ 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 async def test_configure_mtls_channel_disabled(self): @@ -78,6 +86,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 +104,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 +121,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): @@ -127,19 +138,33 @@ 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 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"} @@ -161,5 +186,163 @@ async def test_configure_mtls_channel_custom_request(self): session = sessions.AsyncAuthorizedSession( 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. + assert session._is_mtls is False + 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): + """ + 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 + 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() + + @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" + ), 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() + + @pytest.mark.asyncio + async def test_configure_mtls_channel_close_exception_does_not_abort(self): + """ + Tests that if old_auth_request.close() raises an exception, the mTLS + configuration is still considered successful, and is_mtls remains True + without raising MutualTLSChannelError. + """ + 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" + ), 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") + + 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) + + # Mock close() of the initial self._auth_request to raise an exception + session._auth_request.close = mock.AsyncMock( + side_effect=Exception("Mock close error") + ) + + # Should complete successfully without raising MutualTLSChannelError + await session.configure_mtls_channel() + + assert session._is_mtls is True + assert session._cached_cert == b"fake_cert_data" + await session.close() diff --git a/packages/google-auth/tests/transport/test_grpc.py b/packages/google-auth/tests/transport/test_grpc.py index 7ebd14758e55..9f3c117ed933 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. @@ -468,6 +474,41 @@ 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, @@ -475,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 @@ -484,3 +527,120 @@ 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 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" + ) + + @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 + ) diff --git a/packages/google-auth/tests/transport/test_requests.py b/packages/google-auth/tests/transport/test_requests.py index a7617d44992c..d13f0fac2f20 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 @@ -503,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( @@ -553,8 +567,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 @@ -757,6 +771,82 @@ 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 retains its previous mTLS state and MutualTlsAdapter + assert auth_session.is_mtls + assert isinstance( + auth_session.adapters["https://"], + google.auth.transport.requests._MutualTlsAdapter, + ) + + 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 d9753b9e90cf..04ace52a2350 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 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() + mock_make_mutual_tls_http.assert_not_called() @mock.patch( "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True @@ -277,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"): @@ -287,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 @@ -300,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() - - # 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() + 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() def test_clear_pool_on_del(self): http = mock.create_autospec(urllib3.PoolManager) @@ -322,10 +344,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 @@ -334,8 +365,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 @@ -345,14 +376,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()) @@ -526,3 +563,72 @@ 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 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( + 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)